core/cli: resume sessions, history, editline Tab, higher tool rounds

Default to file SQLite DB and latest session; /history and /rounds;
bind Tab for libedit; max tool rounds default 32.
This commit is contained in:
2026-07-14 19:15:12 +08:00
parent 366199fc0c
commit bd6f8f5d9a
11 changed files with 223 additions and 13 deletions
+4 -3
View File
@@ -70,12 +70,13 @@ Module-level `@tool` handlers. Call `set_workspace_root()` before use.
Click app + readline REPL. Entry: `plyngent` / `python -m plyngent`.
- **`plyngent chat`**: provider/model selection (flags or interactive), SQLite sessions via config `[database]`, `/help` slash commands.
- **`plyngent chat`**: provider/model selection (flags or interactive), SQLite sessions via config `[database]` (file DB under user data if unset/`:memory:`), resumes latest session by default (`--new` / `--session`).
- Slash: `/history`, `/sessions`, `/resume`, `/rounds`, …
- **`plyngent providers`**: list config providers.
- **`plyngent config path|edit`**: print or open config in `$EDITOR` (`shlex`-split, e.g. `codium --wait`).
- If no providers and `$EDITOR` is set, chat/providers prompt to edit config then reload.
- Tools default on (`--tools` / `--no-tools`); workspace defaults to cwd.
- Readline: Tab completion for slash commands/args; history file under platformdirs user data (`repl_history`).
- Tools default on (`--tools` / `--no-tools`); workspace defaults to cwd; `--max-rounds` default 32.
- Readline/editline: Tab completion; input history file under platformdirs user data (`repl_history`).
### Composition utility: `Forward` descriptor
+1 -1
View File
@@ -29,7 +29,7 @@ if TYPE_CHECKING:
from .tools import ToolRegistry
DEFAULT_MAX_ROUNDS = 8
DEFAULT_MAX_ROUNDS = 32
async def run_chat_loop( # noqa: PLR0913
+47 -2
View File
@@ -6,7 +6,9 @@ from typing import TYPE_CHECKING
import click
import msgspec
from platformdirs import user_data_path
from plyngent.agent.loop import DEFAULT_MAX_ROUNDS
from plyngent.cli.editor import (
load_config_with_optional_edit,
open_in_editor,
@@ -23,13 +25,21 @@ from plyngent.tools import set_workspace_root
if TYPE_CHECKING:
from plyngent.config.store import ConfigStore
_DEFAULT_DB_FILENAME = "chat.db"
def _load_config(config_path: Path | None) -> ConfigStore:
return load_config_with_optional_edit(config_path)
def _database_config(store: ConfigStore) -> DatabaseConfig:
return msgspec.convert(dict(store.database), DatabaseConfig)
raw = dict(store.database)
# Prefer a durable file DB so sessions survive CLI restarts.
if raw.get("url") in {None, "", ":memory:"} and raw.get("implementation", "sqlite") == "sqlite":
db_path = user_data_path("plyngent", ensure_exists=True) / _DEFAULT_DB_FILENAME
raw = {**raw, "implementation": "sqlite", "url": str(db_path)}
click.secho(f"using database: {db_path}", fg="bright_black")
return msgspec.convert(raw, DatabaseConfig)
async def _run_chat( # noqa: PLR0913
@@ -40,6 +50,8 @@ async def _run_chat( # noqa: PLR0913
tools: bool,
workspace: Path,
session_id: int | None,
max_rounds: int,
new_session: bool,
) -> None:
store = _load_config(config_path)
if store.bad_providers:
@@ -64,11 +76,20 @@ async def _run_chat( # noqa: PLR0913
provider=provider,
model=model_id,
tools_enabled=tools,
max_rounds=max_rounds,
)
if session_id is not None:
await state.resume_session(session_id)
else:
click.echo(f"resumed session {session_id} ({len(state.agent.messages)} messages)")
elif new_session:
await state.new_session()
else:
mode = await state.resume_latest_or_new()
if mode == "resume":
click.echo(
f"resumed latest session {state.session_id} "
f"({len(state.agent.messages)} messages); use --new for a fresh chat"
)
await run_repl(state)
finally:
await memory.close()
@@ -98,6 +119,20 @@ def main() -> None:
help="Workspace root for tools (default: cwd).",
)
@click.option("--session", "session_id", type=int, default=None, help="Resume session id.")
@click.option(
"--new",
"new_session",
is_flag=True,
default=False,
help="Start a new session instead of resuming the latest.",
)
@click.option(
"--max-rounds",
type=int,
default=DEFAULT_MAX_ROUNDS,
show_default=True,
help="Max tool-loop rounds per user turn.",
)
def chat_cmd( # noqa: PLR0913
config_path: Path | None,
provider_name: str | None,
@@ -105,8 +140,16 @@ def chat_cmd( # noqa: PLR0913
tools: bool, # noqa: FBT001
workspace: Path | None,
session_id: int | None,
new_session: bool, # noqa: FBT001
max_rounds: int,
) -> None:
"""Interactive chat REPL with optional tools and session memory."""
if max_rounds < 1:
msg = "--max-rounds must be >= 1"
raise click.ClickException(msg)
if session_id is not None and new_session:
msg = "use either --session or --new, not both"
raise click.ClickException(msg)
root = workspace if workspace is not None else Path.cwd()
asyncio.run(
_run_chat(
@@ -116,6 +159,8 @@ def chat_cmd( # noqa: PLR0913
tools=tools,
workspace=root,
session_id=session_id,
max_rounds=max_rounds,
new_session=new_session,
)
)
+2
View File
@@ -54,3 +54,5 @@ async def render_events(events: AsyncIterator[AgentEvent]) -> None:
_ = event
if printed_text:
click.echo()
# Blank line before the next readline prompt so log and input are not jammed.
click.echo()
+4 -3
View File
@@ -17,9 +17,10 @@ _MINIMAL_CONFIG = """\
# plyngent configuration
# edit providers below
[database]
implementation = "sqlite"
url = ":memory:"
# Optional: omit [database] to use ~/.local/share/plyngent/chat.db (Linux).
# [database]
# implementation = "sqlite"
# url = "/path/to/chat.db"
# [providers.example]
# preset = "openai-compatible"
+17 -2
View File
@@ -20,12 +20,14 @@ SLASH_COMMANDS: tuple[str, ...] = (
"/quit",
"/exit",
"/clear",
"/history",
"/sessions",
"/new",
"/resume",
"/provider",
"/model",
"/tools",
"/rounds",
)
_TOOLS_ARGS: tuple[str, ...] = ("on", "off")
@@ -72,11 +74,24 @@ def _argument_options(state: ReplState, command: str, text: str) -> list[str]:
if command == "/tools":
return filter_prefix(text, list(_TOOLS_ARGS))
if command == "/resume":
# Session ids are numeric; no sync list without await — skip.
return []
return []
def bind_tab_complete(readline_mod: object) -> None:
"""Bind Tab to completion for GNU readline and libedit/editline."""
parse = getattr(readline_mod, "parse_and_bind", None)
if not callable(parse):
return
# GNU readline
_ = parse("tab: complete")
# libedit (common on macOS / some Linux builds; this host reports backend=editline)
_ = parse("bind ^I rl_complete")
# Some libedit builds use the python: prefix in .editrc-style binds
with contextlib.suppress(Exception):
_ = parse("python:bind ^I rl_complete")
def setup_readline(state: ReplState) -> None:
"""Configure Tab completion and persistent history when readline is available."""
try:
@@ -84,7 +99,7 @@ def setup_readline(state: ReplState) -> None:
except ImportError:
return
readline.parse_and_bind("tab: complete")
bind_tab_complete(readline)
# Treat path-like chars as part of a token so /help completes as one word.
readline.set_completer_delims(" \t\n")
readline.set_completer(build_completer(state))
+92 -1
View File
@@ -5,32 +5,46 @@ from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING
import click
from msgspec import UNSET
from plyngent.cli.display import render_events
from plyngent.cli.readline_setup import setup_readline
from plyngent.cli.selection import select_model, select_provider
from plyngent.lmproto.openai_compatible.model import (
AssistantChatMessage,
AssistantFunctionToolCall,
ToolChatMessage,
UserChatMessage,
)
from plyngent.runtime import ProviderNotSupportedError
if TYPE_CHECKING:
from plyngent.cli.state import ReplState
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
HELP_TEXT = """\
Commands:
/help Show this help
/quit, /exit Leave the REPL
/clear Clear in-memory conversation (keeps session id)
/history [n] Show last n messages in this session (default 20)
/sessions List sessions
/new [name] Start a new session
/resume <id> Resume a session by id
/provider [name] Show or switch provider
/model [id] Show or switch model
/tools [on|off] Show or toggle tools
/rounds [n] Show or set max tool-loop rounds
Tab completes slash commands and some arguments (provider, model, tools).
Use --session ID or /resume to continue a prior chat after restart.
"""
type SlashHandler = Callable[[], None | Awaitable[None]]
_DEFAULT_HISTORY_LINES = 20
_CONTENT_PREVIEW = 200
async def _cmd_sessions(state: ReplState) -> None:
sessions = await state.memory.list_sessions()
@@ -107,9 +121,82 @@ def _cmd_tools(state: ReplState, arg: str) -> None:
click.echo(f"tools={'on' if state.tools_enabled else 'off'}")
def _cmd_rounds(state: ReplState, arg: str) -> None:
token = arg.strip()
if not token:
click.echo(f"max_rounds={state.max_rounds}")
return
try:
value = int(token)
except ValueError:
click.echo("usage: /rounds <positive integer>")
return
if value < 1:
click.echo("max_rounds must be >= 1")
return
state.max_rounds = value
state.agent.max_rounds = value
click.echo(f"max_rounds={state.max_rounds}")
def _cmd_clear(state: ReplState) -> None:
state.agent.messages.clear()
click.echo("conversation cleared")
click.echo("conversation cleared (in-memory only; DB history kept)")
def _preview_content(text: str | None) -> str:
if not text:
return ""
if len(text) <= _CONTENT_PREVIEW:
return text
return text[:_CONTENT_PREVIEW] + ""
def _format_history_message(index: int, message: AnyChatMessage) -> str:
if isinstance(message, UserChatMessage):
return f"{index}. user: {_preview_content(message.content)}"
if isinstance(message, AssistantChatMessage):
parts: list[str] = []
if isinstance(message.content, str) and message.content:
parts.append(_preview_content(message.content))
tool_calls = message.tool_calls
if tool_calls is not UNSET and tool_calls:
names: list[str] = []
for call in tool_calls:
if isinstance(call, AssistantFunctionToolCall):
names.append(call.function.name)
else:
names.append("custom")
parts.append(f"tool_calls=[{', '.join(names)}]")
body = " ".join(parts) if parts else "(empty)"
return f"{index}. assistant: {body}"
if isinstance(message, ToolChatMessage):
return f"{index}. tool({message.tool_call_id}): {_preview_content(message.content)}"
role = getattr(message, "role", type(message).__name__)
content = getattr(message, "content", "")
return f"{index}. {role}: {_preview_content(str(content))}"
def _cmd_history(state: ReplState, arg: str) -> None:
token = arg.strip()
limit = _DEFAULT_HISTORY_LINES
if token:
try:
limit = int(token)
except ValueError:
click.echo("usage: /history [n]")
return
if limit < 1:
click.echo("n must be >= 1")
return
messages = state.agent.messages
if not messages:
click.echo("(no messages in this session)")
return
start = max(0, len(messages) - limit)
click.echo(f"session={state.session_id} messages={len(messages)} showing={len(messages) - start}")
for offset, message in enumerate(messages[start:]):
click.echo(_format_history_message(start + offset, message))
async def _dispatch_slash(state: ReplState, command: str, arg: str) -> bool:
@@ -119,12 +206,14 @@ async def _dispatch_slash(state: ReplState, command: str, arg: str) -> bool:
handlers: dict[str, SlashHandler] = {
"help": lambda: click.echo(HELP_TEXT),
"clear": lambda: _cmd_clear(state),
"history": lambda: _cmd_history(state, arg),
"sessions": lambda: _cmd_sessions(state),
"new": lambda: _cmd_new(state, arg),
"resume": lambda: _cmd_resume(state, arg),
"provider": lambda: _cmd_provider(state, arg),
"model": lambda: _cmd_model(state, arg),
"tools": lambda: _cmd_tools(state, arg),
"rounds": lambda: _cmd_rounds(state, arg),
}
handler = handlers.get(command)
if handler is None:
@@ -156,6 +245,7 @@ async def run_repl(state: ReplState) -> None:
click.echo(
f"plyngent chat provider={state.provider_name} model={state.model} "
f"session={state.session_id} tools={'on' if state.tools_enabled else 'off'} "
f"rounds={state.max_rounds} messages={len(state.agent.messages)}"
)
click.echo("Type /help for commands. Empty line is ignored.")
@@ -183,3 +273,4 @@ async def run_repl(state: ReplState) -> None:
await render_events(state.agent.run(line))
except Exception as exc: # noqa: BLE001 — show API/runtime errors in REPL
click.secho(f"error: {exc}", fg="red")
click.echo() # match render_events spacing before next prompt
+13
View File
@@ -4,6 +4,7 @@ from dataclasses import dataclass, field
from typing import TYPE_CHECKING, cast
from plyngent.agent import ChatAgent, ChatClient, ToolRegistry
from plyngent.agent.loop import DEFAULT_MAX_ROUNDS
from plyngent.runtime import create_client
from plyngent.tools import DEFAULT_TOOLS
@@ -26,6 +27,7 @@ class ReplState:
provider: Provider
model: str
tools_enabled: bool
max_rounds: int = DEFAULT_MAX_ROUNDS
client: ChatClient = field(init=False)
agent: ChatAgent = field(init=False)
session_id: int | None = None
@@ -47,6 +49,7 @@ class ReplState:
tools=self._tool_registry(),
memory=self.memory,
session_id=self.session_id,
max_rounds=self.max_rounds,
)
def rebuild_client(self) -> None:
@@ -69,3 +72,13 @@ class ReplState:
self.session_id = session_id
self.agent = self._make_agent()
await self.agent.load_history()
async def resume_latest_or_new(self, name: str = "chat") -> str:
"""Resume the most recently updated session, or create one if none exist."""
sessions = await self.memory.list_sessions()
if not sessions:
await self.new_session(name=name)
return "new"
latest = max(sessions, key=lambda s: (s.updated_at, s.sid))
await self.resume_session(latest.sid)
return "resume"
+6
View File
@@ -159,6 +159,12 @@ async def test_max_rounds() -> None:
assert len(client.calls) == 2 # noqa: PLR2004
async def test_default_max_rounds_is_generous() -> None:
from plyngent.agent.loop import DEFAULT_MAX_ROUNDS
assert DEFAULT_MAX_ROUNDS >= 16 # noqa: PLR2004
async def test_chat_agent_memory_roundtrip() -> None:
store = await MemoryStore.open(DatabaseConfig())
session = await store.create_session(name="t")
+17
View File
@@ -80,6 +80,23 @@ def test_completer_commands(tmp_path: object, monkeypatch: object) -> None:
assert set(found) <= set(SLASH_COMMANDS)
def test_bind_tab_complete_runs() -> None:
from plyngent.cli.readline_setup import bind_tab_complete
class FakeReadline:
binds: list[str]
def __init__(self) -> None:
self.binds = []
def parse_and_bind(self, s: str) -> None:
self.binds.append(s)
fake = FakeReadline()
bind_tab_complete(fake)
assert any("complete" in b or "rl_complete" in b for b in fake.binds)
def test_completer_provider_args(tmp_path: object, monkeypatch: object) -> None:
import readline
from pathlib import Path
+19
View File
@@ -121,3 +121,22 @@ async def test_resume(state: ReplState) -> None:
assert sid is not None
state.agent.messages.clear()
assert await handle_slash(state, f"/resume {sid}") is True
async def test_history(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
from plyngent.lmproto.openai_compatible.model import AssistantChatMessage, UserChatMessage
state.agent.messages = [
UserChatMessage(content="hello"),
AssistantChatMessage(content="hi there"),
]
assert await handle_slash(state, "/history") is True
out = capsys.readouterr().out
assert "user: hello" in out
assert "assistant: hi there" in out
async def test_rounds(state: ReplState) -> None:
assert await handle_slash(state, "/rounds 40") is True
assert state.max_rounds == 40 # noqa: PLR2004
assert state.agent.max_rounds == 40 # noqa: PLR2004