mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-23 14:14:57 +08:00
Compare commits
35 Commits
v0.1.0
..
d473cd2508
| Author | SHA1 | Date | |
|---|---|---|---|
| d473cd2508 | |||
| badcb3c9f3 | |||
| fc74b271bd | |||
| 1012894f0d | |||
| 020e9215bf | |||
| de79dec96f | |||
| b1a8c23582 | |||
| 9a9c808ea0 | |||
| edd54a427c | |||
| 896dc98d20 | |||
| c8a0aeda8d | |||
| 29230af911 | |||
| 4ff03d8135 | |||
| a7166d863b | |||
| 9b38a3b333 | |||
| 39f9a7f636 | |||
| 7f49c47105 | |||
| c95d1df028 | |||
| fb5b9f187b | |||
| abcb345c0d | |||
| 596af1ea1b | |||
| 57236937eb | |||
| 96cdd98e62 | |||
| 2368e46efd | |||
| 65d248064e | |||
| e04fda4bc6 | |||
| a282231082 | |||
| 844220e013 | |||
| 61c1cb2d70 | |||
| f7a562740e | |||
| 39acd5967e | |||
| b8ed9e0ee9 | |||
| 8ebe68b3af | |||
| 18008de7f1 | |||
| 2761877460 |
@@ -12,12 +12,20 @@ Plyngent is an LLM chat and agent toolkit (Python 3.14+, PDM-managed). Single-us
|
|||||||
pdm install # first-time dependency setup
|
pdm install # first-time dependency setup
|
||||||
pdm sync # sync after pulling changes
|
pdm sync # sync after pulling changes
|
||||||
|
|
||||||
pdm run basedpyright . # type checking (basedpyright, "recommended" strictness)
|
|
||||||
pdm run ruff check . # linting
|
pdm run ruff check . # linting
|
||||||
pdm run ruff format . # formatting
|
pdm run ruff format . # apply formatting
|
||||||
|
pdm run ruff format --check . # CI: fail if unformatted (do not skip)
|
||||||
|
pdm run basedpyright . # type checking (basedpyright, "recommended" strictness)
|
||||||
pdm run pytest # tests (pytest-asyncio auto mode)
|
pdm run pytest # tests (pytest-asyncio auto mode)
|
||||||
|
|
||||||
|
# Commit gateway (prek — https://prek.j178.dev/): ruff check/format + basedpyright
|
||||||
|
uv tool install prek # once
|
||||||
|
prek install # wire git pre-commit hook (once per clone)
|
||||||
|
prek run --all-files # run all hooks on demand
|
||||||
```
|
```
|
||||||
|
|
||||||
|
GitHub Actions runs `ruff check`, `ruff format --check`, `basedpyright`, then `pytest`. Local commits run the same checks via `prek.toml` so `ruff format` is not forgotten.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### Data modeling: `msgspec.Struct`
|
### Data modeling: `msgspec.Struct`
|
||||||
@@ -70,15 +78,16 @@ Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `
|
|||||||
|
|
||||||
Module-level `@tool` handlers. Call `set_workspace_root()` before use.
|
Module-level `@tool` handlers. Call `set_workspace_root()` before use.
|
||||||
|
|
||||||
- **`workspace`**: path resolve under root; path substring denylist; command basename denylist; clearer deny messages.
|
- **`workspace`**: path resolve under primary root **or** temporary allowlist roots; path substring denylist; command basename denylist; clearer deny messages.
|
||||||
- **`file`**: `read_file`, `write_file`, `listdir`, `tree`, `glob_paths`, `grep_files` (regex, skip VCS/binary), `edit_replace`, `edit_lineno` (1-based range), `copy_path` / `move_path` / `delete_path`.
|
- **`file`**: `read_file` (`with_lineno`), `write_file`, `listdir`, `tree` (VCS + default noise dirs + optional `skip_dirs` / denylist walk), `glob_paths`, `grep_files` (regex, skip VCS/binary), `edit_replace`, `edit_lineno` (1-based range), `copy_path` / `move_path` / `delete_path`, `new_temporary_workspace` (system temp allowlist; cleanup on chat exit; no session rebind).
|
||||||
- **`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` (literal) / `write_pty_keys` (escapes) / `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=)` (**CSI sanitized** so host TTY is never reset); keys via `write_pty_keys` only (`\xHH`, `ctrl+x`, `key=esc`); 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`. Soft confirm also for shells/REPLs and `python|bash -c` one-liners (argv + code preview); deny may include a user comment for the model. 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`.
|
- **`todo`**: LIFO stack of **task groups** (not a queue of tasks) — `todo_push` creates one group of siblings; `todo_pop` removes the whole top group; `todo_update` by id; DFS breakdown push[T1,T2]→push[T1.1…]→pop→push[T2.1…]; stored on session row; agent injects a developer review message if open todos and no todo tool use in the turn.
|
||||||
|
- **`DEFAULT_TOOLS`**: file + process + vcs + chat + todo tool list for a `ToolRegistry`.
|
||||||
|
|
||||||
### Prompting (`prompting.py`)
|
### Prompting (`prompting.py`)
|
||||||
|
|
||||||
@@ -88,8 +97,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. `/model --persist` / `/models --persist` write model catalog entries into TOML. `/todos` for human show/push/pop/clear of the todo stack.
|
||||||
- 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 +121,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,25 @@ 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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Commit gateway** ([prek](https://prek.j178.dev/)): runs ruff check + format and basedpyright on `git commit` so format is not forgotten.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv tool install prek # once
|
||||||
|
prek install # once per clone (installs .git/hooks/pre-commit)
|
||||||
|
prek run --all-files # run all hooks on demand
|
||||||
|
```
|
||||||
|
|
||||||
|
Config: `prek.toml`. CI still runs the same checks in GitHub Actions.
|
||||||
|
|
||||||
## Basic usage
|
## Basic usage
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -152,7 +162,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).
|
||||||
@@ -186,16 +196,21 @@ Type `/help` in the REPL for the live list. Common ones:
|
|||||||
| Command | Purpose |
|
| Command | Purpose |
|
||||||
|---------|---------|
|
|---------|---------|
|
||||||
| `/status` | Provider, session, context/usage estimates |
|
| `/status` | Provider, session, context/usage estimates |
|
||||||
| `/history [n]` | Recent messages |
|
| `/history [n\|last]` | Recent messages (default preview; `last`/`1` = full + markdown) |
|
||||||
|
| `/history --full` | Full bodies for the selected window |
|
||||||
| `/sessions` | Sessions for this workspace |
|
| `/sessions` | Sessions for this workspace |
|
||||||
| `/new` `/resume` `/rename` `/delete` | Session lifecycle (`/delete` confirms) |
|
| `/new` `/resume` `/rename` `/delete` | Session lifecycle (`/delete` confirms) |
|
||||||
| `/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 |
|
||||||
| `/models` | List config + remote `GET /models` (`--refresh` bypasses cache) |
|
| `/model --persist` | Save current model id into `plyngent.toml` catalog |
|
||||||
|
| `/models` | List config + remote `GET /models` (always re-fetches) |
|
||||||
|
| `/models --persist` | Merge remote catalog into TOML for this provider |
|
||||||
|
| `/todos` | Todo/task stack: list, push, pop, done, clear |
|
||||||
| `/config` | Edit `plyngent.toml` in `$EDITOR` and reload |
|
| `/config` | Edit `plyngent.toml` in `$EDITOR` and reload |
|
||||||
| `/quit` | Leave the REPL |
|
| `/quit` | Leave the REPL |
|
||||||
|
|
||||||
@@ -209,14 +224,17 @@ 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 (including `tree` with default noise-dir skips), `run_command` / PTY (POSIX openpty; Windows ConPTY via pywinpty), read-only VCS (git), human prompts (`ask_user_line` / `ask_user_choice` / `ask_user_form`), and todo stack tools (`todo_list` / `todo_push` / `todo_pop` / `todo_update` / `todo_clear`).
|
||||||
|
|
||||||
Safety defaults:
|
Safety defaults:
|
||||||
|
|
||||||
- Paths stay under the workspace; optional `path_denylist` substrings.
|
- Paths stay under the workspace; optional `path_denylist` substrings (`tree` also skips denylisted children by default).
|
||||||
- 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.
|
||||||
|
Prefer file tools over full-screen editors (`vim`/`nano`) for edits. `read_pty` sanitizes CSI/controls
|
||||||
|
so tool results cannot reprogram the host TTY (no host terminal reset on exit).
|
||||||
|
`write_pty` is literal text only; use `write_pty_keys` for `\xHH`, `ctrl+x`, `key=esc|enter|…`.
|
||||||
|
|
||||||
## 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.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Commit gateway via prek (https://prek.j178.dev/) — drop-in pre-commit compatible.
|
||||||
|
# Install: uv tool install prek (or: pipx install prek)
|
||||||
|
# Wire git: prek install
|
||||||
|
# Manual: prek run --all-files
|
||||||
|
|
||||||
|
default_install_hook_types = ["pre-commit"]
|
||||||
|
default_stages = ["pre-commit"]
|
||||||
|
default_language_version = { python = "python3.14" }
|
||||||
|
|
||||||
|
# Ruff: lint (with autofix) then format — order matters (fix before format).
|
||||||
|
# https://docs.astral.sh/ruff/integrations/#pre-commit
|
||||||
|
[[repos]]
|
||||||
|
repo = "https://github.com/astral-sh/ruff-pre-commit"
|
||||||
|
rev = "v0.15.22"
|
||||||
|
hooks = [
|
||||||
|
{ id = "ruff-check", args = ["--fix"], types_or = ["python", "pyi"] },
|
||||||
|
{ id = "ruff-format", types_or = ["python", "pyi"] },
|
||||||
|
]
|
||||||
|
|
||||||
|
# basedpyright: full-project typecheck (same as CI `pdm run basedpyright .`).
|
||||||
|
# Mirror hook: https://docs.basedpyright.com/latest/installation/prek-hook/
|
||||||
|
# Uses system+PDM so imports resolve from the project venv (isolated hook envs lack deps).
|
||||||
|
[[repos]]
|
||||||
|
repo = "local"
|
||||||
|
hooks = [
|
||||||
|
{ id = "basedpyright", name = "basedpyright", language = "system", entry = "pdm run basedpyright .", pass_filenames = false, files = "\\.pyi?$" },
|
||||||
|
]
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "plyngent"
|
name = "plyngent"
|
||||||
version = "0.1.0"
|
version = "0.1.2"
|
||||||
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" },
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
from .client import ChatClient
|
from .client import ChatClient
|
||||||
from .events import AgentEvent
|
from .events import AgentEvent
|
||||||
|
from .todo_stack import TodoStack
|
||||||
from .tools import ToolRegistry
|
from .tools import ToolRegistry
|
||||||
|
|
||||||
type LimitContinueHook = Callable[[str], bool | Awaitable[bool]]
|
type LimitContinueHook = Callable[[str], bool | Awaitable[bool]]
|
||||||
@@ -118,6 +119,7 @@ class ChatAgent:
|
|||||||
max_tool_result_chars: int
|
max_tool_result_chars: int
|
||||||
parallel_tools: bool
|
parallel_tools: bool
|
||||||
max_context_tokens: int
|
max_context_tokens: int
|
||||||
|
todo_stack: TodoStack | None
|
||||||
messages: list[AnyChatMessage]
|
messages: list[AnyChatMessage]
|
||||||
session_usage: TokenUsage
|
session_usage: TokenUsage
|
||||||
last_turn_usage: TokenUsage
|
last_turn_usage: TokenUsage
|
||||||
@@ -143,6 +145,7 @@ class ChatAgent:
|
|||||||
max_tool_result_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS,
|
max_tool_result_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS,
|
||||||
parallel_tools: bool = True,
|
parallel_tools: bool = True,
|
||||||
max_context_tokens: int = DEFAULT_CONTEXT_MAX_TOKENS,
|
max_context_tokens: int = DEFAULT_CONTEXT_MAX_TOKENS,
|
||||||
|
todo_stack: TodoStack | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.client = client
|
self.client = client
|
||||||
self.model = model
|
self.model = model
|
||||||
@@ -157,6 +160,7 @@ class ChatAgent:
|
|||||||
self.max_tool_result_chars = max_tool_result_chars
|
self.max_tool_result_chars = max_tool_result_chars
|
||||||
self.parallel_tools = parallel_tools
|
self.parallel_tools = parallel_tools
|
||||||
self.max_context_tokens = max_context_tokens
|
self.max_context_tokens = max_context_tokens
|
||||||
|
self.todo_stack = todo_stack
|
||||||
self.messages = list(messages) if messages is not None else []
|
self.messages = list(messages) if messages is not None else []
|
||||||
self.session_usage = TokenUsage()
|
self.session_usage = TokenUsage()
|
||||||
self.last_turn_usage = TokenUsage()
|
self.last_turn_usage = TokenUsage()
|
||||||
@@ -200,14 +204,38 @@ class ChatAgent:
|
|||||||
if self._persist_from == 0:
|
if self._persist_from == 0:
|
||||||
self._persist_from = 1
|
self._persist_from = 1
|
||||||
|
|
||||||
|
def replace_messages(
|
||||||
|
self,
|
||||||
|
messages: Sequence[AnyChatMessage],
|
||||||
|
*,
|
||||||
|
persisted: bool = True,
|
||||||
|
persist_from: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Replace in-memory history and align the persistence checkpoint.
|
||||||
|
|
||||||
|
When *persist_from* is set, it is used as the checkpoint cursor
|
||||||
|
(clamped to ``[0, len(messages)]``). Otherwise *persisted* true means
|
||||||
|
all messages are already stored; false means none are.
|
||||||
|
"""
|
||||||
|
self.messages = list(messages)
|
||||||
|
if persist_from is not None:
|
||||||
|
self._persist_from = max(0, min(persist_from, len(self.messages)))
|
||||||
|
else:
|
||||||
|
self._persist_from = len(self.messages) if persisted else 0
|
||||||
|
self._ensure_system_prompt()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def persist_from(self) -> int:
|
||||||
|
"""Index of the first unpersisted message (checkpoint cursor)."""
|
||||||
|
return self._persist_from
|
||||||
|
|
||||||
async def load_history(self) -> None:
|
async def load_history(self) -> None:
|
||||||
"""Replace in-memory messages from the bound memory session."""
|
"""Replace in-memory messages from the bound memory session."""
|
||||||
if self.memory is None or self.session_id is None:
|
if self.memory is None or self.session_id is None:
|
||||||
msg = "load_history requires memory and session_id"
|
msg = "load_history requires memory and session_id"
|
||||||
raise RuntimeError(msg)
|
raise RuntimeError(msg)
|
||||||
self.messages = await self.memory.list_messages(self.session_id)
|
loaded = await self.memory.list_messages(self.session_id)
|
||||||
self._persist_from = len(self.messages)
|
self.replace_messages(loaded, persisted=True)
|
||||||
self._ensure_system_prompt()
|
|
||||||
|
|
||||||
async def bind_session(self, session_id: int, *, load: bool = True) -> None:
|
async def bind_session(self, session_id: int, *, load: bool = True) -> None:
|
||||||
"""Attach a memory session id; optionally load existing messages."""
|
"""Attach a memory session id; optionally load existing messages."""
|
||||||
@@ -265,6 +293,8 @@ class ChatAgent:
|
|||||||
side-effecting tools. Unfinished assistant/stream suffix is rolled back.
|
side-effecting tools. Unfinished assistant/stream suffix is rolled back.
|
||||||
"""
|
"""
|
||||||
user_index = self._user_index(user_msg)
|
user_index = self._user_index(user_msg)
|
||||||
|
if self.todo_stack is not None:
|
||||||
|
self.todo_stack.begin_turn()
|
||||||
|
|
||||||
completed = False
|
completed = False
|
||||||
turn_usage = TokenUsage()
|
turn_usage = TokenUsage()
|
||||||
@@ -283,6 +313,7 @@ class ChatAgent:
|
|||||||
max_tool_result_chars=self.max_tool_result_chars,
|
max_tool_result_chars=self.max_tool_result_chars,
|
||||||
parallel_tools=self.parallel_tools,
|
parallel_tools=self.parallel_tools,
|
||||||
max_context_tokens=self.max_context_tokens,
|
max_context_tokens=self.max_context_tokens,
|
||||||
|
todo_stack=self.todo_stack,
|
||||||
):
|
):
|
||||||
if isinstance(event, UsageEvent):
|
if isinstance(event, UsageEvent):
|
||||||
turn_rounds += 1
|
turn_rounds += 1
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ from plyngent.lmproto.openai_compatible.model import (
|
|||||||
AssistantChatMessage,
|
AssistantChatMessage,
|
||||||
AssistantFunctionToolCall,
|
AssistantFunctionToolCall,
|
||||||
ChatCompletionsParam,
|
ChatCompletionsParam,
|
||||||
|
DeveloperChatMessage,
|
||||||
SystemChatMessage,
|
SystemChatMessage,
|
||||||
ToolChatMessage,
|
|
||||||
UserChatMessage,
|
UserChatMessage,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,6 +49,8 @@ def format_transcript(messages: Sequence[AnyChatMessage]) -> str:
|
|||||||
for msg in messages:
|
for msg in messages:
|
||||||
if isinstance(msg, SystemChatMessage):
|
if isinstance(msg, SystemChatMessage):
|
||||||
lines.append(f"[system] {msg.content}")
|
lines.append(f"[system] {msg.content}")
|
||||||
|
elif isinstance(msg, DeveloperChatMessage):
|
||||||
|
lines.append(f"[developer] {msg.content}")
|
||||||
elif isinstance(msg, UserChatMessage):
|
elif isinstance(msg, UserChatMessage):
|
||||||
lines.append(f"[user] {msg.content}")
|
lines.append(f"[user] {msg.content}")
|
||||||
elif isinstance(msg, AssistantChatMessage):
|
elif isinstance(msg, AssistantChatMessage):
|
||||||
@@ -62,10 +64,9 @@ def format_transcript(messages: Sequence[AnyChatMessage]) -> str:
|
|||||||
lines.append(f"[assistant tool_call] {call.function.name}({call.function.arguments})")
|
lines.append(f"[assistant tool_call] {call.function.name}({call.function.arguments})")
|
||||||
else:
|
else:
|
||||||
lines.append(f"[assistant tool_call] custom id={call.id}")
|
lines.append(f"[assistant tool_call] custom id={call.id}")
|
||||||
elif isinstance(msg, ToolChatMessage):
|
|
||||||
lines.append(f"[tool {msg.tool_call_id}] {msg.content}")
|
|
||||||
else:
|
else:
|
||||||
lines.append(f"[message] {msg!r}")
|
# ToolChatMessage (remaining AnyChatMessage arm)
|
||||||
|
lines.append(f"[tool {msg.tool_call_id}] {msg.content}")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from plyngent.lmproto.openai_compatible.model import (
|
|||||||
AssistantChatMessage,
|
AssistantChatMessage,
|
||||||
AssistantFunctionToolCall,
|
AssistantFunctionToolCall,
|
||||||
ChatCompletionsParam,
|
ChatCompletionsParam,
|
||||||
|
DeveloperChatMessage,
|
||||||
StreamOptions,
|
StreamOptions,
|
||||||
StreamToolCallDelta,
|
StreamToolCallDelta,
|
||||||
ToolChatMessage,
|
ToolChatMessage,
|
||||||
@@ -45,6 +46,7 @@ if TYPE_CHECKING:
|
|||||||
from plyngent.lmproto.openai_compatible.model import AnyChatMessage, AnyToolItem
|
from plyngent.lmproto.openai_compatible.model import AnyChatMessage, AnyToolItem
|
||||||
|
|
||||||
from .client import ChatClient
|
from .client import ChatClient
|
||||||
|
from .todo_stack import TodoStack
|
||||||
from .tools import ToolRegistry
|
from .tools import ToolRegistry
|
||||||
|
|
||||||
type LimitContinueHook = Callable[[str], bool | Awaitable[bool]]
|
type LimitContinueHook = Callable[[str], bool | Awaitable[bool]]
|
||||||
@@ -114,6 +116,66 @@ async def _execute_tool_calls(
|
|||||||
yield ToolResultEvent(message=tool_msg)
|
yield ToolResultEvent(message=tool_msg)
|
||||||
|
|
||||||
|
|
||||||
|
def _assistant_has_payload(assistant: AssistantChatMessage) -> bool:
|
||||||
|
"""True if the model produced text, reasoning, or tool calls."""
|
||||||
|
if assistant.tool_calls is not UNSET and assistant.tool_calls:
|
||||||
|
return True
|
||||||
|
if isinstance(assistant.content, str) and assistant.content.strip():
|
||||||
|
return True
|
||||||
|
reasoning = assistant.reasoning_content
|
||||||
|
return bool(isinstance(reasoning, str) and reasoning.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _finish_reason_value(finish: object) -> str | None:
|
||||||
|
if finish is UNSET or finish is None:
|
||||||
|
return None
|
||||||
|
if isinstance(finish, str) and finish.strip():
|
||||||
|
return finish.strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_assistant_terminal(
|
||||||
|
assistant: AssistantChatMessage,
|
||||||
|
*,
|
||||||
|
finish_reason: str | None,
|
||||||
|
stream_terminal: bool,
|
||||||
|
) -> None:
|
||||||
|
"""Raise when the round is not a usable agent stop.
|
||||||
|
|
||||||
|
Distinguishes empty generation, truncated/filtered stops, and missing
|
||||||
|
stream terminals (network/client glitch) from a normal stop/tool_calls end.
|
||||||
|
"""
|
||||||
|
reason = (finish_reason or "").lower() or None
|
||||||
|
|
||||||
|
if reason in {"length", "content_filter"}:
|
||||||
|
label = "truncated (max tokens)" if reason == "length" else "content filter"
|
||||||
|
detail = ""
|
||||||
|
if isinstance(assistant.content, str) and assistant.content.strip():
|
||||||
|
detail = f"; partial text kept ({len(assistant.content)} chars)"
|
||||||
|
msg = f"model stopped early: {label}{detail}"
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
|
||||||
|
if reason in {"failed", "incomplete", "cancelled"}:
|
||||||
|
msg = f"model response status: {reason}"
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
|
||||||
|
if _assistant_has_payload(assistant):
|
||||||
|
return
|
||||||
|
|
||||||
|
if not stream_terminal and reason is None:
|
||||||
|
msg = (
|
||||||
|
"stream ended without a terminal signal (no finish_reason / response.completed) and empty assistant output"
|
||||||
|
)
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
|
||||||
|
if reason in {"stop", "tool_calls", "function_call"} or reason is None:
|
||||||
|
msg = "empty model completion (no text, reasoning, or tool calls)"
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
|
||||||
|
msg = f"empty model completion (finish_reason={reason})"
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
|
||||||
|
|
||||||
async def _non_stream_round(
|
async def _non_stream_round(
|
||||||
client: ChatClient,
|
client: ChatClient,
|
||||||
param: ChatCompletionsParam,
|
param: ChatCompletionsParam,
|
||||||
@@ -122,7 +184,14 @@ async def _non_stream_round(
|
|||||||
if not response.choices:
|
if not response.choices:
|
||||||
msg = "chat completion response contained no choices"
|
msg = "chat completion response contained no choices"
|
||||||
raise RuntimeError(msg)
|
raise RuntimeError(msg)
|
||||||
assistant = response.choices[0].message
|
choice = response.choices[0]
|
||||||
|
assistant = choice.message
|
||||||
|
finish = _finish_reason_value(choice.finish_reason)
|
||||||
|
_validate_assistant_terminal(
|
||||||
|
assistant,
|
||||||
|
finish_reason=finish,
|
||||||
|
stream_terminal=True,
|
||||||
|
)
|
||||||
reasoning = assistant.reasoning_content
|
reasoning = assistant.reasoning_content
|
||||||
if isinstance(reasoning, str) and reasoning:
|
if isinstance(reasoning, str) and reasoning:
|
||||||
yield ReasoningDeltaEvent(content=reasoning)
|
yield ReasoningDeltaEvent(content=reasoning)
|
||||||
@@ -154,6 +223,8 @@ async def _stream_round(
|
|||||||
reasoning_parts: list[str] = []
|
reasoning_parts: list[str] = []
|
||||||
tool_deltas: list[StreamToolCallDelta] = []
|
tool_deltas: list[StreamToolCallDelta] = []
|
||||||
last_api_usage: object = UNSET
|
last_api_usage: object = UNSET
|
||||||
|
finish_reason: str | None = None
|
||||||
|
saw_terminal = False
|
||||||
|
|
||||||
async for chunk in stream:
|
async for chunk in stream:
|
||||||
parsed = token_usage_from_api(chunk.usage)
|
parsed = token_usage_from_api(chunk.usage)
|
||||||
@@ -162,6 +233,10 @@ async def _stream_round(
|
|||||||
if not chunk.choices:
|
if not chunk.choices:
|
||||||
continue
|
continue
|
||||||
choice = chunk.choices[0]
|
choice = chunk.choices[0]
|
||||||
|
fr = _finish_reason_value(choice.finish_reason)
|
||||||
|
if fr is not None:
|
||||||
|
finish_reason = fr
|
||||||
|
saw_terminal = True
|
||||||
delta = choice.delta
|
delta = choice.delta
|
||||||
if isinstance(delta.reasoning_content, str) and delta.reasoning_content:
|
if isinstance(delta.reasoning_content, str) and delta.reasoning_content:
|
||||||
reasoning_parts.append(delta.reasoning_content)
|
reasoning_parts.append(delta.reasoning_content)
|
||||||
@@ -185,6 +260,16 @@ async def _stream_round(
|
|||||||
tool_calls=tool_calls,
|
tool_calls=tool_calls,
|
||||||
reasoning_content=full_reasoning or UNSET,
|
reasoning_content=full_reasoning or UNSET,
|
||||||
)
|
)
|
||||||
|
# Usage-only final chunks leave choices empty; a non-empty usage after
|
||||||
|
# content still is not a finish_reason. Prefer explicit finish_reason.
|
||||||
|
# If we got payload but no finish_reason, treat as terminal (some providers
|
||||||
|
# omit it); if empty and no finish_reason, flag as missing terminal.
|
||||||
|
stream_terminal = saw_terminal or _assistant_has_payload(assistant)
|
||||||
|
_validate_assistant_terminal(
|
||||||
|
assistant,
|
||||||
|
finish_reason=finish_reason,
|
||||||
|
stream_terminal=stream_terminal,
|
||||||
|
)
|
||||||
yield AssistantMessageEvent(message=assistant)
|
yield AssistantMessageEvent(message=assistant)
|
||||||
yield UsageEvent(
|
yield UsageEvent(
|
||||||
usage=resolve_round_usage(last_api_usage, param.messages, assistant),
|
usage=resolve_round_usage(last_api_usage, param.messages, assistant),
|
||||||
@@ -235,6 +320,7 @@ async def run_chat_loop(
|
|||||||
max_tool_result_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS,
|
max_tool_result_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS,
|
||||||
parallel_tools: bool = True,
|
parallel_tools: bool = True,
|
||||||
max_context_tokens: int = DEFAULT_CONTEXT_MAX_TOKENS,
|
max_context_tokens: int = DEFAULT_CONTEXT_MAX_TOKENS,
|
||||||
|
todo_stack: TodoStack | None = None,
|
||||||
) -> AsyncIterator[AgentEvent]:
|
) -> AsyncIterator[AgentEvent]:
|
||||||
"""Multi-round chat/tool loop; mutates ``messages`` in place and yields events.
|
"""Multi-round chat/tool loop; mutates ``messages`` in place and yields events.
|
||||||
|
|
||||||
@@ -242,6 +328,10 @@ async def run_chat_loop(
|
|||||||
text deltas as chunks arrive; tool calls are merged from stream deltas.
|
text deltas as chunks arrive; tool calls are merged from stream deltas.
|
||||||
Multiple tool calls in one round run in parallel when ``parallel_tools``.
|
Multiple tool calls in one round run in parallel when ``parallel_tools``.
|
||||||
Request payloads may shrink older tool results when over ``max_context_tokens``.
|
Request payloads may shrink older tool results when over ``max_context_tokens``.
|
||||||
|
|
||||||
|
When *todo_stack* is set and still has open items after a natural stop with
|
||||||
|
no ``todo_*`` tool use this turn, injects a review user message and continues
|
||||||
|
once (so the model must check or update the stack).
|
||||||
"""
|
"""
|
||||||
tool_items: Sequence[AnyToolItem] | None = None
|
tool_items: Sequence[AnyToolItem] | None = None
|
||||||
if tools is not None and len(tools) > 0:
|
if tools is not None and len(tools) > 0:
|
||||||
@@ -252,6 +342,7 @@ async def run_chat_loop(
|
|||||||
# Calibrate soft-compact from last model call's prompt_tokens (API preferred).
|
# Calibrate soft-compact from last model call's prompt_tokens (API preferred).
|
||||||
prompt_tokens_hint: int | None = None
|
prompt_tokens_hint: int | None = None
|
||||||
sent_estimate_tokens: int | None = None
|
sent_estimate_tokens: int | None = None
|
||||||
|
todo_review_injected = False
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
while rounds_used < allowance:
|
while rounds_used < allowance:
|
||||||
@@ -280,6 +371,11 @@ async def run_chat_loop(
|
|||||||
assistant = _last_assistant(messages, pre_len)
|
assistant = _last_assistant(messages, pre_len)
|
||||||
tool_calls = assistant.tool_calls
|
tool_calls = assistant.tool_calls
|
||||||
if tool_calls is UNSET or not tool_calls or tools is None:
|
if tool_calls is UNSET or not tool_calls or tools is None:
|
||||||
|
if todo_stack is not None and todo_stack.needs_review() and not todo_review_injected:
|
||||||
|
todo_review_injected = True
|
||||||
|
# Non-user control identity so retry/history don't treat this as human input.
|
||||||
|
messages.append(DeveloperChatMessage(content=todo_stack.review_prompt()))
|
||||||
|
continue
|
||||||
return
|
return
|
||||||
async for event in _execute_tool_calls(
|
async for event in _execute_tool_calls(
|
||||||
tools,
|
tools,
|
||||||
|
|||||||
@@ -29,10 +29,10 @@ from plyngent.lmproto.openai_compatible.model import (
|
|||||||
ChatCompletionsParam,
|
ChatCompletionsParam,
|
||||||
ChunkChoice,
|
ChunkChoice,
|
||||||
DeltaMessage,
|
DeltaMessage,
|
||||||
|
DeveloperChatMessage,
|
||||||
StreamFunctionDelta,
|
StreamFunctionDelta,
|
||||||
StreamToolCallDelta,
|
StreamToolCallDelta,
|
||||||
SystemChatMessage,
|
SystemChatMessage,
|
||||||
ToolChatMessage,
|
|
||||||
ToolFunctionItem,
|
ToolFunctionItem,
|
||||||
UserChatMessage,
|
UserChatMessage,
|
||||||
)
|
)
|
||||||
@@ -97,21 +97,22 @@ def chat_messages_to_responses_input(
|
|||||||
if isinstance(message, SystemChatMessage):
|
if isinstance(message, SystemChatMessage):
|
||||||
if message.content.strip():
|
if message.content.strip():
|
||||||
instructions_parts.append(message.content)
|
instructions_parts.append(message.content)
|
||||||
|
elif isinstance(message, DeveloperChatMessage):
|
||||||
|
# Keep mid-turn control as input developer messages (not folded into instructions).
|
||||||
|
if message.content.strip():
|
||||||
|
items.append(ResponseEasyInputMessage(role="developer", content=message.content))
|
||||||
elif isinstance(message, UserChatMessage):
|
elif isinstance(message, UserChatMessage):
|
||||||
items.append(ResponseEasyInputMessage(role="user", content=message.content))
|
items.append(ResponseEasyInputMessage(role="user", content=message.content))
|
||||||
elif isinstance(message, AssistantChatMessage):
|
elif isinstance(message, AssistantChatMessage):
|
||||||
items.extend(_assistant_to_input_items(message))
|
items.extend(_assistant_to_input_items(message))
|
||||||
elif isinstance(message, ToolChatMessage):
|
else:
|
||||||
|
# ToolChatMessage (remaining AnyChatMessage arm)
|
||||||
items.append(
|
items.append(
|
||||||
ResponseFunctionToolCallOutput(
|
ResponseFunctionToolCallOutput(
|
||||||
call_id=message.tool_call_id,
|
call_id=message.tool_call_id,
|
||||||
output=message.content,
|
output=message.content,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
content = getattr(message, "content", None)
|
|
||||||
if isinstance(content, str) and content:
|
|
||||||
items.append(ResponseEasyInputMessage(role="user", content=content))
|
|
||||||
|
|
||||||
instructions = "\n\n".join(instructions_parts) if instructions_parts else None
|
instructions = "\n\n".join(instructions_parts) if instructions_parts else None
|
||||||
return instructions, items
|
return instructions, items
|
||||||
@@ -158,10 +159,33 @@ def _reasoning_summary_text(response: Response) -> str:
|
|||||||
return "".join(parts)
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def responses_status_to_finish_reason(
|
||||||
|
response: Response,
|
||||||
|
*,
|
||||||
|
has_tool_calls: bool,
|
||||||
|
) -> str:
|
||||||
|
"""Map Responses ``status`` to a chat-style finish_reason for the agent loop."""
|
||||||
|
status = response.status
|
||||||
|
status_s = status if isinstance(status, str) else None
|
||||||
|
if status_s == "incomplete":
|
||||||
|
details = response.incomplete_details
|
||||||
|
if details is not UNSET and details is not None:
|
||||||
|
raw_reason = details.reason
|
||||||
|
if raw_reason is not UNSET and raw_reason == "content_filter":
|
||||||
|
return "content_filter"
|
||||||
|
return "length"
|
||||||
|
if status_s in {"failed", "cancelled"}:
|
||||||
|
return status_s
|
||||||
|
if has_tool_calls:
|
||||||
|
return "tool_calls"
|
||||||
|
return "stop"
|
||||||
|
|
||||||
|
|
||||||
def response_to_chat_completion(response: Response) -> ChatCompletionResponse:
|
def response_to_chat_completion(response: Response) -> ChatCompletionResponse:
|
||||||
"""Wrap Responses result as a synthetic chat completion for the agent loop."""
|
"""Wrap Responses result as a synthetic chat completion for the agent loop."""
|
||||||
assistant = response_to_assistant_message(response)
|
assistant = response_to_assistant_message(response)
|
||||||
finish: str | None = "tool_calls" if assistant.tool_calls is not UNSET else "stop"
|
has_tools = assistant.tool_calls is not UNSET and bool(assistant.tool_calls)
|
||||||
|
finish = responses_status_to_finish_reason(response, has_tool_calls=has_tools)
|
||||||
usage = response.usage if response.usage is not UNSET else UNSET
|
usage = response.usage if response.usage is not UNSET else UNSET
|
||||||
created = int(response.created_at)
|
created = int(response.created_at)
|
||||||
return ChatCompletionResponse(
|
return ChatCompletionResponse(
|
||||||
@@ -180,6 +204,28 @@ def response_to_chat_completion(response: Response) -> ChatCompletionResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def finish_reason_chunk(
|
||||||
|
*,
|
||||||
|
model: str,
|
||||||
|
finish_reason: str,
|
||||||
|
created: int = 0,
|
||||||
|
) -> ChatCompletionChunk:
|
||||||
|
"""Terminal stream chunk carrying only ``finish_reason`` (no delta text)."""
|
||||||
|
return ChatCompletionChunk(
|
||||||
|
id="resp-stream",
|
||||||
|
object="chat.completion.chunk",
|
||||||
|
created=created,
|
||||||
|
model=model,
|
||||||
|
choices=[
|
||||||
|
ChunkChoice(
|
||||||
|
index=0,
|
||||||
|
delta=DeltaMessage(),
|
||||||
|
finish_reason=cast("Any", finish_reason),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def text_delta_chunk(*, model: str, content: str, created: int = 0) -> ChatCompletionChunk:
|
def text_delta_chunk(*, model: str, content: str, created: int = 0) -> ChatCompletionChunk:
|
||||||
return ChatCompletionChunk(
|
return ChatCompletionChunk(
|
||||||
id="resp-stream",
|
id="resp-stream",
|
||||||
|
|||||||
@@ -8,8 +8,11 @@ from msgspec import UNSET
|
|||||||
|
|
||||||
from plyngent.agent.responses_bridge import (
|
from plyngent.agent.responses_bridge import (
|
||||||
chat_param_to_responses_kwargs,
|
chat_param_to_responses_kwargs,
|
||||||
|
finish_reason_chunk,
|
||||||
reasoning_delta_chunk,
|
reasoning_delta_chunk,
|
||||||
|
response_to_assistant_message,
|
||||||
response_to_chat_completion,
|
response_to_chat_completion,
|
||||||
|
responses_status_to_finish_reason,
|
||||||
text_delta_chunk,
|
text_delta_chunk,
|
||||||
tool_call_chunks_from_response,
|
tool_call_chunks_from_response,
|
||||||
usage_chunk_from_response,
|
usage_chunk_from_response,
|
||||||
@@ -109,9 +112,17 @@ class ResponsesChatClient:
|
|||||||
except TypeError, ValueError, msgspec.ValidationError:
|
except TypeError, ValueError, msgspec.ValidationError:
|
||||||
final = None
|
final = None
|
||||||
|
|
||||||
if final is not None:
|
if final is None:
|
||||||
|
# Stream ended without response.completed — signal missing terminal.
|
||||||
|
# Loop will treat empty + no finish_reason as a glitch.
|
||||||
|
return
|
||||||
|
|
||||||
|
assistant = response_to_assistant_message(final)
|
||||||
|
has_tools = assistant.tool_calls is not UNSET and bool(assistant.tool_calls)
|
||||||
|
finish = responses_status_to_finish_reason(final, has_tool_calls=has_tools)
|
||||||
for chunk in tool_call_chunks_from_response(final, model=model):
|
for chunk in tool_call_chunks_from_response(final, model=model):
|
||||||
yield chunk
|
yield chunk
|
||||||
|
yield finish_reason_chunk(model=model, finish_reason=finish)
|
||||||
usage = usage_chunk_from_response(final, model=model)
|
usage = usage_chunk_from_response(final, model=model)
|
||||||
if usage is not None:
|
if usage is not None:
|
||||||
yield usage
|
yield usage
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Literal, cast
|
||||||
|
|
||||||
|
import msgspec
|
||||||
|
from msgspec import Struct, field
|
||||||
|
|
||||||
|
type TodoStatus = Literal["pending", "in_progress", "done", "cancelled"]
|
||||||
|
|
||||||
|
_OPEN: frozenset[str] = frozenset({"pending", "in_progress"})
|
||||||
|
|
||||||
|
|
||||||
|
class TodoItem(Struct, omit_defaults=True):
|
||||||
|
"""One task inside a group (siblings share a group; not stacked individually)."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
status: TodoStatus = "pending"
|
||||||
|
notes: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class TodoGroup(Struct, omit_defaults=True):
|
||||||
|
"""One stack entry: a group of sibling tasks pushed together."""
|
||||||
|
|
||||||
|
items: list[TodoItem] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class TodoStackData(Struct, omit_defaults=True):
|
||||||
|
"""LIFO of **groups**: ``groups[0]`` bottom, ``groups[-1]`` **TOP** group."""
|
||||||
|
|
||||||
|
groups: list[TodoGroup] = field(default_factory=list)
|
||||||
|
next_id: int = 1
|
||||||
|
|
||||||
|
|
||||||
|
class TodoStack:
|
||||||
|
"""LIFO stack of **task groups** (not a queue of individual tasks).
|
||||||
|
|
||||||
|
- **Push** always creates **one new group** containing one or more sibling
|
||||||
|
tasks (``push T1, T2`` is one frame, not two stack levels).
|
||||||
|
- **Pop** removes the entire **top group**.
|
||||||
|
- Within a group, update tasks by id (done / in_progress / …).
|
||||||
|
|
||||||
|
Breakdown pattern::
|
||||||
|
|
||||||
|
push [T1, T2] # top group = {T1, T2}
|
||||||
|
push [T1.1, T1.2] # top group = {T1.1, T1.2}; under = {T1, T2}
|
||||||
|
# finish T1.1 / T1.2 via update…
|
||||||
|
pop # leave child group; top again = {T1, T2}
|
||||||
|
push [T2.1] # top group = {T2.1}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, data: TodoStackData | None = None) -> None:
|
||||||
|
self._data: TodoStackData = data if data is not None else TodoStackData()
|
||||||
|
self._touched_this_turn: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def groups(self) -> list[TodoGroup]:
|
||||||
|
"""Bottom → top (last is top group)."""
|
||||||
|
return self._data.groups
|
||||||
|
|
||||||
|
@property
|
||||||
|
def top_group(self) -> TodoGroup | None:
|
||||||
|
if not self._data.groups:
|
||||||
|
return None
|
||||||
|
return self._data.groups[-1]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def depth(self) -> int:
|
||||||
|
return len(self._data.groups)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def touched_this_turn(self) -> bool:
|
||||||
|
return self._touched_this_turn
|
||||||
|
|
||||||
|
def begin_turn(self) -> None:
|
||||||
|
self._touched_this_turn = False
|
||||||
|
|
||||||
|
def mark_touched(self) -> None:
|
||||||
|
self._touched_this_turn = True
|
||||||
|
|
||||||
|
def all_items(self) -> list[TodoItem]:
|
||||||
|
return [item for group in self._data.groups for item in group.items]
|
||||||
|
|
||||||
|
def open_items(self) -> list[TodoItem]:
|
||||||
|
return [item for item in self.all_items() if item.status in _OPEN]
|
||||||
|
|
||||||
|
def is_empty(self) -> bool:
|
||||||
|
return not self._data.groups
|
||||||
|
|
||||||
|
def needs_review(self) -> bool:
|
||||||
|
return bool(self.open_items()) and not self._touched_this_turn
|
||||||
|
|
||||||
|
def to_data(self) -> TodoStackData:
|
||||||
|
return self._data
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_raw(cls, raw: object | None) -> TodoStack: # noqa: C901, PLR0911
|
||||||
|
if raw is None or not isinstance(raw, dict):
|
||||||
|
return cls()
|
||||||
|
blob = cast("dict[str, object]", raw)
|
||||||
|
|
||||||
|
# Current shape: {groups: [...], next_id}
|
||||||
|
if "groups" in blob:
|
||||||
|
try:
|
||||||
|
data = msgspec.convert(raw, type=TodoStackData)
|
||||||
|
except msgspec.ValidationError, TypeError, ValueError:
|
||||||
|
return cls()
|
||||||
|
if data.next_id < 1:
|
||||||
|
data = msgspec.structs.replace(data, next_id=1)
|
||||||
|
return cls(data)
|
||||||
|
|
||||||
|
# Intermediate nested frames → groups (same structure).
|
||||||
|
if "frames" in blob:
|
||||||
|
try:
|
||||||
|
frames_raw = blob.get("frames")
|
||||||
|
next_raw = blob.get("next_id", 1)
|
||||||
|
next_id = max(1, int(next_raw) if isinstance(next_raw, int | str) else 1)
|
||||||
|
groups: list[TodoGroup] = []
|
||||||
|
if isinstance(frames_raw, list):
|
||||||
|
for frame in cast("list[object]", frames_raw):
|
||||||
|
if not isinstance(frame, dict):
|
||||||
|
continue
|
||||||
|
frame_map = cast("dict[str, object]", frame)
|
||||||
|
frame_items = msgspec.convert(frame_map.get("items"), type=list[TodoItem])
|
||||||
|
if frame_items:
|
||||||
|
groups.append(TodoGroup(items=frame_items))
|
||||||
|
return cls(TodoStackData(groups=groups, next_id=next_id))
|
||||||
|
except msgspec.ValidationError, TypeError, ValueError:
|
||||||
|
return cls()
|
||||||
|
|
||||||
|
# Flat items list → one group (legacy).
|
||||||
|
if "items" in blob:
|
||||||
|
try:
|
||||||
|
items = msgspec.convert(blob.get("items"), type=list[TodoItem])
|
||||||
|
next_raw = blob.get("next_id", 1)
|
||||||
|
next_id = max(1, int(next_raw) if isinstance(next_raw, int | str) else 1)
|
||||||
|
except msgspec.ValidationError, TypeError, ValueError:
|
||||||
|
return cls()
|
||||||
|
groups = [TodoGroup(items=items)] if items else []
|
||||||
|
return cls(TodoStackData(groups=groups, next_id=next_id))
|
||||||
|
|
||||||
|
return cls()
|
||||||
|
|
||||||
|
def to_raw(self) -> dict[str, object]:
|
||||||
|
out: object = msgspec.to_builtins(self._data)
|
||||||
|
if not isinstance(out, dict):
|
||||||
|
return {"groups": [], "next_id": 1}
|
||||||
|
raw = cast("dict[object, object]", out)
|
||||||
|
return {str(key): value for key, value in raw.items()}
|
||||||
|
|
||||||
|
def render(self) -> str:
|
||||||
|
if not self._data.groups:
|
||||||
|
return "(todo stack empty — no groups)"
|
||||||
|
lines: list[str] = [
|
||||||
|
f"(LIFO of groups: depth={self.depth}; TOP group = current breakdown level)",
|
||||||
|
]
|
||||||
|
# Top group first for the model.
|
||||||
|
for offset, group in enumerate(reversed(self._data.groups)):
|
||||||
|
depth = self.depth - 1 - offset
|
||||||
|
tag = " TOP" if offset == 0 else ""
|
||||||
|
lines.append(f"group d={depth}{tag}:")
|
||||||
|
if not group.items:
|
||||||
|
lines.append(" (empty group)")
|
||||||
|
continue
|
||||||
|
for item in group.items:
|
||||||
|
mark = {
|
||||||
|
"pending": "[ ]",
|
||||||
|
"in_progress": "[~]",
|
||||||
|
"done": "[x]",
|
||||||
|
"cancelled": "[-]",
|
||||||
|
}.get(item.status, "[?]")
|
||||||
|
note = f" — {item.notes}" if item.notes else ""
|
||||||
|
lines.append(f" {mark} {item.id}: {item.title}{note}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def review_prompt(self) -> str:
|
||||||
|
"""Compact, model-facing nag: open work still exists this turn."""
|
||||||
|
open_items = self.open_items()
|
||||||
|
n_open = len(open_items)
|
||||||
|
n_groups = self.depth
|
||||||
|
lines = [
|
||||||
|
f"[TODO OPEN] {n_open} open item(s) across {n_groups} group(s) "
|
||||||
|
f"(not empty). You did not call todo_* this turn.",
|
||||||
|
"Tools: todo_list | todo_push(titles=[...]) | todo_update | todo_pop | todo_clear",
|
||||||
|
"Rules: TOP group = current level; push=one sibling group; pop=remove whole TOP group.",
|
||||||
|
"Act on open work before ending (finish/pop TOP, or push child tasks). Stack:",
|
||||||
|
self.render(),
|
||||||
|
]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _existing_numeric_ids(self) -> list[int]:
|
||||||
|
"""Numeric suffixes of ids that look like ``tN`` (for counter reuse)."""
|
||||||
|
return [
|
||||||
|
int(item.id[1:])
|
||||||
|
for group in self._data.groups
|
||||||
|
for item in group.items
|
||||||
|
if item.id.startswith("t") and item.id[1:].isdigit()
|
||||||
|
]
|
||||||
|
|
||||||
|
def _sync_next_id(self) -> None:
|
||||||
|
"""Set ``next_id`` to one past the highest live id (or 1 if empty)."""
|
||||||
|
nums = self._existing_numeric_ids()
|
||||||
|
self._data.next_id = max(nums) + 1 if nums else 1
|
||||||
|
|
||||||
|
def _alloc(self, title: str, *, notes: str, status: TodoStatus) -> TodoItem:
|
||||||
|
item_id = f"t{self._data.next_id}"
|
||||||
|
self._data.next_id += 1
|
||||||
|
return TodoItem(id=item_id, title=title, status=status, notes=notes)
|
||||||
|
|
||||||
|
def push_group(
|
||||||
|
self,
|
||||||
|
titles: list[str],
|
||||||
|
*,
|
||||||
|
notes: str = "",
|
||||||
|
status: TodoStatus = "pending",
|
||||||
|
) -> TodoGroup:
|
||||||
|
"""Push **one** new group containing all *titles* as siblings."""
|
||||||
|
cleaned = [t.strip() for t in titles if t and t.strip()]
|
||||||
|
if not cleaned:
|
||||||
|
msg = "at least one non-empty title is required"
|
||||||
|
raise ValueError(msg)
|
||||||
|
note = notes.strip()
|
||||||
|
# Rebase counter on live ids so clear/pop do not leave a high watermark.
|
||||||
|
self._sync_next_id()
|
||||||
|
items = [self._alloc(title, notes=note, status=status) for title in cleaned]
|
||||||
|
group = TodoGroup(items=items)
|
||||||
|
self._data.groups.append(group)
|
||||||
|
self.mark_touched()
|
||||||
|
return group
|
||||||
|
|
||||||
|
def push(self, title: str, *, notes: str = "", status: TodoStatus = "pending") -> TodoItem:
|
||||||
|
"""Push a group with a single task (still one stack level)."""
|
||||||
|
group = self.push_group([title], notes=notes, status=status)
|
||||||
|
return group.items[0]
|
||||||
|
|
||||||
|
def pop(self) -> TodoGroup | None:
|
||||||
|
"""Pop and return the **top group** (all siblings in that push)."""
|
||||||
|
if not self._data.groups:
|
||||||
|
return None
|
||||||
|
group = self._data.groups.pop()
|
||||||
|
self._sync_next_id()
|
||||||
|
self.mark_touched()
|
||||||
|
return group
|
||||||
|
|
||||||
|
def clear(self) -> int:
|
||||||
|
n = sum(len(g.items) for g in self._data.groups)
|
||||||
|
self._data.groups.clear()
|
||||||
|
self._sync_next_id()
|
||||||
|
if n:
|
||||||
|
self.mark_touched()
|
||||||
|
return n
|
||||||
|
|
||||||
|
def get(self, item_id: str) -> TodoItem | None:
|
||||||
|
for item in self.all_items():
|
||||||
|
if item.id == item_id:
|
||||||
|
return item
|
||||||
|
return None
|
||||||
|
|
||||||
|
def update(
|
||||||
|
self,
|
||||||
|
item_id: str,
|
||||||
|
*,
|
||||||
|
title: str | None = None,
|
||||||
|
status: TodoStatus | None = None,
|
||||||
|
notes: str | None = None,
|
||||||
|
) -> TodoItem:
|
||||||
|
for group in self._data.groups:
|
||||||
|
for index, item in enumerate(group.items):
|
||||||
|
if item.id != item_id:
|
||||||
|
continue
|
||||||
|
new_title = title.strip() if title is not None else item.title
|
||||||
|
if not new_title:
|
||||||
|
msg = "title must not be empty"
|
||||||
|
raise ValueError(msg)
|
||||||
|
new_status = status if status is not None else item.status
|
||||||
|
new_notes = notes if notes is not None else item.notes
|
||||||
|
updated = TodoItem(id=item.id, title=new_title, status=new_status, notes=new_notes)
|
||||||
|
group.items[index] = updated
|
||||||
|
self.mark_touched()
|
||||||
|
return updated
|
||||||
|
msg = f"unknown todo id {item_id!r}"
|
||||||
|
raise KeyError(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_push_titles(raw: str) -> list[str]:
|
||||||
|
"""Parse multi-title push: JSON array, newlines, or ``;``-separated."""
|
||||||
|
text = raw.strip()
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
if text.startswith("["):
|
||||||
|
try:
|
||||||
|
data: object = msgspec.json.decode(text.encode())
|
||||||
|
except msgspec.DecodeError, UnicodeEncodeError:
|
||||||
|
data = None
|
||||||
|
if isinstance(data, list):
|
||||||
|
out = [item.strip() for item in cast("list[object]", data) if isinstance(item, str) and item.strip()]
|
||||||
|
if out:
|
||||||
|
return out
|
||||||
|
parts: list[str] = []
|
||||||
|
for line in text.replace(";", "\n").splitlines():
|
||||||
|
token = line.strip()
|
||||||
|
if token:
|
||||||
|
parts.append(token)
|
||||||
|
return parts
|
||||||
@@ -13,9 +13,11 @@ from plyngent.typedef import JSONSchema # noqa: TC001
|
|||||||
|
|
||||||
type ToolHandler = Callable[..., Any | Awaitable[Any]]
|
type ToolHandler = Callable[..., Any | Awaitable[Any]]
|
||||||
type DangerClassifier = Callable[[str, Mapping[str, object]], str | None]
|
type DangerClassifier = Callable[[str, Mapping[str, object]], str | None]
|
||||||
|
# Returns True to allow, False to deny, or a non-empty str as denial reason for the model.
|
||||||
|
type ToolConfirmResult = bool | str
|
||||||
type ToolConfirmHook = Callable[
|
type ToolConfirmHook = Callable[
|
||||||
[str, Mapping[str, object], str],
|
[str, Mapping[str, object], str],
|
||||||
bool | Awaitable[bool],
|
ToolConfirmResult | Awaitable[ToolConfirmResult],
|
||||||
]
|
]
|
||||||
|
|
||||||
_PRIMITIVE_SCHEMA: dict[type, JSONSchema] = {
|
_PRIMITIVE_SCHEMA: dict[type, JSONSchema] = {
|
||||||
@@ -191,6 +193,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)
|
||||||
@@ -207,7 +214,11 @@ class ToolRegistry:
|
|||||||
return msgspec.json.encode(result).decode()
|
return msgspec.json.encode(result).decode()
|
||||||
|
|
||||||
async def _maybe_confirm(self, name: str, args: dict[str, object]) -> str | None:
|
async def _maybe_confirm(self, name: str, args: dict[str, object]) -> str | None:
|
||||||
"""Return an error string if the user denies a dangerous tool, else None."""
|
"""If confirm is required and denied, return an error string for the model.
|
||||||
|
|
||||||
|
The confirm hook may return ``True`` (allow), ``False`` (deny), or a
|
||||||
|
non-empty string (deny with user comment for the model).
|
||||||
|
"""
|
||||||
if self._danger is None or self._on_confirm is None:
|
if self._danger is None or self._on_confirm is None:
|
||||||
return None
|
return None
|
||||||
reason = self._danger(name, args)
|
reason = self._danger(name, args)
|
||||||
@@ -218,12 +229,14 @@ class ToolRegistry:
|
|||||||
reason = self._danger(name, args)
|
reason = self._danger(name, args)
|
||||||
if reason is None:
|
if reason is None:
|
||||||
return None
|
return None
|
||||||
allowed = self._on_confirm(name, args, reason)
|
decision = self._on_confirm(name, args, reason)
|
||||||
if inspect.isawaitable(allowed):
|
if inspect.isawaitable(decision):
|
||||||
allowed = await allowed
|
decision = await decision
|
||||||
if allowed:
|
if decision is True:
|
||||||
return None
|
return None
|
||||||
return f"error: user denied tool {name!r} ({reason})"
|
if isinstance(decision, str) and decision.strip():
|
||||||
|
return f"error: tool {name!r} denied by user confirm ({reason}); user comment: {decision.strip()}"
|
||||||
|
return f"error: tool {name!r} denied by user confirm ({reason})"
|
||||||
|
|
||||||
async def execute(self, name: str, arguments_json: str) -> str:
|
async def execute(self, name: str, arguments_json: str) -> str:
|
||||||
"""Run a tool by name; returns a string result (errors become error text)."""
|
"""Run a tool by name; returns a string result (errors become error text)."""
|
||||||
|
|||||||
+36
-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)
|
||||||
|
|
||||||
@@ -279,8 +307,10 @@ async def _run_chat( # noqa: C901, PLR0912 — chat orchestration
|
|||||||
finally:
|
finally:
|
||||||
await memory.close()
|
await memory.close()
|
||||||
from plyngent.tools.process.pty_session import PtyManager
|
from plyngent.tools.process.pty_session import PtyManager
|
||||||
|
from plyngent.tools.temp_workspace import cleanup_temporary_workspaces
|
||||||
|
|
||||||
PtyManager.close_all()
|
PtyManager.close_all()
|
||||||
|
_ = cleanup_temporary_workspaces()
|
||||||
|
|
||||||
|
|
||||||
def _configure_logging(level: str) -> None:
|
def _configure_logging(level: str) -> None:
|
||||||
@@ -367,7 +397,7 @@ def main(ctx: click.Context, log_level: str) -> None:
|
|||||||
"--yes",
|
"--yes",
|
||||||
is_flag=True,
|
is_flag=True,
|
||||||
default=False,
|
default=False,
|
||||||
help="Allow destructive tools without confirm (one-shot / non-interactive).",
|
help="Enable YOLO: skip destructive-tool confirms (sticky for this process).",
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--quiet",
|
"--quiet",
|
||||||
|
|||||||
+75
-48
@@ -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()
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import asyncio
|
|||||||
import contextlib
|
import contextlib
|
||||||
import signal
|
import signal
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from contextvars import ContextVar
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -13,13 +12,16 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
type SigHandler = Callable[[int, FrameType | None], None] | int | None
|
type SigHandler = Callable[[int, FrameType | None], None] | int | None
|
||||||
|
|
||||||
_allow_task_cancel: ContextVar[bool] = ContextVar("allow_task_cancel", default=True)
|
# Depth of nested pause_task_cancel_for_prompt. Must be a plain int (not ContextVar):
|
||||||
|
# asyncio.add_signal_handler freezes the ContextVar snapshot at install time, so a
|
||||||
|
# ContextVar reset after reinstall would leave SIGINT permanently non-cancelling.
|
||||||
|
_prompt_depth: int = 0
|
||||||
_reinstall_holder: list[Callable[[], None] | None] = [None]
|
_reinstall_holder: list[Callable[[], None] | None] = [None]
|
||||||
|
|
||||||
|
|
||||||
def allow_task_cancel() -> bool:
|
def allow_task_cancel() -> bool:
|
||||||
"""Whether the CLI SIGINT handler should cancel the in-flight turn task."""
|
"""Whether the CLI SIGINT handler should cancel the in-flight turn task."""
|
||||||
return _allow_task_cancel.get()
|
return _prompt_depth == 0
|
||||||
|
|
||||||
|
|
||||||
def set_sigint_reinstall(callback: Callable[[], None] | None) -> None:
|
def set_sigint_reinstall(callback: Callable[[], None] | None) -> None:
|
||||||
@@ -34,8 +36,14 @@ def pause_task_cancel_for_prompt() -> Generator[None]:
|
|||||||
Must run on the main thread (signal handlers are main-thread only).
|
Must run on the main thread (signal handlers are main-thread only).
|
||||||
Restores the default SIGINT handler so ``click.confirm`` can receive
|
Restores the default SIGINT handler so ``click.confirm`` can receive
|
||||||
KeyboardInterrupt / Abort instead of the asyncio turn being cancelled.
|
KeyboardInterrupt / Abort instead of the asyncio turn being cancelled.
|
||||||
|
|
||||||
|
Nested prompts are supported via a depth counter. The asyncio SIGINT
|
||||||
|
handler is reinstalled only after depth returns to 0, and only after
|
||||||
|
cancel is re-enabled, so the handler never freezes ``allow=False``.
|
||||||
"""
|
"""
|
||||||
token = _allow_task_cancel.set(False)
|
|
||||||
|
global _prompt_depth # noqa: PLW0603
|
||||||
|
_prompt_depth += 1
|
||||||
loop_handler_removed = False
|
loop_handler_removed = False
|
||||||
previous: SigHandler = signal.SIG_DFL
|
previous: SigHandler = signal.SIG_DFL
|
||||||
try:
|
try:
|
||||||
@@ -56,11 +64,15 @@ def pause_task_cancel_for_prompt() -> Generator[None]:
|
|||||||
finally:
|
finally:
|
||||||
with contextlib.suppress(ValueError):
|
with contextlib.suppress(ValueError):
|
||||||
_ = signal.signal(signal.SIGINT, previous)
|
_ = signal.signal(signal.SIGINT, previous)
|
||||||
|
_prompt_depth = max(0, _prompt_depth - 1)
|
||||||
|
# Reinstall only when fully out of prompts and cancel is allowed again.
|
||||||
|
# Order matters: decrement depth first so allow_task_cancel() is True
|
||||||
|
# before reinstall (and handlers never freeze allow=False).
|
||||||
|
if _prompt_depth == 0 and loop_handler_removed:
|
||||||
reinstall = _reinstall_holder[0]
|
reinstall = _reinstall_holder[0]
|
||||||
if loop_handler_removed and reinstall is not None:
|
if reinstall is not None:
|
||||||
with contextlib.suppress(RuntimeError, NotImplementedError, ValueError):
|
with contextlib.suppress(RuntimeError, NotImplementedError, ValueError):
|
||||||
reinstall()
|
reinstall()
|
||||||
_allow_task_cancel.reset(token)
|
|
||||||
|
|
||||||
|
|
||||||
async def run_in_prompt_thread[**P, R](func: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R:
|
async def run_in_prompt_thread[**P, R](func: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R:
|
||||||
|
|||||||
+118
-13
@@ -1,16 +1,20 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
from typing import TYPE_CHECKING, Literal
|
from typing import TYPE_CHECKING, Literal
|
||||||
|
|
||||||
from plyngent.cli.interrupt import pause_task_cancel_for_prompt
|
from plyngent.cli.interrupt import pause_task_cancel_for_prompt
|
||||||
from plyngent.prompting import (
|
from plyngent.prompting import (
|
||||||
ChoiceOption,
|
ChoiceOption,
|
||||||
NonInteractiveError,
|
NonInteractiveError,
|
||||||
|
ask,
|
||||||
|
ask_async,
|
||||||
choose,
|
choose,
|
||||||
choose_async,
|
choose_async,
|
||||||
configure_prompting,
|
configure_prompting,
|
||||||
confirm,
|
confirm,
|
||||||
confirm_async,
|
confirm_async,
|
||||||
|
get_prompt_backend,
|
||||||
)
|
)
|
||||||
from plyngent.tools.process.pty_session import PtyManager
|
from plyngent.tools.process.pty_session import PtyManager
|
||||||
|
|
||||||
@@ -19,6 +23,78 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
type WorkspaceMismatchChoice = Literal["keep", "rebind", "abort"]
|
type WorkspaceMismatchChoice = Literal["keep", "rebind", "abort"]
|
||||||
|
|
||||||
|
_BOX_MIN_WIDTH = 40
|
||||||
|
_BOX_MAX_WIDTH = 100
|
||||||
|
_BOX_PAD = 2 # spaces inside left/right borders
|
||||||
|
|
||||||
|
|
||||||
|
def _terminal_width() -> int:
|
||||||
|
try:
|
||||||
|
return max(_BOX_MIN_WIDTH, min(_BOX_MAX_WIDTH, shutil.get_terminal_size(fallback=(80, 24)).columns))
|
||||||
|
except OSError:
|
||||||
|
return 80
|
||||||
|
|
||||||
|
|
||||||
|
def _wrap_line(text: str, width: int) -> list[str]:
|
||||||
|
"""Hard-wrap a single logical line to *width* (no word-break library)."""
|
||||||
|
width = max(width, 8)
|
||||||
|
if not text:
|
||||||
|
return [""]
|
||||||
|
if len(text) <= width:
|
||||||
|
return [text]
|
||||||
|
out: list[str] = []
|
||||||
|
rest = text
|
||||||
|
while rest:
|
||||||
|
if len(rest) <= width:
|
||||||
|
out.append(rest)
|
||||||
|
break
|
||||||
|
# Prefer break at last space in the window.
|
||||||
|
window = rest[:width]
|
||||||
|
break_at = window.rfind(" ")
|
||||||
|
if break_at >= width // 2:
|
||||||
|
out.append(rest[:break_at])
|
||||||
|
rest = rest[break_at + 1 :]
|
||||||
|
else:
|
||||||
|
out.append(rest[:width])
|
||||||
|
rest = rest[width:]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def format_tool_confirm_box(name: str, reason: str) -> str:
|
||||||
|
"""Multi-line boxed confirm body (header + reason lines).
|
||||||
|
|
||||||
|
Printed with :meth:`PromptBackend.echo` before a short ``confirm()`` prompt
|
||||||
|
so terminals keep newlines (unlike a single jammed readline line).
|
||||||
|
"""
|
||||||
|
term = _terminal_width()
|
||||||
|
inner = max(20, term - 4) # room for ``│ `` + `` │``
|
||||||
|
header = f"confirm · tool {name!r}"
|
||||||
|
body_lines: list[str] = []
|
||||||
|
for raw in reason.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
|
||||||
|
body_lines.extend(_wrap_line(raw, inner - _BOX_PAD))
|
||||||
|
content = [header, "─" * min(inner, max(len(header), 12)), *body_lines]
|
||||||
|
width = min(inner, max(len(line) for line in content) + _BOX_PAD)
|
||||||
|
width = max(width, min(inner, len(header) + _BOX_PAD))
|
||||||
|
top = "┌" + "─" * (width + 2) + "┐"
|
||||||
|
bottom = "└" + "─" * (width + 2) + "┘"
|
||||||
|
rows = [top]
|
||||||
|
for line in content:
|
||||||
|
# Second line is a separator drawn with box dashes already in content.
|
||||||
|
if line.startswith("─") and set(line) <= {"─"}:
|
||||||
|
rows.append("├" + "─" * (width + 2) + "┤")
|
||||||
|
continue
|
||||||
|
padded = line[:width].ljust(width)
|
||||||
|
rows.append(f"│ {padded} │")
|
||||||
|
rows.append(bottom)
|
||||||
|
return "\n".join(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _echo_tool_confirm(name: str, reason: str) -> None:
|
||||||
|
backend = get_prompt_backend()
|
||||||
|
backend.echo()
|
||||||
|
backend.secho(format_tool_confirm_box(name, reason), fg="yellow")
|
||||||
|
backend.echo()
|
||||||
|
|
||||||
|
|
||||||
def _prompt_continue_limit_sync(reason: str) -> bool:
|
def _prompt_continue_limit_sync(reason: str) -> bool:
|
||||||
try:
|
try:
|
||||||
@@ -44,33 +120,62 @@ async def prompt_continue_limit_async(reason: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _prompt_confirm_tool_sync(name: str, args: Mapping[str, object], reason: str) -> bool:
|
def _prompt_confirm_tool_sync(name: str, args: Mapping[str, object], reason: str) -> bool | str:
|
||||||
|
"""True allow; False deny; non-empty str = deny with comment for the model."""
|
||||||
del args
|
del args
|
||||||
try:
|
try:
|
||||||
return confirm(
|
_echo_tool_confirm(name, reason)
|
||||||
f"[confirm] tool {name!r}: {reason}\nAllow this tool call?",
|
allowed = confirm("Allow this tool call?", default=False)
|
||||||
default=False,
|
|
||||||
)
|
|
||||||
except NonInteractiveError:
|
except NonInteractiveError:
|
||||||
return False
|
return False
|
||||||
|
if allowed:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
comment = ask(
|
||||||
|
"Optional comment for the agent (why denied; empty to skip):",
|
||||||
|
default="",
|
||||||
|
).strip()
|
||||||
|
except NonInteractiveError:
|
||||||
|
return False
|
||||||
|
return comment or False
|
||||||
|
|
||||||
|
|
||||||
def prompt_confirm_tool(name: str, args: Mapping[str, object], reason: str) -> bool:
|
def prompt_confirm_tool(name: str, args: Mapping[str, object], reason: str) -> bool | str:
|
||||||
"""Ask whether to allow a destructive tool call (TTY). Default is deny."""
|
"""Ask whether to allow a dangerous tool call (TTY). Default is deny.
|
||||||
|
|
||||||
|
Prints a multi-line boxed summary, then a short y/N prompt. On deny,
|
||||||
|
optionally collect a free-text comment for the model.
|
||||||
|
"""
|
||||||
with pause_task_cancel_for_prompt():
|
with pause_task_cancel_for_prompt():
|
||||||
return _prompt_confirm_tool_sync(name, args, reason)
|
return _prompt_confirm_tool_sync(name, args, reason)
|
||||||
|
|
||||||
|
|
||||||
async def prompt_confirm_tool_async(name: str, args: Mapping[str, object], reason: str) -> bool:
|
async def prompt_confirm_tool_async(name: str, args: Mapping[str, object], reason: str) -> bool | str:
|
||||||
"""Async variant: confirm off the event loop."""
|
"""Async confirm: True allow, False deny, str = deny with user comment."""
|
||||||
del args
|
del args
|
||||||
try:
|
try:
|
||||||
return await confirm_async(
|
# Box is printed inside the worker thread via confirm's backend.
|
||||||
f"[confirm] tool {name!r}: {reason}\nAllow this tool call?",
|
def _run() -> bool:
|
||||||
default=False,
|
_echo_tool_confirm(name, reason)
|
||||||
)
|
return confirm("Allow this tool call?", default=False)
|
||||||
|
|
||||||
|
from plyngent.prompting import run_prompt_async
|
||||||
|
|
||||||
|
allowed = await run_prompt_async(_run)
|
||||||
except NonInteractiveError:
|
except NonInteractiveError:
|
||||||
return False
|
return False
|
||||||
|
if allowed:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
comment = (
|
||||||
|
await ask_async(
|
||||||
|
"Optional comment for the agent (why denied; empty to skip):",
|
||||||
|
default="",
|
||||||
|
)
|
||||||
|
).strip()
|
||||||
|
except NonInteractiveError:
|
||||||
|
return False
|
||||||
|
return comment or False
|
||||||
|
|
||||||
|
|
||||||
def _prompt_workspace_mismatch_sync(
|
def _prompt_workspace_mismatch_sync(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -54,8 +54,10 @@ def build_completer(state: ReplState) -> Callable[[str, int], str | None]:
|
|||||||
options = filter_prefix(text, slash_commands())
|
options = filter_prefix(text, slash_commands())
|
||||||
else:
|
else:
|
||||||
head = buffer[:begidx].strip()
|
head = buffer[:begidx].strip()
|
||||||
command = head.split()[0] if head else ""
|
tokens = head.split() if head else []
|
||||||
options = complete_slash_args(state, command, text)
|
command = tokens[0] if tokens else ""
|
||||||
|
prior_args = tokens[1:] if len(tokens) > 1 else []
|
||||||
|
options = complete_slash_args(state, command, text, prior_args=prior_args)
|
||||||
if state_index < len(options):
|
if state_index < len(options):
|
||||||
return options[state_index]
|
return options[state_index]
|
||||||
return None
|
return None
|
||||||
@@ -77,6 +79,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 +108,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)
|
||||||
|
try:
|
||||||
_ = await run_user_text_with_retries(state.agent, text)
|
_ = await run_user_text_with_retries(state.agent, text)
|
||||||
|
finally:
|
||||||
|
state.expire_yolo_once()
|
||||||
continue
|
continue
|
||||||
|
|
||||||
_echo_user(entry)
|
_echo_user(entry)
|
||||||
|
try:
|
||||||
_ = await run_user_text_with_retries(state.agent, entry)
|
_ = await run_user_text_with_retries(state.agent, entry)
|
||||||
|
finally:
|
||||||
|
state.expire_yolo_once()
|
||||||
|
|||||||
@@ -17,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
|
||||||
|
|
||||||
|
|
||||||
@@ -51,6 +66,9 @@ async def run_cancellable[T](coro: Coroutine[object, object, T]) -> T:
|
|||||||
installed = False
|
installed = False
|
||||||
|
|
||||||
def _on_sigint() -> None:
|
def _on_sigint() -> None:
|
||||||
|
# allow_task_cancel() uses a process-level depth counter (not ContextVar)
|
||||||
|
# so this remains correct even if the handler was installed under a
|
||||||
|
# frozen context (asyncio signal handles capture contextvars).
|
||||||
if allow_task_cancel() and not task.done():
|
if allow_task_cancel() and not task.done():
|
||||||
_ = task.cancel()
|
_ = task.cancel()
|
||||||
|
|
||||||
|
|||||||
+457
-46
@@ -15,7 +15,8 @@ from plyngent.cli.selection import select_model, select_provider
|
|||||||
from plyngent.lmproto.openai_compatible.model import (
|
from plyngent.lmproto.openai_compatible.model import (
|
||||||
AssistantChatMessage,
|
AssistantChatMessage,
|
||||||
AssistantFunctionToolCall,
|
AssistantFunctionToolCall,
|
||||||
ToolChatMessage,
|
DeveloperChatMessage,
|
||||||
|
SystemChatMessage,
|
||||||
UserChatMessage,
|
UserChatMessage,
|
||||||
)
|
)
|
||||||
from plyngent.runtime import ProviderNotSupportedError
|
from plyngent.runtime import ProviderNotSupportedError
|
||||||
@@ -23,23 +24,67 @@ 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")
|
||||||
|
_TODO_ACTION_CHOICES = (
|
||||||
|
"list",
|
||||||
|
"show",
|
||||||
|
"ls",
|
||||||
|
"push",
|
||||||
|
"pop",
|
||||||
|
"done",
|
||||||
|
"cancel",
|
||||||
|
"pending",
|
||||||
|
"in_progress",
|
||||||
|
"clear",
|
||||||
|
)
|
||||||
|
_ROUNDS_CHOICES = ("8", "16", "32", "64", "128")
|
||||||
|
|
||||||
|
|
||||||
|
class HistoryLimitType(click.ParamType[int]):
|
||||||
|
"""``N`` (int >= 1) or the shortcut ``last`` (equivalent to ``1``)."""
|
||||||
|
|
||||||
|
name: str = "history_limit"
|
||||||
|
|
||||||
|
@override
|
||||||
|
def convert(self, value: object, param: click.Parameter | None, ctx: click.Context | None) -> int:
|
||||||
|
if isinstance(value, int):
|
||||||
|
if value < 1:
|
||||||
|
self.fail("must be >= 1", param, ctx)
|
||||||
|
return value
|
||||||
|
text = str(value).strip().lower()
|
||||||
|
if text == "last":
|
||||||
|
return 1
|
||||||
|
try:
|
||||||
|
n = int(text, 10)
|
||||||
|
except ValueError:
|
||||||
|
self.fail("expected an integer N or 'last'", param, ctx)
|
||||||
|
if n < 1:
|
||||||
|
self.fail("must be >= 1", param, ctx)
|
||||||
|
return n
|
||||||
|
|
||||||
|
@override
|
||||||
|
def shell_complete(self, ctx: click.Context, param: click.Parameter, incomplete: str) -> list[CompletionItem]:
|
||||||
|
del ctx, param
|
||||||
|
tokens = ("last", "1", "5", "10", "20")
|
||||||
|
return [CompletionItem(token) for token in tokens if incomplete == "" or token.startswith(incomplete.lower())]
|
||||||
|
|
||||||
|
|
||||||
HELP_FOOTER = (
|
HELP_FOOTER = (
|
||||||
"User messages are saved immediately. On API errors or Ctrl+C, partial\n"
|
"User messages are saved immediately. On API errors or Ctrl+C, partial\n"
|
||||||
"assistant/tool output is discarded but the user message stays (so /retry\n"
|
"assistant/tool output is discarded but the user message stays (so /retry\n"
|
||||||
"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 arguments (provider, model, session ids,\n"
|
||||||
"stream, verbose, export). Use --session ID or /resume to continue a prior\n"
|
"todos actions, on/off, yolo, export, flags). Use --session ID or /resume to\n"
|
||||||
"chat after restart.\n"
|
"continue a prior 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'
|
||||||
"Long prompts: /edit opens $EDITOR.\n"
|
"Long prompts: /edit opens $EDITOR.\n"
|
||||||
@@ -86,6 +131,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)."""
|
||||||
|
|
||||||
@@ -105,6 +174,80 @@ class ExportFormatParam(click.ParamType[str]):
|
|||||||
EXPORT_FORMAT = ExportFormatParam()
|
EXPORT_FORMAT = ExportFormatParam()
|
||||||
|
|
||||||
|
|
||||||
|
class TodosActionParam(click.ParamType[str]):
|
||||||
|
"""First token of ``/todos``: list|push|pop|done|…"""
|
||||||
|
|
||||||
|
name: str = "todos_action"
|
||||||
|
|
||||||
|
@override
|
||||||
|
def convert(self, value: Any, param: click.Parameter | None, ctx: click.Context | None) -> str:
|
||||||
|
del param, ctx
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
@override
|
||||||
|
def shell_complete(self, ctx: click.Context, param: click.Parameter, incomplete: str) -> list[CompletionItem]:
|
||||||
|
del ctx, param
|
||||||
|
return _filter_choices(incomplete, _TODO_ACTION_CHOICES)
|
||||||
|
|
||||||
|
|
||||||
|
TODOS_ACTION = TodosActionParam()
|
||||||
|
|
||||||
|
|
||||||
|
class SessionIdParam(click.ParamType[int]):
|
||||||
|
"""Session id for ``/resume`` / ``/delete`` (from ReplState cache + current)."""
|
||||||
|
|
||||||
|
name: str = "session_id"
|
||||||
|
|
||||||
|
@override
|
||||||
|
def convert(self, value: Any, param: click.Parameter | None, ctx: click.Context | None) -> int:
|
||||||
|
if isinstance(value, int):
|
||||||
|
return value
|
||||||
|
text = str(value).strip()
|
||||||
|
try:
|
||||||
|
return int(text, 10)
|
||||||
|
except ValueError:
|
||||||
|
self.fail("expected a session id (integer)", param, ctx)
|
||||||
|
|
||||||
|
@override
|
||||||
|
def shell_complete(self, ctx: click.Context, param: click.Parameter, incomplete: str) -> list[CompletionItem]:
|
||||||
|
del param
|
||||||
|
state = _repl_state(ctx)
|
||||||
|
if state is None:
|
||||||
|
return []
|
||||||
|
# Sync cache only — no async from readline Tab (no awaitlet greenlet).
|
||||||
|
return _filter_choices(incomplete, state.session_ids_for_complete())
|
||||||
|
|
||||||
|
|
||||||
|
SESSION_ID = SessionIdParam()
|
||||||
|
|
||||||
|
|
||||||
|
class RoundsParam(click.ParamType[int]):
|
||||||
|
"""Max tool-loop rounds (int >= 1); Tab suggests common values."""
|
||||||
|
|
||||||
|
name: str = "rounds"
|
||||||
|
|
||||||
|
@override
|
||||||
|
def convert(self, value: Any, param: click.Parameter | None, ctx: click.Context | None) -> int:
|
||||||
|
if isinstance(value, int):
|
||||||
|
n = value
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
n = int(str(value).strip(), 10)
|
||||||
|
except ValueError:
|
||||||
|
self.fail("expected an integer >= 1", param, ctx)
|
||||||
|
if n < 1:
|
||||||
|
self.fail("max_rounds must be >= 1", param, ctx)
|
||||||
|
return n
|
||||||
|
|
||||||
|
@override
|
||||||
|
def shell_complete(self, ctx: click.Context, param: click.Parameter, incomplete: str) -> list[CompletionItem]:
|
||||||
|
del ctx, param
|
||||||
|
return _filter_choices(incomplete, _ROUNDS_CHOICES)
|
||||||
|
|
||||||
|
|
||||||
|
ROUNDS = RoundsParam()
|
||||||
|
|
||||||
|
|
||||||
class ProviderNameParam(click.ParamType[str]):
|
class ProviderNameParam(click.ParamType[str]):
|
||||||
name: str = "provider"
|
name: str = "provider"
|
||||||
|
|
||||||
@@ -207,21 +350,55 @@ def slash_command_names() -> list[str]:
|
|||||||
return sorted(f"/{name}" for name in slash.list_commands(ctx))
|
return sorted(f"/{name}" for name in slash.list_commands(ctx))
|
||||||
|
|
||||||
|
|
||||||
def complete_slash_args(state: ReplState, command: str, incomplete: str) -> list[str]:
|
def complete_slash_args(
|
||||||
"""Tab-complete arguments for ``command`` (e.g. ``/stream``) from ParamTypes.
|
state: ReplState,
|
||||||
|
command: str,
|
||||||
|
incomplete: str,
|
||||||
|
*,
|
||||||
|
prior_args: Sequence[str] | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Tab-complete arguments/options for ``command`` from ParamTypes.
|
||||||
|
|
||||||
Uses the first :class:`click.Argument` on the registered command whose type
|
*prior_args* are tokens already typed after the command name (so
|
||||||
implements :meth:`~click.ParamType.shell_complete` with candidates.
|
``/todos done t`` can complete todo ids). Options are completed when
|
||||||
|
*incomplete* starts with ``-``.
|
||||||
"""
|
"""
|
||||||
name = command.lstrip("/").lower()
|
name = command.lstrip("/").lower()
|
||||||
ctx = click.Context(slash, obj=state)
|
ctx = click.Context(slash, obj=state)
|
||||||
cmd = slash.get_command(ctx, name)
|
cmd = slash.get_command(ctx, name)
|
||||||
if cmd is None:
|
if cmd is None:
|
||||||
return []
|
return []
|
||||||
|
prior = list(prior_args or ())
|
||||||
|
|
||||||
with click.Context(cmd, info_name=name, parent=ctx, obj=state) as sub:
|
with click.Context(cmd, info_name=name, parent=ctx, obj=state) as sub:
|
||||||
for param in cmd.params:
|
# Flag / option completion (e.g. --persist, --full).
|
||||||
if not isinstance(param, click.Argument):
|
if incomplete.startswith("-"):
|
||||||
continue
|
flags = [
|
||||||
|
opt
|
||||||
|
for param in cmd.params
|
||||||
|
if isinstance(param, click.Option)
|
||||||
|
for opt in param.opts
|
||||||
|
if opt.startswith(incomplete)
|
||||||
|
]
|
||||||
|
if flags:
|
||||||
|
return sorted(set(flags))
|
||||||
|
|
||||||
|
# Contextual /todos second token: todo ids after done|cancel|pending|in_progress.
|
||||||
|
if name == "todos" and prior:
|
||||||
|
action = prior[0].lower()
|
||||||
|
if action in {"done", "cancel", "pending", "in_progress"} and len(prior) == 1:
|
||||||
|
ids = [item.id for item in state.todo_stack.all_items()]
|
||||||
|
return [i for i in ids if i.startswith(incomplete)]
|
||||||
|
|
||||||
|
# Positional arguments: complete the next unset argument.
|
||||||
|
arg_params = [p for p in cmd.params if isinstance(p, click.Argument)]
|
||||||
|
arg_index = len(prior)
|
||||||
|
if arg_index < len(arg_params):
|
||||||
|
param = arg_params[arg_index]
|
||||||
|
items = param.type.shell_complete(sub, param, incomplete)
|
||||||
|
if items:
|
||||||
|
return [item.value for item in items]
|
||||||
|
for param in arg_params:
|
||||||
items = param.type.shell_complete(sub, param, incomplete)
|
items = param.type.shell_complete(sub, param, incomplete)
|
||||||
if items:
|
if items:
|
||||||
return [item.value for item in items]
|
return [item.value for item in items]
|
||||||
@@ -355,7 +532,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"
|
||||||
@@ -372,6 +550,7 @@ def status_cmd(state: ReplState) -> None:
|
|||||||
def sessions_cmd(state: ReplState) -> None:
|
def sessions_cmd(state: ReplState) -> None:
|
||||||
"""List sessions for this workspace (newest first)."""
|
"""List sessions for this workspace (newest first)."""
|
||||||
sessions = _await(state.memory.list_sessions(workspace=state.workspace))
|
sessions = _await(state.memory.list_sessions(workspace=state.workspace))
|
||||||
|
state.remember_session_ids([s.sid for s in sessions])
|
||||||
if not sessions:
|
if not sessions:
|
||||||
click.echo(f"(no sessions for workspace {state.workspace})")
|
click.echo(f"(no sessions for workspace {state.workspace})")
|
||||||
return
|
return
|
||||||
@@ -409,7 +588,7 @@ def rename_cmd(state: ReplState, name: tuple[str, ...]) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@slash.command("delete")
|
@slash.command("delete")
|
||||||
@click.argument("session_id", type=int, required=False)
|
@click.argument("session_id", type=SESSION_ID, required=False)
|
||||||
@click.pass_obj
|
@click.pass_obj
|
||||||
def delete_cmd(state: ReplState, session_id: int | None) -> None:
|
def delete_cmd(state: ReplState, session_id: int | None) -> None:
|
||||||
"""Hard-delete a session (confirm; current → new empty)."""
|
"""Hard-delete a session (confirm; current → new empty)."""
|
||||||
@@ -512,7 +691,7 @@ def export_cmd(state: ReplState, parts: tuple[str, ...]) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@slash.command("resume")
|
@slash.command("resume")
|
||||||
@click.argument("session_id", type=int, required=False)
|
@click.argument("session_id", type=SESSION_ID, required=False)
|
||||||
@click.pass_obj
|
@click.pass_obj
|
||||||
def resume_cmd(state: ReplState, session_id: int | None) -> None:
|
def resume_cmd(state: ReplState, session_id: int | None) -> None:
|
||||||
"""Resume session id, or latest for this workspace if omitted."""
|
"""Resume session id, or latest for this workspace if omitted."""
|
||||||
@@ -550,6 +729,11 @@ def compact_cmd(state: ReplState, name: str | None) -> None:
|
|||||||
return
|
return
|
||||||
preview = summary if len(summary) <= _COMPACT_PREVIEW else summary[:_COMPACT_PREVIEW] + "…"
|
preview = summary if len(summary) <= _COMPACT_PREVIEW else summary[:_COMPACT_PREVIEW] + "…"
|
||||||
click.echo(f"compacted session {old_id} -> new session {new_id}")
|
click.echo(f"compacted session {old_id} -> new session {new_id}")
|
||||||
|
click.secho(
|
||||||
|
f"active session is summary-only ({len(state.agent.messages)} messages); "
|
||||||
|
f"full history remains on /resume {old_id}",
|
||||||
|
fg="bright_black",
|
||||||
|
)
|
||||||
click.secho(preview, fg="bright_black")
|
click.secho(preview, fg="bright_black")
|
||||||
|
|
||||||
|
|
||||||
@@ -581,7 +765,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:
|
||||||
@@ -610,13 +795,19 @@ def provider_cmd(state: ReplState, name: str | None) -> None:
|
|||||||
|
|
||||||
@slash.command("models")
|
@slash.command("models")
|
||||||
@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.option(
|
||||||
|
"--persist",
|
||||||
|
is_flag=True,
|
||||||
|
help="Merge remote (and recovered) model ids into plyngent.toml for this provider.",
|
||||||
|
)
|
||||||
@click.pass_obj
|
@click.pass_obj
|
||||||
def models_cmd(state: ReplState, *, refresh: bool) -> None:
|
def models_cmd(state: ReplState, *, refresh: bool, persist: bool) -> None: # noqa: C901
|
||||||
"""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()
|
||||||
@@ -634,6 +825,14 @@ def models_cmd(state: ReplState, *, refresh: bool) -> None:
|
|||||||
except (KeyError, ValueError) as exc:
|
except (KeyError, ValueError) as exc:
|
||||||
click.secho(f"could not recover provider: {exc}", fg="yellow", err=True)
|
click.secho(f"could not recover provider: {exc}", fg="yellow", err=True)
|
||||||
|
|
||||||
|
if persist:
|
||||||
|
try:
|
||||||
|
path = state.persist_models_to_config(mode="catalog", catalog_ids=remote or ())
|
||||||
|
click.echo(f"persisted {len(state.provider.models)} model(s) for {state.provider_name!r} to {path}")
|
||||||
|
except (KeyError, ValueError, OSError) as exc:
|
||||||
|
click.secho(f"error: could not persist models: {exc}", fg="red")
|
||||||
|
return
|
||||||
|
|
||||||
config_ids = set(state.config_model_ids())
|
config_ids = set(state.config_model_ids())
|
||||||
choices = model_choices_for_provider(state.provider, remote_ids=remote)
|
choices = model_choices_for_provider(state.provider, remote_ids=remote)
|
||||||
|
|
||||||
@@ -643,10 +842,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,21 +854,30 @@ 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@slash.command("model")
|
@slash.command("model")
|
||||||
@click.argument("model_id", required=False, type=MODEL_ID)
|
@click.argument("model_id", required=False, type=MODEL_ID)
|
||||||
|
@click.option(
|
||||||
|
"--persist",
|
||||||
|
is_flag=True,
|
||||||
|
help="Write the current (or newly selected) model id into plyngent.toml.",
|
||||||
|
)
|
||||||
@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, *, persist: bool) -> 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}")
|
``/model --persist`` saves the active model id into the provider catalog in
|
||||||
return
|
TOML (faster Tab/list on next launch). ``/model <id> --persist`` switches
|
||||||
|
then saves.
|
||||||
|
"""
|
||||||
|
if model_id:
|
||||||
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(),
|
||||||
@@ -680,6 +888,17 @@ def model_cmd(state: ReplState, model_id: str | None) -> None:
|
|||||||
click.echo(f"switched model to {state.model}")
|
click.echo(f"switched model to {state.model}")
|
||||||
except click.ClickException as exc:
|
except click.ClickException as exc:
|
||||||
click.echo(f"error: {exc}")
|
click.echo(f"error: {exc}")
|
||||||
|
return
|
||||||
|
elif not persist:
|
||||||
|
click.echo(f"model={state.model}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if persist:
|
||||||
|
try:
|
||||||
|
path = state.persist_models_to_config(mode="current")
|
||||||
|
click.echo(f"persisted model {state.model!r} for {state.provider_name!r} to {path}")
|
||||||
|
except (KeyError, ValueError, OSError) as exc:
|
||||||
|
click.secho(f"error: could not persist model: {exc}", fg="red")
|
||||||
|
|
||||||
|
|
||||||
@slash.command("tools")
|
@slash.command("tools")
|
||||||
@@ -698,6 +917,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
|
||||||
@@ -753,45 +990,160 @@ def markdown_cmd(state: ReplState, enabled: bool | None) -> None: # noqa: FBT00
|
|||||||
|
|
||||||
|
|
||||||
@slash.command("rounds")
|
@slash.command("rounds")
|
||||||
@click.argument("n", required=False, type=int)
|
@click.argument("n", required=False, type=ROUNDS)
|
||||||
@click.pass_obj
|
@click.pass_obj
|
||||||
def rounds_cmd(state: ReplState, n: int | None) -> None:
|
def rounds_cmd(state: ReplState, n: int | None) -> None:
|
||||||
"""Show or set max tool-loop rounds."""
|
"""Show or set max tool-loop rounds."""
|
||||||
if n is None:
|
if n is None:
|
||||||
click.echo(f"max_rounds={state.max_rounds}")
|
click.echo(f"max_rounds={state.max_rounds}")
|
||||||
return
|
return
|
||||||
if n < 1:
|
|
||||||
msg = "max_rounds must be >= 1"
|
|
||||||
raise click.UsageError(msg)
|
|
||||||
state.max_rounds = n
|
state.max_rounds = n
|
||||||
state.agent.max_rounds = n
|
state.agent.max_rounds = n
|
||||||
click.echo(f"max_rounds={state.max_rounds}")
|
click.echo(f"max_rounds={state.max_rounds}")
|
||||||
|
|
||||||
|
|
||||||
@slash.command("history")
|
@slash.command("history")
|
||||||
@click.argument("n", required=False, type=int)
|
@click.argument("n", required=False, type=HistoryLimitType())
|
||||||
|
@click.option(
|
||||||
|
"--full",
|
||||||
|
"mode_full",
|
||||||
|
is_flag=True,
|
||||||
|
default=False,
|
||||||
|
help="Print full message bodies (markdown for assistant when TTY).",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--preview",
|
||||||
|
"mode_preview",
|
||||||
|
is_flag=True,
|
||||||
|
default=False,
|
||||||
|
help="Force short previews even for a single message.",
|
||||||
|
)
|
||||||
@click.pass_obj
|
@click.pass_obj
|
||||||
def history_cmd(state: ReplState, n: int | None) -> None:
|
def history_cmd(
|
||||||
"""Show last n messages in this session (default 20)."""
|
state: ReplState,
|
||||||
limit = _DEFAULT_HISTORY_LINES if n is None else n
|
n: int | None,
|
||||||
if limit < 1:
|
*,
|
||||||
msg = "n must be >= 1"
|
mode_full: bool,
|
||||||
|
mode_preview: bool,
|
||||||
|
) -> None:
|
||||||
|
"""Show recent messages (default: last 20 as short previews).
|
||||||
|
|
||||||
|
``/history last`` or ``/history 1`` shows the last message in full with Rich
|
||||||
|
markdown when available. Use ``--full`` for multiple messages in full;
|
||||||
|
``--preview`` forces the short form even for a single message.
|
||||||
|
"""
|
||||||
|
if mode_full and mode_preview:
|
||||||
|
msg = "use only one of --full / --preview"
|
||||||
raise click.UsageError(msg)
|
raise click.UsageError(msg)
|
||||||
|
limit = _DEFAULT_HISTORY_LINES if n is None else n
|
||||||
messages = state.agent.messages
|
messages = state.agent.messages
|
||||||
if not messages:
|
if not messages:
|
||||||
click.echo("(no messages in this session)")
|
click.echo("(no messages in this session)")
|
||||||
return
|
return
|
||||||
start = max(0, len(messages) - limit)
|
start = max(0, len(messages) - limit)
|
||||||
click.echo(f"session={state.session_id} messages={len(messages)} showing={len(messages) - start}")
|
slice_msgs = messages[start:]
|
||||||
for offset, message in enumerate(messages[start:]):
|
# Full: --full, or single-message window (last / 1) unless --preview.
|
||||||
click.echo(_format_history_message(start + offset, message))
|
full = mode_full or (len(slice_msgs) == 1 and not mode_preview)
|
||||||
|
mode_tag = "full" if full else "preview"
|
||||||
|
click.echo(f"session={state.session_id} messages={len(messages)} showing={len(slice_msgs)} mode={mode_tag}")
|
||||||
|
for offset, message in enumerate(slice_msgs):
|
||||||
|
idx = start + offset
|
||||||
|
if full:
|
||||||
|
_print_history_message_full(idx, message)
|
||||||
|
else:
|
||||||
|
click.echo(_format_history_message(idx, message))
|
||||||
if state.agent.pending_retry_text is not None:
|
if state.agent.pending_retry_text is not None:
|
||||||
|
pending = state.agent.pending_retry_text
|
||||||
|
if full:
|
||||||
|
click.secho("(pending retry) user:", fg="yellow")
|
||||||
|
click.echo(pending)
|
||||||
|
else:
|
||||||
click.secho(
|
click.secho(
|
||||||
f"(pending retry) user: {_preview_content(state.agent.pending_retry_text)}",
|
f"(pending retry) user: {_preview_content(pending)}",
|
||||||
fg="yellow",
|
fg="yellow",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@slash.command("todos")
|
||||||
|
@click.argument("action", required=False, type=TODOS_ACTION)
|
||||||
|
@click.argument("rest", required=False, nargs=-1, type=str)
|
||||||
|
@click.pass_obj
|
||||||
|
def todos_cmd( # noqa: C901, PLR0911, PLR0912, PLR0915
|
||||||
|
state: ReplState, action: str | None, rest: tuple[str, ...]
|
||||||
|
) -> None:
|
||||||
|
"""Show or edit the LIFO stack of **task groups**.
|
||||||
|
|
||||||
|
``/todos`` — list (top group first)
|
||||||
|
``/todos push T1; T2`` — one new group of siblings
|
||||||
|
``/todos pop`` — pop entire top group
|
||||||
|
``/todos done <id>`` / ``/todos cancel <id>`` — set status
|
||||||
|
``/todos clear`` — wipe stack
|
||||||
|
"""
|
||||||
|
from plyngent.agent.todo_stack import parse_push_titles
|
||||||
|
|
||||||
|
stack = state.todo_stack
|
||||||
|
act = (action or "list").strip().lower()
|
||||||
|
if act in {"list", "show", "ls"}:
|
||||||
|
click.echo(stack.render())
|
||||||
|
return
|
||||||
|
if act == "push":
|
||||||
|
raw = " ".join(rest).strip()
|
||||||
|
if not raw:
|
||||||
|
click.echo('error: usage: /todos push <title> | /todos push ["T1","T2"] | T1; T2')
|
||||||
|
return
|
||||||
|
titles = parse_push_titles(raw)
|
||||||
|
if not titles:
|
||||||
|
click.echo("error: no titles to push (use a JSON array or ; / newlines)")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
group = stack.push_group(titles)
|
||||||
|
except ValueError as exc:
|
||||||
|
click.echo(f"error: {exc}")
|
||||||
|
return
|
||||||
|
_await(state.persist_todo_stack())
|
||||||
|
ids = ", ".join(i.id for i in group.items)
|
||||||
|
click.echo(f"pushed group depth={stack.depth} items=[{ids}]")
|
||||||
|
click.echo(stack.render())
|
||||||
|
return
|
||||||
|
if act == "pop":
|
||||||
|
group = stack.pop()
|
||||||
|
if group is None:
|
||||||
|
click.echo("todo stack empty")
|
||||||
|
return
|
||||||
|
_await(state.persist_todo_stack())
|
||||||
|
titles = ", ".join(f"{i.id}:{i.title}" for i in group.items) or "(empty)"
|
||||||
|
click.echo(f"popped TOP group ({titles})")
|
||||||
|
click.echo(stack.render())
|
||||||
|
return
|
||||||
|
if act in {"done", "cancel", "pending", "in_progress"}:
|
||||||
|
if not rest:
|
||||||
|
click.echo(f"error: usage: /todos {act} <id>")
|
||||||
|
return
|
||||||
|
if act == "done":
|
||||||
|
new_status = "done"
|
||||||
|
elif act == "cancel":
|
||||||
|
new_status = "cancelled"
|
||||||
|
elif act == "pending":
|
||||||
|
new_status = "pending"
|
||||||
|
else:
|
||||||
|
new_status = "in_progress"
|
||||||
|
try:
|
||||||
|
item = stack.update(rest[0], status=new_status)
|
||||||
|
except (KeyError, ValueError) as exc:
|
||||||
|
click.echo(f"error: {exc}")
|
||||||
|
return
|
||||||
|
_await(state.persist_todo_stack())
|
||||||
|
click.echo(f"updated {item.id} → {item.status}")
|
||||||
|
click.echo(stack.render())
|
||||||
|
return
|
||||||
|
if act == "clear":
|
||||||
|
n = stack.clear()
|
||||||
|
_await(state.persist_todo_stack())
|
||||||
|
click.echo(f"cleared {n} item(s)")
|
||||||
|
return
|
||||||
|
click.echo("error: usage: /todos [list|push|pop|done|cancel|clear] …")
|
||||||
|
|
||||||
|
|
||||||
@slash.command("retry")
|
@slash.command("retry")
|
||||||
@click.pass_obj
|
@click.pass_obj
|
||||||
def retry_cmd(state: ReplState) -> None:
|
def retry_cmd(state: ReplState) -> None:
|
||||||
@@ -811,9 +1163,71 @@ def _preview_content(text: str | None) -> str:
|
|||||||
return text[:_CONTENT_PREVIEW] + "…"
|
return text[:_CONTENT_PREVIEW] + "…"
|
||||||
|
|
||||||
|
|
||||||
|
def _print_history_assistant_full(index: int, message: AssistantChatMessage) -> None:
|
||||||
|
from plyngent.cli.display import markdown_render_available, print_markdown
|
||||||
|
|
||||||
|
click.secho(f"{index}. assistant:", fg="cyan")
|
||||||
|
content = message.content
|
||||||
|
has_text = isinstance(content, str) and content.strip()
|
||||||
|
if has_text and isinstance(content, str):
|
||||||
|
if markdown_render_available():
|
||||||
|
print_markdown(content, label="")
|
||||||
|
else:
|
||||||
|
click.echo(content)
|
||||||
|
reasoning = message.reasoning_content
|
||||||
|
has_reason = isinstance(reasoning, str) and reasoning.strip()
|
||||||
|
if has_reason and isinstance(reasoning, str):
|
||||||
|
click.secho("reasoning:", fg="bright_black")
|
||||||
|
click.echo(reasoning)
|
||||||
|
tool_calls = message.tool_calls
|
||||||
|
has_tools = tool_calls is not UNSET and bool(tool_calls)
|
||||||
|
if has_tools and tool_calls is not UNSET:
|
||||||
|
for call in tool_calls:
|
||||||
|
if isinstance(call, AssistantFunctionToolCall):
|
||||||
|
click.secho(
|
||||||
|
f" tool_call {call.id}: {call.function.name}({call.function.arguments})",
|
||||||
|
fg="yellow",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
click.secho(f" tool_call custom id={call.id}", fg="yellow")
|
||||||
|
if not (has_text or has_reason or has_tools):
|
||||||
|
click.echo("(empty)")
|
||||||
|
click.echo()
|
||||||
|
|
||||||
|
|
||||||
|
def _print_history_message_full(index: int, message: AnyChatMessage) -> None:
|
||||||
|
"""Print one history message with full body; Rich markdown for assistant text."""
|
||||||
|
if isinstance(message, UserChatMessage):
|
||||||
|
click.secho(f"{index}. user:", fg="green")
|
||||||
|
click.echo(message.content or "")
|
||||||
|
click.echo()
|
||||||
|
return
|
||||||
|
if isinstance(message, DeveloperChatMessage):
|
||||||
|
click.secho(f"{index}. developer:", fg="blue")
|
||||||
|
click.echo(message.content or "")
|
||||||
|
click.echo()
|
||||||
|
return
|
||||||
|
if isinstance(message, SystemChatMessage):
|
||||||
|
click.secho(f"{index}. system:", fg="bright_black")
|
||||||
|
click.echo(message.content or "")
|
||||||
|
click.echo()
|
||||||
|
return
|
||||||
|
if isinstance(message, AssistantChatMessage):
|
||||||
|
_print_history_assistant_full(index, message)
|
||||||
|
return
|
||||||
|
# ToolChatMessage (remaining AnyChatMessage arm)
|
||||||
|
click.secho(f"{index}. tool({message.tool_call_id}):", fg="magenta")
|
||||||
|
click.echo(message.content or "")
|
||||||
|
click.echo()
|
||||||
|
|
||||||
|
|
||||||
def _format_history_message(index: int, message: AnyChatMessage) -> str:
|
def _format_history_message(index: int, message: AnyChatMessage) -> str:
|
||||||
if isinstance(message, UserChatMessage):
|
if isinstance(message, UserChatMessage):
|
||||||
return f"{index}. user: {_preview_content(message.content)}"
|
return f"{index}. user: {_preview_content(message.content)}"
|
||||||
|
if isinstance(message, DeveloperChatMessage):
|
||||||
|
return f"{index}. developer: {_preview_content(message.content)}"
|
||||||
|
if isinstance(message, SystemChatMessage):
|
||||||
|
return f"{index}. system: {_preview_content(message.content)}"
|
||||||
if isinstance(message, AssistantChatMessage):
|
if isinstance(message, AssistantChatMessage):
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
if isinstance(message.content, str) and message.content:
|
if isinstance(message.content, str) and message.content:
|
||||||
@@ -829,11 +1243,8 @@ def _format_history_message(index: int, message: AnyChatMessage) -> str:
|
|||||||
parts.append(f"tool_calls=[{', '.join(names)}]")
|
parts.append(f"tool_calls=[{', '.join(names)}]")
|
||||||
body = " ".join(parts) if parts else "(empty)"
|
body = " ".join(parts) if parts else "(empty)"
|
||||||
return f"{index}. assistant: {body}"
|
return f"{index}. assistant: {body}"
|
||||||
if isinstance(message, ToolChatMessage):
|
# ToolChatMessage
|
||||||
return f"{index}. tool({message.tool_call_id}): {_preview_content(message.content)}"
|
return f"{index}. tool({message.tool_call_id}): {_preview_content(message.content)}"
|
||||||
role = getattr(message, "role", type(message).__name__)
|
|
||||||
content = getattr(message, "content", "")
|
|
||||||
return f"{index}. {role}: {_preview_content(str(content))}"
|
|
||||||
|
|
||||||
|
|
||||||
def _run_slash_argv(args: Sequence[str], state: ReplState) -> None:
|
def _run_slash_argv(args: Sequence[str], state: ReplState) -> None:
|
||||||
|
|||||||
+151
-11
@@ -4,10 +4,11 @@ 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
|
||||||
|
from plyngent.agent.todo_stack import TodoStack
|
||||||
from plyngent.cli.models_source import (
|
from plyngent.cli.models_source import (
|
||||||
DEFAULT_MODELS_CACHE_TTL,
|
DEFAULT_MODELS_CACHE_TTL,
|
||||||
client_supports_models,
|
client_supports_models,
|
||||||
@@ -17,14 +18,18 @@ from plyngent.cli.models_source import (
|
|||||||
)
|
)
|
||||||
from plyngent.memory.database.store import normalize_workspace
|
from plyngent.memory.database.store import normalize_workspace
|
||||||
from plyngent.runtime import create_client
|
from plyngent.runtime import create_client
|
||||||
from plyngent.tools import DEFAULT_TOOLS, set_workspace_root
|
from plyngent.tools import DEFAULT_TOOLS, set_todo_stack, set_workspace_root
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
from plyngent.config.models import Provider
|
from plyngent.config.models import Provider
|
||||||
from plyngent.config.store import ConfigStore
|
from plyngent.config.store import ConfigStore
|
||||||
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,13 +49,18 @@ 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)
|
||||||
agent: ChatAgent = field(init=False)
|
agent: ChatAgent = field(init=False)
|
||||||
session_id: int | None = None
|
session_id: int | None = None
|
||||||
|
todo_stack: TodoStack = field(default_factory=TodoStack)
|
||||||
|
_todo_persist_tasks: set[object] = field(default_factory=set, init=False, repr=False)
|
||||||
|
# Session ids for Tab complete (updated when listing/creating/resuming).
|
||||||
|
_session_id_cache: list[int] = field(default_factory=list, init=False, repr=False)
|
||||||
# Remote GET /models cache (per provider base).
|
# Remote GET /models cache (per provider base).
|
||||||
_remote_models: list[str] | None = field(default=None, init=False, repr=False)
|
_remote_models: list[str] | None = field(default=None, init=False, repr=False)
|
||||||
_remote_models_fetched_at: float | None = field(default=None, init=False, repr=False)
|
_remote_models_fetched_at: float | None = field(default=None, init=False, repr=False)
|
||||||
@@ -63,6 +73,7 @@ class ReplState:
|
|||||||
self.workspace = Path(self.workspace).expanduser().resolve()
|
self.workspace = Path(self.workspace).expanduser().resolve()
|
||||||
self.agent = self._make_agent()
|
self.agent = self._make_agent()
|
||||||
self.sync_display_flags()
|
self.sync_display_flags()
|
||||||
|
self._bind_todo_tools()
|
||||||
|
|
||||||
def sync_display_flags(self) -> None:
|
def sync_display_flags(self) -> None:
|
||||||
from plyngent.cli.display import set_markdown_enabled, set_verbose_tool_results
|
from plyngent.cli.display import set_markdown_enabled, set_verbose_tool_results
|
||||||
@@ -77,10 +88,70 @@ 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 _bind_todo_tools(self) -> None:
|
||||||
|
"""Point module-level todo tools at this session stack + persist hook."""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
def on_change() -> None:
|
||||||
|
if self.session_id is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
except RuntimeError:
|
||||||
|
return
|
||||||
|
task = loop.create_task(self.memory.update_session_todo_stack(self.session_id, self.todo_stack.to_raw()))
|
||||||
|
# Keep a strong ref until done so the task is not GC'd mid-flight.
|
||||||
|
self._todo_persist_tasks.add(task)
|
||||||
|
task.add_done_callback(self._todo_persist_tasks.discard)
|
||||||
|
|
||||||
|
set_todo_stack(self.todo_stack, on_change=on_change)
|
||||||
|
|
||||||
|
async def persist_todo_stack(self) -> None:
|
||||||
|
"""Write the in-memory todo stack to the active session row."""
|
||||||
|
if self.session_id is None:
|
||||||
|
return
|
||||||
|
_ = await self.memory.update_session_todo_stack(self.session_id, self.todo_stack.to_raw())
|
||||||
|
|
||||||
|
async def load_todo_stack(self) -> None:
|
||||||
|
"""Load todo stack from the active session (empty if none)."""
|
||||||
|
if self.session_id is None:
|
||||||
|
self.todo_stack = TodoStack()
|
||||||
|
self._bind_todo_tools()
|
||||||
|
if hasattr(self, "agent"):
|
||||||
|
self.agent.todo_stack = self.todo_stack
|
||||||
|
return
|
||||||
|
raw = await self.memory.get_session_todo_stack(self.session_id)
|
||||||
|
self.todo_stack = TodoStack.from_raw(raw)
|
||||||
|
self._bind_todo_tools()
|
||||||
|
if hasattr(self, "agent"):
|
||||||
|
self.agent.todo_stack = self.todo_stack
|
||||||
|
|
||||||
def _tool_registry(self) -> ToolRegistry | None:
|
def _tool_registry(self) -> ToolRegistry | None:
|
||||||
if not self.tools_enabled:
|
if not self.tools_enabled:
|
||||||
@@ -88,7 +159,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,
|
||||||
@@ -115,18 +186,22 @@ class ReplState:
|
|||||||
max_tool_result_chars=agent_cfg.max_tool_result_chars,
|
max_tool_result_chars=agent_cfg.max_tool_result_chars,
|
||||||
parallel_tools=agent_cfg.parallel_tools,
|
parallel_tools=agent_cfg.parallel_tools,
|
||||||
max_context_tokens=agent_cfg.max_context_tokens,
|
max_context_tokens=agent_cfg.max_context_tokens,
|
||||||
|
todo_stack=self.todo_stack,
|
||||||
)
|
)
|
||||||
|
|
||||||
def rebuild_client(self) -> None:
|
def rebuild_client(self) -> None:
|
||||||
"""Recreate client and agent after provider/model/tools change."""
|
"""Recreate client and agent after provider/model/tools change."""
|
||||||
messages = list(self.agent.messages)
|
messages = list(self.agent.messages)
|
||||||
|
persist_from = self.agent.persist_from
|
||||||
# Preserve live stream toggle if agent already exists.
|
# Preserve live stream toggle if agent already exists.
|
||||||
if hasattr(self, "agent"):
|
if hasattr(self, "agent"):
|
||||||
self.stream_enabled = self.agent.stream
|
self.stream_enabled = self.agent.stream
|
||||||
self.client = cast("ChatClient", cast("object", create_client(self.provider)))
|
self.client = cast("ChatClient", cast("object", create_client(self.provider)))
|
||||||
self.agent = self._make_agent()
|
self.agent = self._make_agent()
|
||||||
self.agent.messages = messages
|
# Restore history without re-marking already-stored messages as dirty.
|
||||||
|
self.agent.replace_messages(messages, persist_from=persist_from)
|
||||||
self.sync_display_flags()
|
self.sync_display_flags()
|
||||||
|
self._bind_todo_tools()
|
||||||
# Drop remote catalog when provider identity/url changed (not on model-only switch).
|
# Drop remote catalog when provider identity/url changed (not on model-only switch).
|
||||||
if self._remote_models_key is not None and self._remote_models_key != self._models_cache_key():
|
if self._remote_models_key is not None and self._remote_models_key != self._models_cache_key():
|
||||||
self.invalidate_remote_models()
|
self.invalidate_remote_models()
|
||||||
@@ -142,6 +217,29 @@ 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 remember_session_ids(self, ids: Sequence[int]) -> None:
|
||||||
|
"""Cache session ids for Tab completion (``/resume`` / ``/delete``)."""
|
||||||
|
self._session_id_cache = [int(i) for i in ids]
|
||||||
|
|
||||||
|
def session_ids_for_complete(self) -> list[str]:
|
||||||
|
"""String session ids for completers (cache + current session if any)."""
|
||||||
|
seen: set[int] = set()
|
||||||
|
out: list[str] = []
|
||||||
|
for sid in self._session_id_cache:
|
||||||
|
if sid not in seen:
|
||||||
|
seen.add(sid)
|
||||||
|
out.append(str(sid))
|
||||||
|
if self.session_id is not None and self.session_id not in seen:
|
||||||
|
out.insert(0, str(self.session_id))
|
||||||
|
return out
|
||||||
|
|
||||||
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:
|
||||||
@@ -258,6 +356,28 @@ class ReplState:
|
|||||||
model=self.model,
|
model=self.model,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def persist_models_to_config(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
mode: Literal["current", "catalog"],
|
||||||
|
catalog_ids: Sequence[str] | None = None,
|
||||||
|
) -> Path:
|
||||||
|
"""Merge model id(s) into TOML for the current provider and write disk.
|
||||||
|
|
||||||
|
*mode* ``current``: ensure :attr:`model` is in the provider catalog.
|
||||||
|
*mode* ``catalog``: union *catalog_ids* (or empty) into the catalog.
|
||||||
|
|
||||||
|
Returns the config path written. Raises ``OSError`` / ``ValueError`` /
|
||||||
|
``KeyError`` on failure.
|
||||||
|
"""
|
||||||
|
if mode == "current":
|
||||||
|
self.provider = self.config.ensure_model(self.provider_name, self.model)
|
||||||
|
else:
|
||||||
|
ids = list(catalog_ids) if catalog_ids is not None else []
|
||||||
|
self.provider = self.config.merge_models(self.provider_name, ids)
|
||||||
|
self.config.write()
|
||||||
|
return self.config.path
|
||||||
|
|
||||||
def _try_set_provider(self, pname: str) -> bool:
|
def _try_set_provider(self, pname: str) -> bool:
|
||||||
import click
|
import click
|
||||||
|
|
||||||
@@ -321,7 +441,11 @@ class ReplState:
|
|||||||
model=self.model,
|
model=self.model,
|
||||||
)
|
)
|
||||||
self.session_id = session.sid
|
self.session_id = session.sid
|
||||||
|
if session.sid not in self._session_id_cache:
|
||||||
|
self._session_id_cache.insert(0, session.sid)
|
||||||
|
self.todo_stack = TodoStack()
|
||||||
self.agent = self._make_agent()
|
self.agent = self._make_agent()
|
||||||
|
self._bind_todo_tools()
|
||||||
|
|
||||||
async def rename_current_session(self, name: str) -> SessionRow:
|
async def rename_current_session(self, name: str) -> SessionRow:
|
||||||
if self.session_id is None:
|
if self.session_id is None:
|
||||||
@@ -372,11 +496,14 @@ class ReplState:
|
|||||||
raise ValueError(msg) from exc
|
raise ValueError(msg) from exc
|
||||||
|
|
||||||
self.session_id = session_id
|
self.session_id = session_id
|
||||||
|
if session_id not in self._session_id_cache:
|
||||||
|
self._session_id_cache.insert(0, session_id)
|
||||||
if self.apply_session_llm(row):
|
if self.apply_session_llm(row):
|
||||||
self.rebuild_client()
|
self.rebuild_client()
|
||||||
else:
|
else:
|
||||||
self.agent = self._make_agent()
|
self.agent = self._make_agent()
|
||||||
await self.agent.load_history()
|
await self.agent.load_history()
|
||||||
|
await self.load_todo_stack()
|
||||||
|
|
||||||
async def resume_latest_or_new(self, name: str = "chat") -> str:
|
async def resume_latest_or_new(self, name: str = "chat") -> str:
|
||||||
"""Resume most recently updated session for this workspace, or create one."""
|
"""Resume most recently updated session for this workspace, or create one."""
|
||||||
@@ -391,6 +518,7 @@ class ReplState:
|
|||||||
else:
|
else:
|
||||||
self.agent = self._make_agent()
|
self.agent = self._make_agent()
|
||||||
await self.agent.load_history()
|
await self.agent.load_history()
|
||||||
|
await self.load_todo_stack()
|
||||||
_ = await self.memory.touch_session(latest.sid)
|
_ = await self.memory.touch_session(latest.sid)
|
||||||
return "resume"
|
return "resume"
|
||||||
|
|
||||||
@@ -398,6 +526,7 @@ class ReplState:
|
|||||||
"""Soft-compact + model-summarize current history into a new workspace session.
|
"""Soft-compact + model-summarize current history into a new workspace session.
|
||||||
|
|
||||||
Returns ``(old_session_id, new_session_id, summary)``.
|
Returns ``(old_session_id, new_session_id, summary)``.
|
||||||
|
Todo stack is carried into the new session.
|
||||||
"""
|
"""
|
||||||
from plyngent.agent.compact import build_compacted_seed_messages, summarize_messages
|
from plyngent.agent.compact import build_compacted_seed_messages, summarize_messages
|
||||||
|
|
||||||
@@ -429,6 +558,7 @@ class ReplState:
|
|||||||
system_prompt=self.config.agent_config.compact_system_prompt or None,
|
system_prompt=self.config.agent_config.compact_system_prompt or None,
|
||||||
user_prefix=self.config.agent_config.compact_user_prefix or None,
|
user_prefix=self.config.agent_config.compact_user_prefix or None,
|
||||||
)
|
)
|
||||||
|
carried_todos = self.todo_stack.to_raw()
|
||||||
session_name = name or f"compact-from-{old_id}"
|
session_name = name or f"compact-from-{old_id}"
|
||||||
await self.new_session(name=session_name)
|
await self.new_session(name=session_name)
|
||||||
new_id = self.session_id
|
new_id = self.session_id
|
||||||
@@ -442,7 +572,17 @@ class ReplState:
|
|||||||
source_session_id=old_id,
|
source_session_id=old_id,
|
||||||
seed_text=self.config.agent_config.compact_seed_text or None,
|
seed_text=self.config.agent_config.compact_seed_text or None,
|
||||||
)
|
)
|
||||||
self.agent.messages = list(seed)
|
|
||||||
for message in seed:
|
for message in seed:
|
||||||
_ = await self.memory.append_message(new_id, message)
|
_ = await self.memory.append_message(new_id, message)
|
||||||
|
# Reload from DB so RAM matches stored rows and the persist cursor is correct
|
||||||
|
# (assigning messages alone left _persist_from at 0 and broke later checkpoints).
|
||||||
|
await self.agent.load_history()
|
||||||
|
if not self.agent.messages:
|
||||||
|
msg = f"compact session {new_id} has no messages after seed"
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
# Carry open/closed todos so multi-step work survives compact.
|
||||||
|
self.todo_stack = TodoStack.from_raw(carried_todos)
|
||||||
|
self._bind_todo_tools()
|
||||||
|
self.agent.todo_stack = self.todo_stack
|
||||||
|
await self.persist_todo_stack()
|
||||||
return old_id, new_id, summary
|
return old_id, new_id, summary
|
||||||
|
|||||||
@@ -200,6 +200,59 @@ class ConfigStore:
|
|||||||
_ = self._bad_providers.pop(name, None)
|
_ = self._bad_providers.pop(name, None)
|
||||||
return promoted
|
return promoted
|
||||||
|
|
||||||
|
def _take_provider(self, name: str) -> Provider:
|
||||||
|
"""Return a ready or recoverable provider, promoting recoverable into ready map."""
|
||||||
|
if name in self._providers:
|
||||||
|
return self._providers[name]
|
||||||
|
if name in self._recoverable_providers:
|
||||||
|
provider = self._recoverable_providers.pop(name)
|
||||||
|
self._providers[name] = provider
|
||||||
|
_ = self._bad_providers.pop(name, None)
|
||||||
|
return provider
|
||||||
|
msg = f"unknown provider {name!r}"
|
||||||
|
raise KeyError(msg)
|
||||||
|
|
||||||
|
def ensure_model(self, name: str, model_id: str) -> Provider:
|
||||||
|
"""Ensure *model_id* exists under ``providers[name].models`` (in memory).
|
||||||
|
|
||||||
|
Does not write the TOML file unless the caller invokes :meth:`write`.
|
||||||
|
"""
|
||||||
|
mid = model_id.strip()
|
||||||
|
if not mid:
|
||||||
|
msg = "model id must not be empty"
|
||||||
|
raise ValueError(msg)
|
||||||
|
provider = self._take_provider(name)
|
||||||
|
if mid in provider.models:
|
||||||
|
return provider
|
||||||
|
models = dict(provider.models)
|
||||||
|
models[mid] = ModelConfig()
|
||||||
|
updated = msgspec.structs.replace(provider, models=models)
|
||||||
|
self._providers[name] = updated
|
||||||
|
return updated
|
||||||
|
|
||||||
|
def merge_models(self, name: str, model_ids: Sequence[str]) -> Provider:
|
||||||
|
"""Union *model_ids* into the provider catalog (keep existing configs).
|
||||||
|
|
||||||
|
Does not write the TOML file unless the caller invokes :meth:`write`.
|
||||||
|
"""
|
||||||
|
provider = self._take_provider(name)
|
||||||
|
models = dict(provider.models)
|
||||||
|
added = False
|
||||||
|
for raw in model_ids:
|
||||||
|
mid = raw.strip() if raw else ""
|
||||||
|
if not mid or mid in models:
|
||||||
|
continue
|
||||||
|
models[mid] = ModelConfig()
|
||||||
|
added = True
|
||||||
|
if not added and models:
|
||||||
|
return provider
|
||||||
|
if not models:
|
||||||
|
msg = f"cannot merge models for provider {name!r}: no model ids"
|
||||||
|
raise ValueError(msg)
|
||||||
|
updated = msgspec.structs.replace(provider, models=models)
|
||||||
|
self._providers[name] = updated
|
||||||
|
return updated
|
||||||
|
|
||||||
# -- persistence --
|
# -- persistence --
|
||||||
|
|
||||||
def write(self) -> None:
|
def write(self) -> None:
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ class Session(PlyngentBase):
|
|||||||
# Last selected provider/model for this session (config provider key + model id).
|
# Last selected provider/model for this session (config provider key + model id).
|
||||||
provider_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
provider_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
model: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
model: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||||
|
# Todo/task stack JSON for multi-step sub-tasks (optional).
|
||||||
|
todo_stack: Mapped[dict[str, object] | None] = mapped_column(JSON(), nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Self
|
from typing import TYPE_CHECKING, Self, cast
|
||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
from sqlalchemy import delete, select, text
|
from sqlalchemy import delete, select, text
|
||||||
@@ -68,6 +68,7 @@ class MemoryStore:
|
|||||||
_ = await conn.run_sync(PlyngentBase.metadata.create_all)
|
_ = await conn.run_sync(PlyngentBase.metadata.create_all)
|
||||||
await conn.run_sync(_migrate_session_workspace)
|
await conn.run_sync(_migrate_session_workspace)
|
||||||
await conn.run_sync(_migrate_session_llm)
|
await conn.run_sync(_migrate_session_llm)
|
||||||
|
await conn.run_sync(_migrate_session_todo_stack)
|
||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
"""Dispose the underlying engine."""
|
"""Dispose the underlying engine."""
|
||||||
@@ -208,6 +209,31 @@ class MemoryStore:
|
|||||||
await session.refresh(row)
|
await session.refresh(row)
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
async def get_session_todo_stack(self, sid: int) -> dict[str, object] | None:
|
||||||
|
"""Return the stored todo stack JSON for *sid*, or None."""
|
||||||
|
async with self._session_factory() as session:
|
||||||
|
row = await session.get(Session, sid)
|
||||||
|
if row is None:
|
||||||
|
msg = f"session not found: {sid}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
raw = row.todo_stack
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
return {str(k): v for k, v in cast("dict[object, object]", raw).items()}
|
||||||
|
|
||||||
|
async def update_session_todo_stack(self, sid: int, data: dict[str, object] | None) -> Session:
|
||||||
|
"""Persist todo stack JSON for a session (None clears)."""
|
||||||
|
async with self._session_factory() as session:
|
||||||
|
row = await session.get(Session, sid)
|
||||||
|
if row is None:
|
||||||
|
msg = f"session not found: {sid}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
row.todo_stack = data
|
||||||
|
row.updated_at = datetime.now(UTC)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(row)
|
||||||
|
return row
|
||||||
|
|
||||||
async def rename_session(self, sid: int, name: str) -> Session:
|
async def rename_session(self, sid: int, name: str) -> Session:
|
||||||
"""Rename a session (max 64 characters, non-empty after strip)."""
|
"""Rename a session (max 64 characters, non-empty after strip)."""
|
||||||
cleaned = name.strip()
|
cleaned = name.strip()
|
||||||
@@ -308,3 +334,14 @@ def _migrate_session_llm(sync_conn: object) -> None:
|
|||||||
_ = sync_conn.execute(text("ALTER TABLE session ADD COLUMN provider_name VARCHAR(128)"))
|
_ = sync_conn.execute(text("ALTER TABLE session ADD COLUMN provider_name VARCHAR(128)"))
|
||||||
if "model" not in columns:
|
if "model" not in columns:
|
||||||
_ = sync_conn.execute(text("ALTER TABLE session ADD COLUMN model VARCHAR(256)"))
|
_ = sync_conn.execute(text("ALTER TABLE session ADD COLUMN model VARCHAR(256)"))
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_session_todo_stack(sync_conn: object) -> None:
|
||||||
|
"""Add ``session.todo_stack`` JSON for the todo/task sub-task stack."""
|
||||||
|
from sqlalchemy.engine import Connection
|
||||||
|
|
||||||
|
if not isinstance(sync_conn, Connection):
|
||||||
|
return
|
||||||
|
columns = _session_columns(sync_conn)
|
||||||
|
if "todo_stack" not in columns:
|
||||||
|
_ = sync_conn.execute(text("ALTER TABLE session ADD COLUMN todo_stack JSON"))
|
||||||
|
|||||||
@@ -21,6 +21,17 @@ from .process import open_pty as open_pty
|
|||||||
from .process import read_pty as read_pty
|
from .process import read_pty as read_pty
|
||||||
from .process import run_command as run_command
|
from .process import run_command as run_command
|
||||||
from .process import write_pty as write_pty
|
from .process import write_pty as write_pty
|
||||||
|
from .process import write_pty_keys as write_pty_keys
|
||||||
|
from .temp_workspace import cleanup_temporary_workspaces as cleanup_temporary_workspaces
|
||||||
|
from .temp_workspace import new_temporary_workspace as new_temporary_workspace
|
||||||
|
from .todo import TODO_TOOLS as TODO_TOOLS
|
||||||
|
from .todo import get_todo_stack as get_todo_stack
|
||||||
|
from .todo import set_todo_stack as set_todo_stack
|
||||||
|
from .todo import todo_clear as todo_clear
|
||||||
|
from .todo import todo_list as todo_list
|
||||||
|
from .todo import todo_pop as todo_pop
|
||||||
|
from .todo import todo_push as todo_push
|
||||||
|
from .todo import todo_update as todo_update
|
||||||
from .vcs import VCS_TOOLS as VCS_TOOLS
|
from .vcs import VCS_TOOLS as VCS_TOOLS
|
||||||
from .vcs import vcs_branch as vcs_branch
|
from .vcs import vcs_branch as vcs_branch
|
||||||
from .vcs import vcs_diff as vcs_diff
|
from .vcs import vcs_diff as vcs_diff
|
||||||
@@ -31,14 +42,19 @@ from .workspace import (
|
|||||||
DEFAULT_COMMAND_DENYLIST as DEFAULT_COMMAND_DENYLIST,
|
DEFAULT_COMMAND_DENYLIST as DEFAULT_COMMAND_DENYLIST,
|
||||||
)
|
)
|
||||||
from .workspace import WorkspaceError as WorkspaceError
|
from .workspace import WorkspaceError as WorkspaceError
|
||||||
|
from .workspace import add_workspace_allowlist as add_workspace_allowlist
|
||||||
from .workspace import check_command_allowed as check_command_allowed
|
from .workspace import check_command_allowed as check_command_allowed
|
||||||
|
from .workspace import clear_workspace_allowlist as clear_workspace_allowlist
|
||||||
from .workspace import clear_workspace_root as clear_workspace_root
|
from .workspace import clear_workspace_root as clear_workspace_root
|
||||||
from .workspace import get_command_denylist as get_command_denylist
|
from .workspace import get_command_denylist as get_command_denylist
|
||||||
from .workspace import get_path_denylist as get_path_denylist
|
from .workspace import get_path_denylist as get_path_denylist
|
||||||
from .workspace import get_workspace_root as get_workspace_root
|
from .workspace import get_workspace_root as get_workspace_root
|
||||||
|
from .workspace import list_workspace_allowlist as list_workspace_allowlist
|
||||||
|
from .workspace import pop_owned_temporary_workspaces as pop_owned_temporary_workspaces
|
||||||
|
from .workspace import remove_workspace_allowlist as remove_workspace_allowlist
|
||||||
from .workspace import resolve_path as resolve_path
|
from .workspace import resolve_path as resolve_path
|
||||||
from .workspace import set_command_denylist as set_command_denylist
|
from .workspace import set_command_denylist as set_command_denylist
|
||||||
from .workspace import set_path_denylist as set_path_denylist
|
from .workspace import set_path_denylist as set_path_denylist
|
||||||
from .workspace import set_workspace_root as set_workspace_root
|
from .workspace import set_workspace_root as set_workspace_root
|
||||||
|
|
||||||
DEFAULT_TOOLS = [*FILE_TOOLS, *PROCESS_TOOLS, *VCS_TOOLS, *CHAT_TOOLS]
|
DEFAULT_TOOLS = [*FILE_TOOLS, *PROCESS_TOOLS, *VCS_TOOLS, *CHAT_TOOLS, *TODO_TOOLS]
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
+172
-24
@@ -1,11 +1,111 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, cast
|
||||||
|
|
||||||
from plyngent.tools.workspace import WorkspaceError, resolve_path
|
from plyngent.tools.workspace import WorkspaceError, resolve_path
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping, Sequence
|
||||||
|
|
||||||
|
_DISPLAY_ARGV_MAX = 400
|
||||||
|
_CODE_PREVIEW_MAX = 240
|
||||||
|
|
||||||
|
_SHELL_BASENAMES: frozenset[str] = frozenset(
|
||||||
|
{
|
||||||
|
"bash",
|
||||||
|
"sh",
|
||||||
|
"zsh",
|
||||||
|
"fish",
|
||||||
|
"dash",
|
||||||
|
"ksh",
|
||||||
|
"csh",
|
||||||
|
"tcsh",
|
||||||
|
"powershell",
|
||||||
|
"pwsh",
|
||||||
|
"cmd",
|
||||||
|
"cmd.exe",
|
||||||
|
"python",
|
||||||
|
"python3",
|
||||||
|
"python2",
|
||||||
|
"ipython",
|
||||||
|
"ipython3",
|
||||||
|
"node",
|
||||||
|
"nodejs",
|
||||||
|
"deno",
|
||||||
|
"bun",
|
||||||
|
"ruby",
|
||||||
|
"perl",
|
||||||
|
"php",
|
||||||
|
"lua",
|
||||||
|
"r",
|
||||||
|
"julia",
|
||||||
|
"irb",
|
||||||
|
"pry",
|
||||||
|
"ghci",
|
||||||
|
"scala",
|
||||||
|
"jshell",
|
||||||
|
"sqlite3",
|
||||||
|
"psql",
|
||||||
|
"mysql",
|
||||||
|
"mongo",
|
||||||
|
"redis-cli",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _basename(argv0: str) -> str:
|
||||||
|
name = argv0.replace("\\", "/").rsplit("/", 1)[-1]
|
||||||
|
name = name.removesuffix(".exe")
|
||||||
|
return name.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _as_argv(args: Mapping[str, object]) -> list[str] | None:
|
||||||
|
command = args.get("command")
|
||||||
|
if not isinstance(command, list) or not command:
|
||||||
|
return None
|
||||||
|
out: list[str] = []
|
||||||
|
for part_obj in cast("list[object]", command):
|
||||||
|
if not isinstance(part_obj, str):
|
||||||
|
return None
|
||||||
|
out.append(part_obj)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _find_dash_c_code(argv: Sequence[str]) -> str | None:
|
||||||
|
"""Return the argument after ``-c`` / ``-c…`` if present (python/bash/node style)."""
|
||||||
|
for index, part in enumerate(argv[1:], start=1):
|
||||||
|
if part == "-c":
|
||||||
|
return argv[index + 1] if index + 1 < len(argv) else ""
|
||||||
|
# Combined short forms are uncommon; only exact -c is supported.
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _shell_or_dash_c_reason(argv: Sequence[str], *, via: str) -> str | None:
|
||||||
|
"""Confirm interactive shells/REPLs and ``-c`` one-liners so the user can inspect argv.
|
||||||
|
|
||||||
|
Multi-line reason (shown inside the CLI confirm box). ``via`` is a short
|
||||||
|
label (tool name), not repeated on every line.
|
||||||
|
"""
|
||||||
|
if not argv:
|
||||||
|
return None
|
||||||
|
base = _basename(argv[0])
|
||||||
|
display = " ".join(argv)
|
||||||
|
if len(display) > _DISPLAY_ARGV_MAX:
|
||||||
|
display = display[:_DISPLAY_ARGV_MAX] + "…"
|
||||||
|
|
||||||
|
code = _find_dash_c_code(argv)
|
||||||
|
if code is not None:
|
||||||
|
preview = code if len(code) <= _CODE_PREVIEW_MAX else code[:_CODE_PREVIEW_MAX] + "…"
|
||||||
|
return f"{via}: {base} -c (review code before allow)\nargv:\n {display}\n-c code:\n {preview}"
|
||||||
|
|
||||||
|
if base in _SHELL_BASENAMES and len(argv) == 1:
|
||||||
|
return f"{via}: interactive {base!r} (review before allow)\nargv:\n {display}"
|
||||||
|
|
||||||
|
# e.g. python -i, bash --login without -c still needs a glance.
|
||||||
|
if base in _SHELL_BASENAMES:
|
||||||
|
return f"{via}: shell/runtime {base!r} (review before allow)\nargv:\n {display}"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _write_file_reason(args: Mapping[str, object]) -> str | None:
|
def _write_file_reason(args: Mapping[str, object]) -> str | None:
|
||||||
@@ -15,32 +115,80 @@ def _write_file_reason(args: Mapping[str, object]) -> str | None:
|
|||||||
try:
|
try:
|
||||||
target = resolve_path(path)
|
target = resolve_path(path)
|
||||||
except WorkspaceError:
|
except WorkspaceError:
|
||||||
return None
|
return f"write file {path!r}"
|
||||||
if target.exists() or target.is_symlink():
|
return f"write file {path!r} ({target})"
|
||||||
return f"overwrite existing file {path!r}"
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def classify_danger(name: str, args: Mapping[str, object]) -> str | None:
|
def _edit_replace_reason(args: Mapping[str, object]) -> str | None:
|
||||||
|
path = args.get("path")
|
||||||
|
if not isinstance(path, str) or not path:
|
||||||
|
return None
|
||||||
|
return f"edit (replace) in {path!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def _edit_lineno_reason(args: Mapping[str, object]) -> str | None:
|
||||||
|
path = args.get("path")
|
||||||
|
if not isinstance(path, str) or not path:
|
||||||
|
return None
|
||||||
|
start = args.get("start_line")
|
||||||
|
end = args.get("end_line")
|
||||||
|
return f"edit lines {start}-{end} in {path!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_path_reason(args: Mapping[str, object]) -> str | None:
|
||||||
|
src = args.get("src")
|
||||||
|
dst = args.get("dst")
|
||||||
|
return f"copy {src!r} → {dst!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def _move_path_reason(args: Mapping[str, object]) -> str | None:
|
||||||
|
src = args.get("src")
|
||||||
|
dst = args.get("dst")
|
||||||
|
return f"move {src!r} → {dst!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_path_reason(args: Mapping[str, object]) -> str | None:
|
||||||
|
path = args.get("path")
|
||||||
|
recursive = bool(args.get("recursive", False))
|
||||||
|
extra = " recursively" if recursive else ""
|
||||||
|
return f"delete path {path!r}{extra}"
|
||||||
|
|
||||||
|
|
||||||
|
def _run_command_reason(args: Mapping[str, object]) -> str | None:
|
||||||
|
argv = _as_argv(args)
|
||||||
|
if argv is None:
|
||||||
|
return None
|
||||||
|
return _shell_or_dash_c_reason(argv, via="run_command")
|
||||||
|
|
||||||
|
|
||||||
|
def _open_pty_reason(args: Mapping[str, object]) -> str | None:
|
||||||
|
argv = _as_argv(args)
|
||||||
|
if argv is None:
|
||||||
|
return None
|
||||||
|
return _shell_or_dash_c_reason(argv, via="open_pty")
|
||||||
|
|
||||||
|
|
||||||
|
def classify_danger(name: str, args: Mapping[str, object]) -> str | None: # noqa: PLR0911
|
||||||
"""Return a short reason if ``name``/``args`` need user confirm, else ``None``.
|
"""Return a short reason if ``name``/``args`` need user confirm, else ``None``.
|
||||||
|
|
||||||
Hard denylists (paths/commands) still raise independently. This only covers
|
Hard denylists (paths/commands) still raise independently. This only covers
|
||||||
soft confirms for mutating tools that policy otherwise allows.
|
soft confirms for mutating tools and risky shell/REPL launches
|
||||||
|
(interactive shells and ``python -c`` / ``bash -c`` one-liners).
|
||||||
"""
|
"""
|
||||||
reasons: dict[str, str | None] = {
|
if name == "delete_path":
|
||||||
"delete_path": (
|
return _delete_path_reason(args)
|
||||||
f"delete path {args.get('path', '')!r} recursively"
|
if name == "move_path":
|
||||||
if bool(args.get("recursive", False))
|
return _move_path_reason(args)
|
||||||
else f"delete path {args.get('path', '')!r}"
|
if name == "copy_path":
|
||||||
),
|
return _copy_path_reason(args)
|
||||||
"move_path": f"move {args.get('src', '')!r} -> {args.get('dst', '')!r}",
|
if name == "write_file":
|
||||||
"copy_path": (
|
return _write_file_reason(args)
|
||||||
f"copy with overwrite {args.get('src', '')!r} -> {args.get('dst', '')!r}"
|
if name == "edit_replace":
|
||||||
if bool(args.get("overwrite", False))
|
return _edit_replace_reason(args)
|
||||||
else None
|
if name == "edit_lineno":
|
||||||
),
|
return _edit_lineno_reason(args)
|
||||||
"write_file": _write_file_reason(args),
|
if name == "run_command":
|
||||||
}
|
return _run_command_reason(args)
|
||||||
if name not in reasons:
|
if name == "open_pty":
|
||||||
|
return _open_pty_reason(args)
|
||||||
return None
|
return None
|
||||||
return reasons[name]
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from plyngent.tools.temp_workspace import new_temporary_workspace as new_temporary_workspace
|
||||||
|
|
||||||
from .edit_lineno import edit_lineno as edit_lineno
|
from .edit_lineno import edit_lineno as edit_lineno
|
||||||
from .edit_replace import edit_replace as edit_replace
|
from .edit_replace import edit_replace as edit_replace
|
||||||
from .fs_ops import copy_path as copy_path
|
from .fs_ops import copy_path as copy_path
|
||||||
@@ -22,4 +24,5 @@ FILE_TOOLS = [
|
|||||||
copy_path,
|
copy_path,
|
||||||
move_path,
|
move_path,
|
||||||
delete_path,
|
delete_path,
|
||||||
|
new_temporary_workspace,
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3,12 +3,33 @@ from __future__ import annotations
|
|||||||
from plyngent.agent import tool
|
from plyngent.agent import tool
|
||||||
from plyngent.tools.workspace import resolve_path
|
from plyngent.tools.workspace import resolve_path
|
||||||
|
|
||||||
|
_LINENO_WIDTH = 6
|
||||||
|
|
||||||
|
|
||||||
|
def _format_with_lineno(lines: list[str], *, start_lineno: int) -> str:
|
||||||
|
"""Prefix each line with a 1-based absolute line number (``edit_lineno`` style)."""
|
||||||
|
out: list[str] = []
|
||||||
|
for index, line in enumerate(lines):
|
||||||
|
lineno = start_lineno + index
|
||||||
|
# Strip keepends for the body; re-add a single newline after the prefix.
|
||||||
|
body = line.rstrip("\r\n")
|
||||||
|
out.append(f"{lineno:>{_LINENO_WIDTH}}|{body}\n")
|
||||||
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
@tool
|
@tool
|
||||||
def read_file(path: str, *, offset: int = 0, limit: int | None = None) -> str:
|
def read_file(
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int | None = None,
|
||||||
|
with_lineno: bool = False,
|
||||||
|
) -> str:
|
||||||
"""Read a text file under the workspace.
|
"""Read a text file under the workspace.
|
||||||
|
|
||||||
``offset`` is 0-based line start; ``limit`` is max lines (None = rest of file).
|
``offset`` is 0-based line start; ``limit`` is max lines (None = rest of file).
|
||||||
|
When ``with_lineno`` is true, each line is prefixed with its 1-based file line
|
||||||
|
number (`` N|…``), matching ``edit_lineno`` numbering.
|
||||||
"""
|
"""
|
||||||
target = resolve_path(path)
|
target = resolve_path(path)
|
||||||
if not target.is_file():
|
if not target.is_file():
|
||||||
@@ -21,4 +42,7 @@ def read_file(path: str, *, offset: int = 0, limit: int | None = None) -> str:
|
|||||||
end = len(lines) if limit is None else min(len(lines), start + limit)
|
end = len(lines) if limit is None else min(len(lines), start + limit)
|
||||||
if start >= len(lines):
|
if start >= len(lines):
|
||||||
return ""
|
return ""
|
||||||
return "".join(lines[start:end])
|
slice_lines = lines[start:end]
|
||||||
|
if with_lineno:
|
||||||
|
return _format_with_lineno(slice_lines, start_lineno=start + 1)
|
||||||
|
return "".join(slice_lines)
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ from dataclasses import dataclass
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from plyngent.agent import tool
|
from plyngent.agent import tool
|
||||||
from plyngent.tools.workspace import WorkspaceError, resolve_path
|
from plyngent.tools.workspace import WorkspaceError, get_path_denylist, resolve_path
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Sequence
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
DEFAULT_MAX_DEPTH = 4
|
DEFAULT_MAX_DEPTH = 4
|
||||||
@@ -26,21 +27,58 @@ VCS_DIR_NAMES: frozenset[str] = frozenset(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Extra noise dirs skipped by default (in addition to VCS / optional hidden).
|
||||||
|
# Pass skip_dirs=[] to disable this list (VCS still always skipped).
|
||||||
|
DEFAULT_NOISE_DIR_NAMES: frozenset[str] = frozenset(
|
||||||
|
{
|
||||||
|
"node_modules",
|
||||||
|
"__pycache__",
|
||||||
|
".venv",
|
||||||
|
"venv",
|
||||||
|
"dist",
|
||||||
|
"build",
|
||||||
|
"target",
|
||||||
|
".tox",
|
||||||
|
".mypy_cache",
|
||||||
|
".ruff_cache",
|
||||||
|
".pytest_cache",
|
||||||
|
"coverage",
|
||||||
|
".next",
|
||||||
|
".nuxt",
|
||||||
|
".turbo",
|
||||||
|
".cache",
|
||||||
|
"eggs",
|
||||||
|
".eggs",
|
||||||
|
"htmlcov",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class _TreeLimits:
|
class _TreeLimits:
|
||||||
max_depth: int
|
max_depth: int
|
||||||
max_entries: int
|
max_entries: int
|
||||||
skip_hidden_dirs: bool
|
skip_hidden_dirs: bool
|
||||||
|
skip_basenames: frozenset[str]
|
||||||
|
apply_path_denylist: bool
|
||||||
|
|
||||||
|
|
||||||
def _skip_directory(name: str, *, skip_hidden_dirs: bool) -> bool:
|
def _skip_directory(name: str, *, limits: _TreeLimits) -> bool:
|
||||||
if name in VCS_DIR_NAMES:
|
if name in VCS_DIR_NAMES or name in limits.skip_basenames:
|
||||||
return True
|
return True
|
||||||
return bool(skip_hidden_dirs and name.startswith("."))
|
return bool(limits.skip_hidden_dirs and name.startswith("."))
|
||||||
|
|
||||||
|
|
||||||
def _list_children(directory: Path, *, skip_hidden_dirs: bool) -> list[Path] | str:
|
def _path_denied(path: Path) -> bool:
|
||||||
|
"""True when resolved path matches a path_denylist substring."""
|
||||||
|
denylist = get_path_denylist()
|
||||||
|
if not denylist:
|
||||||
|
return False
|
||||||
|
resolved_str = str(path).replace("\\", "/")
|
||||||
|
return any(pattern and pattern.replace("\\", "/") in resolved_str for pattern in denylist)
|
||||||
|
|
||||||
|
|
||||||
|
def _list_children(directory: Path, *, limits: _TreeLimits) -> list[Path] | str:
|
||||||
try:
|
try:
|
||||||
children = list(directory.iterdir())
|
children = list(directory.iterdir())
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
@@ -51,7 +89,9 @@ def _list_children(directory: Path, *, skip_hidden_dirs: bool) -> list[Path] | s
|
|||||||
is_dir = child.is_dir()
|
is_dir = child.is_dir()
|
||||||
except OSError:
|
except OSError:
|
||||||
continue
|
continue
|
||||||
if is_dir and _skip_directory(child.name, skip_hidden_dirs=skip_hidden_dirs):
|
if is_dir and _skip_directory(child.name, limits=limits):
|
||||||
|
continue
|
||||||
|
if limits.apply_path_denylist and _path_denied(child):
|
||||||
continue
|
continue
|
||||||
visible.append(child)
|
visible.append(child)
|
||||||
# Directories first, then files; alphabetical within each group.
|
# Directories first, then files; alphabetical within each group.
|
||||||
@@ -70,7 +110,7 @@ def _render_tree(
|
|||||||
if depth >= limits.max_depth:
|
if depth >= limits.max_depth:
|
||||||
return
|
return
|
||||||
|
|
||||||
children = _list_children(directory, skip_hidden_dirs=limits.skip_hidden_dirs)
|
children = _list_children(directory, limits=limits)
|
||||||
if isinstance(children, str):
|
if isinstance(children, str):
|
||||||
lines.append(f"{prefix}{children}")
|
lines.append(f"{prefix}{children}")
|
||||||
return
|
return
|
||||||
@@ -104,6 +144,13 @@ def _render_tree(
|
|||||||
lines.append(f"{prefix}└── … ({more} more entries not shown)")
|
lines.append(f"{prefix}└── … ({more} more entries not shown)")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_skip_basenames(skip_dirs: Sequence[str] | None) -> frozenset[str]:
|
||||||
|
"""None → default noise set; explicit list (including empty) replaces defaults."""
|
||||||
|
if skip_dirs is None:
|
||||||
|
return DEFAULT_NOISE_DIR_NAMES
|
||||||
|
return frozenset(name for name in skip_dirs if name)
|
||||||
|
|
||||||
|
|
||||||
@tool
|
@tool
|
||||||
def tree(
|
def tree(
|
||||||
path: str = ".",
|
path: str = ".",
|
||||||
@@ -111,13 +158,22 @@ def tree(
|
|||||||
max_depth: int = DEFAULT_MAX_DEPTH,
|
max_depth: int = DEFAULT_MAX_DEPTH,
|
||||||
max_entries: int = DEFAULT_MAX_ENTRIES,
|
max_entries: int = DEFAULT_MAX_ENTRIES,
|
||||||
skip_hidden_dirs: bool = True,
|
skip_hidden_dirs: bool = True,
|
||||||
|
skip_dirs: list[str] | None = None,
|
||||||
|
apply_path_denylist: bool = True,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Show a directory tree under the workspace.
|
"""Show a directory tree under the workspace.
|
||||||
|
|
||||||
Always skips VCS metadata directories (``.git``, ``.hg``, ``.svn``, …).
|
Always skips VCS metadata directories (``.git``, ``.hg``, ``.svn``, …).
|
||||||
|
By default also skips common noise dirs (``node_modules``, ``__pycache__``,
|
||||||
|
``.venv``, ``dist``, …). Pass ``skip_dirs=[]`` to disable the noise list
|
||||||
|
(VCS still skipped). Pass an explicit list to replace the default noise set.
|
||||||
|
|
||||||
By default skips other dot-directories (not hidden files). Use
|
By default skips other dot-directories (not hidden files). Use
|
||||||
``skip_hidden_dirs=false`` to include them.
|
``skip_hidden_dirs=false`` to include them.
|
||||||
|
|
||||||
|
``apply_path_denylist`` (default true) hides entries whose full path matches
|
||||||
|
the agent ``path_denylist`` policy.
|
||||||
|
|
||||||
``max_depth`` limits how deep directories are expanded (1 = origin + children).
|
``max_depth`` limits how deep directories are expanded (1 = origin + children).
|
||||||
``max_entries`` caps how many entries are listed per directory.
|
``max_entries`` caps how many entries are listed per directory.
|
||||||
"""
|
"""
|
||||||
@@ -135,15 +191,18 @@ def tree(
|
|||||||
|
|
||||||
root_label = path.rstrip("/\\") or "."
|
root_label = path.rstrip("/\\") or "."
|
||||||
lines = [f"{root_label}/"]
|
lines = [f"{root_label}/"]
|
||||||
_render_tree(
|
|
||||||
origin,
|
|
||||||
prefix="",
|
|
||||||
depth=0,
|
|
||||||
limits = _TreeLimits(
|
limits = _TreeLimits(
|
||||||
max_depth=max_depth,
|
max_depth=max_depth,
|
||||||
max_entries=max_entries,
|
max_entries=max_entries,
|
||||||
skip_hidden_dirs=skip_hidden_dirs,
|
skip_hidden_dirs=skip_hidden_dirs,
|
||||||
),
|
skip_basenames=_resolve_skip_basenames(skip_dirs),
|
||||||
|
apply_path_denylist=apply_path_denylist,
|
||||||
|
)
|
||||||
|
_render_tree(
|
||||||
|
origin,
|
||||||
|
prefix="",
|
||||||
|
depth=0,
|
||||||
|
limits=limits,
|
||||||
lines=lines,
|
lines=lines,
|
||||||
)
|
)
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
from .close_pty import close_pty as close_pty
|
from .close_pty import close_pty as close_pty
|
||||||
from .open_pty import open_pty as open_pty
|
from .open_pty import open_pty as open_pty
|
||||||
|
from .pty_terminal import decode_write_data as decode_write_data
|
||||||
|
from .pty_terminal import sanitize_pty_output_for_tool as sanitize_pty_output_for_tool
|
||||||
from .read_pty import read_pty as read_pty
|
from .read_pty import read_pty as read_pty
|
||||||
from .run_command import run_command as run_command
|
from .run_command import run_command as run_command
|
||||||
from .write_pty import write_pty as write_pty
|
from .write_pty import write_pty as write_pty
|
||||||
|
from .write_pty_keys import write_pty_keys as write_pty_keys
|
||||||
|
|
||||||
PROCESS_TOOLS = [run_command, open_pty, read_pty, write_pty, close_pty]
|
PROCESS_TOOLS = [run_command, open_pty, read_pty, write_pty, write_pty_keys, close_pty]
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from threading import Lock
|
|||||||
from typing import TYPE_CHECKING, ClassVar
|
from typing import TYPE_CHECKING, ClassVar
|
||||||
|
|
||||||
from plyngent.tools.process.pty_backend import PtyHandle, pty_available, spawn_pty
|
from plyngent.tools.process.pty_backend import PtyHandle, pty_available, spawn_pty
|
||||||
|
from plyngent.tools.process.pty_terminal import sanitize_pty_output_for_tool
|
||||||
from plyngent.tools.workspace import WorkspaceError, check_command_allowed, resolve_path
|
from plyngent.tools.workspace import WorkspaceError, check_command_allowed, resolve_path
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -286,6 +287,8 @@ class PtyManager:
|
|||||||
truncated = len(data) > max_bytes
|
truncated = len(data) > max_bytes
|
||||||
if truncated:
|
if truncated:
|
||||||
data = data[:max_bytes]
|
data = data[:max_bytes]
|
||||||
|
# Escape CSI so tool results echoed to the host TTY cannot reprogram it.
|
||||||
|
data = sanitize_pty_output_for_tool(data)
|
||||||
budget_exhausted = cls._maybe_raise_budget(session)
|
budget_exhausted = cls._maybe_raise_budget(session)
|
||||||
return PtyReadResult(
|
return PtyReadResult(
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
@@ -354,6 +357,8 @@ class PtyManager:
|
|||||||
with cls._lock:
|
with cls._lock:
|
||||||
_ = cls._sessions.pop(session_id, None)
|
_ = cls._sessions.pop(session_id, None)
|
||||||
|
|
||||||
|
# No host TTY reset: tool-facing read_pty sanitizes CSI so chat echo
|
||||||
|
# cannot reprogram the user terminal.
|
||||||
return PtyCloseResult(
|
return PtyCloseResult(
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
closed=True,
|
closed=True,
|
||||||
@@ -381,6 +386,7 @@ class PtyManager:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def close_all(cls) -> None:
|
def close_all(cls) -> None:
|
||||||
|
"""Close every open session (no host TTY reset)."""
|
||||||
with cls._lock:
|
with cls._lock:
|
||||||
ids = list(cls._sessions.keys())
|
ids = list(cls._sessions.keys())
|
||||||
for session_id in ids:
|
for session_id in ids:
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""PTY payload helpers for agent tools.
|
||||||
|
|
||||||
|
Child PTY I/O stays on the master FD. Tool results are printed on the *user*
|
||||||
|
TTY by the CLI, so any CSI/control bytes in ``read_pty`` data would reprogram
|
||||||
|
the host terminal. We sanitize on the tool boundary instead of resetting the
|
||||||
|
host after close (which flashes the screen on every chat exit).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Match common \xNN / \uNNNN / named ctrl+ / esc sequences in write_pty_keys data.
|
||||||
|
_HEX_BYTE = re.compile(r"\\x([0-9A-Fa-f]{2})")
|
||||||
|
_HEX_U = re.compile(r"\\u([0-9A-Fa-f]{4})")
|
||||||
|
_CTRL = re.compile(r"ctrl\+([a-z@\[\\\]\^_])", re.IGNORECASE)
|
||||||
|
_KEY = re.compile(r"key=(esc|escape|enter|tab|up|down|left|right)", re.IGNORECASE)
|
||||||
|
_SIMPLE = {
|
||||||
|
r"\n": "\n",
|
||||||
|
r"\r": "\r",
|
||||||
|
r"\t": "\t",
|
||||||
|
r"\e": "\x1b",
|
||||||
|
r"\E": "\x1b",
|
||||||
|
r"\0": "\x00",
|
||||||
|
}
|
||||||
|
|
||||||
|
_KEY_MAP = {
|
||||||
|
"esc": "\x1b",
|
||||||
|
"escape": "\x1b",
|
||||||
|
"enter": "\r",
|
||||||
|
"tab": "\t",
|
||||||
|
"up": "\x1b[A",
|
||||||
|
"down": "\x1b[B",
|
||||||
|
"right": "\x1b[C",
|
||||||
|
"left": "\x1b[D",
|
||||||
|
}
|
||||||
|
_SIMPLE_ESCAPE_LEN = 2
|
||||||
|
|
||||||
|
# C0 controls except TAB/LF/CR (those stay for readable logs).
|
||||||
|
_UNSAFE_CTRL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_pty_output_for_tool(data: str) -> str:
|
||||||
|
"""Make PTY bytes safe to print on the host as tool/chat text.
|
||||||
|
|
||||||
|
- ESC becomes the two-character sequence ``\\\\x1b`` so CSI is not executed
|
||||||
|
when the CLI echoes tool results.
|
||||||
|
- Other C0 controls (except tab/LF/CR) become ``\\\\xHH``.
|
||||||
|
"""
|
||||||
|
if not data:
|
||||||
|
return data
|
||||||
|
|
||||||
|
def _esc_ctrl(match: re.Match[str]) -> str:
|
||||||
|
return f"\\x{ord(match.group(0)):02x}"
|
||||||
|
|
||||||
|
# ESC first as a stable, readable form used in docs/tests.
|
||||||
|
out = data.replace("\x1b", "\\x1b")
|
||||||
|
return _UNSAFE_CTRL.sub(_esc_ctrl, out)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_write_data(data: str) -> str:
|
||||||
|
"""Expand agent-friendly escapes into raw PTY input (for ``write_pty_keys`` only).
|
||||||
|
|
||||||
|
Supported (as literal characters in ``data``):
|
||||||
|
|
||||||
|
- ``\\n`` ``\\r`` ``\\t`` ``\\e`` / ``\\E`` (ESC) ``\\0``
|
||||||
|
- ``\\xHH`` byte, ``\\uHHHH`` Unicode code point
|
||||||
|
- ``ctrl+c`` / ``ctrl+x`` ... (case-insensitive; A-Z or @ [ \\ ] ^ _)
|
||||||
|
- ``key=esc`` ``key=enter`` ``key=tab`` ``key=up|down|left|right``
|
||||||
|
"""
|
||||||
|
if not data:
|
||||||
|
return data
|
||||||
|
|
||||||
|
out: list[str] = []
|
||||||
|
i = 0
|
||||||
|
n = len(data)
|
||||||
|
while i < n:
|
||||||
|
rest = data[i:]
|
||||||
|
m_key = _KEY.match(rest)
|
||||||
|
if m_key is not None:
|
||||||
|
out.append(_KEY_MAP[m_key.group(1).lower()])
|
||||||
|
i += m_key.end()
|
||||||
|
continue
|
||||||
|
m_ctrl = _CTRL.match(rest)
|
||||||
|
if m_ctrl is not None:
|
||||||
|
ch = m_ctrl.group(1).upper()
|
||||||
|
out.append(chr((ord(ch) - ord("@")) & 0x1F))
|
||||||
|
i += m_ctrl.end()
|
||||||
|
continue
|
||||||
|
m_hex = _HEX_BYTE.match(rest)
|
||||||
|
if m_hex is not None:
|
||||||
|
out.append(chr(int(m_hex.group(1), 16)))
|
||||||
|
i += m_hex.end()
|
||||||
|
continue
|
||||||
|
m_u = _HEX_U.match(rest)
|
||||||
|
if m_u is not None:
|
||||||
|
out.append(chr(int(m_u.group(1), 16)))
|
||||||
|
i += m_u.end()
|
||||||
|
continue
|
||||||
|
if rest.startswith("\\") and len(rest) >= _SIMPLE_ESCAPE_LEN:
|
||||||
|
pair = rest[:_SIMPLE_ESCAPE_LEN]
|
||||||
|
if pair in _SIMPLE:
|
||||||
|
out.append(_SIMPLE[pair])
|
||||||
|
i += _SIMPLE_ESCAPE_LEN
|
||||||
|
continue
|
||||||
|
out.append(data[i])
|
||||||
|
i += 1
|
||||||
|
return "".join(out)
|
||||||
@@ -6,22 +6,31 @@ from plyngent.tools.workspace import WorkspaceError
|
|||||||
from .pty_session import PtyManager
|
from .pty_session import PtyManager
|
||||||
|
|
||||||
|
|
||||||
@tool
|
def write_pty_payload(session_id: int, raw: str) -> str:
|
||||||
def write_pty(session_id: int, data: str) -> str:
|
"""Write raw bytes (as str) to the PTY and format the tool status string."""
|
||||||
"""Write text to a PTY session (interactive input). Does not append a newline."""
|
PtyManager.write(session_id, raw)
|
||||||
try:
|
|
||||||
PtyManager.write(session_id, data)
|
|
||||||
session = PtyManager.refresh(session_id)
|
session = PtyManager.refresh(session_id)
|
||||||
except WorkspaceError as exc:
|
|
||||||
return f"error: {exc}"
|
|
||||||
except OSError as exc:
|
|
||||||
return f"error: failed to write PTY: {exc}"
|
|
||||||
exit_disp = "" if session.exit_code is None else str(session.exit_code)
|
exit_disp = "" if session.exit_code is None else str(session.exit_code)
|
||||||
return "\n".join(
|
return "\n".join(
|
||||||
[
|
[
|
||||||
f"session_id={session_id}",
|
f"session_id={session_id}",
|
||||||
f"alive={'true' if session.alive else 'false'}",
|
f"alive={'true' if session.alive else 'false'}",
|
||||||
f"exit_code={exit_disp}",
|
f"exit_code={exit_disp}",
|
||||||
f"wrote={len(data)}",
|
f"wrote={len(raw.encode())}",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
def write_pty(session_id: int, data: str) -> str:
|
||||||
|
"""Write **literal** text to a PTY session. Does not append a newline.
|
||||||
|
|
||||||
|
``data`` is sent unchanged (no ``ctrl+x`` / ``\\\\xHH`` expansion). For
|
||||||
|
control sequences use :func:`write_pty_keys`.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return write_pty_payload(session_id, data)
|
||||||
|
except WorkspaceError as exc:
|
||||||
|
return f"error: {exc}"
|
||||||
|
except OSError as exc:
|
||||||
|
return f"error: failed to write PTY: {exc}"
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from plyngent.agent import tool
|
||||||
|
from plyngent.tools.workspace import WorkspaceError
|
||||||
|
|
||||||
|
from .pty_terminal import decode_write_data
|
||||||
|
from .write_pty import write_pty_payload
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
def write_pty_keys(session_id: int, data: str) -> str:
|
||||||
|
"""Write to a PTY after expanding key escapes (never for normal typing).
|
||||||
|
|
||||||
|
Use this only for control sequences. Prefer :func:`write_pty` for plain text
|
||||||
|
so strings like ``press ctrl+c`` are not rewritten.
|
||||||
|
|
||||||
|
Escapes (literal characters in ``data``):
|
||||||
|
|
||||||
|
- ``\\\\n`` ``\\\\r`` ``\\\\t`` ``\\\\e``/``\\\\E`` (ESC) ``\\\\0``
|
||||||
|
- ``\\\\xHH`` byte, ``\\\\uHHHH`` Unicode code point
|
||||||
|
- ``ctrl+c`` / ``ctrl+x`` ... (case-insensitive)
|
||||||
|
- ``key=esc|enter|tab|up|down|left|right``
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
raw = decode_write_data(data)
|
||||||
|
return write_pty_payload(session_id, raw)
|
||||||
|
except WorkspaceError as exc:
|
||||||
|
return f"error: {exc}"
|
||||||
|
except OSError as exc:
|
||||||
|
return f"error: failed to write PTY: {exc}"
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from plyngent.agent import tool
|
||||||
|
from plyngent.tools.workspace import (
|
||||||
|
MAX_TEMPORARY_WORKSPACES,
|
||||||
|
WorkspaceError,
|
||||||
|
add_workspace_allowlist,
|
||||||
|
list_workspace_allowlist,
|
||||||
|
pop_owned_temporary_workspaces,
|
||||||
|
remove_workspace_allowlist,
|
||||||
|
)
|
||||||
|
|
||||||
|
_DEFAULT_PREFIX = "ws"
|
||||||
|
_PREFIX_MAX_LEN = 32
|
||||||
|
_PREFIX_SAFE = re.compile(rf"^[A-Za-z0-9_-]{{1,{_PREFIX_MAX_LEN}}}$")
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_prefix(prefix: str) -> str:
|
||||||
|
token = (prefix or _DEFAULT_PREFIX).strip() or _DEFAULT_PREFIX
|
||||||
|
# Replace unsafe characters so mkdtemp stays portable on Windows/POSIX.
|
||||||
|
cleaned = re.sub(r"[^A-Za-z0-9_-]+", "-", token)
|
||||||
|
cleaned = cleaned.strip("-_") or _DEFAULT_PREFIX
|
||||||
|
if len(cleaned) > _PREFIX_MAX_LEN:
|
||||||
|
cleaned = cleaned[:_PREFIX_MAX_LEN]
|
||||||
|
if not _PREFIX_SAFE.match(cleaned):
|
||||||
|
return _DEFAULT_PREFIX
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
def _is_under_system_temp(path: Path) -> bool:
|
||||||
|
"""True if *path* is under the OS temporary directory (safe to rmtree)."""
|
||||||
|
try:
|
||||||
|
temp_root = Path(tempfile.gettempdir()).expanduser().resolve()
|
||||||
|
resolved = path.expanduser().resolve()
|
||||||
|
_ = resolved.relative_to(temp_root)
|
||||||
|
except OSError, ValueError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_temporary_workspaces() -> int:
|
||||||
|
"""Remove directories created by :func:`new_temporary_workspace` (chat exit).
|
||||||
|
|
||||||
|
Only deletes paths still under the system temp dir. Returns the number of
|
||||||
|
directories removed.
|
||||||
|
"""
|
||||||
|
removed = 0
|
||||||
|
for path in pop_owned_temporary_workspaces():
|
||||||
|
if not _is_under_system_temp(path):
|
||||||
|
remove_workspace_allowlist(path)
|
||||||
|
continue
|
||||||
|
if path.is_dir():
|
||||||
|
shutil.rmtree(path, ignore_errors=True)
|
||||||
|
removed += 1
|
||||||
|
remove_workspace_allowlist(path)
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
def new_temporary_workspace(prefix: str = "ws") -> str:
|
||||||
|
"""Create a scratch directory under the system temp dir and allow tool paths in it.
|
||||||
|
|
||||||
|
The project workspace is unchanged: relative paths still resolve there.
|
||||||
|
Use the returned **absolute** path for file/process tools. Temporary
|
||||||
|
workspaces are deleted when this chat process exits (not on each turn).
|
||||||
|
|
||||||
|
Cross-platform (uses ``tempfile``; typically ``/tmp`` on POSIX, ``%TEMP%``
|
||||||
|
on Windows). At most 16 concurrent temporary workspaces per process.
|
||||||
|
"""
|
||||||
|
if len(list_workspace_allowlist()) >= MAX_TEMPORARY_WORKSPACES:
|
||||||
|
return f"error: too many temporary workspaces (max {MAX_TEMPORARY_WORKSPACES})"
|
||||||
|
|
||||||
|
safe = _sanitize_prefix(prefix)
|
||||||
|
try:
|
||||||
|
# mkdtemp is cross-platform; prefix must end with - for readability.
|
||||||
|
path_str = tempfile.mkdtemp(prefix=f"plyngent-{safe}-")
|
||||||
|
path = Path(path_str).resolve()
|
||||||
|
except OSError as exc:
|
||||||
|
return f"error: failed to create temporary workspace: {exc}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
_ = add_workspace_allowlist(path, owned=True)
|
||||||
|
except WorkspaceError as exc:
|
||||||
|
shutil.rmtree(path, ignore_errors=True)
|
||||||
|
return f"error: {exc}"
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"temporary_workspace={path}\n"
|
||||||
|
"note: project workspace unchanged; use this absolute path for tools; "
|
||||||
|
"removed when chat exits"
|
||||||
|
)
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, cast
|
||||||
|
|
||||||
|
from plyngent.agent import tool
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
from plyngent.agent.todo_stack import TodoStack, TodoStatus
|
||||||
|
|
||||||
|
_stack: TodoStack | None = None
|
||||||
|
_on_change: Callable[[], None] | None = None
|
||||||
|
|
||||||
|
_VALID_STATUS = frozenset({"pending", "in_progress", "done", "cancelled"})
|
||||||
|
|
||||||
|
|
||||||
|
def set_todo_stack(stack: TodoStack | None, *, on_change: Callable[[], None] | None = None) -> None:
|
||||||
|
"""Bind the session todo stack for tool handlers (and optional persist hook)."""
|
||||||
|
global _stack, _on_change # noqa: PLW0603 — intentional process bind
|
||||||
|
_stack = stack
|
||||||
|
_on_change = on_change
|
||||||
|
|
||||||
|
|
||||||
|
def get_todo_stack() -> TodoStack | None:
|
||||||
|
return _stack
|
||||||
|
|
||||||
|
|
||||||
|
def _require_stack() -> TodoStack:
|
||||||
|
if _stack is None:
|
||||||
|
msg = "todo stack is not available in this session"
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
return _stack
|
||||||
|
|
||||||
|
|
||||||
|
def _notify() -> None:
|
||||||
|
if _on_change is not None:
|
||||||
|
_on_change()
|
||||||
|
|
||||||
|
|
||||||
|
@tool(name="todo_list")
|
||||||
|
def todo_list() -> str:
|
||||||
|
"""Show the LIFO stack of **task groups** (TOP group = current breakdown level).
|
||||||
|
|
||||||
|
Push creates one group of siblings; pop removes the whole top group.
|
||||||
|
Pattern: push [T1,T2] → push [T1.1,T1.2] → finish children → pop → push [T2.1]…
|
||||||
|
"""
|
||||||
|
stack = _require_stack()
|
||||||
|
stack.mark_touched()
|
||||||
|
_notify()
|
||||||
|
return stack.render()
|
||||||
|
|
||||||
|
|
||||||
|
@tool(name="todo_push")
|
||||||
|
def todo_push(titles: list[str], notes: str = "") -> str:
|
||||||
|
"""Push **one task group** (siblings) onto the stack — not one level per title.
|
||||||
|
|
||||||
|
``titles``: JSON array of strings (tool schema type ``array``). All entries
|
||||||
|
become members of a single new TOP group. Example: ``[\"T1\", \"T2\"]`` pushes
|
||||||
|
one group {T1, T2}; a later ``[\"T1.1\", \"T1.2\"]`` pushes a child group above it.
|
||||||
|
"""
|
||||||
|
stack = _require_stack()
|
||||||
|
parsed = [t.strip() for t in titles if t and t.strip()]
|
||||||
|
if not parsed:
|
||||||
|
return "error: titles must be a non-empty array of strings"
|
||||||
|
try:
|
||||||
|
group = stack.push_group(parsed, notes=notes)
|
||||||
|
except ValueError as exc:
|
||||||
|
return f"error: {exc}"
|
||||||
|
_notify()
|
||||||
|
ids = ", ".join(i.id for i in group.items)
|
||||||
|
return f"pushed group (depth={stack.depth}) items=[{ids}]\n{stack.render()}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool(name="todo_pop")
|
||||||
|
def todo_pop() -> str:
|
||||||
|
"""Pop the entire **top group** (all siblings from that push)."""
|
||||||
|
stack = _require_stack()
|
||||||
|
group = stack.pop()
|
||||||
|
if group is None:
|
||||||
|
return "todo stack empty"
|
||||||
|
_notify()
|
||||||
|
titles = ", ".join(f"{i.id}:{i.title}" for i in group.items) or "(empty)"
|
||||||
|
top = stack.top_group
|
||||||
|
top_s = "(empty)" if top is None else ", ".join(i.id for i in top.items)
|
||||||
|
return f"popped TOP group ({titles}); new top group=[{top_s}]\n{stack.render()}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool(name="todo_update")
|
||||||
|
def todo_update(
|
||||||
|
item_id: str,
|
||||||
|
status: str = "",
|
||||||
|
title: str = "",
|
||||||
|
notes: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""Update a task by id inside any group. Pop the group when that level is done."""
|
||||||
|
stack = _require_stack()
|
||||||
|
status_arg: TodoStatus | None = None
|
||||||
|
if status.strip():
|
||||||
|
token = status.strip().lower()
|
||||||
|
if token not in _VALID_STATUS:
|
||||||
|
return "error: status must be pending, in_progress, done, or cancelled"
|
||||||
|
status_arg = cast("TodoStatus", token)
|
||||||
|
try:
|
||||||
|
item = stack.update(
|
||||||
|
item_id.strip(),
|
||||||
|
title=title if title.strip() else None,
|
||||||
|
status=status_arg,
|
||||||
|
notes=notes if notes != "" else None,
|
||||||
|
)
|
||||||
|
except (KeyError, ValueError) as exc:
|
||||||
|
return f"error: {exc}"
|
||||||
|
_notify()
|
||||||
|
return f"updated {item.id} → {item.status}: {item.title}\n{stack.render()}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool(name="todo_clear")
|
||||||
|
def todo_clear() -> str:
|
||||||
|
"""Clear all groups on the stack."""
|
||||||
|
stack = _require_stack()
|
||||||
|
n = stack.clear()
|
||||||
|
_notify()
|
||||||
|
return f"cleared {n} item(s)"
|
||||||
|
|
||||||
|
|
||||||
|
TODO_TOOLS = [todo_list, todo_push, todo_pop, todo_update, todo_clear]
|
||||||
@@ -26,6 +26,9 @@ DEFAULT_COMMAND_DENYLIST: frozenset[str] = frozenset(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Max concurrent temporary workspaces registered in one process.
|
||||||
|
MAX_TEMPORARY_WORKSPACES = 16
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceError(ValueError):
|
class WorkspaceError(ValueError):
|
||||||
"""Raised when a path or command violates workspace policy."""
|
"""Raised when a path or command violates workspace policy."""
|
||||||
@@ -35,6 +38,14 @@ class _WorkspaceState:
|
|||||||
root: Path | None = None
|
root: Path | None = None
|
||||||
path_denylist: tuple[str, ...] = ()
|
path_denylist: tuple[str, ...] = ()
|
||||||
command_denylist: frozenset[str] = DEFAULT_COMMAND_DENYLIST
|
command_denylist: frozenset[str] = DEFAULT_COMMAND_DENYLIST
|
||||||
|
# Extra roots allowed for resolve_path (e.g. temporary workspaces under system temp).
|
||||||
|
allowlist: list[Path]
|
||||||
|
# Paths created by new_temporary_workspace (subset of allowlist); cleaned on chat exit.
|
||||||
|
temporary_owned: list[Path]
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.allowlist = []
|
||||||
|
self.temporary_owned = []
|
||||||
|
|
||||||
|
|
||||||
_state = _WorkspaceState()
|
_state = _WorkspaceState()
|
||||||
@@ -59,7 +70,7 @@ def get_workspace_root() -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def clear_workspace_root() -> None:
|
def clear_workspace_root() -> None:
|
||||||
"""Clear workspace root (mainly for tests)."""
|
"""Clear workspace root (mainly for tests). Does not clear allowlist."""
|
||||||
_state.root = None
|
_state.root = None
|
||||||
|
|
||||||
|
|
||||||
@@ -82,18 +93,83 @@ def get_command_denylist() -> frozenset[str]:
|
|||||||
return _state.command_denylist
|
return _state.command_denylist
|
||||||
|
|
||||||
|
|
||||||
|
def add_workspace_allowlist(root: Path | str, *, owned: bool = False) -> Path:
|
||||||
|
"""Allow tool paths under *root* in addition to the primary workspace.
|
||||||
|
|
||||||
|
When *owned* is true, the path is also registered for chat-exit cleanup
|
||||||
|
(only paths created by :func:`new_temporary_workspace`).
|
||||||
|
"""
|
||||||
|
path = Path(root).expanduser().resolve()
|
||||||
|
if not path.is_dir():
|
||||||
|
msg = f"allowlist root is not a directory: {path}"
|
||||||
|
raise WorkspaceError(msg)
|
||||||
|
if path not in _state.allowlist:
|
||||||
|
if len(_state.allowlist) >= MAX_TEMPORARY_WORKSPACES and owned:
|
||||||
|
msg = f"too many temporary workspaces (max {MAX_TEMPORARY_WORKSPACES})"
|
||||||
|
raise WorkspaceError(msg)
|
||||||
|
_state.allowlist.append(path)
|
||||||
|
if owned and path not in _state.temporary_owned:
|
||||||
|
_state.temporary_owned.append(path)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def list_workspace_allowlist() -> list[Path]:
|
||||||
|
"""Return a copy of extra allowed roots (not including the primary workspace)."""
|
||||||
|
return list(_state.allowlist)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_workspace_allowlist() -> None:
|
||||||
|
"""Clear allowlist and owned-temp registry (tests). Does not delete directories."""
|
||||||
|
_state.allowlist.clear()
|
||||||
|
_state.temporary_owned.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def pop_owned_temporary_workspaces() -> list[Path]:
|
||||||
|
"""Return and clear the owned temporary workspace list (for chat-exit cleanup).
|
||||||
|
|
||||||
|
Paths remain on the allowlist until the caller removes them via
|
||||||
|
:func:`remove_workspace_allowlist`.
|
||||||
|
"""
|
||||||
|
owned = list(_state.temporary_owned)
|
||||||
|
_state.temporary_owned.clear()
|
||||||
|
return owned
|
||||||
|
|
||||||
|
|
||||||
|
def remove_workspace_allowlist(root: Path | str) -> None:
|
||||||
|
"""Drop *root* from the allowlist if present."""
|
||||||
|
path = Path(root).expanduser().resolve()
|
||||||
|
while path in _state.allowlist:
|
||||||
|
_state.allowlist.remove(path)
|
||||||
|
|
||||||
|
|
||||||
|
def _under_any_root(resolved: Path) -> bool:
|
||||||
|
roots: list[Path] = []
|
||||||
|
if _state.root is not None:
|
||||||
|
roots.append(_state.root)
|
||||||
|
roots.extend(_state.allowlist)
|
||||||
|
for root in roots:
|
||||||
|
try:
|
||||||
|
_ = resolved.relative_to(root)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def resolve_path(path: str | Path) -> Path:
|
def resolve_path(path: str | Path) -> Path:
|
||||||
"""Resolve ``path`` under the workspace root; reject escapes and denylist hits."""
|
"""Resolve ``path`` under the workspace root or an allowlisted temp root.
|
||||||
|
|
||||||
|
Relative paths resolve against the **primary** workspace root. Absolute
|
||||||
|
paths may also land under a temporary workspace allowlist entry.
|
||||||
|
"""
|
||||||
root = get_workspace_root()
|
root = get_workspace_root()
|
||||||
candidate = Path(path)
|
candidate = Path(path)
|
||||||
if not candidate.is_absolute():
|
if not candidate.is_absolute():
|
||||||
candidate = root / candidate
|
candidate = root / candidate
|
||||||
resolved = candidate.expanduser().resolve()
|
resolved = candidate.expanduser().resolve()
|
||||||
try:
|
if not _under_any_root(resolved):
|
||||||
_ = resolved.relative_to(root)
|
|
||||||
except ValueError as exc:
|
|
||||||
msg = f"path escapes workspace root ({root}): {path}"
|
msg = f"path escapes workspace root ({root}): {path}"
|
||||||
raise WorkspaceError(msg) from exc
|
raise WorkspaceError(msg)
|
||||||
# Normalize separators so denylist entries like ``/secrets/`` match on Windows.
|
# Normalize separators so denylist entries like ``/secrets/`` match on Windows.
|
||||||
resolved_str = str(resolved).replace("\\", "/")
|
resolved_str = str(resolved).replace("\\", "/")
|
||||||
for pattern in _state.path_denylist:
|
for pattern in _state.path_denylist:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from plyngent.lmproto.openai_compatible.model import (
|
|||||||
ChatCompletionsParam,
|
ChatCompletionsParam,
|
||||||
ChunkChoice,
|
ChunkChoice,
|
||||||
DeltaMessage,
|
DeltaMessage,
|
||||||
|
FinishReason,
|
||||||
StreamFunctionDelta,
|
StreamFunctionDelta,
|
||||||
StreamToolCallDelta,
|
StreamToolCallDelta,
|
||||||
ToolChatMessage,
|
ToolChatMessage,
|
||||||
@@ -61,6 +62,28 @@ def _chunks_from_response(response: ChatCompletionResponse) -> list[ChatCompleti
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
# Emit terminal finish_reason when content was streamed without one.
|
||||||
|
fr = response.choices[0].finish_reason
|
||||||
|
if (
|
||||||
|
fr
|
||||||
|
and (isinstance(message.content, str) and message.content)
|
||||||
|
and (message.tool_calls is UNSET or not message.tool_calls)
|
||||||
|
):
|
||||||
|
chunks.append(
|
||||||
|
ChatCompletionChunk(
|
||||||
|
id=response.id,
|
||||||
|
object="chat.completion.chunk",
|
||||||
|
created=response.created,
|
||||||
|
model=response.model,
|
||||||
|
choices=[
|
||||||
|
ChunkChoice(
|
||||||
|
index=0,
|
||||||
|
delta=DeltaMessage(),
|
||||||
|
finish_reason=fr,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
tool_calls = message.tool_calls
|
tool_calls = message.tool_calls
|
||||||
if tool_calls is not UNSET and tool_calls:
|
if tool_calls is not UNSET and tool_calls:
|
||||||
deltas: list[StreamToolCallDelta] = []
|
deltas: list[StreamToolCallDelta] = []
|
||||||
@@ -152,7 +175,8 @@ class ScriptedClient:
|
|||||||
def _response(
|
def _response(
|
||||||
message: AssistantChatMessage,
|
message: AssistantChatMessage,
|
||||||
*,
|
*,
|
||||||
usage: dict[str, int] | None = None,
|
usage: dict[str, object] | None = None,
|
||||||
|
finish_reason: FinishReason | None = "stop",
|
||||||
) -> ChatCompletionResponse:
|
) -> ChatCompletionResponse:
|
||||||
return ChatCompletionResponse(
|
return ChatCompletionResponse(
|
||||||
id="1",
|
id="1",
|
||||||
@@ -164,7 +188,7 @@ def _response(
|
|||||||
index=0,
|
index=0,
|
||||||
message=message,
|
message=message,
|
||||||
logprobs={},
|
logprobs={},
|
||||||
finish_reason="stop",
|
finish_reason=finish_reason,
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
system_fingerprint="",
|
system_fingerprint="",
|
||||||
@@ -825,3 +849,118 @@ async def test_tool_result_char_budget() -> None:
|
|||||||
assert len(tool_msgs) == 1
|
assert len(tool_msgs) == 1
|
||||||
assert tool_msgs[0].content.startswith("x" * 20)
|
assert tool_msgs[0].content.startswith("x" * 20)
|
||||||
assert "truncated" in tool_msgs[0].content
|
assert "truncated" in tool_msgs[0].content
|
||||||
|
|
||||||
|
|
||||||
|
async def test_empty_completion_raises() -> None:
|
||||||
|
client = ScriptedClient([_response(AssistantChatMessage(content=None))])
|
||||||
|
messages: list[AnyChatMessage] = [UserChatMessage(content="hi")]
|
||||||
|
with pytest.raises(RuntimeError, match="empty model completion"):
|
||||||
|
_ = [e async for e in run_chat_loop(client, messages, model="m", stream=False)]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_length_finish_raises() -> None:
|
||||||
|
client = ScriptedClient(
|
||||||
|
[
|
||||||
|
_response(
|
||||||
|
AssistantChatMessage(content="partial"),
|
||||||
|
finish_reason="length",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
messages: list[AnyChatMessage] = [UserChatMessage(content="hi")]
|
||||||
|
with pytest.raises(RuntimeError, match="truncated"):
|
||||||
|
_ = [e async for e in run_chat_loop(client, messages, model="m", stream=False)]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_content_filter_finish_raises() -> None:
|
||||||
|
client = ScriptedClient(
|
||||||
|
[
|
||||||
|
_response(
|
||||||
|
AssistantChatMessage(content=""),
|
||||||
|
finish_reason="content_filter",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
messages: list[AnyChatMessage] = [UserChatMessage(content="hi")]
|
||||||
|
with pytest.raises(RuntimeError, match="content filter"):
|
||||||
|
_ = [e async for e in run_chat_loop(client, messages, model="m", stream=False)]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_stream_missing_terminal_empty_raises() -> None:
|
||||||
|
class EmptyStreamClient:
|
||||||
|
@overload
|
||||||
|
async def chat_completions(
|
||||||
|
self, param: ChatCompletionsParam, *, stream: Literal[False] = False
|
||||||
|
) -> ChatCompletionResponse: ...
|
||||||
|
|
||||||
|
@overload
|
||||||
|
async def chat_completions(
|
||||||
|
self, param: ChatCompletionsParam, *, stream: Literal[True]
|
||||||
|
) -> AsyncIterator[ChatCompletionChunk]: ...
|
||||||
|
|
||||||
|
async def chat_completions(
|
||||||
|
self, param: ChatCompletionsParam, *, stream: bool = False
|
||||||
|
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
|
||||||
|
del param
|
||||||
|
if not stream:
|
||||||
|
return _response(AssistantChatMessage(content="x"))
|
||||||
|
|
||||||
|
async def chunks() -> AsyncIterator[ChatCompletionChunk]:
|
||||||
|
# Usage-only style chunk with no finish_reason and no content.
|
||||||
|
yield ChatCompletionChunk(
|
||||||
|
id="1",
|
||||||
|
object="chat.completion.chunk",
|
||||||
|
created=0,
|
||||||
|
model="t",
|
||||||
|
choices=[],
|
||||||
|
usage={"prompt_tokens": 1, "completion_tokens": 0, "total_tokens": 1},
|
||||||
|
)
|
||||||
|
|
||||||
|
return chunks()
|
||||||
|
|
||||||
|
messages: list[AnyChatMessage] = [UserChatMessage(content="hi")]
|
||||||
|
with pytest.raises(RuntimeError, match="stream ended without a terminal"):
|
||||||
|
_ = [e async for e in run_chat_loop(EmptyStreamClient(), messages, model="m", stream=True)]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_stream_payload_without_finish_reason_ok() -> None:
|
||||||
|
"""Some providers omit finish_reason; non-empty text still counts as terminal."""
|
||||||
|
|
||||||
|
class PayloadNoFinishClient:
|
||||||
|
@overload
|
||||||
|
async def chat_completions(
|
||||||
|
self, param: ChatCompletionsParam, *, stream: Literal[False] = False
|
||||||
|
) -> ChatCompletionResponse: ...
|
||||||
|
|
||||||
|
@overload
|
||||||
|
async def chat_completions(
|
||||||
|
self, param: ChatCompletionsParam, *, stream: Literal[True]
|
||||||
|
) -> AsyncIterator[ChatCompletionChunk]: ...
|
||||||
|
|
||||||
|
async def chat_completions(
|
||||||
|
self, param: ChatCompletionsParam, *, stream: bool = False
|
||||||
|
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
|
||||||
|
del param
|
||||||
|
if not stream:
|
||||||
|
return _response(AssistantChatMessage(content="ok"))
|
||||||
|
|
||||||
|
async def chunks() -> AsyncIterator[ChatCompletionChunk]:
|
||||||
|
yield ChatCompletionChunk(
|
||||||
|
id="1",
|
||||||
|
object="chat.completion.chunk",
|
||||||
|
created=0,
|
||||||
|
model="t",
|
||||||
|
choices=[
|
||||||
|
ChunkChoice(
|
||||||
|
index=0,
|
||||||
|
delta=DeltaMessage(content="ok"),
|
||||||
|
finish_reason=None,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return chunks()
|
||||||
|
|
||||||
|
messages: list[AnyChatMessage] = [UserChatMessage(content="hi")]
|
||||||
|
events = [e async for e in run_chat_loop(PayloadNoFinishClient(), messages, model="m", stream=True)]
|
||||||
|
assert any(isinstance(e, TextDeltaEvent) and e.content == "ok" for e in events)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from plyngent.agent.responses_bridge import (
|
|||||||
chat_param_to_responses_kwargs,
|
chat_param_to_responses_kwargs,
|
||||||
response_to_assistant_message,
|
response_to_assistant_message,
|
||||||
response_to_chat_completion,
|
response_to_chat_completion,
|
||||||
|
responses_status_to_finish_reason,
|
||||||
tool_items_to_response_tools,
|
tool_items_to_response_tools,
|
||||||
)
|
)
|
||||||
from plyngent.agent.responses_client import ResponsesChatClient
|
from plyngent.agent.responses_client import ResponsesChatClient
|
||||||
@@ -100,6 +101,31 @@ def test_response_to_assistant_with_tools() -> None:
|
|||||||
assert completion.choices[0].message.content == "done"
|
assert completion.choices[0].message.content == "done"
|
||||||
assert isinstance(completion.usage, dict)
|
assert isinstance(completion.usage, dict)
|
||||||
assert completion.usage["input_tokens"] == 10
|
assert completion.usage["input_tokens"] == 10
|
||||||
|
assert completion.choices[0].finish_reason == "tool_calls"
|
||||||
|
|
||||||
|
|
||||||
|
def test_responses_status_to_finish_reason_incomplete() -> None:
|
||||||
|
body = {
|
||||||
|
"id": "resp_i",
|
||||||
|
"object": "response",
|
||||||
|
"created_at": 1,
|
||||||
|
"model": "gpt-test",
|
||||||
|
"status": "incomplete",
|
||||||
|
"incomplete_details": {"reason": "max_output_tokens"},
|
||||||
|
"output": [
|
||||||
|
{
|
||||||
|
"id": "msg_1",
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"status": "incomplete",
|
||||||
|
"content": [{"type": "output_text", "text": "partial", "annotations": []}],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
resp = msgspec.convert(body, Response)
|
||||||
|
assert responses_status_to_finish_reason(resp, has_tool_calls=False) == "length"
|
||||||
|
completion = response_to_chat_completion(resp)
|
||||||
|
assert completion.choices[0].finish_reason == "length"
|
||||||
|
|
||||||
|
|
||||||
def test_chat_param_to_responses_kwargs() -> None:
|
def test_chat_param_to_responses_kwargs() -> None:
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Literal, overload
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from plyngent.agent import ChatAgent, ToolRegistry
|
||||||
|
from plyngent.agent.todo_stack import TodoStack, parse_push_titles
|
||||||
|
from plyngent.config.models import DatabaseConfig
|
||||||
|
from plyngent.lmproto.openai_compatible.model import (
|
||||||
|
AssistantChatMessage,
|
||||||
|
ChatCompletionChoice,
|
||||||
|
ChatCompletionChunk,
|
||||||
|
ChatCompletionResponse,
|
||||||
|
ChatCompletionsParam,
|
||||||
|
DeveloperChatMessage,
|
||||||
|
UserChatMessage,
|
||||||
|
)
|
||||||
|
from plyngent.memory import MemoryStore
|
||||||
|
from plyngent.tools.todo import TODO_TOOLS, set_todo_stack
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_push_titles() -> None:
|
||||||
|
assert parse_push_titles("only") == ["only"]
|
||||||
|
assert parse_push_titles("T1\nT2") == ["T1", "T2"]
|
||||||
|
assert parse_push_titles("T1; T2; T3") == ["T1", "T2", "T3"]
|
||||||
|
assert parse_push_titles('["A", "B"]') == ["A", "B"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ids_reuse_after_clear() -> None:
|
||||||
|
stack = TodoStack()
|
||||||
|
g = stack.push_group(["A", "B"])
|
||||||
|
assert [i.id for i in g.items] == ["t1", "t2"]
|
||||||
|
assert stack.clear() == 2
|
||||||
|
g2 = stack.push_group(["C"])
|
||||||
|
assert [i.id for i in g2.items] == ["t1"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ids_reuse_after_pop() -> None:
|
||||||
|
stack = TodoStack()
|
||||||
|
_ = stack.push_group(["A", "B"]) # t1, t2
|
||||||
|
g2 = stack.push_group(["C"]) # t3
|
||||||
|
assert g2.items[0].id == "t3"
|
||||||
|
_ = stack.pop() # drop t3
|
||||||
|
g3 = stack.push_group(["D"])
|
||||||
|
# Highest live id is t2 → next is t3 again
|
||||||
|
assert g3.items[0].id == "t3"
|
||||||
|
|
||||||
|
|
||||||
|
def test_push_is_group_not_per_task_stack() -> None:
|
||||||
|
"""Multi-title push is one group; pop removes the whole group."""
|
||||||
|
stack = TodoStack()
|
||||||
|
g = stack.push_group(["T1", "T2"])
|
||||||
|
assert stack.depth == 1
|
||||||
|
assert [i.title for i in g.items] == ["T1", "T2"]
|
||||||
|
assert stack.top_group is g
|
||||||
|
# Not two stack levels of single tasks
|
||||||
|
assert len(stack.groups) == 1
|
||||||
|
|
||||||
|
g2 = stack.push_group(["T1.1", "T1.2"])
|
||||||
|
assert stack.depth == 2
|
||||||
|
assert [i.title for i in g2.items] == ["T1.1", "T1.2"]
|
||||||
|
|
||||||
|
popped = stack.pop()
|
||||||
|
assert popped is not None
|
||||||
|
assert [i.title for i in popped.items] == ["T1.1", "T1.2"]
|
||||||
|
assert stack.depth == 1
|
||||||
|
assert stack.top_group is not None
|
||||||
|
assert [i.title for i in stack.top_group.items] == ["T1", "T2"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_dfs_breakdown_with_groups() -> None:
|
||||||
|
"""push [T1,T2] → push [T1.1,T1.2] → pop → push [T2.1]."""
|
||||||
|
stack = TodoStack()
|
||||||
|
root = stack.push_group(["T1", "T2"])
|
||||||
|
children = stack.push_group(["T1.1", "T1.2"])
|
||||||
|
assert stack.depth == 2
|
||||||
|
stack.update(children.items[0].id, status="done")
|
||||||
|
stack.update(children.items[1].id, status="done")
|
||||||
|
_ = stack.pop()
|
||||||
|
assert stack.depth == 1
|
||||||
|
stack.update(root.items[0].id, status="done")
|
||||||
|
_ = stack.push_group(["T2.1"])
|
||||||
|
assert stack.depth == 2
|
||||||
|
assert stack.top_group is not None
|
||||||
|
assert stack.top_group.items[0].title == "T2.1"
|
||||||
|
assert stack.groups[0].items[1].title == "T2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_title_still_one_group() -> None:
|
||||||
|
stack = TodoStack()
|
||||||
|
item = stack.push("only")
|
||||||
|
assert stack.depth == 1
|
||||||
|
assert item.title == "only"
|
||||||
|
g = stack.pop()
|
||||||
|
assert g is not None and len(g.items) == 1
|
||||||
|
assert stack.is_empty()
|
||||||
|
|
||||||
|
|
||||||
|
def test_todo_stack_needs_review() -> None:
|
||||||
|
stack = TodoStack()
|
||||||
|
assert not stack.needs_review()
|
||||||
|
_ = stack.push("work")
|
||||||
|
stack.begin_turn()
|
||||||
|
assert stack.needs_review()
|
||||||
|
stack.mark_touched()
|
||||||
|
assert not stack.needs_review()
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_flat_and_frames_migrate() -> None:
|
||||||
|
flat = TodoStack.from_raw(
|
||||||
|
{
|
||||||
|
"items": [{"id": "t1", "title": "old", "status": "pending", "notes": ""}],
|
||||||
|
"next_id": 2,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert flat.depth == 1
|
||||||
|
assert flat.all_items()[0].title == "old"
|
||||||
|
|
||||||
|
framed = TodoStack.from_raw(
|
||||||
|
{
|
||||||
|
"frames": [
|
||||||
|
{"items": [{"id": "t1", "title": "T1", "status": "pending", "notes": ""}]},
|
||||||
|
{"items": [{"id": "t2", "title": "T1.1", "status": "pending", "notes": ""}]},
|
||||||
|
],
|
||||||
|
"next_id": 3,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert framed.depth == 2
|
||||||
|
assert framed.top_group is not None
|
||||||
|
assert framed.top_group.items[0].title == "T1.1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_todo_stack_roundtrip_raw() -> None:
|
||||||
|
stack = TodoStack()
|
||||||
|
_ = stack.push_group(["x", "y"], notes="n")
|
||||||
|
raw = stack.to_raw()
|
||||||
|
assert "groups" in raw
|
||||||
|
restored = TodoStack.from_raw(raw)
|
||||||
|
assert restored.depth == 1
|
||||||
|
assert [i.title for i in restored.groups[0].items] == ["x", "y"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_todo_tools_and_persist(tmp_path: object) -> None:
|
||||||
|
del tmp_path
|
||||||
|
memory = await MemoryStore.open(DatabaseConfig())
|
||||||
|
try:
|
||||||
|
session = await memory.create_session(name="t")
|
||||||
|
stack = TodoStack()
|
||||||
|
set_todo_stack(stack, on_change=None)
|
||||||
|
registry = ToolRegistry(list(TODO_TOOLS))
|
||||||
|
out = await registry.execute("todo_push", '{"titles": ["T1", "T2"]}')
|
||||||
|
assert "group" in out.lower() or "pushed" in out
|
||||||
|
assert stack.depth == 1
|
||||||
|
assert [i.title for i in stack.groups[0].items] == ["T1", "T2"]
|
||||||
|
out = await registry.execute("todo_push", '{"titles": ["T1", "T2"]}')
|
||||||
|
assert stack.depth == 2
|
||||||
|
out3 = await registry.execute("todo_pop", "{}")
|
||||||
|
assert "popped" in out3
|
||||||
|
assert stack.depth == 1
|
||||||
|
_ = await memory.update_session_todo_stack(session.sid, stack.to_raw())
|
||||||
|
loaded = await memory.get_session_todo_stack(session.sid)
|
||||||
|
assert loaded is not None
|
||||||
|
again = TodoStack.from_raw(loaded)
|
||||||
|
assert again.depth == 1
|
||||||
|
assert [i.title for i in again.groups[0].items] == ["T1", "T2"]
|
||||||
|
finally:
|
||||||
|
set_todo_stack(None)
|
||||||
|
await memory.close()
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptedClient:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
@overload
|
||||||
|
async def chat_completions(
|
||||||
|
self, param: ChatCompletionsParam, *, stream: Literal[False] = False
|
||||||
|
) -> ChatCompletionResponse: ...
|
||||||
|
|
||||||
|
@overload
|
||||||
|
async def chat_completions(
|
||||||
|
self, param: ChatCompletionsParam, *, stream: Literal[True]
|
||||||
|
) -> AsyncIterator[ChatCompletionChunk]: ...
|
||||||
|
|
||||||
|
async def chat_completions(
|
||||||
|
self, param: ChatCompletionsParam, *, stream: bool = False
|
||||||
|
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
|
||||||
|
del stream
|
||||||
|
self.calls += 1
|
||||||
|
text = "ok" if self.calls > 1 else "done without todos"
|
||||||
|
for msg in param.messages:
|
||||||
|
if isinstance(msg, DeveloperChatMessage) and "[TODO OPEN]" in msg.content:
|
||||||
|
text = "reviewed stack"
|
||||||
|
break
|
||||||
|
return ChatCompletionResponse(
|
||||||
|
id="1",
|
||||||
|
object="chat.completion",
|
||||||
|
created=0,
|
||||||
|
model="m",
|
||||||
|
choices=[
|
||||||
|
ChatCompletionChoice(
|
||||||
|
index=0,
|
||||||
|
message=AssistantChatMessage(content=text),
|
||||||
|
logprobs={},
|
||||||
|
finish_reason="stop",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
system_fingerprint="",
|
||||||
|
usage={},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_loop_injects_todo_review_when_untouched() -> None:
|
||||||
|
stack = TodoStack()
|
||||||
|
_ = stack.push("open work")
|
||||||
|
stack.begin_turn()
|
||||||
|
client = ScriptedClient()
|
||||||
|
agent = ChatAgent(
|
||||||
|
client, # type: ignore[arg-type]
|
||||||
|
model="m",
|
||||||
|
tools=ToolRegistry(list(TODO_TOOLS)),
|
||||||
|
stream=False,
|
||||||
|
todo_stack=stack,
|
||||||
|
)
|
||||||
|
set_todo_stack(stack)
|
||||||
|
try:
|
||||||
|
async for _event in agent.run("do stuff"):
|
||||||
|
pass
|
||||||
|
assert client.calls >= 2
|
||||||
|
assert any(isinstance(m, DeveloperChatMessage) and "[TODO OPEN]" in m.content for m in agent.messages)
|
||||||
|
assert not any(isinstance(m, UserChatMessage) and "[TODO OPEN]" in m.content for m in agent.messages)
|
||||||
|
finally:
|
||||||
|
set_todo_stack(None)
|
||||||
@@ -99,11 +99,63 @@ async def test_compact_to_new_session(tmp_path: Path) -> None:
|
|||||||
assert "compacted goals" in summary
|
assert "compacted goals" in summary
|
||||||
loaded = await memory.list_messages(new_id)
|
loaded = await memory.list_messages(new_id)
|
||||||
assert any("compacted goals" in getattr(m, "content", "") for m in loaded)
|
assert any("compacted goals" in getattr(m, "content", "") for m in loaded)
|
||||||
|
# After compact, RAM matches DB and the persist cursor treats seed as stored.
|
||||||
|
assert len(state.agent.messages) == len(loaded)
|
||||||
|
assert state.agent.persist_from == len(state.agent.messages)
|
||||||
# Old session still exists and is listable
|
# Old session still exists and is listable
|
||||||
sessions = await memory.list_sessions(workspace=tmp_path)
|
sessions = await memory.list_sessions(workspace=tmp_path)
|
||||||
ids = {s.sid for s in sessions}
|
ids = {s.sid for s in sessions}
|
||||||
assert old_id in ids
|
assert old_id in ids
|
||||||
assert new_id in ids
|
assert new_id in ids
|
||||||
|
|
||||||
|
# Simulate restart: resume compact session, continue without duplicating seed.
|
||||||
|
state2 = ReplState(
|
||||||
|
config=config,
|
||||||
|
memory=memory,
|
||||||
|
workspace=tmp_path,
|
||||||
|
provider_name="local",
|
||||||
|
provider=provider,
|
||||||
|
model="gpt-test",
|
||||||
|
tools_enabled=False,
|
||||||
|
)
|
||||||
|
state2.client = SummaryClient()
|
||||||
|
await state2.resume_session(new_id)
|
||||||
|
assert len(state2.agent.messages) == len(loaded)
|
||||||
|
assert state2.agent.persist_from == len(state2.agent.messages)
|
||||||
|
assert any("compacted goals" in getattr(m, "content", "") for m in state2.agent.messages)
|
||||||
|
# Full original conversation still only on the old session.
|
||||||
|
old_loaded = await memory.list_messages(old_id)
|
||||||
|
assert any(isinstance(m, UserChatMessage) and m.content == "do work" for m in old_loaded)
|
||||||
|
finally:
|
||||||
|
await memory.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rebuild_client_preserves_persist_cursor(tmp_path: Path) -> None:
|
||||||
|
_ = set_workspace_root(tmp_path)
|
||||||
|
memory = await MemoryStore.open(DatabaseConfig())
|
||||||
|
try:
|
||||||
|
provider = OpenAIProvider(access_key_or_token="sk-test")
|
||||||
|
config = ConfigStore(path=tmp_path / "plyngent.toml", document=tomlkit.document())
|
||||||
|
config.providers = {"local": provider}
|
||||||
|
state = ReplState(
|
||||||
|
config=config,
|
||||||
|
memory=memory,
|
||||||
|
workspace=tmp_path,
|
||||||
|
provider_name="local",
|
||||||
|
provider=provider,
|
||||||
|
model="gpt-test",
|
||||||
|
tools_enabled=False,
|
||||||
|
)
|
||||||
|
state.client = SummaryClient()
|
||||||
|
await state.new_session("s")
|
||||||
|
state.agent.replace_messages(
|
||||||
|
[UserChatMessage(content="hi"), AssistantChatMessage(content="yo")],
|
||||||
|
persisted=True,
|
||||||
|
)
|
||||||
|
assert state.agent.persist_from == 2
|
||||||
|
state.rebuild_client()
|
||||||
|
assert len(state.agent.messages) == 2
|
||||||
|
assert state.agent.persist_from == 2
|
||||||
finally:
|
finally:
|
||||||
await memory.close()
|
await memory.close()
|
||||||
|
|
||||||
|
|||||||
@@ -41,9 +41,43 @@ async def test_render_reasoning_and_text(capsys: pytest.CaptureFixture[str]) ->
|
|||||||
assert "think" in out
|
assert "think" in out
|
||||||
assert "assistant:" in out
|
assert "assistant:" in out
|
||||||
assert "hello" in out
|
assert "hello" in out
|
||||||
|
# Labels on their own lines (content begins after newline).
|
||||||
|
assert "assistant:\nhello" in out or "assistant:\r\nhello" in out
|
||||||
set_markdown_enabled(True)
|
set_markdown_enabled(True)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_flush_markdown_on_source_change(
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Assistant segment before tools is flushed so later text is a new segment."""
|
||||||
|
monkeypatch.setattr("plyngent.cli.display.markdown_render_available", lambda: True)
|
||||||
|
set_markdown_enabled(True)
|
||||||
|
from plyngent.agent import ToolCallEvent
|
||||||
|
from plyngent.lmproto.openai_compatible.model import (
|
||||||
|
AssistantFunctionTool,
|
||||||
|
AssistantFunctionToolCall,
|
||||||
|
)
|
||||||
|
|
||||||
|
call = AssistantFunctionToolCall(
|
||||||
|
id="1",
|
||||||
|
function=AssistantFunctionTool(name="read_file", arguments="{}"),
|
||||||
|
)
|
||||||
|
await render_events(
|
||||||
|
_aiter(
|
||||||
|
[
|
||||||
|
TextDeltaEvent(content="before **tool**"),
|
||||||
|
ToolCallEvent(tool_call=call),
|
||||||
|
TextDeltaEvent(content="after"),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
markdown=True,
|
||||||
|
)
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "[tool]" in out
|
||||||
|
assert "after" in out
|
||||||
|
|
||||||
|
|
||||||
async def test_tool_result_preview_vs_verbose(capsys: pytest.CaptureFixture[str]) -> None:
|
async def test_tool_result_preview_vs_verbose(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
long = "x" * 200
|
long = "x" * 200
|
||||||
msg = ToolChatMessage(content=long, tool_call_id="1")
|
msg = ToolChatMessage(content=long, tool_call_id="1")
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import signal
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from plyngent.cli.interrupt import (
|
from plyngent.cli.interrupt import (
|
||||||
allow_task_cancel,
|
allow_task_cancel,
|
||||||
pause_task_cancel_for_prompt,
|
pause_task_cancel_for_prompt,
|
||||||
run_in_prompt_thread,
|
run_in_prompt_thread,
|
||||||
|
set_sigint_reinstall,
|
||||||
)
|
)
|
||||||
from plyngent.cli.limits import prompt_continue_limit, prompt_continue_limit_async
|
from plyngent.cli.limits import prompt_continue_limit, prompt_continue_limit_async
|
||||||
|
from plyngent.cli.retry import run_cancellable
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
import pytest
|
pass
|
||||||
|
|
||||||
|
|
||||||
def test_pause_task_cancel_for_prompt() -> None:
|
def test_pause_task_cancel_for_prompt() -> None:
|
||||||
@@ -20,6 +26,16 @@ def test_pause_task_cancel_for_prompt() -> None:
|
|||||||
assert allow_task_cancel() is True
|
assert allow_task_cancel() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_nested_pause_depth() -> None:
|
||||||
|
assert allow_task_cancel() is True
|
||||||
|
with pause_task_cancel_for_prompt():
|
||||||
|
assert allow_task_cancel() is False
|
||||||
|
with pause_task_cancel_for_prompt():
|
||||||
|
assert allow_task_cancel() is False
|
||||||
|
assert allow_task_cancel() is False
|
||||||
|
assert allow_task_cancel() is True
|
||||||
|
|
||||||
|
|
||||||
def test_prompt_continue_limit_under_pause(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_prompt_continue_limit_under_pause(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
def _confirm(*_a: object, **_k: object) -> bool:
|
def _confirm(*_a: object, **_k: object) -> bool:
|
||||||
assert allow_task_cancel() is False
|
assert allow_task_cancel() is False
|
||||||
@@ -36,7 +52,6 @@ async def test_run_in_prompt_thread_pauses_cancel() -> None:
|
|||||||
def work() -> str:
|
def work() -> str:
|
||||||
return "ok"
|
return "ok"
|
||||||
|
|
||||||
# ContextVar may not propagate to worker threads; assert pause around the call.
|
|
||||||
result = await run_in_prompt_thread(work)
|
result = await run_in_prompt_thread(work)
|
||||||
assert result == "ok"
|
assert result == "ok"
|
||||||
assert allow_task_cancel() is True
|
assert allow_task_cancel() is True
|
||||||
@@ -48,3 +63,53 @@ async def test_prompt_continue_limit_async(monkeypatch: pytest.MonkeyPatch) -> N
|
|||||||
|
|
||||||
monkeypatch.setattr("click.confirm", _confirm)
|
monkeypatch.setattr("click.confirm", _confirm)
|
||||||
assert await prompt_continue_limit_async("too many rounds") is True
|
assert await prompt_continue_limit_async("too many rounds") is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sigint_cancels_after_prompt_pause() -> None:
|
||||||
|
"""Regression: after a mid-turn prompt, SIGINT must still cancel the turn.
|
||||||
|
|
||||||
|
Previously, reinstalling the asyncio SIGINT handler while
|
||||||
|
allow_task_cancel was still False froze that value into the callback
|
||||||
|
forever (ContextVar snapshot at add_signal_handler).
|
||||||
|
"""
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
try:
|
||||||
|
loop.add_signal_handler(signal.SIGINT, lambda: None)
|
||||||
|
loop.remove_signal_handler(signal.SIGINT)
|
||||||
|
except NotImplementedError, RuntimeError, ValueError:
|
||||||
|
pytest.skip("asyncio signal handlers not available on this platform")
|
||||||
|
|
||||||
|
started = asyncio.Event()
|
||||||
|
cancelled = asyncio.Event()
|
||||||
|
|
||||||
|
async def hang() -> None:
|
||||||
|
started.set()
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(3600)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
cancelled.set()
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def turn() -> None:
|
||||||
|
# Simulate soft-confirm pause mid-turn, then keep streaming.
|
||||||
|
with pause_task_cancel_for_prompt():
|
||||||
|
assert allow_task_cancel() is False
|
||||||
|
assert allow_task_cancel() is True
|
||||||
|
await hang()
|
||||||
|
|
||||||
|
task = asyncio.create_task(run_cancellable(turn()))
|
||||||
|
await started.wait()
|
||||||
|
# Give run_cancellable a tick to install the handler after the pause reinstall.
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
assert allow_task_cancel() is True
|
||||||
|
# Deliver SIGINT the same way Ctrl+C does under asyncio.
|
||||||
|
loop.call_soon(lambda: None) # ensure loop is processing
|
||||||
|
# Invoke the process signal: raise SIGINT to this process.
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.kill(os.getpid(), signal.SIGINT)
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
|
assert cancelled.is_set()
|
||||||
|
# Cleanup any leftover reinstall hook from run_cancellable
|
||||||
|
set_sigint_reinstall(None)
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from plyngent.cli.limits import install_cli_limit_hooks, prompt_confirm_tool, prompt_continue_limit
|
from plyngent.cli.limits import (
|
||||||
|
format_tool_confirm_box,
|
||||||
|
install_cli_limit_hooks,
|
||||||
|
prompt_confirm_tool,
|
||||||
|
prompt_continue_limit,
|
||||||
|
)
|
||||||
from plyngent.prompting import get_prompt_backend, temporary_backend
|
from plyngent.prompting import get_prompt_backend, temporary_backend
|
||||||
from plyngent.tools.process.pty_session import PtyManager
|
from plyngent.tools.process.pty_session import PtyManager
|
||||||
from tests.test_prompting import ScriptedBackend
|
from tests.test_prompting import ScriptedBackend
|
||||||
@@ -24,6 +29,17 @@ def test_prompt_confirm_tool_default_deny() -> None:
|
|||||||
assert prompt_confirm_tool("delete_path", {"path": "x"}, "delete path 'x'") is False
|
assert prompt_confirm_tool("delete_path", {"path": "x"}, "delete path 'x'") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_tool_confirm_box_multiline() -> None:
|
||||||
|
reason = "run_command: python3 -c (review code before allow)\nargv:\n python3 -c print(1)\n-c code:\n print(1)"
|
||||||
|
box = format_tool_confirm_box("run_command", reason)
|
||||||
|
assert "┌" in box and "└" in box
|
||||||
|
assert "confirm · tool 'run_command'" in box
|
||||||
|
assert "python3 -c" in box
|
||||||
|
assert "print(1)" in box
|
||||||
|
# Distinct lines, not one jammed row
|
||||||
|
assert box.count("\n") >= 4
|
||||||
|
|
||||||
|
|
||||||
def test_install_cli_limit_hooks() -> None:
|
def test_install_cli_limit_hooks() -> None:
|
||||||
install_cli_limit_hooks()
|
install_cli_limit_hooks()
|
||||||
assert callable(getattr(PtyManager, "_limit_continue", None))
|
assert callable(getattr(PtyManager, "_limit_continue", None))
|
||||||
|
|||||||
@@ -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,22 @@ 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"]
|
||||||
|
assert "push" in complete_slash_args(state, "/todos", "p")
|
||||||
|
assert "pop" in complete_slash_args(state, "/todos", "p")
|
||||||
|
assert "--persist" in complete_slash_args(state, "/model", "--p")
|
||||||
|
assert "--full" in complete_slash_args(state, "/history", "--f")
|
||||||
|
assert "32" in complete_slash_args(state, "/rounds", "3")
|
||||||
|
|
||||||
|
from plyngent.agent.todo_stack import TodoStack
|
||||||
|
|
||||||
|
state.todo_stack = TodoStack()
|
||||||
|
_ = state.todo_stack.push_group(["alpha", "beta"])
|
||||||
|
ids = complete_slash_args(state, "/todos", "t", prior_args=["done"])
|
||||||
|
assert "t1" in ids
|
||||||
|
assert "t2" in ids
|
||||||
|
|
||||||
|
state.remember_session_ids([3, 12, 40])
|
||||||
|
assert complete_slash_args(state, "/resume", "1") == ["12"]
|
||||||
|
assert complete_slash_args(state, "/delete", "4") == ["40"]
|
||||||
|
|||||||
@@ -109,8 +109,10 @@ async def test_help_command_usage_line(state: ReplState, capsys: pytest.CaptureF
|
|||||||
async def test_help_history_no_fake_options(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
async def test_help_history_no_fake_options(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
assert await handle_slash(state, "/help history") is True
|
assert await handle_slash(state, "/help history") is True
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert "Usage: /history [N]" in out
|
assert "Usage: /history" in out
|
||||||
assert "Options:" not in out
|
# Real flags for full/preview are documented; Click's meta --help is not.
|
||||||
|
assert "--full" in out
|
||||||
|
assert "--preview" in out
|
||||||
assert " --help" not in out
|
assert " --help" not in out
|
||||||
|
|
||||||
|
|
||||||
@@ -341,6 +343,93 @@ 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_model_persist_writes_config(
|
||||||
|
state: ReplState, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
# Seed a TOML-backed store so write() has a real path.
|
||||||
|
path = tmp_path / "plyngent.toml"
|
||||||
|
_ = path.write_text(
|
||||||
|
"""
|
||||||
|
[providers.local]
|
||||||
|
preset = "openai"
|
||||||
|
access_key_or_token = "sk-test"
|
||||||
|
|
||||||
|
[providers.local.models.gpt-test]
|
||||||
|
""",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
from plyngent.config import load as load_config
|
||||||
|
|
||||||
|
state.config = load_config(path)
|
||||||
|
state.provider_name = "local"
|
||||||
|
state.provider = state.config.providers["local"]
|
||||||
|
state.model = "gpt-new-id"
|
||||||
|
assert await handle_slash(state, "/model --persist") is True
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "persisted model" in out
|
||||||
|
assert "gpt-new-id" in out
|
||||||
|
state.config.reload()
|
||||||
|
assert "gpt-new-id" in state.config.providers["local"].models
|
||||||
|
assert "gpt-test" in state.config.providers["local"].models
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
@@ -357,10 +446,61 @@ async def test_history(state: ReplState, capsys: pytest.CaptureFixture[str]) ->
|
|||||||
]
|
]
|
||||||
assert await handle_slash(state, "/history") is True
|
assert await handle_slash(state, "/history") is True
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
|
assert "mode=preview" in out
|
||||||
assert "user: hello" in out
|
assert "user: hello" in out
|
||||||
assert "assistant: hi there" in out
|
assert "assistant: hi there" in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_history_last_full(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
from plyngent.lmproto.openai_compatible.model import AssistantChatMessage, UserChatMessage
|
||||||
|
|
||||||
|
long_body = "# Title\n\n" + ("paragraph text. " * 20)
|
||||||
|
state.agent.messages = [
|
||||||
|
UserChatMessage(content="hello"),
|
||||||
|
AssistantChatMessage(content=long_body),
|
||||||
|
]
|
||||||
|
assert await handle_slash(state, "/history last") is True
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "mode=full" in out
|
||||||
|
assert "assistant:" in out
|
||||||
|
# Full mode should not use the 200-char preview ellipsis on the body alone
|
||||||
|
assert long_body[:50] in out or "Title" in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_history_one_full(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
from plyngent.lmproto.openai_compatible.model import UserChatMessage
|
||||||
|
|
||||||
|
state.agent.messages = [UserChatMessage(content="only me")]
|
||||||
|
assert await handle_slash(state, "/history 1") is True
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "mode=full" in out
|
||||||
|
assert "only me" in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_history_one_preview_flag(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
from plyngent.lmproto.openai_compatible.model import UserChatMessage
|
||||||
|
|
||||||
|
state.agent.messages = [UserChatMessage(content="x" * 250)]
|
||||||
|
assert await handle_slash(state, "/history 1 --preview") is True
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "mode=preview" in out
|
||||||
|
assert "…" in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_history_full_flag(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
from plyngent.lmproto.openai_compatible.model import AssistantChatMessage, UserChatMessage
|
||||||
|
|
||||||
|
body = "full " * 100
|
||||||
|
state.agent.messages = [
|
||||||
|
UserChatMessage(content="u"),
|
||||||
|
AssistantChatMessage(content=body),
|
||||||
|
]
|
||||||
|
assert await handle_slash(state, "/history 2 --full") is True
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "mode=full" in out
|
||||||
|
assert body[:40] in out
|
||||||
|
|
||||||
|
|
||||||
async def test_rounds(state: ReplState) -> None:
|
async def test_rounds(state: ReplState) -> None:
|
||||||
assert await handle_slash(state, "/rounds 40") is True
|
assert await handle_slash(state, "/rounds 40") is True
|
||||||
assert state.max_rounds == 40
|
assert state.max_rounds == 40
|
||||||
@@ -378,6 +518,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",
|
||||||
|
|||||||
@@ -179,6 +179,39 @@ def test_write_new_config() -> None:
|
|||||||
assert config.providers["foo2"].access_key_or_token == "sk-00301212"
|
assert config.providers["foo2"].access_key_or_token == "sk-00301212"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_model_and_merge_models(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "models-persist.toml"
|
||||||
|
_ = path.write_text(
|
||||||
|
"""
|
||||||
|
[providers.local]
|
||||||
|
preset = "openai-compatible"
|
||||||
|
access_key_or_token = "sk-test"
|
||||||
|
url = "https://example/v1"
|
||||||
|
|
||||||
|
[providers.local.models.base]
|
||||||
|
""",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
config = plyngent.config.load(path)
|
||||||
|
assert "base" in config.providers["local"].models
|
||||||
|
assert "extra" not in config.providers["local"].models
|
||||||
|
|
||||||
|
provider = config.ensure_model("local", "extra")
|
||||||
|
assert "extra" in provider.models
|
||||||
|
assert "base" in provider.models
|
||||||
|
# idempotent
|
||||||
|
_ = config.ensure_model("local", "extra")
|
||||||
|
config.write()
|
||||||
|
config.reload()
|
||||||
|
assert set(config.providers["local"].models) == {"base", "extra"}
|
||||||
|
assert config.providers["local"].access_key_or_token == "sk-test"
|
||||||
|
|
||||||
|
_ = config.merge_models("local", ["extra", "remote-a", "remote-b"])
|
||||||
|
config.write()
|
||||||
|
config.reload()
|
||||||
|
assert set(config.providers["local"].models) == {"base", "extra", "remote-a", "remote-b"}
|
||||||
|
|
||||||
|
|
||||||
def test_update_config() -> None:
|
def test_update_config() -> None:
|
||||||
file = Path(__file__).parent / "plyngent-edit-2.toml"
|
file = Path(__file__).parent / "plyngent-edit-2.toml"
|
||||||
_ = shutil.copy(Path(__file__).parent / "plyngent-valid.toml", file)
|
_ = shutil.copy(Path(__file__).parent / "plyngent-valid.toml", file)
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ from typing import TYPE_CHECKING
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from plyngent.tools.workspace import clear_workspace_root, set_workspace_root
|
from plyngent.tools.workspace import (
|
||||||
|
clear_workspace_allowlist,
|
||||||
|
clear_workspace_root,
|
||||||
|
set_workspace_root,
|
||||||
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
@@ -14,6 +18,8 @@ if TYPE_CHECKING:
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def workspace(tmp_path: Path) -> Iterator[Path]:
|
def workspace(tmp_path: Path) -> Iterator[Path]:
|
||||||
clear_workspace_root()
|
clear_workspace_root()
|
||||||
|
clear_workspace_allowlist()
|
||||||
root = set_workspace_root(tmp_path)
|
root = set_workspace_root(tmp_path)
|
||||||
yield root
|
yield root
|
||||||
|
clear_workspace_allowlist()
|
||||||
clear_workspace_root()
|
clear_workspace_root()
|
||||||
|
|||||||
@@ -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:")
|
||||||
|
|||||||
@@ -11,18 +11,71 @@ def test_classify_delete_and_move() -> None:
|
|||||||
assert "move" in (classify_danger("move_path", {"src": "a", "dst": "b"}) or "")
|
assert "move" in (classify_danger("move_path", {"src": "a", "dst": "b"}) or "")
|
||||||
|
|
||||||
|
|
||||||
def test_classify_copy_overwrite_only() -> None:
|
def test_classify_copy() -> None:
|
||||||
assert classify_danger("copy_path", {"src": "a", "dst": "b"}) is None
|
assert "copy" in (classify_danger("copy_path", {"src": "a", "dst": "b"}) or "")
|
||||||
assert "overwrite" in (classify_danger("copy_path", {"src": "a", "dst": "b", "overwrite": True}) or "")
|
|
||||||
|
|
||||||
|
|
||||||
def test_classify_write_file_overwrite(workspace: object) -> None:
|
def test_classify_write_and_edits(tmp_path: Path) -> None:
|
||||||
assert isinstance(workspace, Path)
|
from plyngent.tools.workspace import clear_workspace_root, set_workspace_root
|
||||||
_ = (workspace / "x.txt").write_text("old", encoding="utf-8")
|
|
||||||
assert "overwrite" in (classify_danger("write_file", {"path": "x.txt", "content": "n"}) or "")
|
set_workspace_root(tmp_path)
|
||||||
assert classify_danger("write_file", {"path": "new.txt", "content": "n"}) is None
|
try:
|
||||||
|
reason = classify_danger("write_file", {"path": "x.txt"})
|
||||||
|
assert reason is not None and "write file" in reason
|
||||||
|
assert "edit" in (classify_danger("edit_replace", {"path": "x.txt"}) or "")
|
||||||
|
assert "lines" in (classify_danger("edit_lineno", {"path": "x.txt", "start_line": 1, "end_line": 2}) or "")
|
||||||
|
finally:
|
||||||
|
clear_workspace_root()
|
||||||
|
|
||||||
|
|
||||||
def test_classify_safe_tools() -> None:
|
def test_classify_safe_tools() -> None:
|
||||||
assert classify_danger("read_file", {"path": "a"}) is None
|
assert classify_danger("read_file", {"path": "a"}) is None
|
||||||
|
assert classify_danger("listdir", {"path": "."}) is None
|
||||||
assert classify_danger("run_command", {"command": ["echo", "hi"]}) is None
|
assert classify_danger("run_command", {"command": ["echo", "hi"]}) is None
|
||||||
|
assert classify_danger("open_pty", {"command": ["true"]}) is None
|
||||||
|
assert classify_danger("run_command", {"command": ["ls", "-la"]}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_shell_and_dash_c() -> None:
|
||||||
|
r = classify_danger("run_command", {"command": ["bash", "-c", "rm -rf /"]})
|
||||||
|
assert r is not None
|
||||||
|
assert "bash -c" in r
|
||||||
|
assert "rm -rf" in r
|
||||||
|
|
||||||
|
r2 = classify_danger("run_command", {"command": ["python3", "-c", "print(1)"]})
|
||||||
|
assert r2 is not None
|
||||||
|
assert "python" in r2
|
||||||
|
assert "-c" in r2
|
||||||
|
assert "print(1)" in r2
|
||||||
|
|
||||||
|
r_py = classify_danger("run_command", {"command": ["python", "-c", "import os"]})
|
||||||
|
assert r_py is not None and "python -c" in r_py
|
||||||
|
|
||||||
|
r3 = classify_danger("open_pty", {"command": ["bash"]})
|
||||||
|
assert r3 is not None and "bash" in r3
|
||||||
|
assert "interactive" in r3 or "review" in r3
|
||||||
|
|
||||||
|
r4 = classify_danger("open_pty", {"command": ["python3"]})
|
||||||
|
assert r4 is not None and "python" in r4
|
||||||
|
|
||||||
|
r5 = classify_danger("open_pty", {"command": ["python3", "-c", "x=1"]})
|
||||||
|
assert r5 is not None and "-c" in r5
|
||||||
|
|
||||||
|
|
||||||
|
async def test_confirm_deny_with_comment() -> None:
|
||||||
|
from plyngent.agent.tools import ToolRegistry, tool
|
||||||
|
from plyngent.tools.danger import classify_danger as danger
|
||||||
|
|
||||||
|
@tool
|
||||||
|
def delete_path(path: str) -> str:
|
||||||
|
return f"deleted {path}"
|
||||||
|
|
||||||
|
async def deny_comment(name: str, args: object, reason: str) -> str:
|
||||||
|
del name, args, reason
|
||||||
|
return "too destructive for this session"
|
||||||
|
|
||||||
|
reg = ToolRegistry([delete_path], danger=danger, on_confirm=deny_comment)
|
||||||
|
out = await reg.execute("delete_path", '{"path": "x"}')
|
||||||
|
assert "denied" in out
|
||||||
|
assert "user comment:" in out
|
||||||
|
assert "too destructive" in out
|
||||||
|
|||||||
@@ -35,6 +35,18 @@ def test_read_offset_limit(workspace: object) -> None:
|
|||||||
assert call_sync(read_file, "lines.txt", offset=1, limit=2) == "b\nc\n"
|
assert call_sync(read_file, "lines.txt", offset=1, limit=2) == "b\nc\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_with_lineno(workspace: object) -> None:
|
||||||
|
del workspace
|
||||||
|
_ = call_sync(write_file, "num.txt", "a\nb\nc\n")
|
||||||
|
out = call_sync(read_file, "num.txt", with_lineno=True)
|
||||||
|
assert " 1|a\n" in out
|
||||||
|
assert " 2|b\n" in out
|
||||||
|
assert " 3|c\n" in out
|
||||||
|
# offset is 0-based; line numbers stay absolute 1-based file lines
|
||||||
|
mid = call_sync(read_file, "num.txt", offset=1, limit=1, with_lineno=True)
|
||||||
|
assert mid == " 2|b\n"
|
||||||
|
|
||||||
|
|
||||||
def test_listdir_missing(workspace: object) -> None:
|
def test_listdir_missing(workspace: object) -> None:
|
||||||
del workspace
|
del workspace
|
||||||
assert "error" in call_sync(listdir, "nope")
|
assert "error" in call_sync(listdir, "nope")
|
||||||
|
|||||||
@@ -4,7 +4,14 @@ import asyncio
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from plyngent.tools.process import close_pty, open_pty, read_pty, run_command, write_pty
|
from plyngent.tools.process import (
|
||||||
|
close_pty,
|
||||||
|
open_pty,
|
||||||
|
read_pty,
|
||||||
|
run_command,
|
||||||
|
write_pty,
|
||||||
|
write_pty_keys,
|
||||||
|
)
|
||||||
from plyngent.tools.process.pty_session import PtyManager
|
from plyngent.tools.process.pty_session import PtyManager
|
||||||
from plyngent.tools.workspace import set_command_denylist
|
from plyngent.tools.workspace import set_command_denylist
|
||||||
from tests.test_tools.helpers import call_async, call_sync
|
from tests.test_tools.helpers import call_async, call_sync
|
||||||
@@ -289,6 +296,85 @@ def test_pty_master_not_inheritable(workspace: object) -> None:
|
|||||||
PtyManager.close_all()
|
PtyManager.close_all()
|
||||||
|
|
||||||
|
|
||||||
|
def test_decode_write_data_escapes() -> None:
|
||||||
|
from plyngent.tools.process.pty_terminal import decode_write_data
|
||||||
|
|
||||||
|
assert decode_write_data(r"\x0f") == "\x0f"
|
||||||
|
assert decode_write_data("ctrl+x") == "\x18"
|
||||||
|
assert decode_write_data("CTRL+O") == "\x0f"
|
||||||
|
assert decode_write_data(r"\e") == "\x1b"
|
||||||
|
assert decode_write_data("key=esc") == "\x1b"
|
||||||
|
assert decode_write_data("key=enter") == "\r"
|
||||||
|
assert decode_write_data(r"a\nb") == "a\nb"
|
||||||
|
assert decode_write_data("plain") == "plain"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_pty_output_escapes_csi() -> None:
|
||||||
|
from plyngent.tools.process.pty_terminal import sanitize_pty_output_for_tool
|
||||||
|
|
||||||
|
raw = chr(0x1B) + "[?1049hhello" + chr(0x1B) + "[0m" + chr(7)
|
||||||
|
safe = sanitize_pty_output_for_tool(raw)
|
||||||
|
assert chr(0x1B) not in safe
|
||||||
|
assert "\\x1b" in safe
|
||||||
|
assert "hello" in safe
|
||||||
|
assert "\\x07" in safe
|
||||||
|
|
||||||
|
|
||||||
|
def test_close_all_empty_is_noop() -> None:
|
||||||
|
"""Ctrl-D / chat exit with no sessions must not touch the host TTY."""
|
||||||
|
PtyManager.close_all()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_read_pty_sanitizes_esc(workspace: object) -> None:
|
||||||
|
del workspace
|
||||||
|
try:
|
||||||
|
opened = call_sync(open_pty, _py("print(chr(0x1B)+'[31mred'+chr(0x1B)+'[0m')"))
|
||||||
|
session_id = _session_id(opened)
|
||||||
|
text = await call_async(read_pty, session_id, timeout=2.0, until="red")
|
||||||
|
assert "red" in text
|
||||||
|
payload = text.split("--- data ---", 1)[-1]
|
||||||
|
assert chr(0x1B) not in payload
|
||||||
|
assert "\\x1b" in payload or "red" in payload
|
||||||
|
_ = await call_async(close_pty, session_id)
|
||||||
|
finally:
|
||||||
|
PtyManager.close_all()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_write_pty_keys_ctrl_escape(workspace: object) -> None:
|
||||||
|
del workspace
|
||||||
|
try:
|
||||||
|
opened = call_sync(
|
||||||
|
open_pty,
|
||||||
|
_py(
|
||||||
|
"import sys\n"
|
||||||
|
"data = sys.stdin.buffer.read(1)\n"
|
||||||
|
"sys.stdout.buffer.write(data)\n"
|
||||||
|
"sys.stdout.buffer.flush()\n"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
session_id = _session_id(opened)
|
||||||
|
written = call_sync(write_pty_keys, session_id, "ctrl+c")
|
||||||
|
assert "wrote=1" in written
|
||||||
|
text = await call_async(read_pty, session_id, timeout=2.0)
|
||||||
|
assert "error" not in text.lower() or "alive=" in text
|
||||||
|
_ = await call_async(close_pty, session_id)
|
||||||
|
finally:
|
||||||
|
PtyManager.close_all()
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_pty_is_literal() -> None:
|
||||||
|
"""Plain write_pty must not call the keys decoder."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from plyngent.tools.process.pty_terminal import decode_write_data
|
||||||
|
|
||||||
|
assert decode_write_data("press ctrl+c to cancel") == "press " + chr(3) + " to cancel"
|
||||||
|
src = inspect.getsource(write_pty.handler)
|
||||||
|
assert "decode_write_data" not in src
|
||||||
|
doc = write_pty.description or write_pty.handler.__doc__ or ""
|
||||||
|
assert "literal" in doc.lower()
|
||||||
|
|
||||||
|
|
||||||
def test_pty_backend_available() -> None:
|
def test_pty_backend_available() -> None:
|
||||||
from plyngent.tools.process.pty_backend import pty_available
|
from plyngent.tools.process.pty_backend import pty_available
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from plyngent.tools.file import read_file, write_file
|
||||||
|
from plyngent.tools.temp_workspace import cleanup_temporary_workspaces, new_temporary_workspace
|
||||||
|
from plyngent.tools.workspace import (
|
||||||
|
WorkspaceError,
|
||||||
|
clear_workspace_allowlist,
|
||||||
|
list_workspace_allowlist,
|
||||||
|
resolve_path,
|
||||||
|
)
|
||||||
|
from tests.test_tools.helpers import call_sync
|
||||||
|
|
||||||
|
|
||||||
|
def _temp_path_from_tool_output(out: str) -> Path:
|
||||||
|
line = next(part for part in out.splitlines() if part.startswith("temporary_workspace="))
|
||||||
|
return Path(line.split("=", 1)[1].strip())
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_temporary_workspace_allowlist(workspace: object) -> None:
|
||||||
|
assert isinstance(workspace, Path)
|
||||||
|
out = call_sync(new_temporary_workspace, "unit")
|
||||||
|
assert "temporary_workspace=" in out
|
||||||
|
assert "project workspace unchanged" in out
|
||||||
|
temp = _temp_path_from_tool_output(out)
|
||||||
|
assert temp.is_dir()
|
||||||
|
assert temp in list_workspace_allowlist()
|
||||||
|
|
||||||
|
# Absolute path under temp is allowed; project relative still works.
|
||||||
|
target = temp / "scratch.txt"
|
||||||
|
_ = target.write_text("hello-temp", encoding="utf-8")
|
||||||
|
assert resolve_path(str(target)) == target.resolve()
|
||||||
|
assert call_sync(read_file, str(target)) == "hello-temp"
|
||||||
|
|
||||||
|
_ = call_sync(write_file, "project.txt", "proj")
|
||||||
|
assert call_sync(read_file, "project.txt") == "proj"
|
||||||
|
|
||||||
|
# Sibling under system temp that we did not allowlist still fails.
|
||||||
|
outside = temp.parent / "not-ours-should-fail"
|
||||||
|
with pytest.raises(WorkspaceError, match="escapes"):
|
||||||
|
_ = resolve_path(str(outside))
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_removes_owned_temps(workspace: object) -> None:
|
||||||
|
assert isinstance(workspace, Path)
|
||||||
|
out = call_sync(new_temporary_workspace)
|
||||||
|
temp = _temp_path_from_tool_output(out)
|
||||||
|
assert temp.is_dir()
|
||||||
|
n = cleanup_temporary_workspaces()
|
||||||
|
assert n >= 1
|
||||||
|
assert not temp.exists()
|
||||||
|
assert list_workspace_allowlist() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_prefix_sanitized(workspace: object) -> None:
|
||||||
|
del workspace
|
||||||
|
out = call_sync(new_temporary_workspace, "bad/../x y!")
|
||||||
|
assert "temporary_workspace=" in out
|
||||||
|
assert not out.startswith("error")
|
||||||
|
_ = cleanup_temporary_workspaces()
|
||||||
|
clear_workspace_allowlist()
|
||||||
@@ -83,3 +83,58 @@ def test_tree_invalid_limits(workspace: object) -> None:
|
|||||||
del workspace
|
del workspace
|
||||||
assert "max_depth" in call_sync(tree, ".", max_depth=0)
|
assert "max_depth" in call_sync(tree, ".", max_depth=0)
|
||||||
assert "max_entries" in call_sync(tree, ".", max_entries=0)
|
assert "max_entries" in call_sync(tree, ".", max_entries=0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tree_default_noise_dirs(workspace: object) -> None:
|
||||||
|
assert isinstance(workspace, Path)
|
||||||
|
(workspace / "src").mkdir()
|
||||||
|
_ = (workspace / "src" / "app.py").write_text("x", encoding="utf-8")
|
||||||
|
(workspace / "node_modules").mkdir()
|
||||||
|
_ = (workspace / "node_modules" / "pkg.js").write_text("x", encoding="utf-8")
|
||||||
|
(workspace / "__pycache__").mkdir()
|
||||||
|
_ = (workspace / "__pycache__" / "x.pyc").write_text("x", encoding="utf-8")
|
||||||
|
out = call_sync(tree, ".")
|
||||||
|
assert "src/" in out
|
||||||
|
assert "app.py" in out
|
||||||
|
assert "node_modules" not in out
|
||||||
|
assert "__pycache__" not in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_tree_skip_dirs_empty_shows_noise(workspace: object) -> None:
|
||||||
|
assert isinstance(workspace, Path)
|
||||||
|
(workspace / "node_modules").mkdir()
|
||||||
|
_ = (workspace / "node_modules" / "pkg.js").write_text("x", encoding="utf-8")
|
||||||
|
out = call_sync(tree, ".", skip_dirs=[])
|
||||||
|
assert "node_modules/" in out
|
||||||
|
assert "pkg.js" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_tree_skip_dirs_custom(workspace: object) -> None:
|
||||||
|
assert isinstance(workspace, Path)
|
||||||
|
(workspace / "keep_me").mkdir()
|
||||||
|
_ = (workspace / "keep_me" / "a.txt").write_text("x", encoding="utf-8")
|
||||||
|
(workspace / "drop_me").mkdir()
|
||||||
|
_ = (workspace / "drop_me" / "b.txt").write_text("x", encoding="utf-8")
|
||||||
|
# Custom list replaces default noise set (node_modules not in list → would show if present).
|
||||||
|
out = call_sync(tree, ".", skip_dirs=["drop_me"])
|
||||||
|
assert "keep_me/" in out
|
||||||
|
assert "drop_me" not in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_tree_path_denylist_walk(workspace: object) -> None:
|
||||||
|
from plyngent.tools.workspace import set_path_denylist
|
||||||
|
|
||||||
|
assert isinstance(workspace, Path)
|
||||||
|
(workspace / "ok").mkdir()
|
||||||
|
_ = (workspace / "ok" / "a.txt").write_text("x", encoding="utf-8")
|
||||||
|
(workspace / "secrets").mkdir()
|
||||||
|
_ = (workspace / "secrets" / "key.txt").write_text("x", encoding="utf-8")
|
||||||
|
set_path_denylist(["/secrets"])
|
||||||
|
try:
|
||||||
|
out = call_sync(tree, ".")
|
||||||
|
assert "ok/" in out
|
||||||
|
assert "secrets" not in out
|
||||||
|
out2 = call_sync(tree, ".", apply_path_denylist=False, skip_dirs=[])
|
||||||
|
assert "secrets/" in out2
|
||||||
|
finally:
|
||||||
|
set_path_denylist(None)
|
||||||
|
|||||||
Reference in New Issue
Block a user