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