mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-23 14:14:57 +08:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 844220e013 | |||
| 61c1cb2d70 | |||
| f7a562740e | |||
| 39acd5967e | |||
| b8ed9e0ee9 | |||
| 8ebe68b3af | |||
| 18008de7f1 | |||
| 2761877460 |
@@ -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`
|
||||
|
||||
@@ -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
@@ -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" },
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
_ = await run_user_text_with_retries(state.agent, text)
|
||||
try:
|
||||
_ = await run_user_text_with_retries(state.agent, text)
|
||||
finally:
|
||||
state.expire_yolo_once()
|
||||
continue
|
||||
|
||||
_echo_user(entry)
|
||||
_ = await run_user_text_with_retries(state.agent, entry)
|
||||
try:
|
||||
_ = await run_user_text_with_retries(state.agent, entry)
|
||||
finally:
|
||||
state.expire_yolo_once()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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``.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:")
|
||||
|
||||
Reference in New Issue
Block a user