mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-23 05:55:16 +08:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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:
|
||||
@@ -155,7 +244,8 @@ async def run_repl(state: ReplState) -> None:
|
||||
setup_readline(state)
|
||||
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"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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user