diff --git a/CLAUDE.md b/CLAUDE.md index 3f0d9af..68d51ee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/src/plyngent/agent/loop.py b/src/plyngent/agent/loop.py index 24ad32d..fa6e1fb 100644 --- a/src/plyngent/agent/loop.py +++ b/src/plyngent/agent/loop.py @@ -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 diff --git a/src/plyngent/cli/app.py b/src/plyngent/cli/app.py index 44b1700..9f73c79 100644 --- a/src/plyngent/cli/app.py +++ b/src/plyngent/cli/app.py @@ -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, ) ) diff --git a/src/plyngent/cli/display.py b/src/plyngent/cli/display.py index e00e793..3321994 100644 --- a/src/plyngent/cli/display.py +++ b/src/plyngent/cli/display.py @@ -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() diff --git a/src/plyngent/cli/editor.py b/src/plyngent/cli/editor.py index dd31904..1bb1879 100644 --- a/src/plyngent/cli/editor.py +++ b/src/plyngent/cli/editor.py @@ -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" diff --git a/src/plyngent/cli/readline_setup.py b/src/plyngent/cli/readline_setup.py index aa3907d..5932221 100644 --- a/src/plyngent/cli/readline_setup.py +++ b/src/plyngent/cli/readline_setup.py @@ -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)) diff --git a/src/plyngent/cli/repl.py b/src/plyngent/cli/repl.py index be52b1f..7a57074 100644 --- a/src/plyngent/cli/repl.py +++ b/src/plyngent/cli/repl.py @@ -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 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 ") + 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 diff --git a/src/plyngent/cli/state.py b/src/plyngent/cli/state.py index a87d6e5..bdb6ee9 100644 --- a/src/plyngent/cli/state.py +++ b/src/plyngent/cli/state.py @@ -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" diff --git a/tests/test_agent/test_loop.py b/tests/test_agent/test_loop.py index 0374c87..ac14922 100644 --- a/tests/test_agent/test_loop.py +++ b/tests/test_agent/test_loop.py @@ -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") diff --git a/tests/test_cli/test_readline_setup.py b/tests/test_cli/test_readline_setup.py index d4acf29..63e9e89 100644 --- a/tests/test_cli/test_readline_setup.py +++ b/tests/test_cli/test_readline_setup.py @@ -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 diff --git a/tests/test_cli/test_repl_commands.py b/tests/test_cli/test_repl_commands.py index b853c91..1e1e1ab 100644 --- a/tests/test_cli/test_repl_commands.py +++ b/tests/test_cli/test_repl_commands.py @@ -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