9 Commits

Author SHA1 Message Date
NCBM a282231082 docs+ci: document ruff format --check; format app.py 2026-07-17 17:57:00 +08:00
NCBM 844220e013 project: bump version to 0.1.1 2026-07-17 17:41:38 +08:00
NCBM 61c1cb2d70 core/cli: GNU readline UTF-8 meta binds for CJK input
set input-meta/output-meta on and convert-meta off (best-effort; full
grapheme delete still depends on terminal/readline — see CPython #142162).
2026-07-17 15:36:29 +08:00
NCBM f7a562740e core/cli: 10 auto-retries with 5/10/15/20 then +10s waits
default_retry_delays builds (5,10,15,20,30,…80) for DEFAULT_MAX_AUTO_RETRIES=10.
2026-07-17 15:36:29 +08:00
NCBM 39acd5967e tools/chat: rename tools to ask_user_line, ask_user_choice, ask_user_form
Model-facing names via @tool(name=…); Python symbols unchanged.
2026-07-17 15:36:29 +08:00
NCBM b8ed9e0ee9 core/cli: assistant/reasoning on new lines; flush markdown on source change
Labels end with a newline before content. Switching reasoning↔assistant or
hitting tools/errors flushes the assistant markdown buffer so streams do not mix.
2026-07-17 15:36:29 +08:00
NCBM 8ebe68b3af core/cli: remote-first model lists; always fetch GET /models
Prefer provider catalog over config for selection/Tab; fetch at startup,
/provider, /model, and /models. Config-only ids remain as fallback.
2026-07-17 15:36:06 +08:00
NCBM 18008de7f1 docs: document /yolo and --yes soft-confirm overrides 2026-07-17 14:55:34 +08:00
NCBM 2761877460 core/cli: YOLO mode on|off|once for soft destructive confirms
Session /yolo and --yes skip classify_danger prompts; once expires after the
next user turn. Hard path/command denylists unchanged.
2026-07-17 14:55:34 +08:00
23 changed files with 462 additions and 121 deletions
+10 -7
View File
@@ -12,12 +12,15 @@ Plyngent is an LLM chat and agent toolkit (Python 3.14+, PDM-managed). Single-us
pdm install # first-time dependency setup pdm install # first-time dependency setup
pdm sync # sync after pulling changes pdm sync # sync after pulling changes
pdm run basedpyright . # type checking (basedpyright, "recommended" strictness)
pdm run ruff check . # linting pdm run ruff check . # linting
pdm run ruff format . # formatting pdm run ruff format . # apply formatting
pdm run ruff format --check . # CI: fail if unformatted (do not skip)
pdm run basedpyright . # type checking (basedpyright, "recommended" strictness)
pdm run pytest # tests (pytest-asyncio auto mode) pdm run pytest # tests (pytest-asyncio auto mode)
``` ```
GitHub Actions runs `ruff check`, `ruff format --check`, `basedpyright`, then `pytest`. Format locally with `pdm run ruff format .` so CI does not fail after publish.
## Architecture ## Architecture
### Data modeling: `msgspec.Struct` ### Data modeling: `msgspec.Struct`
@@ -75,9 +78,9 @@ Module-level `@tool` handlers. Call `set_workspace_root()` before use.
- **`process`**: `run_command` (argv, no shell, timeout, optional stdin/env); PTY `open_pty` / `read_pty` / `write_pty` / `close_pty` (POSIX: `pty`+`fork`; Windows: ConPTY via `pywinpty` env marker dep). - **`process`**: `run_command` (argv, no shell, timeout, optional stdin/env); PTY `open_pty` / `read_pty` / `write_pty` / `close_pty` (POSIX: `pty`+`fork`; Windows: ConPTY via `pywinpty` env marker dep).
- PTY: backend in `pty_backend.py`; structured status (`alive`/`exit_code`/`data`); `read_pty(..., until=)`; session limit/idle TTL/output budget; close terminate→kill. - PTY: backend in `pty_backend.py`; structured status (`alive`/`exit_code`/`data`); `read_pty(..., until=)`; session limit/idle TTL/output budget; close terminate→kill.
- CLI limit hooks: interactive confirm to raise tool-loop rounds, PTY session cap, or PTY output budget. - CLI limit hooks: interactive confirm to raise tool-loop rounds, PTY session cap, or PTY output budget.
- Destructive confirms: `classify_danger` + `ToolRegistry(on_confirm=…)`; CLI default deny; config `confirm_destructive` / `path_denylist`. - Destructive confirms: `classify_danger` + `ToolRegistry(on_confirm=…)`; CLI default deny; config `confirm_destructive` / `path_denylist`. Session YOLO: `/yolo on|off|once` and `--yes` (skip soft confirms; hard denylists unchanged; `once` expires after the next user turn).
- **`vcs`**: read-only VCS tools (`vcs_kind` / `vcs_status` / `vcs_diff` / `vcs_log` / `vcs_branch`) via `VcsBackend` protocol; **git** implemented; detectors are pluggable for other systems. - **`vcs`**: read-only VCS tools (`vcs_kind` / `vcs_status` / `vcs_diff` / `vcs_log` / `vcs_branch`) via `VcsBackend` protocol; **git** implemented; detectors are pluggable for other systems.
- **`chat`**: human prompts as tools — `ask_user`, `choose_user`, `form_user` (shared `prompting` core). - **`chat`**: human prompts as tools — `ask_user_line` / `ask_user_choice` / `ask_user_form` (shared `prompting` core).
- **`DEFAULT_TOOLS`**: file + process + vcs + chat tool list for a `ToolRegistry`. - **`DEFAULT_TOOLS`**: file + process + vcs + chat tool list for a `ToolRegistry`.
### Prompting (`prompting.py`) ### Prompting (`prompting.py`)
@@ -88,8 +91,8 @@ Shared interactive I/O: `ask` / `choose` / `form` / `confirm` with pluggable bac
Click app + readline REPL. Entry: `plyngent` / `python -m plyngent`. Click app + readline REPL. Entry: `plyngent` / `python -m plyngent`.
- **`plyngent chat`**: provider/model (flags or interactive; Tab via readline in `prompting`); sessions store `provider_name`/`model` and restore on resume; SQLite via `[database]` (file DB under user data if unset/`:memory:`); workspace-bound; resume latest for cwd/`--workspace` by default (`--new` / `--session`). One-shot: `-p/--prompt` and non-TTY stdin; exit codes 0/1/2/3; `--yes`, `--stream/--no-stream`, `--quiet`. Root `--log-level`. - **`plyngent chat`**: provider/model (flags or interactive; Tab via readline in `prompting`); sessions store `provider_name`/`model` and restore on resume; SQLite via `[database]` (file DB under user data if unset/`:memory:`); workspace-bound; resume latest for cwd/`--workspace` by default (`--new` / `--session`). One-shot: `-p/--prompt` and non-TTY stdin; exit codes 0/1/2/3; `--yes` (YOLO on), `--stream/--no-stream`, `--quiet`. Root `--log-level`.
- Slash: Click group in `cli/slash.py` + `awaitlet` for async work; Tab completer from registry + ParamType `shell_complete`. Multiline `"""``"""`; `/edit` via `$EDITOR`. - Slash: Click group in `cli/slash.py` + `awaitlet` for async work; Tab completer from registry + ParamType `shell_complete`. Multiline `"""``"""`; `/edit` via `$EDITOR`. `/yolo on|off|once` for soft destructive confirms.
- Explicit `/resume` or `--session` from another workspace prompts: **keep** / **update** / **abort**. - Explicit `/resume` or `--session` from another workspace prompts: **keep** / **update** / **abort**.
- Failed/cancelled turns: user kept; **committed tool rounds kept** (side effects not re-run on `/retry`); only unfinished assistant rolled back; Ctrl+C cancels; TTY confirms off-loop; auto-retry 10s/20s/30s then `/retry`. - Failed/cancelled turns: user kept; **committed tool rounds kept** (side effects not re-run on `/retry`); only unfinished assistant rolled back; Ctrl+C cancels; TTY confirms off-loop; auto-retry 10s/20s/30s then `/retry`.
- **`plyngent providers`**, **`config path|edit`**. No providers + `$EDITOR` → optional edit then reload. - **`plyngent providers`**, **`config path|edit`**. No providers + `$EDITOR` → optional edit then reload.
@@ -112,7 +115,7 @@ Basedpyright `recommended`. Ruff includes `ANN` (private return types `ANN202` i
- **Phase G (CLI polish + hardening)** — single-user only; multi-tenant stays Phase H. - **Phase G (CLI polish + hardening)** — single-user only; multi-tenant stays Phase H.
**Done** **Done**
- G0: `prompting` (`ask`/`choose`/`form`/`confirm`) + chat tools (`ask_user`/`choose_user`/`form_user`) - G0: `prompting` (`ask`/`choose`/`form`/`confirm`) + chat tools (`ask_user_line`/`ask_user_choice`/`ask_user_form`)
- G1: `ReasoningDeltaEvent`, `/stream`, `/verbose` - G1: `ReasoningDeltaEvent`, `/stream`, `/verbose`
- G2: `/rename`, `/delete` (confirm), `/export md|json` - G2: `/rename`, `/delete` (confirm), `/export md|json`
- G2.5: Click slash registry (`cli/slash.py`) + `awaitlet` for sync Click / async memory; auto `/help`; completer from `slash.list_commands` - G2.5: Click slash registry (`cli/slash.py`) + `awaitlet` for sync Click / async memory; auto `/help`; completer from `slash.list_commands`
+9 -6
View File
@@ -62,15 +62,17 @@ pdm sync # after pull
pdm run plyngent --help pdm run plyngent --help
``` ```
Dev checks: Dev checks (same order as CI):
```bash ```bash
pdm run basedpyright .
pdm run ruff check . pdm run ruff check .
pdm run ruff format . pdm run ruff format --check . # or: pdm run ruff format . to apply
pdm run basedpyright .
pdm run pytest pdm run pytest
``` ```
CI fails if sources are not Ruff-formatted (`ruff format --check`). Run `pdm run ruff format .` before push.
## Basic usage ## Basic usage
```bash ```bash
@@ -152,7 +154,7 @@ plyngent chat --session 3
| `--max-rounds` | Tool-loop rounds per turn (default 32) | | `--max-rounds` | Tool-loop rounds per turn (default 32) |
| `--stream` / `--no-stream` | Streaming deltas (default on) | | `--stream` / `--no-stream` | Streaming deltas (default on) |
| `--quiet` | Less status on stderr | | `--quiet` | Less status on stderr |
| `--yes` | Allow destructive tools without confirm (also for one-shot) | | `--yes` | YOLO on: skip destructive-tool confirms for this process |
| `--log-level` | On the root CLI: `DEBUG`, `INFO`, `WARNING`, … | | `--log-level` | On the root CLI: `DEBUG`, `INFO`, `WARNING`, … |
Sessions resume the **most recently updated** session for the current workspace unless you pass `--new` or `--session`. Each session remembers the last **provider** and **model** (restored on resume so you are not re-prompted). Sessions resume the **most recently updated** session for the current workspace unless you pass `--new` or `--session`. Each session remembers the last **provider** and **model** (restored on resume so you are not re-prompted).
@@ -192,6 +194,7 @@ Type `/help` in the REPL for the live list. Common ones:
| `/export [md\|json] [path]` | Transcript from DB (no secrets) | | `/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 |
| `/stream` `/verbose` `/markdown` `/tools` `/rounds` | Toggles and limits | | `/stream` `/verbose` `/markdown` `/tools` `/rounds` | Toggles and limits |
| `/yolo [on\|off\|once]` | Soft destructive confirms: sticky skip, off, or next turn only |
| `/retry` | Re-run incomplete last user turn (after error/cancel) | | `/retry` | Re-run incomplete last user turn (after error/cancel) |
| `/provider` `/model` | Switch without restarting | | `/provider` `/model` | Switch without restarting |
@@ -209,13 +212,13 @@ User messages are saved immediately. On API error or Ctrl+C, partial assistant/t
## Tools (when enabled) ## Tools (when enabled)
Default registry: file ops, `run_command` / PTY (POSIX openpty; Windows ConPTY via pywinpty), read-only VCS (git), and human prompts (`ask_user` / `choose_user` / `form_user`). Default registry: file ops, `run_command` / PTY (POSIX openpty; Windows ConPTY via pywinpty), read-only VCS (git), and human prompts (`ask_user_line` / `ask_user_choice` / `ask_user_form`).
Safety defaults: Safety defaults:
- Paths stay under the workspace; optional `path_denylist` substrings. - Paths stay under the workspace; optional `path_denylist` substrings.
- Command basename denylist (e.g. dangerous shells/utilities). - Command basename denylist (e.g. dangerous shells/utilities).
- Destructive tools (delete/move/overwrite) can require confirm (`confirm_destructive`; default deny in non-TTY). - Destructive tools (delete/move/overwrite) can require confirm (`confirm_destructive`; default deny in non-TTY). Override for the session with `/yolo on|off|once` or startup `--yes` (path/command denylists still apply).
- PTY sessions: caps, idle TTL, output budget; master FD is non-inheritable; sessions closed on chat exit. - PTY sessions: caps, idle TTL, output budget; master FD is non-inheritable; sessions closed on chat exit.
## Usage / context (CLI) ## Usage / context (CLI)
+4 -1
View File
@@ -45,7 +45,10 @@ Then for every projects, check the dev dependency group where you can find tools
Type checking: `pdm run basedpyright .` Type checking: `pdm run basedpyright .`
Linting: `pdm run ruff check .` Linting: `pdm run ruff check .`
Formating: `pdm run ruff format .` Formatting (apply): `pdm run ruff format .`
Formatting (CI check): `pdm run ruff format --check .`
CI (`.github/workflows/ci.yml`) runs lint, **format check**, typecheck, then tests. Always run `pdm run ruff format .` before push so `ruff format --check` does not fail on `main` after a release tag has already published.
<!-- Contents in comments are for projects with long-period developments, but this is a new project. <!-- Contents in comments are for projects with long-period developments, but this is a new project.
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "plyngent" name = "plyngent"
version = "0.1.0" version = "0.1.1"
description = "LLM chat and agent toolkit" description = "LLM chat and agent toolkit"
authors = [ authors = [
{ name = "HivertMoZara", email = "worldmozara@163.com" }, { name = "HivertMoZara", email = "worldmozara@163.com" },
+5
View File
@@ -191,6 +191,11 @@ class ToolRegistry:
def __len__(self) -> int: def __len__(self) -> int:
return len(self._tools) return len(self._tools)
@property
def soft_confirm(self) -> bool:
"""True when dangerous tools are gated by ``on_confirm``."""
return self._danger is not None and self._on_confirm is not None
async def _invoke(self, definition: ToolDefinition, args: dict[str, object]) -> str: async def _invoke(self, definition: ToolDefinition, args: dict[str, object]) -> str:
try: try:
result = definition.handler(**args) result = definition.handler(**args)
+34 -6
View File
@@ -171,13 +171,16 @@ async def _run_oneshot(state: ReplState, prompt_text: str) -> int:
try: try:
ok = await run_user_text_with_retries(state.agent, prompt_text, delays=()) ok = await run_user_text_with_retries(state.agent, prompt_text, delays=())
except asyncio.CancelledError: except asyncio.CancelledError:
state.expire_yolo_once(quiet=True)
return EXIT_CANCELLED return EXIT_CANCELLED
except KeyboardInterrupt: except KeyboardInterrupt:
state.expire_yolo_once(quiet=True)
return EXIT_CANCELLED return EXIT_CANCELLED
state.expire_yolo_once(quiet=True)
return EXIT_OK if ok else EXIT_TURN_FAILED return EXIT_OK if ok else EXIT_TURN_FAILED
async def _run_chat( # noqa: C901, PLR0912 — chat orchestration async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
*, *,
config_path: Path | None, config_path: Path | None,
provider_name: str | None, provider_name: str | None,
@@ -206,7 +209,10 @@ async def _run_chat( # noqa: C901, PLR0912 — chat orchestration
_warn_recoverable_providers(store.recoverable_providers) _warn_recoverable_providers(store.recoverable_providers)
_setup_workspace_and_hooks(store, workspace, interactive=interactive) _setup_workspace_and_hooks(store, workspace, interactive=interactive)
confirm_destructive: bool | None = False if yes else None # --yes forces sticky YOLO; else derive from config.confirm_destructive.
from plyngent.cli.state import YoloMode
yolo: YoloMode | None = "on" if yes else None
memory = await MemoryStore.open(_database_config(store, quiet=quiet or oneshot)) memory = await MemoryStore.open(_database_config(store, quiet=quiet or oneshot))
try: try:
@@ -239,8 +245,27 @@ async def _run_chat( # noqa: C901, PLR0912 — chat orchestration
preferred_model=preferred_model, preferred_model=preferred_model,
interactive=interactive, interactive=interactive,
) )
model_id = select_model(provider, preferred=preferred_model, interactive=interactive) # Build client early so we can always try GET /models for remote-first lists.
_ = create_client(provider) from plyngent.cli.models_source import (
client_supports_models,
fetch_remote_model_ids,
model_choices_for_provider,
)
client = create_client(provider)
remote_ids: list[str] | None = None
try:
if client_supports_models(client):
remote_ids = await fetch_remote_model_ids(client)
except RuntimeError, TypeError, OSError, ValueError:
remote_ids = None
choices = model_choices_for_provider(provider, remote_ids=remote_ids)
model_id = select_model(
provider,
preferred=preferred_model,
interactive=interactive,
choices=choices,
)
except ProviderNotSupportedError as exc: except ProviderNotSupportedError as exc:
raise click.ClickException(str(exc)) from exc raise click.ClickException(str(exc)) from exc
@@ -255,8 +280,11 @@ async def _run_chat( # noqa: C901, PLR0912 — chat orchestration
max_rounds=max_rounds, max_rounds=max_rounds,
stream_enabled=stream, stream_enabled=stream,
interactive_limits=interactive, interactive_limits=interactive,
confirm_destructive=confirm_destructive, yolo=yolo,
) )
# Seed remote model cache from startup fetch so Tab/complete stays warm.
if remote_ids is not None:
state.seed_remote_models(remote_ids)
if not quiet and not oneshot: if not quiet and not oneshot:
click.secho(f"workspace: {state.workspace}", fg="bright_black", err=True) click.secho(f"workspace: {state.workspace}", fg="bright_black", err=True)
@@ -367,7 +395,7 @@ def main(ctx: click.Context, log_level: str) -> None:
"--yes", "--yes",
is_flag=True, is_flag=True,
default=False, default=False,
help="Allow destructive tools without confirm (one-shot / non-interactive).", help="Enable YOLO: skip destructive-tool confirms (sticky for this process).",
) )
@click.option( @click.option(
"--quiet", "--quiet",
+75 -48
View File
@@ -4,7 +4,7 @@ import contextlib
import os import os
import sys import sys
from contextvars import ContextVar from contextvars import ContextVar
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Literal
import click import click
@@ -32,6 +32,8 @@ _TOOL_ARGS_PREVIEW = 80
_verbose_tool_results: ContextVar[bool] = ContextVar("verbose_tool_results", default=False) _verbose_tool_results: ContextVar[bool] = ContextVar("verbose_tool_results", default=False)
_markdown_enabled: ContextVar[bool] = ContextVar("markdown_enabled", default=True) _markdown_enabled: ContextVar[bool] = ContextVar("markdown_enabled", default=True)
type StreamSource = Literal["reasoning", "assistant"]
def set_verbose_tool_results(enabled: bool) -> None: # noqa: FBT001 def set_verbose_tool_results(enabled: bool) -> None: # noqa: FBT001
"""Set whether tool results print in full (True) or as a short preview.""" """Set whether tool results print in full (True) or as a short preview."""
@@ -87,26 +89,40 @@ def _clear_streamed_lines(line_count: int) -> None:
def _line_count_for_clear(label: str, body: str) -> int: def _line_count_for_clear(label: str, body: str) -> int:
"""Approximate terminal lines used by ``label + body`` for cursor erase.""" """Approximate terminal lines used by ``label\\n + body`` for cursor erase."""
if not body and not label: if not body and not label:
return 0 return 0
text = label + body # Label is on its own line; body may contain newlines.
# +1 for trailing newline after the stream block in render_events. text = f"{label}\n{body}" if label else body
return text.count("\n") + 1 return text.count("\n") + 1
def print_markdown(text: str, *, label: str = "assistant:") -> None: def print_markdown(text: str, *, label: str = "assistant:") -> None:
"""Render *text* as markdown via Rich, with a cyan label.""" """Render *text* as markdown via Rich; *label* on its own line when set."""
from rich.console import Console from rich.console import Console
from rich.markdown import Markdown from rich.markdown import Markdown
from rich.text import Text from rich.text import Text
console = Console(file=sys.stdout, highlight=False) console = Console(file=sys.stdout, highlight=False)
if label: if label:
console.print(Text(label, style="cyan"), end="") console.print(Text(label, style="cyan"))
console.print(Markdown(text)) console.print(Markdown(text))
def _flush_assistant_markdown(body: str, *, pretty: bool) -> None:
"""Replace the plain assistant stream with markdown when enabled."""
if not body.strip():
click.echo()
return
if pretty:
lines = _line_count_for_clear("assistant:", body)
_clear_streamed_lines(lines)
print_markdown(body, label="assistant:")
click.echo()
else:
click.echo()
async def render_events( # noqa: C901, PLR0912, PLR0915 async def render_events( # noqa: C901, PLR0912, PLR0915
events: AsyncIterator[AgentEvent], events: AsyncIterator[AgentEvent],
*, *,
@@ -115,38 +131,65 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
) -> None: ) -> None:
"""Print agent events to the terminal (text deltas stream as they arrive). """Print agent events to the terminal (text deltas stream as they arrive).
When markdown is enabled and stdout is a TTY, the plain streamed assistant Assistant and reasoning each start on a new line after their label. When the
text is replaced at end-of-turn with a Rich markdown render. content source changes (reasoning ↔ assistant, or tools/errors), the
assistant markdown buffer is flushed so streams do not mix and Rich can
re-render completed assistant segments.
""" """
show_full = get_verbose_tool_results() if verbose is None else verbose show_full = get_verbose_tool_results() if verbose is None else verbose
use_markdown = get_markdown_enabled() if markdown is None else markdown use_markdown = get_markdown_enabled() if markdown is None else markdown
pretty = bool(use_markdown and markdown_render_available()) pretty = bool(use_markdown and markdown_render_available())
printed_reasoning = False source: StreamSource | None = None
printed_text = False
assistant_buf: list[str] = [] assistant_buf: list[str] = []
# Tools/errors after assistant text: skip replace (would erase tool lines). printed_reasoning = False
interrupted_by_other = False printed_assistant = False
def flush_assistant() -> None:
nonlocal source, assistant_buf, printed_assistant
if source != "assistant" and not assistant_buf:
return
body = "".join(assistant_buf)
assistant_buf = []
if printed_assistant:
_flush_assistant_markdown(body, pretty=pretty)
printed_assistant = False
if source == "assistant":
source = None
def begin_reasoning() -> None:
nonlocal source, printed_reasoning
if source == "reasoning":
return
if source == "assistant":
flush_assistant()
click.echo()
click.secho("reasoning:", fg="bright_black")
source = "reasoning"
printed_reasoning = True
def begin_assistant() -> None:
nonlocal source, printed_assistant
if source == "assistant":
return
if source == "reasoning":
click.echo() # end reasoning stream line
source = None
click.echo()
click.secho("assistant:", fg="cyan")
source = "assistant"
printed_assistant = True
async for event in events: async for event in events:
if isinstance(event, ReasoningDeltaEvent): if isinstance(event, ReasoningDeltaEvent):
if not printed_reasoning: begin_reasoning()
click.echo()
click.secho("reasoning: ", fg="bright_black", nl=False)
printed_reasoning = True
_echo_stream(event.content) _echo_stream(event.content)
elif isinstance(event, TextDeltaEvent): elif isinstance(event, TextDeltaEvent):
if printed_reasoning and not printed_text: begin_assistant()
click.echo()
if not printed_text:
click.echo()
click.secho("assistant: ", fg="cyan", nl=False)
printed_text = True
assistant_buf.append(event.content) assistant_buf.append(event.content)
_echo_stream(event.content) _echo_stream(event.content)
elif isinstance(event, ToolCallEvent): elif isinstance(event, ToolCallEvent):
if printed_text: flush_assistant()
interrupted_by_other = True
call = event.tool_call call = event.tool_call
if isinstance(call, AssistantFunctionToolCall): if isinstance(call, AssistantFunctionToolCall):
args = _preview(call.function.arguments, _TOOL_ARGS_PREVIEW) args = _preview(call.function.arguments, _TOOL_ARGS_PREVIEW)
@@ -154,8 +197,7 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
else: else:
click.secho(f"\n[tool] custom id={call.id}", fg="yellow") click.secho(f"\n[tool] custom id={call.id}", fg="yellow")
elif isinstance(event, ToolResultEvent): elif isinstance(event, ToolResultEvent):
if printed_text: flush_assistant()
interrupted_by_other = True
content = event.message.content content = event.message.content
if show_full: if show_full:
click.secho(f"[tool ok]\n{content}", fg="magenta") click.secho(f"[tool ok]\n{content}", fg="magenta")
@@ -164,8 +206,7 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
one_line = preview.replace("\n", " ") one_line = preview.replace("\n", " ")
click.secho(f"[tool ok] {one_line}", fg="magenta") click.secho(f"[tool ok] {one_line}", fg="magenta")
elif isinstance(event, ErrorEvent): elif isinstance(event, ErrorEvent):
if printed_text: flush_assistant()
interrupted_by_other = True
suffix = "" suffix = ""
if event.source: if event.source:
suffix += f" source={event.source}" suffix += f" source={event.source}"
@@ -173,15 +214,13 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
suffix += " (fatal)" suffix += " (fatal)"
click.secho(f"\n[error]{suffix} {event.message}", fg="bright_red") click.secho(f"\n[error]{suffix} {event.message}", fg="bright_red")
elif isinstance(event, CancelledEvent): elif isinstance(event, CancelledEvent):
if printed_text: flush_assistant()
interrupted_by_other = True
if event.reason: if event.reason:
click.secho(f"\n[cancelled] {event.reason}", fg="yellow") click.secho(f"\n[cancelled] {event.reason}", fg="yellow")
else: else:
click.secho("\n[cancelled]", fg="yellow") click.secho("\n[cancelled]", fg="yellow")
elif isinstance(event, MaxRoundsEvent): elif isinstance(event, MaxRoundsEvent):
if printed_text: flush_assistant()
interrupted_by_other = True
if event.continued: if event.continued:
click.secho( click.secho(
f"\n[max rounds {event.rounds} reached — continuing with a higher allowance]", f"\n[max rounds {event.rounds} reached — continuing with a higher allowance]",
@@ -195,21 +234,9 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
# AssistantMessageEvent — text already shown via TextDeltaEvent. # AssistantMessageEvent — text already shown via TextDeltaEvent.
_ = event _ = event
full_assistant = "".join(assistant_buf) # End-of-turn: flush any open assistant segment; close reasoning stream.
if pretty and printed_text and full_assistant.strip() and not interrupted_by_other: if assistant_buf or printed_assistant:
# Replace plain stream with markdown (assistant block only). flush_assistant()
lines = _line_count_for_clear("assistant: ", full_assistant) elif printed_reasoning:
# Also account for blank line before label when reasoning was shown.
if printed_reasoning:
lines += 1
_clear_streamed_lines(lines)
if printed_reasoning:
# Reasoning already printed above; leave it and only re-print assistant.
pass
print_markdown(full_assistant, label="assistant: ")
click.echo()
return
if printed_text or printed_reasoning:
click.echo() click.echo()
click.echo() click.echo()
+29 -8
View File
@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
import inspect import inspect
from typing import TYPE_CHECKING, Protocol, cast, runtime_checkable from typing import TYPE_CHECKING, Literal, Protocol, cast, runtime_checkable
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Iterable, Sequence from collections.abc import Iterable, Sequence
@@ -11,6 +11,8 @@ if TYPE_CHECKING:
# Cache remote catalog this long (seconds) unless /models --refresh. # Cache remote catalog this long (seconds) unless /models --refresh.
DEFAULT_MODELS_CACHE_TTL = 300.0 DEFAULT_MODELS_CACHE_TTL = 300.0
type ModelListPrefer = Literal["remote", "union", "config"]
@runtime_checkable @runtime_checkable
class SupportsModels(Protocol): class SupportsModels(Protocol):
@@ -25,12 +27,30 @@ def config_model_ids(provider: Provider) -> list[str]:
def merge_model_choices( def merge_model_choices(
config_ids: Iterable[str], config_ids: Iterable[str],
remote_ids: Iterable[str] | None = None, remote_ids: Iterable[str] | None = None,
*,
prefer: ModelListPrefer = "remote",
) -> list[str]: ) -> list[str]:
"""Union config and remote ids (sorted, unique).""" """Merge config and remote model ids.
merged: set[str] = {i for i in config_ids if i}
if remote_ids is not None: *prefer*:
merged.update(i for i in remote_ids if i) - ``remote`` (default): remote catalog first (sorted), then config-only ids
return sorted(merged) - ``union``: sorted unique union
- ``config``: config first, then remote-only ids
"""
config_list = [i for i in config_ids if i]
remote_list = [i for i in (remote_ids or ()) if i]
if not remote_list:
return sorted(set(config_list))
if prefer == "union":
return sorted(set(config_list) | set(remote_list))
remote_sorted = sorted(set(remote_list))
config_only = sorted(set(config_list) - set(remote_sorted))
if prefer == "remote":
return [*remote_sorted, *config_only]
# config first
config_sorted = sorted(set(config_list))
remote_only = sorted(set(remote_list) - set(config_sorted))
return [*config_sorted, *remote_only]
def client_supports_models(client: object) -> bool: def client_supports_models(client: object) -> bool:
@@ -57,6 +77,7 @@ def model_choices_for_provider(
provider: Provider, provider: Provider,
*, *,
remote_ids: Sequence[str] | None = None, remote_ids: Sequence[str] | None = None,
prefer: ModelListPrefer = "remote",
) -> list[str]: ) -> list[str]:
"""Config plus remote catalog for selection / Tab complete.""" """Config plus remote catalog for selection / Tab complete (remote-first)."""
return merge_model_choices(config_model_ids(provider), remote_ids) return merge_model_choices(config_model_ids(provider), remote_ids, prefer=prefer)
+22
View File
@@ -77,6 +77,27 @@ def bind_tab_complete(readline_mod: object) -> None:
_ = parse("python:bind ^I rl_complete") _ = parse("python:bind ^I rl_complete")
def bind_utf8_input(readline_mod: object) -> None:
"""Best-effort 8-bit / UTF-8 settings for GNU readline (CJK backspace).
GNU readline can mishandle wide characters when meta conversion is on.
These binds are no-ops or ignored on libedit. Full grapheme editing still
depends on the terminal and readline version (see CPython #142162).
"""
parse = getattr(readline_mod, "parse_and_bind", None)
if not callable(parse):
return
for cmd in (
"set input-meta on",
"set output-meta on",
"set convert-meta off",
"set enable-meta-key on",
"set horizontal-scroll-mode off",
):
with contextlib.suppress(Exception):
_ = parse(cmd)
def setup_readline(state: ReplState) -> None: def setup_readline(state: ReplState) -> None:
"""Configure Tab completion and persistent history when readline is available.""" """Configure Tab completion and persistent history when readline is available."""
try: try:
@@ -85,6 +106,7 @@ def setup_readline(state: ReplState) -> None:
return return
bind_tab_complete(readline) bind_tab_complete(readline)
bind_utf8_input(readline)
# Treat path-like chars as part of a token so /help completes as one word. # Treat path-like chars as part of a token so /help completes as one word.
readline.set_completer_delims(" \t\n") readline.set_completer_delims(" \t\n")
readline.set_completer(build_completer(state)) readline.set_completer(build_completer(state))
+9 -1
View File
@@ -25,12 +25,14 @@ def _echo_user(text: str) -> None:
async def run_repl(state: ReplState) -> None: async def run_repl(state: ReplState) -> None:
"""Interactive chat loop with readline editing, history, and Tab completion.""" """Interactive chat loop with readline editing, history, and Tab completion."""
setup_readline(state) setup_readline(state)
yolo = state.effective_yolo()
yolo_part = f" yolo={yolo}" if yolo != "off" else ""
click.echo( click.echo(
f"plyngent chat provider={state.provider_name} model={state.model} " 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)} " f"rounds={state.max_rounds} messages={len(state.agent.messages)} "
f"stream={'on' if state.agent.stream else 'off'} " f"stream={'on' if state.agent.stream else 'off'} "
f"verbose={'on' if state.verbose else 'off'}" f"verbose={'on' if state.verbose else 'off'}{yolo_part}"
) )
click.echo('Type /help for commands. Multiline: """""". Empty line is ignored.') click.echo('Type /help for commands. Multiline: """""". Empty line is ignored.')
@@ -52,8 +54,14 @@ async def run_repl(state: ReplState) -> None:
text = state.pending_user_text text = state.pending_user_text
state.pending_user_text = None state.pending_user_text = None
_echo_user(text) _echo_user(text)
try:
_ = await run_user_text_with_retries(state.agent, text) _ = await run_user_text_with_retries(state.agent, text)
finally:
state.expire_yolo_once()
continue continue
_echo_user(entry) _echo_user(entry)
try:
_ = await run_user_text_with_retries(state.agent, entry) _ = await run_user_text_with_retries(state.agent, entry)
finally:
state.expire_yolo_once()
+17 -2
View File
@@ -17,8 +17,23 @@ if TYPE_CHECKING:
from plyngent.agent import AgentEvent from plyngent.agent import AgentEvent
from plyngent.agent.chat import ChatAgent from plyngent.agent.chat import ChatAgent
# Wait before retry attempt 1, 2, and 3 (after the first failure). # Auto-retry budget after the first failure (10 attempts by default).
DEFAULT_RETRY_DELAYS_SECONDS: tuple[float, ...] = (10.0, 20.0, 30.0) DEFAULT_MAX_AUTO_RETRIES = 10
# First four waits; each further wait is previous + 10s.
_RETRY_BASE_DELAYS_SECONDS: tuple[float, ...] = (5.0, 10.0, 15.0, 20.0)
def default_retry_delays(max_retries: int = DEFAULT_MAX_AUTO_RETRIES) -> tuple[float, ...]:
"""Build wait times: 5, 10, 15, 20, then +10s each step, length *max_retries*."""
if max_retries <= 0:
return ()
delays: list[float] = list(_RETRY_BASE_DELAYS_SECONDS)
while len(delays) < max_retries:
delays.append(delays[-1] + 10.0)
return tuple(delays[:max_retries])
DEFAULT_RETRY_DELAYS_SECONDS: tuple[float, ...] = default_retry_delays()
_PREVIEW_LEN = 80 _PREVIEW_LEN = 80
+58 -11
View File
@@ -23,13 +23,14 @@ from plyngent.runtime import ProviderNotSupportedError
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Awaitable, Sequence from collections.abc import Awaitable, Sequence
from plyngent.cli.state import ReplState from plyngent.cli.state import ReplState, YoloMode
from plyngent.lmproto.openai_compatible.model import AnyChatMessage from plyngent.lmproto.openai_compatible.model import AnyChatMessage
_DEFAULT_HISTORY_LINES = 20 _DEFAULT_HISTORY_LINES = 20
_CONTENT_PREVIEW = 200 _CONTENT_PREVIEW = 200
_COMPACT_PREVIEW = 400 _COMPACT_PREVIEW = 400
_ON_OFF_CHOICES = ("on", "off") _ON_OFF_CHOICES = ("on", "off")
_YOLO_MODE_CHOICES = ("on", "off", "once")
_EXPORT_FORMAT_CHOICES = ("md", "json") _EXPORT_FORMAT_CHOICES = ("md", "json")
HELP_FOOTER = ( HELP_FOOTER = (
@@ -38,7 +39,7 @@ HELP_FOOTER = (
"works after resume, not only via readline history). Auto-retry: 10s/20s/30s.\n" "works after resume, not only via readline history). Auto-retry: 10s/20s/30s.\n"
"\n" "\n"
"Tab completes slash commands and some arguments (provider, model, tools,\n" "Tab completes slash commands and some arguments (provider, model, tools,\n"
"stream, verbose, export). Use --session ID or /resume to continue a prior\n" "stream, verbose, yolo, export). Use --session ID or /resume to continue a prior\n"
"chat after restart.\n" "chat after restart.\n"
"\n" "\n"
'Multiline: start a message with """ then end a later line with """.\n' 'Multiline: start a message with """ then end a later line with """.\n'
@@ -86,6 +87,30 @@ class OnOffParam(click.ParamType[bool]):
ON_OFF = OnOffParam() ON_OFF = OnOffParam()
class YoloModeParam(click.ParamType[str]):
"""Accept on|off|once for soft destructive-tool confirms."""
name: str = "yolo_mode"
@override
def convert(self, value: Any, param: click.Parameter | None, ctx: click.Context | None) -> str:
if isinstance(value, str) and value in _YOLO_MODE_CHOICES:
return value
token = str(value).strip().lower()
if token in _YOLO_MODE_CHOICES:
return token
msg = "expected on, off, or once"
raise click.BadParameter(msg, ctx=ctx, param=param)
@override
def shell_complete(self, ctx: click.Context, param: click.Parameter, incomplete: str) -> list[CompletionItem]:
del ctx, param
return _filter_choices(incomplete, _YOLO_MODE_CHOICES)
YOLO_MODE = YoloModeParam()
class ExportFormatParam(click.ParamType[str]): class ExportFormatParam(click.ParamType[str]):
"""First token of /export: md|json (or a path if not a format).""" """First token of /export: md|json (or a path if not a format)."""
@@ -355,7 +380,8 @@ def status_cmd(state: ReplState) -> None:
f"tools={'on' if state.tools_enabled else 'off'} " f"tools={'on' if state.tools_enabled else 'off'} "
f"rounds={state.max_rounds} " f"rounds={state.max_rounds} "
f"stream={'on' if state.agent.stream else 'off'} " f"stream={'on' if state.agent.stream else 'off'} "
f"verbose={'on' if state.verbose else 'off'}\n" f"verbose={'on' if state.verbose else 'off'} "
f"yolo={state.effective_yolo()}\n"
f"context_tokens={ctx_tilde}{ctx_tokens}/{ctx_budget} ({ctx_tag}) " f"context_tokens={ctx_tilde}{ctx_tokens}/{ctx_budget} ({ctx_tag}) "
f"context_chars={ctx_chars} " f"context_chars={ctx_chars} "
f"tool_result_max={state.agent.max_tool_result_chars}\n" f"tool_result_max={state.agent.max_tool_result_chars}\n"
@@ -581,7 +607,8 @@ def provider_cmd(state: ReplState, name: str | None) -> None:
state.provider_name = pname state.provider_name = pname
state.provider = provider state.provider = provider
state.rebuild_client() state.rebuild_client()
choices = _await(state.merged_model_choices(refresh=False)) # Always request remote catalog for the new provider (bypass stale cache).
choices = _await(state.merged_model_choices(refresh=True))
if prev_model and (prev_model in choices or prev_model in provider.models): if prev_model and (prev_model in choices or prev_model in provider.models):
state.model = prev_model state.model = prev_model
else: else:
@@ -612,11 +639,12 @@ def provider_cmd(state: ReplState, name: str | None) -> None:
@click.option("--refresh", is_flag=True, help="Bypass cache and re-fetch GET /models.") @click.option("--refresh", is_flag=True, help="Bypass cache and re-fetch GET /models.")
@click.pass_obj @click.pass_obj
def models_cmd(state: ReplState, *, refresh: bool) -> None: def models_cmd(state: ReplState, *, refresh: bool) -> None:
"""List models (config plus remote GET /models).""" """List models (remote-first, plus config-only ids). Always tries GET /models."""
del refresh # always re-fetch; flag kept for CLI compatibility / docs
remote: list[str] | None = None remote: list[str] | None = None
remote_err: str | None = None remote_err: str | None = None
try: try:
remote = _await(state.ensure_remote_models(refresh=refresh)) remote = _await(state.ensure_remote_models(refresh=True))
except (RuntimeError, TypeError, OSError, ValueError) as exc: except (RuntimeError, TypeError, OSError, ValueError) as exc:
remote_err = str(exc) remote_err = str(exc)
remote = state.cached_remote_models() remote = state.cached_remote_models()
@@ -643,10 +671,10 @@ def models_cmd(state: ReplState, *, refresh: bool) -> None:
remote_set = set(remote or ()) remote_set = set(remote or ())
for mid in choices: for mid in choices:
tags: list[str] = [] tags: list[str] = []
if mid in config_ids:
tags.append("config")
if mid in remote_set: if mid in remote_set:
tags.append("remote") tags.append("remote")
if mid in config_ids:
tags.append("config")
suffix = f" ({', '.join(tags)})" if tags else "" suffix = f" ({', '.join(tags)})" if tags else ""
mark = " *" if mid == state.model else "" mark = " *" if mid == state.model else ""
click.echo(f"{mid}{mark}{suffix}") click.echo(f"{mid}{mark}{suffix}")
@@ -655,7 +683,8 @@ def models_cmd(state: ReplState, *, refresh: bool) -> None:
click.secho(f"remote list unavailable: {remote_err}", fg="yellow", err=True) click.secho(f"remote list unavailable: {remote_err}", fg="yellow", err=True)
elif remote is not None: elif remote is not None:
click.echo( click.echo(
f"({len(remote)} remote, {len(config_ids)} config; cache TTL {int(DEFAULT_MODELS_CACHE_TTL)}s)", f"(remote-first: {len(remote)} remote, {len(config_ids)} config; "
f"cache TTL {int(DEFAULT_MODELS_CACHE_TTL)}s)",
err=True, err=True,
) )
@@ -664,12 +693,12 @@ def models_cmd(state: ReplState, *, refresh: bool) -> None:
@click.argument("model_id", required=False, type=MODEL_ID) @click.argument("model_id", required=False, type=MODEL_ID)
@click.pass_obj @click.pass_obj
def model_cmd(state: ReplState, model_id: str | None) -> None: def model_cmd(state: ReplState, model_id: str | None) -> None:
"""Show or switch model (Tab: config plus cached remote).""" """Show or switch model (Tab: remote-first plus config; live fetch on pick)."""
if not model_id: if not model_id:
click.echo(f"model={state.model}") click.echo(f"model={state.model}")
return return
try: try:
choices = _await(state.merged_model_choices(refresh=False)) choices = _await(state.merged_model_choices(refresh=True))
state.model = select_model( state.model = select_model(
state.provider, state.provider,
preferred=model_id.strip(), preferred=model_id.strip(),
@@ -698,6 +727,24 @@ def tools_cmd(state: ReplState, enabled: bool | None) -> None: # noqa: FBT001
click.echo(f"tools={'on' if enabled else 'off'}") click.echo(f"tools={'on' if enabled else 'off'}")
@slash.command("yolo")
@click.argument("mode", required=False, type=YOLO_MODE, metavar="[on|off|once]")
@click.pass_obj
def yolo_cmd(state: ReplState, mode: str | None) -> None:
"""Show or set YOLO mode for soft destructive-tool confirms.
``off`` (default when config ``confirm_destructive`` is true): prompt on
delete/move/overwrite (deny in non-TTY). ``on``: skip confirms for the
process. ``once``: skip for the next user turn only, then return to ``off``.
Path/command denylists still apply. Omit the argument to print the value.
"""
if mode is None:
click.echo(f"yolo={state.effective_yolo()}")
return
state.set_yolo(cast("YoloMode", mode))
click.echo(f"yolo={state.effective_yolo()}")
@slash.command("stream") @slash.command("stream")
@click.argument("enabled", required=False, type=ON_OFF, metavar="[on|off]") @click.argument("enabled", required=False, type=ON_OFF, metavar="[on|off]")
@click.pass_obj @click.pass_obj
+40 -8
View File
@@ -4,7 +4,7 @@ import contextlib
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, cast from typing import TYPE_CHECKING, Literal, cast
from plyngent.agent import ChatAgent, ChatClient, ToolRegistry from plyngent.agent import ChatAgent, ChatClient, ToolRegistry
from plyngent.agent.loop import DEFAULT_MAX_ROUNDS from plyngent.agent.loop import DEFAULT_MAX_ROUNDS
@@ -25,6 +25,8 @@ if TYPE_CHECKING:
from plyngent.memory import MemoryStore from plyngent.memory import MemoryStore
from plyngent.memory.database.schema import Session as SessionRow from plyngent.memory.database.schema import Session as SessionRow
type YoloMode = Literal["off", "on", "once"]
@dataclass @dataclass
class ReplState: class ReplState:
@@ -44,8 +46,9 @@ class ReplState:
markdown_enabled: bool = True markdown_enabled: bool = True
# One-shot / scripts: never prompt to raise tool-loop limits. # One-shot / scripts: never prompt to raise tool-loop limits.
interactive_limits: bool = True interactive_limits: bool = True
# When False, skip destructive-tool confirms (e.g. --yes). # Soft destructive-tool confirms: None → derive from config.confirm_destructive.
confirm_destructive: bool | None = None # off = confirm; on = skip (sticky); once = skip next user turn then off.
yolo: YoloMode | None = None
# Set by /edit; REPL sends as the next user turn then clears. # Set by /edit; REPL sends as the next user turn then clears.
pending_user_text: str | None = None pending_user_text: str | None = None
client: ChatClient = field(init=False) client: ChatClient = field(init=False)
@@ -77,10 +80,32 @@ class ReplState:
raise RuntimeError(msg) raise RuntimeError(msg)
return key return key
def _confirm_destructive(self) -> bool: def effective_yolo(self) -> YoloMode:
if self.confirm_destructive is not None: """Resolved YOLO mode (session override or config default)."""
return self.confirm_destructive if self.yolo is not None:
return self.config.agent_config.confirm_destructive return self.yolo
return "off" if self.config.agent_config.confirm_destructive else "on"
def soft_confirm_enabled(self) -> bool:
"""Whether destructive tools should prompt (or deny non-interactively)."""
return self.effective_yolo() == "off"
def set_yolo(self, mode: YoloMode) -> None:
"""Set YOLO mode; rebuild tool registry when soft-confirm hooks change."""
prev = self.soft_confirm_enabled()
self.yolo = mode
if prev != self.soft_confirm_enabled():
self.rebuild_client()
def expire_yolo_once(self, *, quiet: bool = False) -> None:
"""If mode is ``once``, drop back to ``off`` after a user turn."""
if self.effective_yolo() != "once":
return
self.set_yolo("off")
if not quiet:
import click
click.secho("yolo=off (once expired)", fg="bright_black", err=True)
def _tool_registry(self) -> ToolRegistry | None: def _tool_registry(self) -> ToolRegistry | None:
if not self.tools_enabled: if not self.tools_enabled:
@@ -88,7 +113,7 @@ class ReplState:
from plyngent.cli.limits import prompt_confirm_tool_async from plyngent.cli.limits import prompt_confirm_tool_async
from plyngent.tools.danger import classify_danger from plyngent.tools.danger import classify_danger
if self._confirm_destructive(): if self.soft_confirm_enabled():
return ToolRegistry( return ToolRegistry(
list(DEFAULT_TOOLS), list(DEFAULT_TOOLS),
danger=classify_danger, danger=classify_danger,
@@ -142,6 +167,13 @@ class ReplState:
self._remote_models_key = None self._remote_models_key = None
self._remote_models_error = None self._remote_models_error = None
def seed_remote_models(self, ids: list[str]) -> None:
"""Install a freshly fetched remote catalog into the session cache."""
self._remote_models = list(ids)
self._remote_models_fetched_at = time.monotonic()
self._remote_models_key = self._models_cache_key()
self._remote_models_error = None
def cached_remote_models(self) -> list[str] | None: def cached_remote_models(self) -> list[str] | None:
"""Return cached remote ids if still valid for the current provider.""" """Return cached remote ids if still valid for the current provider."""
if self._remote_models is None or self._remote_models_fetched_at is None: if self._remote_models is None or self._remote_models_fetched_at is None:
+2 -2
View File
@@ -4,9 +4,9 @@ from plyngent.agent import tool
from plyngent.prompting import NonInteractiveError, ask_async from plyngent.prompting import NonInteractiveError, ask_async
@tool @tool(name="ask_user_line")
async def ask_user(question: str, default: str = "") -> str: async def ask_user(question: str, default: str = "") -> str:
"""Ask the human a free-form question and return their answer. """Ask the human a free-form one-line question and return their answer.
Always allows arbitrary text. Use for clarifying requirements, preferences, Always allows arbitrary text. Use for clarifying requirements, preferences,
or any input that is not a fixed menu. Optional ``default`` is used if the or any input that is not a fixed menu. Optional ``default`` is used if the
+2 -2
View File
@@ -46,7 +46,7 @@ def parse_options(raw: str) -> list[ChoiceOption]:
return out return out
@tool @tool(name="ask_user_choice")
async def choose_user( async def choose_user(
question: str, question: str,
options: str, options: str,
@@ -54,7 +54,7 @@ async def choose_user(
*, *,
allow_custom: bool = True, allow_custom: bool = True,
) -> str: ) -> str:
"""Ask the human to pick from options (or type a custom answer). """Ask the human to pick from a list of options (or type a custom answer).
``options`` is a JSON array of strings, or objects with ``options`` is a JSON array of strings, or objects with
``label``, optional ``description``, optional ``value``. ``label``, optional ``description``, optional ``value``.
+2 -2
View File
@@ -54,13 +54,13 @@ def parse_fields(raw: str) -> list[FormField]:
return out return out
@tool @tool(name="ask_user_form")
async def form_user(title: str, fields: str, *, confirm_submit: bool = True) -> str: async def form_user(title: str, fields: str, *, confirm_submit: bool = True) -> str:
"""Run a multi-step form with the human; returns JSON object of answers. """Run a multi-step form with the human; returns JSON object of answers.
``fields`` is a JSON array of objects: ``fields`` is a JSON array of objects:
``name``, ``prompt``, optional ``default``, optional ``options`` (same shape ``name``, ``prompt``, optional ``default``, optional ``options`` (same shape
as choose_user), optional ``allow_custom`` (default true). as ask_user_choice), optional ``allow_custom`` (default true).
When ``confirm_submit`` is true, the human reviews a summary before submit. When ``confirm_submit`` is true, the human reviews a summary before submit.
""" """
try: try:
+10
View File
@@ -56,4 +56,14 @@ async def test_confirm_deny() -> None:
async def test_no_hooks_skips_confirm() -> None: async def test_no_hooks_skips_confirm() -> None:
registry = ToolRegistry([delete_path]) registry = ToolRegistry([delete_path])
assert registry.soft_confirm is False
assert await registry.execute("delete_path", '{"path": "a.txt"}') == "deleted a.txt" assert await registry.execute("delete_path", '{"path": "a.txt"}') == "deleted a.txt"
async def test_soft_confirm_property() -> None:
def on_confirm(name: str, args: Mapping[str, object], reason: str) -> bool:
del name, args, reason
return True
gated = ToolRegistry([delete_path], danger=classify_danger, on_confirm=on_confirm)
assert gated.soft_confirm is True
+34
View File
@@ -41,9 +41,43 @@ async def test_render_reasoning_and_text(capsys: pytest.CaptureFixture[str]) ->
assert "think" in out assert "think" in out
assert "assistant:" in out assert "assistant:" in out
assert "hello" in out assert "hello" in out
# Labels on their own lines (content begins after newline).
assert "assistant:\nhello" in out or "assistant:\r\nhello" in out
set_markdown_enabled(True) set_markdown_enabled(True)
async def test_flush_markdown_on_source_change(
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Assistant segment before tools is flushed so later text is a new segment."""
monkeypatch.setattr("plyngent.cli.display.markdown_render_available", lambda: True)
set_markdown_enabled(True)
from plyngent.agent import ToolCallEvent
from plyngent.lmproto.openai_compatible.model import (
AssistantFunctionTool,
AssistantFunctionToolCall,
)
call = AssistantFunctionToolCall(
id="1",
function=AssistantFunctionTool(name="read_file", arguments="{}"),
)
await render_events(
_aiter(
[
TextDeltaEvent(content="before **tool**"),
ToolCallEvent(tool_call=call),
TextDeltaEvent(content="after"),
]
),
markdown=True,
)
out = capsys.readouterr().out
assert "[tool]" in out
assert "after" in out
async def test_tool_result_preview_vs_verbose(capsys: pytest.CaptureFixture[str]) -> None: async def test_tool_result_preview_vs_verbose(capsys: pytest.CaptureFixture[str]) -> None:
long = "x" * 200 long = "x" * 200
msg = ToolChatMessage(content=long, tool_call_id="1") msg = ToolChatMessage(content=long, tool_call_id="1")
+7 -2
View File
@@ -12,10 +12,14 @@ from plyngent.cli.models_source import (
from plyngent.config.models import ModelConfig, OpenAICompatibleProvider from plyngent.config.models import ModelConfig, OpenAICompatibleProvider
def test_merge_model_choices_union() -> None: def test_merge_model_choices_remote_first() -> None:
assert merge_model_choices(["b", "a"], ["a", "c"]) == ["a", "b", "c"] # remote-first: remote sorted, then config-only
assert merge_model_choices(["b", "a"], ["a", "c"]) == ["a", "c", "b"]
assert merge_model_choices(["a"], None) == ["a"] assert merge_model_choices(["a"], None) == ["a"]
assert merge_model_choices([], ["z"]) == ["z"] assert merge_model_choices([], ["z"]) == ["z"]
assert merge_model_choices(["cfg"], ["remote", "cfg"], prefer="remote") == ["cfg", "remote"]
assert merge_model_choices(["b", "a"], ["a", "c"], prefer="union") == ["a", "b", "c"]
assert merge_model_choices(["b", "a"], ["a", "c"], prefer="config") == ["a", "b", "c"]
def test_model_choices_for_provider() -> None: def test_model_choices_for_provider() -> None:
@@ -26,6 +30,7 @@ def test_model_choices_for_provider() -> None:
) )
assert config_model_ids(provider) == ["cfg"] assert config_model_ids(provider) == ["cfg"]
assert model_choices_for_provider(provider, remote_ids=["remote", "cfg"]) == ["cfg", "remote"] assert model_choices_for_provider(provider, remote_ids=["remote", "cfg"]) == ["cfg", "remote"]
assert model_choices_for_provider(provider, remote_ids=["remote"]) == ["remote", "cfg"]
def test_client_supports_models() -> None: def test_client_supports_models() -> None:
+2
View File
@@ -181,3 +181,5 @@ def test_complete_slash_args_from_registry(tmp_path: object) -> None:
assert complete_slash_args(state, "/model", "a") == ["alpha"] assert complete_slash_args(state, "/model", "a") == ["alpha"]
assert complete_slash_args(state, "/export", "j") == ["json"] assert complete_slash_args(state, "/export", "j") == ["json"]
assert complete_slash_args(state, "/help", "st") == ["status", "stream"] assert complete_slash_args(state, "/help", "st") == ["status", "stream"]
assert complete_slash_args(state, "/yolo", "") == ["on", "off", "once"]
assert complete_slash_args(state, "/yolo", "o") == ["on", "off", "once"]
+58
View File
@@ -341,6 +341,63 @@ async def test_verbose_toggle(state: ReplState) -> None:
assert get_verbose_tool_results() is False assert get_verbose_tool_results() is False
async def test_yolo_toggle(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
assert state.effective_yolo() == "off"
assert state.soft_confirm_enabled() is True
assert await handle_slash(state, "/yolo") is True
assert "yolo=off" in capsys.readouterr().out
assert await handle_slash(state, "/yolo on") is True
assert state.effective_yolo() == "on"
assert state.soft_confirm_enabled() is False
assert "yolo=on" in capsys.readouterr().out
assert await handle_slash(state, "/yolo once") is True
assert state.effective_yolo() == "once"
assert state.soft_confirm_enabled() is False
state.expire_yolo_once(quiet=True)
assert state.effective_yolo() == "off"
assert state.soft_confirm_enabled() is True
assert await handle_slash(state, "/yolo once") is True
state.expire_yolo_once()
err = capsys.readouterr().err
assert "yolo=off (once expired)" in err
assert state.effective_yolo() == "off"
async def test_yolo_rebuilds_tool_registry(tmp_path: Path) -> None:
_ = set_workspace_root(tmp_path)
memory = await MemoryStore.open(DatabaseConfig())
provider = OpenAIProvider(access_key_or_token="sk-test")
config = ConfigStore(path=tmp_path / "plyngent.toml", document=tomlkit.document())
config.providers = {"local": provider}
st = ReplState(
config=config,
memory=memory,
workspace=tmp_path,
provider_name="local",
provider=provider,
model="gpt-test",
tools_enabled=True,
)
try:
assert st.agent.tools is not None
assert st.agent.tools.soft_confirm is True
st.set_yolo("on")
assert st.agent.tools is not None
assert st.agent.tools.soft_confirm is False
st.set_yolo("once")
assert st.agent.tools is not None
assert st.agent.tools.soft_confirm is False
st.set_yolo("off")
assert st.agent.tools is not None
assert st.agent.tools.soft_confirm is True
finally:
await memory.close()
async def test_resume(state: ReplState) -> None: async def test_resume(state: ReplState) -> None:
sid = state.session_id sid = state.session_id
assert sid is not None assert sid is not None
@@ -378,6 +435,7 @@ async def test_status_shows_context_tokens(state: ReplState, capsys: pytest.Capt
assert "(est)" in out # no API usage yet assert "(est)" in out # no API usage yet
assert "context_chars=" in out assert "context_chars=" in out
assert "tool_result_max=" in out assert "tool_result_max=" in out
assert "yolo=off" in out
assert str(state.workspace) in out assert str(state.workspace) in out
state.agent.last_request_usage = TokenUsage( state.agent.last_request_usage = TokenUsage(
+19 -1
View File
@@ -5,7 +5,14 @@ from typing import TYPE_CHECKING, Literal, overload
import pytest import pytest
from plyngent.agent import ChatAgent from plyngent.agent import ChatAgent
from plyngent.cli.retry import retry_pending_with_retries, run_turn_with_retries, sleep_cancellable from plyngent.cli.retry import (
DEFAULT_MAX_AUTO_RETRIES,
DEFAULT_RETRY_DELAYS_SECONDS,
default_retry_delays,
retry_pending_with_retries,
run_turn_with_retries,
sleep_cancellable,
)
from plyngent.config.models import DatabaseConfig from plyngent.config.models import DatabaseConfig
from plyngent.lmproto.openai_compatible.model import ( from plyngent.lmproto.openai_compatible.model import (
AssistantChatMessage, AssistantChatMessage,
@@ -23,6 +30,17 @@ if TYPE_CHECKING:
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
def test_default_retry_delays_schedule() -> None:
assert DEFAULT_MAX_AUTO_RETRIES == 10
assert default_retry_delays(0) == ()
assert default_retry_delays(4) == (5.0, 10.0, 15.0, 20.0)
delays = default_retry_delays(10)
assert delays[:4] == (5.0, 10.0, 15.0, 20.0)
assert delays[4:] == (30.0, 40.0, 50.0, 60.0, 70.0, 80.0)
assert delays == DEFAULT_RETRY_DELAYS_SECONDS
assert len(DEFAULT_RETRY_DELAYS_SECONDS) == 10
def _response(message: AssistantChatMessage) -> ChatCompletionResponse: def _response(message: AssistantChatMessage) -> ChatCompletionResponse:
return ChatCompletionResponse( return ChatCompletionResponse(
id="1", id="1",
+6 -6
View File
@@ -12,7 +12,7 @@ async def test_ask_user_tool() -> None:
backend = ScriptedBackend(["42"]) backend = ScriptedBackend(["42"])
with temporary_backend(backend): with temporary_backend(backend):
registry = ToolRegistry([ask_user]) registry = ToolRegistry([ask_user])
out = await registry.execute("ask_user", '{"question": "Answer?"}') out = await registry.execute("ask_user_line", '{"question": "Answer?"}')
assert out == "42" assert out == "42"
@@ -21,7 +21,7 @@ async def test_choose_user_tool_index() -> None:
with temporary_backend(backend): with temporary_backend(backend):
registry = ToolRegistry([choose_user]) registry = ToolRegistry([choose_user])
out = await registry.execute( out = await registry.execute(
"choose_user", "ask_user_choice",
json.dumps( json.dumps(
{ {
"question": "Pick", "question": "Pick",
@@ -36,7 +36,7 @@ async def test_choose_user_tool_index() -> None:
async def test_choose_user_bad_options() -> None: async def test_choose_user_bad_options() -> None:
registry = ToolRegistry([choose_user]) registry = ToolRegistry([choose_user])
out = await registry.execute( out = await registry.execute(
"choose_user", "ask_user_choice",
json.dumps({"question": "Pick", "options": "not-json"}), json.dumps({"question": "Pick", "options": "not-json"}),
) )
assert out.startswith("error:") assert out.startswith("error:")
@@ -48,7 +48,7 @@ async def test_form_user_tool() -> None:
with temporary_backend(backend): with temporary_backend(backend):
registry = ToolRegistry([form_user]) registry = ToolRegistry([form_user])
out = await registry.execute( out = await registry.execute(
"form_user", "ask_user_form",
json.dumps({"title": "Setup", "fields": fields, "confirm_submit": True}), json.dumps({"title": "Setup", "fields": fields, "confirm_submit": True}),
) )
assert json.loads(out) == {"user": "ncbm"} assert json.loads(out) == {"user": "ncbm"}
@@ -56,11 +56,11 @@ async def test_form_user_tool() -> None:
async def test_chat_tools_in_default_list() -> None: async def test_chat_tools_in_default_list() -> None:
names = {t.name for t in CHAT_TOOLS} names = {t.name for t in CHAT_TOOLS}
assert names == {"ask_user", "choose_user", "form_user"} assert names == {"ask_user_line", "ask_user_choice", "ask_user_form"}
async def test_ask_user_non_interactive_error() -> None: async def test_ask_user_non_interactive_error() -> None:
with temporary_backend(NonInteractiveBackend()): with temporary_backend(NonInteractiveBackend()):
registry = ToolRegistry([ask_user]) registry = ToolRegistry([ask_user])
out = await registry.execute("ask_user", '{"question": "hi"}') out = await registry.execute("ask_user_line", '{"question": "hi"}')
assert out.startswith("error:") assert out.startswith("error:")