8 Commits

Author SHA1 Message Date
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
22 changed files with 446 additions and 113 deletions
+5 -5
View File
@@ -75,9 +75,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).
- 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.
- 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.
- **`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`.
### Prompting (`prompting.py`)
@@ -88,8 +88,8 @@ Shared interactive I/O: `ask` / `choose` / `form` / `confirm` with pluggable bac
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`.
- Slash: Click group in `cli/slash.py` + `awaitlet` for async work; Tab completer from registry + ParamType `shell_complete`. Multiline `"""``"""`; `/edit` via `$EDITOR`.
- **`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`. `/yolo on|off|once` for soft destructive confirms.
- 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`.
- **`plyngent providers`**, **`config path|edit`**. No providers + `$EDITOR` → optional edit then reload.
@@ -112,7 +112,7 @@ Basedpyright `recommended`. Ruff includes `ANN` (private return types `ANN202` i
- **Phase G (CLI polish + hardening)** — single-user only; multi-tenant stays Phase H.
**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`
- 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`
+4 -3
View File
@@ -152,7 +152,7 @@ plyngent chat --session 3
| `--max-rounds` | Tool-loop rounds per turn (default 32) |
| `--stream` / `--no-stream` | Streaming deltas (default on) |
| `--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`, … |
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 +192,7 @@ Type `/help` in the REPL for the live list. Common ones:
| `/export [md\|json] [path]` | Transcript from DB (no secrets) |
| `/compact` | Soft-compact + model summary into a **new** session |
| `/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) |
| `/provider` `/model` | Switch without restarting |
@@ -209,13 +210,13 @@ User messages are saved immediately. On API error or Ctrl+C, partial assistant/t
## 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:
- Paths stay under the workspace; optional `path_denylist` substrings.
- 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.
## Usage / context (CLI)
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "plyngent"
version = "0.1.0"
version = "0.1.1"
description = "LLM chat and agent toolkit"
authors = [
{ name = "HivertMoZara", email = "worldmozara@163.com" },
+5
View File
@@ -191,6 +191,11 @@ class ToolRegistry:
def __len__(self) -> int:
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:
try:
result = definition.handler(**args)
+34 -6
View File
@@ -171,13 +171,16 @@ async def _run_oneshot(state: ReplState, prompt_text: str) -> int:
try:
ok = await run_user_text_with_retries(state.agent, prompt_text, delays=())
except asyncio.CancelledError:
state.expire_yolo_once(quiet=True)
return EXIT_CANCELLED
except KeyboardInterrupt:
state.expire_yolo_once(quiet=True)
return EXIT_CANCELLED
state.expire_yolo_once(quiet=True)
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,
provider_name: str | None,
@@ -206,7 +209,10 @@ async def _run_chat( # noqa: C901, PLR0912 — chat orchestration
_warn_recoverable_providers(store.recoverable_providers)
_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))
try:
@@ -239,8 +245,27 @@ async def _run_chat( # noqa: C901, PLR0912 — chat orchestration
preferred_model=preferred_model,
interactive=interactive,
)
model_id = select_model(provider, preferred=preferred_model, interactive=interactive)
_ = create_client(provider)
# Build client early so we can always try GET /models for remote-first lists.
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:
raise click.ClickException(str(exc)) from exc
@@ -255,8 +280,11 @@ async def _run_chat( # noqa: C901, PLR0912 — chat orchestration
max_rounds=max_rounds,
stream_enabled=stream,
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:
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",
is_flag=True,
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(
"--quiet",
+76 -49
View File
@@ -4,7 +4,7 @@ import contextlib
import os
import sys
from contextvars import ContextVar
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Literal
import click
@@ -32,6 +32,8 @@ _TOOL_ARGS_PREVIEW = 80
_verbose_tool_results: ContextVar[bool] = ContextVar("verbose_tool_results", default=False)
_markdown_enabled: ContextVar[bool] = ContextVar("markdown_enabled", default=True)
type StreamSource = Literal["reasoning", "assistant"]
def set_verbose_tool_results(enabled: bool) -> None: # noqa: FBT001
"""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:
"""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:
return 0
text = label + body
# +1 for trailing newline after the stream block in render_events.
# Label is on its own line; body may contain newlines.
text = f"{label}\n{body}" if label else body
return text.count("\n") + 1
def print_markdown(text: str, *, label: str = "assistant: ") -> None:
"""Render *text* as markdown via Rich, with a cyan label."""
def print_markdown(text: str, *, label: str = "assistant:") -> None:
"""Render *text* as markdown via Rich; *label* on its own line when set."""
from rich.console import Console
from rich.markdown import Markdown
from rich.text import Text
console = Console(file=sys.stdout, highlight=False)
if label:
console.print(Text(label, style="cyan"), end="")
console.print(Text(label, style="cyan"))
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
events: AsyncIterator[AgentEvent],
*,
@@ -115,38 +131,65 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
) -> None:
"""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
text is replaced at end-of-turn with a Rich markdown render.
Assistant and reasoning each start on a new line after their label. When the
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
use_markdown = get_markdown_enabled() if markdown is None else markdown
pretty = bool(use_markdown and markdown_render_available())
printed_reasoning = False
printed_text = False
source: StreamSource | None = None
assistant_buf: list[str] = []
# Tools/errors after assistant text: skip replace (would erase tool lines).
interrupted_by_other = False
printed_reasoning = 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:
if isinstance(event, ReasoningDeltaEvent):
if not printed_reasoning:
click.echo()
click.secho("reasoning: ", fg="bright_black", nl=False)
printed_reasoning = True
begin_reasoning()
_echo_stream(event.content)
elif isinstance(event, TextDeltaEvent):
if printed_reasoning and not printed_text:
click.echo()
if not printed_text:
click.echo()
click.secho("assistant: ", fg="cyan", nl=False)
printed_text = True
begin_assistant()
assistant_buf.append(event.content)
_echo_stream(event.content)
elif isinstance(event, ToolCallEvent):
if printed_text:
interrupted_by_other = True
flush_assistant()
call = event.tool_call
if isinstance(call, AssistantFunctionToolCall):
args = _preview(call.function.arguments, _TOOL_ARGS_PREVIEW)
@@ -154,8 +197,7 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
else:
click.secho(f"\n[tool] custom id={call.id}", fg="yellow")
elif isinstance(event, ToolResultEvent):
if printed_text:
interrupted_by_other = True
flush_assistant()
content = event.message.content
if show_full:
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", " ")
click.secho(f"[tool ok] {one_line}", fg="magenta")
elif isinstance(event, ErrorEvent):
if printed_text:
interrupted_by_other = True
flush_assistant()
suffix = ""
if event.source:
suffix += f" source={event.source}"
@@ -173,15 +214,13 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
suffix += " (fatal)"
click.secho(f"\n[error]{suffix} {event.message}", fg="bright_red")
elif isinstance(event, CancelledEvent):
if printed_text:
interrupted_by_other = True
flush_assistant()
if event.reason:
click.secho(f"\n[cancelled] {event.reason}", fg="yellow")
else:
click.secho("\n[cancelled]", fg="yellow")
elif isinstance(event, MaxRoundsEvent):
if printed_text:
interrupted_by_other = True
flush_assistant()
if event.continued:
click.secho(
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.
_ = event
full_assistant = "".join(assistant_buf)
if pretty and printed_text and full_assistant.strip() and not interrupted_by_other:
# Replace plain stream with markdown (assistant block only).
lines = _line_count_for_clear("assistant: ", full_assistant)
# 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:
# End-of-turn: flush any open assistant segment; close reasoning stream.
if assistant_buf or printed_assistant:
flush_assistant()
elif printed_reasoning:
click.echo()
click.echo()
+29 -8
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
import inspect
from typing import TYPE_CHECKING, Protocol, cast, runtime_checkable
from typing import TYPE_CHECKING, Literal, Protocol, cast, runtime_checkable
if TYPE_CHECKING:
from collections.abc import Iterable, Sequence
@@ -11,6 +11,8 @@ if TYPE_CHECKING:
# Cache remote catalog this long (seconds) unless /models --refresh.
DEFAULT_MODELS_CACHE_TTL = 300.0
type ModelListPrefer = Literal["remote", "union", "config"]
@runtime_checkable
class SupportsModels(Protocol):
@@ -25,12 +27,30 @@ def config_model_ids(provider: Provider) -> list[str]:
def merge_model_choices(
config_ids: Iterable[str],
remote_ids: Iterable[str] | None = None,
*,
prefer: ModelListPrefer = "remote",
) -> list[str]:
"""Union config and remote ids (sorted, unique)."""
merged: set[str] = {i for i in config_ids if i}
if remote_ids is not None:
merged.update(i for i in remote_ids if i)
return sorted(merged)
"""Merge config and remote model ids.
*prefer*:
- ``remote`` (default): remote catalog first (sorted), then config-only ids
- ``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:
@@ -57,6 +77,7 @@ def model_choices_for_provider(
provider: Provider,
*,
remote_ids: Sequence[str] | None = None,
prefer: ModelListPrefer = "remote",
) -> list[str]:
"""Config plus remote catalog for selection / Tab complete."""
return merge_model_choices(config_model_ids(provider), remote_ids)
"""Config plus remote catalog for selection / Tab complete (remote-first)."""
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")
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:
"""Configure Tab completion and persistent history when readline is available."""
try:
@@ -85,6 +106,7 @@ def setup_readline(state: ReplState) -> None:
return
bind_tab_complete(readline)
bind_utf8_input(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))
+9 -1
View File
@@ -25,12 +25,14 @@ def _echo_user(text: str) -> None:
async def run_repl(state: ReplState) -> None:
"""Interactive chat loop with readline editing, history, and Tab completion."""
setup_readline(state)
yolo = state.effective_yolo()
yolo_part = f" yolo={yolo}" if yolo != "off" else ""
click.echo(
f"plyngent chat provider={state.provider_name} model={state.model} "
f"session={state.session_id} tools={'on' if state.tools_enabled else 'off'} "
f"rounds={state.max_rounds} messages={len(state.agent.messages)} "
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.')
@@ -52,8 +54,14 @@ async def run_repl(state: ReplState) -> None:
text = state.pending_user_text
state.pending_user_text = None
_echo_user(text)
try:
_ = await run_user_text_with_retries(state.agent, text)
finally:
state.expire_yolo_once()
continue
_echo_user(entry)
try:
_ = 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.chat import ChatAgent
# Wait before retry attempt 1, 2, and 3 (after the first failure).
DEFAULT_RETRY_DELAYS_SECONDS: tuple[float, ...] = (10.0, 20.0, 30.0)
# Auto-retry budget after the first failure (10 attempts by default).
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
+58 -11
View File
@@ -23,13 +23,14 @@ from plyngent.runtime import ProviderNotSupportedError
if TYPE_CHECKING:
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
_DEFAULT_HISTORY_LINES = 20
_CONTENT_PREVIEW = 200
_COMPACT_PREVIEW = 400
_ON_OFF_CHOICES = ("on", "off")
_YOLO_MODE_CHOICES = ("on", "off", "once")
_EXPORT_FORMAT_CHOICES = ("md", "json")
HELP_FOOTER = (
@@ -38,7 +39,7 @@ HELP_FOOTER = (
"works after resume, not only via readline history). Auto-retry: 10s/20s/30s.\n"
"\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"
"\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()
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]):
"""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"rounds={state.max_rounds} "
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_chars={ctx_chars} "
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 = provider
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):
state.model = prev_model
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.pass_obj
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_err: str | None = None
try:
remote = _await(state.ensure_remote_models(refresh=refresh))
remote = _await(state.ensure_remote_models(refresh=True))
except (RuntimeError, TypeError, OSError, ValueError) as exc:
remote_err = str(exc)
remote = state.cached_remote_models()
@@ -643,10 +671,10 @@ def models_cmd(state: ReplState, *, refresh: bool) -> None:
remote_set = set(remote or ())
for mid in choices:
tags: list[str] = []
if mid in config_ids:
tags.append("config")
if mid in remote_set:
tags.append("remote")
if mid in config_ids:
tags.append("config")
suffix = f" ({', '.join(tags)})" if tags else ""
mark = " *" if mid == state.model else ""
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)
elif remote is not None:
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,
)
@@ -664,12 +693,12 @@ def models_cmd(state: ReplState, *, refresh: bool) -> None:
@click.argument("model_id", required=False, type=MODEL_ID)
@click.pass_obj
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:
click.echo(f"model={state.model}")
return
try:
choices = _await(state.merged_model_choices(refresh=False))
choices = _await(state.merged_model_choices(refresh=True))
state.model = select_model(
state.provider,
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'}")
@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")
@click.argument("enabled", required=False, type=ON_OFF, metavar="[on|off]")
@click.pass_obj
+40 -8
View File
@@ -4,7 +4,7 @@ import contextlib
import time
from dataclasses import dataclass, field
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.loop import DEFAULT_MAX_ROUNDS
@@ -25,6 +25,8 @@ if TYPE_CHECKING:
from plyngent.memory import MemoryStore
from plyngent.memory.database.schema import Session as SessionRow
type YoloMode = Literal["off", "on", "once"]
@dataclass
class ReplState:
@@ -44,8 +46,9 @@ class ReplState:
markdown_enabled: bool = True
# One-shot / scripts: never prompt to raise tool-loop limits.
interactive_limits: bool = True
# When False, skip destructive-tool confirms (e.g. --yes).
confirm_destructive: bool | None = None
# Soft destructive-tool confirms: None → derive from config.confirm_destructive.
# 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.
pending_user_text: str | None = None
client: ChatClient = field(init=False)
@@ -77,10 +80,32 @@ class ReplState:
raise RuntimeError(msg)
return key
def _confirm_destructive(self) -> bool:
if self.confirm_destructive is not None:
return self.confirm_destructive
return self.config.agent_config.confirm_destructive
def effective_yolo(self) -> YoloMode:
"""Resolved YOLO mode (session override or config default)."""
if self.yolo is not None:
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:
if not self.tools_enabled:
@@ -88,7 +113,7 @@ class ReplState:
from plyngent.cli.limits import prompt_confirm_tool_async
from plyngent.tools.danger import classify_danger
if self._confirm_destructive():
if self.soft_confirm_enabled():
return ToolRegistry(
list(DEFAULT_TOOLS),
danger=classify_danger,
@@ -142,6 +167,13 @@ class ReplState:
self._remote_models_key = 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:
"""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:
+2 -2
View File
@@ -4,9 +4,9 @@ from plyngent.agent import tool
from plyngent.prompting import NonInteractiveError, ask_async
@tool
@tool(name="ask_user_line")
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,
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
@tool
@tool(name="ask_user_choice")
async def choose_user(
question: str,
options: str,
@@ -54,7 +54,7 @@ async def choose_user(
*,
allow_custom: bool = True,
) -> 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
``label``, optional ``description``, optional ``value``.
+2 -2
View File
@@ -54,13 +54,13 @@ def parse_fields(raw: str) -> list[FormField]:
return out
@tool
@tool(name="ask_user_form")
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.
``fields`` is a JSON array of objects:
``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.
"""
try:
+10
View File
@@ -56,4 +56,14 @@ async def test_confirm_deny() -> None:
async def test_no_hooks_skips_confirm() -> None:
registry = ToolRegistry([delete_path])
assert registry.soft_confirm is False
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
+36 -2
View File
@@ -37,13 +37,47 @@ async def test_render_reasoning_and_text(capsys: pytest.CaptureFixture[str]) ->
)
)
out = capsys.readouterr().out
assert "reasoning: " in out
assert "reasoning:" in out
assert "think" in out
assert "assistant: " in out
assert "assistant:" 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)
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:
long = "x" * 200
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
def test_merge_model_choices_union() -> None:
assert merge_model_choices(["b", "a"], ["a", "c"]) == ["a", "b", "c"]
def test_merge_model_choices_remote_first() -> None:
# 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([], ["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:
@@ -26,6 +30,7 @@ def test_model_choices_for_provider() -> None:
)
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"]) == ["remote", "cfg"]
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, "/export", "j") == ["json"]
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
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:
sid = state.session_id
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 "context_chars=" in out
assert "tool_result_max=" in out
assert "yolo=off" in out
assert str(state.workspace) in out
state.agent.last_request_usage = TokenUsage(
+19 -1
View File
@@ -5,7 +5,14 @@ from typing import TYPE_CHECKING, Literal, overload
import pytest
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.lmproto.openai_compatible.model import (
AssistantChatMessage,
@@ -23,6 +30,17 @@ if TYPE_CHECKING:
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:
return ChatCompletionResponse(
id="1",
+6 -6
View File
@@ -12,7 +12,7 @@ async def test_ask_user_tool() -> None:
backend = ScriptedBackend(["42"])
with temporary_backend(backend):
registry = ToolRegistry([ask_user])
out = await registry.execute("ask_user", '{"question": "Answer?"}')
out = await registry.execute("ask_user_line", '{"question": "Answer?"}')
assert out == "42"
@@ -21,7 +21,7 @@ async def test_choose_user_tool_index() -> None:
with temporary_backend(backend):
registry = ToolRegistry([choose_user])
out = await registry.execute(
"choose_user",
"ask_user_choice",
json.dumps(
{
"question": "Pick",
@@ -36,7 +36,7 @@ async def test_choose_user_tool_index() -> None:
async def test_choose_user_bad_options() -> None:
registry = ToolRegistry([choose_user])
out = await registry.execute(
"choose_user",
"ask_user_choice",
json.dumps({"question": "Pick", "options": "not-json"}),
)
assert out.startswith("error:")
@@ -48,7 +48,7 @@ async def test_form_user_tool() -> None:
with temporary_backend(backend):
registry = ToolRegistry([form_user])
out = await registry.execute(
"form_user",
"ask_user_form",
json.dumps({"title": "Setup", "fields": fields, "confirm_submit": True}),
)
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:
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:
with temporary_backend(NonInteractiveBackend()):
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:")