27 Commits

Author SHA1 Message Date
NCBM d473cd2508 project: bump version to 0.1.2 2026-07-18 19:43:33 +08:00
NCBM badcb3c9f3 core/cli: fix Ctrl+C after mid-turn prompts (SIGINT cancel)
asyncio signal handlers freeze ContextVars at install time; reinstalling
while allow_task_cancel was still False left cancel permanently disabled.
Use a process-level prompt depth counter and reinstall only after depth=0.
2026-07-18 19:31:40 +08:00
NCBM fc74b271bd core/cli: richer Tab completion for slash commands
Complete /todos actions and todo ids, session ids for /resume|/delete
(from cache after /sessions), /rounds hints, and --flags. Pass prior
tokens so multi-arg completion works.
2026-07-18 19:04:45 +08:00
NCBM 1012894f0d core/cli: multi-line boxed tool confirm prompts
Echo a terminal-width box with wrapped reason (argv / -c code) before a
short Allow? y/N prompt so shell confirms are not jammed on one line.
2026-07-18 18:41:00 +08:00
NCBM 020e9215bf tools: new_temporary_workspace with cross-platform allowlist
Create scratch dirs under system temp (tempfile), allow absolute paths
under them without rebinding the project workspace or session. Cleanup
owned temps on chat exit. Cap 16 concurrent temps; sanitize prefix.
2026-07-18 18:16:06 +08:00
NCBM de79dec96f tools/file: add with_lineno option to read_file
When true, prefix each returned line with absolute 1-based line numbers
(N|…) for edit_lineno-friendly reads; works with offset/limit.
2026-07-18 17:52:25 +08:00
NCBM b1a8c23582 core/tools: soft-confirm shells, REPLs, and python/bash -c one-liners
classify_danger prompts on interactive shell/runtime launches and any -c
code (with argv + snippet preview). Confirm deny can attach a user comment
for the model. Restores ToolRegistry.execute after WIP breakage.
2026-07-18 17:41:44 +08:00
NCBM 9a9c808ea0 core/agent: agent-facing [TODO OPEN] review nag 2026-07-18 16:48:20 +08:00
NCBM edd54a427c core/tools/process: drop host TTY restore; rely on CSI sanitize only 2026-07-18 16:26:23 +08:00
NCBM 896dc98d20 core/tools/process: skip host TTY restore when no PTY closed 2026-07-18 16:14:19 +08:00
NCBM c8a0aeda8d core/agent: reuse todo ids after clear/pop; todo_push titles as array 2026-07-18 16:09:09 +08:00
NCBM 29230af911 core/tools/process: split write_pty (literal) vs write_pty_keys 2026-07-18 15:46:11 +08:00
NCBM 4ff03d8135 core/tools/process: host TTY restore, sanitize read_pty CSI, write_pty key escapes 2026-07-18 15:20:36 +08:00
NCBM a7166d863b core/agent: todo stack is LIFO of task groups, not per-task push
push(titles) creates one group of siblings; pop removes the whole top
group. Matches breakdown: push[T1,T2] → push[T1.1,T1.2] → pop → push[T2.1].
2026-07-18 14:47:38 +08:00
NCBM 9b38a3b333 ci/lint: prek commit gateway (ruff check/format + basedpyright)
Add prek.toml so git commit runs ruff-pre-commit (fix then format) and
pdm-based basedpyright. Document install/install-hook; clear type warnings
that made basedpyright exit non-zero under recommended mode.
2026-07-18 14:10:04 +08:00
NCBM 39f9a7f636 ci/lint: ruff format todo stack and related CLI/agent files 2026-07-18 13:22:42 +08:00
NCBM 7f49c47105 core/agent: todo is a real LIFO stack (not a queue or frame list)
TOP = last element only for pop; multi-push puts first title on top for DFS
breakdown. Dropped nested-frame model; migrate old frames JSON to flat stack.
2026-07-18 13:20:37 +08:00
NCBM c95d1df028 core/agent: nested todo frames for hierarchical push/pop breakdown
push of one or more titles opens a new frame (siblings at one depth);
pop leaves the frame. Supports T1,T2 → T1.1,T1.2 → pop → T2.1 pattern.
Legacy flat session JSON migrates to a single root frame.
2026-07-18 13:15:57 +08:00
NCBM fb5b9f187b core/cli: /history last and --full with Rich markdown 2026-07-18 13:02:46 +08:00
NCBM abcb345c0d core/agent: todo review nag as developer role, not fake user
Internal control uses DeveloperChatMessage so retry/history treat it as
non-human. Responses bridge maps developer mid-turn; compact/history label it.
2026-07-18 12:27:15 +08:00
NCBM 596af1ea1b core/agent+tools: session todo/task stack with review nag
Sub-task stack stored on session.todo_stack; model tools todo_*; slash
/todos for human ops; agent loop injects a review user message when open
todos exist and no todo_* tool ran this turn. Stack carries across /compact.
2026-07-18 01:59:13 +08:00
NCBM 57236937eb docs: model --persist and tree skip_dirs filtering 2026-07-18 01:32:06 +08:00
NCBM 96cdd98e62 tools/file: tree skip_dirs noise defaults and path_denylist walk
Default noise basenames (node_modules, __pycache__, …); skip_dirs=[] disables;
apply_path_denylist hides denied children mid-tree.
2026-07-18 01:32:06 +08:00
NCBM 2368e46efd core/config+cli: /model --persist and /models --persist to TOML
ensure_model / merge_models update the provider catalog in memory; --persist
writes plyngent.toml so next launch has a warmer model list.
2026-07-18 01:32:06 +08:00
NCBM 65d248064e core/agent: classify empty/truncated/missing stream terminals as errors 2026-07-18 00:56:45 +08:00
NCBM e04fda4bc6 core/agent+cli: fix compact session restore and persist cursor
After /compact, reload seed from DB so _persist_from matches stored rows.
rebuild_client preserves the checkpoint via replace_messages. Clarify that
the active session is summary-only and full history stays on the old id.
2026-07-18 00:25:13 +08:00
NCBM a282231082 docs+ci: document ruff format --check; format app.py 2026-07-17 17:57:00 +08:00
50 changed files with 3093 additions and 203 deletions
+18 -9
View File
@@ -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 sync # sync after pulling changes
pdm run basedpyright . # type checking (basedpyright, "recommended" strictness)
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)
# 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
### 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.
- **`workspace`**: path resolve under root; 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`.
- **`process`**: `run_command` (argv, no shell, timeout, optional stdin/env); PTY `open_pty` / `read_pty` / `write_pty` / `close_pty` (POSIX: `pty`+`fork`; Windows: ConPTY via `pywinpty` env marker dep).
- PTY: backend in `pty_backend.py`; structured status (`alive`/`exit_code`/`data`); `read_pty(..., until=)`; session limit/idle TTL/output budget; close terminate→kill.
- **`workspace`**: path resolve under primary root **or** temporary allowlist roots; path substring denylist; command basename denylist; clearer deny messages.
- **`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` (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=)` (**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.
- Destructive confirms: `classify_danger` + `ToolRegistry(on_confirm=…)`; CLI default deny; config `confirm_destructive` / `path_denylist`. Session YOLO: `/yolo on|off|once` and `--yes` (skip soft confirms; hard denylists unchanged; `once` expires after the next user turn).
- 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.
- **`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`)
@@ -89,7 +98,7 @@ Shared interactive I/O: `ask` / `choose` / `form` / `confirm` with pluggable bac
Click app + readline REPL. Entry: `plyngent` / `python -m plyngent`.
- **`plyngent chat`**: provider/model (flags or interactive; Tab via readline in `prompting`); sessions store `provider_name`/`model` and restore on resume; SQLite via `[database]` (file DB under user data if unset/`:memory:`); workspace-bound; resume latest for cwd/`--workspace` by default (`--new` / `--session`). One-shot: `-p/--prompt` and non-TTY stdin; exit codes 0/1/2/3; `--yes` (YOLO on), `--stream/--no-stream`, `--quiet`. Root `--log-level`.
- Slash: Click group in `cli/slash.py` + `awaitlet` for async work; Tab completer from registry + ParamType `shell_complete`. Multiline `"""``"""`; `/edit` via `$EDITOR`. `/yolo on|off|once` for soft destructive confirms.
- 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**.
- 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.
+24 -7
View File
@@ -62,15 +62,25 @@ pdm sync # after pull
pdm run plyngent --help
```
Dev checks:
Dev checks (same order as CI):
```bash
pdm run basedpyright .
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
```
**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
```bash
@@ -186,7 +196,8 @@ Type `/help` in the REPL for the live list. Common ones:
| Command | Purpose |
|---------|---------|
| `/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 |
| `/new` `/resume` `/rename` `/delete` | Session lifecycle (`/delete` confirms) |
| `/export [md\|json] [path]` | Transcript from DB (no secrets) |
@@ -196,7 +207,10 @@ Type `/help` in the REPL for the live list. Common ones:
| `/retry` | Re-run incomplete last user turn (after error/cancel) |
| `/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 |
| `/quit` | Leave the REPL |
@@ -210,14 +224,17 @@ User messages are saved immediately. On API error or Ctrl+C, partial assistant/t
## Tools (when enabled)
Default registry: file ops, `run_command` / PTY (POSIX openpty; Windows ConPTY via pywinpty), read-only VCS (git), and human prompts (`ask_user_line` / `ask_user_choice` / `ask_user_form`).
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:
- 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).
- 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.
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)
+4 -1
View File
@@ -45,7 +45,10 @@ Then for every projects, check the dev dependency group where you can find tools
Type checking: `pdm run basedpyright .`
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.
+27
View File
@@ -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
View File
@@ -1,6 +1,6 @@
[project]
name = "plyngent"
version = "0.1.1"
version = "0.1.2"
description = "LLM chat and agent toolkit"
authors = [
{ name = "HivertMoZara", email = "worldmozara@163.com" },
+34 -3
View File
@@ -29,6 +29,7 @@ if TYPE_CHECKING:
from .client import ChatClient
from .events import AgentEvent
from .todo_stack import TodoStack
from .tools import ToolRegistry
type LimitContinueHook = Callable[[str], bool | Awaitable[bool]]
@@ -118,6 +119,7 @@ class ChatAgent:
max_tool_result_chars: int
parallel_tools: bool
max_context_tokens: int
todo_stack: TodoStack | None
messages: list[AnyChatMessage]
session_usage: TokenUsage
last_turn_usage: TokenUsage
@@ -143,6 +145,7 @@ class ChatAgent:
max_tool_result_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS,
parallel_tools: bool = True,
max_context_tokens: int = DEFAULT_CONTEXT_MAX_TOKENS,
todo_stack: TodoStack | None = None,
) -> None:
self.client = client
self.model = model
@@ -157,6 +160,7 @@ class ChatAgent:
self.max_tool_result_chars = max_tool_result_chars
self.parallel_tools = parallel_tools
self.max_context_tokens = max_context_tokens
self.todo_stack = todo_stack
self.messages = list(messages) if messages is not None else []
self.session_usage = TokenUsage()
self.last_turn_usage = TokenUsage()
@@ -200,14 +204,38 @@ class ChatAgent:
if self._persist_from == 0:
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:
"""Replace in-memory messages from the bound memory session."""
if self.memory is None or self.session_id is None:
msg = "load_history requires memory and session_id"
raise RuntimeError(msg)
self.messages = await self.memory.list_messages(self.session_id)
self._persist_from = len(self.messages)
self._ensure_system_prompt()
loaded = await self.memory.list_messages(self.session_id)
self.replace_messages(loaded, persisted=True)
async def bind_session(self, session_id: int, *, load: bool = True) -> None:
"""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.
"""
user_index = self._user_index(user_msg)
if self.todo_stack is not None:
self.todo_stack.begin_turn()
completed = False
turn_usage = TokenUsage()
@@ -283,6 +313,7 @@ class ChatAgent:
max_tool_result_chars=self.max_tool_result_chars,
parallel_tools=self.parallel_tools,
max_context_tokens=self.max_context_tokens,
todo_stack=self.todo_stack,
):
if isinstance(event, UsageEvent):
turn_rounds += 1
+5 -4
View File
@@ -8,8 +8,8 @@ from plyngent.lmproto.openai_compatible.model import (
AssistantChatMessage,
AssistantFunctionToolCall,
ChatCompletionsParam,
DeveloperChatMessage,
SystemChatMessage,
ToolChatMessage,
UserChatMessage,
)
@@ -49,6 +49,8 @@ def format_transcript(messages: Sequence[AnyChatMessage]) -> str:
for msg in messages:
if isinstance(msg, SystemChatMessage):
lines.append(f"[system] {msg.content}")
elif isinstance(msg, DeveloperChatMessage):
lines.append(f"[developer] {msg.content}")
elif isinstance(msg, UserChatMessage):
lines.append(f"[user] {msg.content}")
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})")
else:
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:
lines.append(f"[message] {msg!r}")
# ToolChatMessage (remaining AnyChatMessage arm)
lines.append(f"[tool {msg.tool_call_id}] {msg.content}")
return "\n".join(lines)
+97 -1
View File
@@ -13,6 +13,7 @@ from plyngent.lmproto.openai_compatible.model import (
AssistantChatMessage,
AssistantFunctionToolCall,
ChatCompletionsParam,
DeveloperChatMessage,
StreamOptions,
StreamToolCallDelta,
ToolChatMessage,
@@ -45,6 +46,7 @@ if TYPE_CHECKING:
from plyngent.lmproto.openai_compatible.model import AnyChatMessage, AnyToolItem
from .client import ChatClient
from .todo_stack import TodoStack
from .tools import ToolRegistry
type LimitContinueHook = Callable[[str], bool | Awaitable[bool]]
@@ -114,6 +116,66 @@ async def _execute_tool_calls(
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(
client: ChatClient,
param: ChatCompletionsParam,
@@ -122,7 +184,14 @@ async def _non_stream_round(
if not response.choices:
msg = "chat completion response contained no choices"
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
if isinstance(reasoning, str) and reasoning:
yield ReasoningDeltaEvent(content=reasoning)
@@ -154,6 +223,8 @@ async def _stream_round(
reasoning_parts: list[str] = []
tool_deltas: list[StreamToolCallDelta] = []
last_api_usage: object = UNSET
finish_reason: str | None = None
saw_terminal = False
async for chunk in stream:
parsed = token_usage_from_api(chunk.usage)
@@ -162,6 +233,10 @@ async def _stream_round(
if not chunk.choices:
continue
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
if isinstance(delta.reasoning_content, str) and delta.reasoning_content:
reasoning_parts.append(delta.reasoning_content)
@@ -185,6 +260,16 @@ async def _stream_round(
tool_calls=tool_calls,
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 UsageEvent(
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,
parallel_tools: bool = True,
max_context_tokens: int = DEFAULT_CONTEXT_MAX_TOKENS,
todo_stack: TodoStack | None = None,
) -> AsyncIterator[AgentEvent]:
"""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.
Multiple tool calls in one round run in parallel when ``parallel_tools``.
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
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).
prompt_tokens_hint: int | None = None
sent_estimate_tokens: int | None = None
todo_review_injected = False
while True:
while rounds_used < allowance:
@@ -280,6 +371,11 @@ async def run_chat_loop(
assistant = _last_assistant(messages, pre_len)
tool_calls = assistant.tool_calls
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
async for event in _execute_tool_calls(
tools,
+53 -7
View File
@@ -29,10 +29,10 @@ from plyngent.lmproto.openai_compatible.model import (
ChatCompletionsParam,
ChunkChoice,
DeltaMessage,
DeveloperChatMessage,
StreamFunctionDelta,
StreamToolCallDelta,
SystemChatMessage,
ToolChatMessage,
ToolFunctionItem,
UserChatMessage,
)
@@ -97,21 +97,22 @@ def chat_messages_to_responses_input(
if isinstance(message, SystemChatMessage):
if message.content.strip():
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):
items.append(ResponseEasyInputMessage(role="user", content=message.content))
elif isinstance(message, AssistantChatMessage):
items.extend(_assistant_to_input_items(message))
elif isinstance(message, ToolChatMessage):
else:
# ToolChatMessage (remaining AnyChatMessage arm)
items.append(
ResponseFunctionToolCallOutput(
call_id=message.tool_call_id,
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
return instructions, items
@@ -158,10 +159,33 @@ def _reasoning_summary_text(response: Response) -> str:
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:
"""Wrap Responses result as a synthetic chat completion for the agent loop."""
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
created = int(response.created_at)
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:
return ChatCompletionChunk(
id="resp-stream",
+12 -1
View File
@@ -8,8 +8,11 @@ from msgspec import UNSET
from plyngent.agent.responses_bridge import (
chat_param_to_responses_kwargs,
finish_reason_chunk,
reasoning_delta_chunk,
response_to_assistant_message,
response_to_chat_completion,
responses_status_to_finish_reason,
text_delta_chunk,
tool_call_chunks_from_response,
usage_chunk_from_response,
@@ -109,9 +112,17 @@ class ResponsesChatClient:
except TypeError, ValueError, msgspec.ValidationError:
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):
yield chunk
yield finish_reason_chunk(model=model, finish_reason=finish)
usage = usage_chunk_from_response(final, model=model)
if usage is not None:
yield usage
+304
View File
@@ -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
+15 -7
View File
@@ -13,9 +13,11 @@ from plyngent.typedef import JSONSchema # noqa: TC001
type ToolHandler = Callable[..., Any | Awaitable[Any]]
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[
[str, Mapping[str, object], str],
bool | Awaitable[bool],
ToolConfirmResult | Awaitable[ToolConfirmResult],
]
_PRIMITIVE_SCHEMA: dict[type, JSONSchema] = {
@@ -212,7 +214,11 @@ class ToolRegistry:
return msgspec.json.encode(result).decode()
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:
return None
reason = self._danger(name, args)
@@ -223,12 +229,14 @@ class ToolRegistry:
reason = self._danger(name, args)
if reason is None:
return None
allowed = self._on_confirm(name, args, reason)
if inspect.isawaitable(allowed):
allowed = await allowed
if allowed:
decision = self._on_confirm(name, args, reason)
if inspect.isawaitable(decision):
decision = await decision
if decision is True:
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:
"""Run a tool by name; returns a string result (errors become error text)."""
+3 -1
View File
@@ -257,7 +257,7 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
try:
if client_supports_models(client):
remote_ids = await fetch_remote_model_ids(client)
except (RuntimeError, TypeError, OSError, ValueError):
except RuntimeError, TypeError, OSError, ValueError:
remote_ids = None
choices = model_choices_for_provider(provider, remote_ids=remote_ids)
model_id = select_model(
@@ -307,8 +307,10 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
finally:
await memory.close()
from plyngent.tools.process.pty_session import PtyManager
from plyngent.tools.temp_workspace import cleanup_temporary_workspaces
PtyManager.close_all()
_ = cleanup_temporary_workspaces()
def _configure_logging(level: str) -> None:
+18 -6
View File
@@ -4,7 +4,6 @@ import asyncio
import contextlib
import signal
from contextlib import contextmanager
from contextvars import ContextVar
from typing import TYPE_CHECKING
if TYPE_CHECKING:
@@ -13,13 +12,16 @@ if TYPE_CHECKING:
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]
def allow_task_cancel() -> bool:
"""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:
@@ -34,8 +36,14 @@ def pause_task_cancel_for_prompt() -> Generator[None]:
Must run on the main thread (signal handlers are main-thread only).
Restores the default SIGINT handler so ``click.confirm`` can receive
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
previous: SigHandler = signal.SIG_DFL
try:
@@ -56,11 +64,15 @@ def pause_task_cancel_for_prompt() -> Generator[None]:
finally:
with contextlib.suppress(ValueError):
_ = 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]
if loop_handler_removed and reinstall is not None:
if reinstall is not None:
with contextlib.suppress(RuntimeError, NotImplementedError, ValueError):
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:
+118 -13
View File
@@ -1,16 +1,20 @@
from __future__ import annotations
import shutil
from typing import TYPE_CHECKING, Literal
from plyngent.cli.interrupt import pause_task_cancel_for_prompt
from plyngent.prompting import (
ChoiceOption,
NonInteractiveError,
ask,
ask_async,
choose,
choose_async,
configure_prompting,
confirm,
confirm_async,
get_prompt_backend,
)
from plyngent.tools.process.pty_session import PtyManager
@@ -19,6 +23,78 @@ if TYPE_CHECKING:
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:
try:
@@ -44,33 +120,62 @@ async def prompt_continue_limit_async(reason: str) -> bool:
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
try:
return confirm(
f"[confirm] tool {name!r}: {reason}\nAllow this tool call?",
default=False,
)
_echo_tool_confirm(name, reason)
allowed = confirm("Allow this tool call?", default=False)
except NonInteractiveError:
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:
"""Ask whether to allow a destructive tool call (TTY). Default is deny."""
def prompt_confirm_tool(name: str, args: Mapping[str, object], reason: str) -> bool | str:
"""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():
return _prompt_confirm_tool_sync(name, args, reason)
async def prompt_confirm_tool_async(name: str, args: Mapping[str, object], reason: str) -> bool:
"""Async variant: confirm off the event loop."""
async def prompt_confirm_tool_async(name: str, args: Mapping[str, object], reason: str) -> bool | str:
"""Async confirm: True allow, False deny, str = deny with user comment."""
del args
try:
return await confirm_async(
f"[confirm] tool {name!r}: {reason}\nAllow this tool call?",
default=False,
)
# Box is printed inside the worker thread via confirm's backend.
def _run() -> bool:
_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:
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(
+4 -2
View File
@@ -54,8 +54,10 @@ def build_completer(state: ReplState) -> Callable[[str, int], str | None]:
options = filter_prefix(text, slash_commands())
else:
head = buffer[:begidx].strip()
command = head.split()[0] if head else ""
options = complete_slash_args(state, command, text)
tokens = head.split() if head else []
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):
return options[state_index]
return None
+3
View File
@@ -66,6 +66,9 @@ async def run_cancellable[T](coro: Coroutine[object, object, T]) -> T:
installed = False
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():
_ = task.cancel()
+401 -37
View File
@@ -15,7 +15,8 @@ from plyngent.cli.selection import select_model, select_provider
from plyngent.lmproto.openai_compatible.model import (
AssistantChatMessage,
AssistantFunctionToolCall,
ToolChatMessage,
DeveloperChatMessage,
SystemChatMessage,
UserChatMessage,
)
from plyngent.runtime import ProviderNotSupportedError
@@ -32,15 +33,58 @@ _COMPACT_PREVIEW = 400
_ON_OFF_CHOICES = ("on", "off")
_YOLO_MODE_CHOICES = ("on", "off", "once")
_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 = (
"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"
"works after resume, not only via readline history). Auto-retry: 10s/20s/30s.\n"
"\n"
"Tab completes slash commands and some arguments (provider, model, tools,\n"
"stream, verbose, yolo, export). Use --session ID or /resume to continue a prior\n"
"chat after restart.\n"
"Tab completes slash commands and arguments (provider, model, session ids,\n"
"todos actions, on/off, yolo, export, flags). Use --session ID or /resume to\n"
"continue a prior chat after restart.\n"
"\n"
'Multiline: start a message with """ then end a later line with """.\n'
"Long prompts: /edit opens $EDITOR.\n"
@@ -130,6 +174,80 @@ class ExportFormatParam(click.ParamType[str]):
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]):
name: str = "provider"
@@ -232,21 +350,55 @@ def slash_command_names() -> list[str]:
return sorted(f"/{name}" for name in slash.list_commands(ctx))
def complete_slash_args(state: ReplState, command: str, incomplete: str) -> list[str]:
"""Tab-complete arguments for ``command`` (e.g. ``/stream``) from ParamTypes.
def complete_slash_args(
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
implements :meth:`~click.ParamType.shell_complete` with candidates.
*prior_args* are tokens already typed after the command name (so
``/todos done t`` can complete todo ids). Options are completed when
*incomplete* starts with ``-``.
"""
name = command.lstrip("/").lower()
ctx = click.Context(slash, obj=state)
cmd = slash.get_command(ctx, name)
if cmd is None:
return []
prior = list(prior_args or ())
with click.Context(cmd, info_name=name, parent=ctx, obj=state) as sub:
for param in cmd.params:
if not isinstance(param, click.Argument):
continue
# Flag / option completion (e.g. --persist, --full).
if incomplete.startswith("-"):
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)
if items:
return [item.value for item in items]
@@ -398,6 +550,7 @@ def status_cmd(state: ReplState) -> None:
def sessions_cmd(state: ReplState) -> None:
"""List sessions for this workspace (newest first)."""
sessions = _await(state.memory.list_sessions(workspace=state.workspace))
state.remember_session_ids([s.sid for s in sessions])
if not sessions:
click.echo(f"(no sessions for workspace {state.workspace})")
return
@@ -435,7 +588,7 @@ def rename_cmd(state: ReplState, name: tuple[str, ...]) -> None:
@slash.command("delete")
@click.argument("session_id", type=int, required=False)
@click.argument("session_id", type=SESSION_ID, required=False)
@click.pass_obj
def delete_cmd(state: ReplState, session_id: int | None) -> None:
"""Hard-delete a session (confirm; current → new empty)."""
@@ -538,7 +691,7 @@ def export_cmd(state: ReplState, parts: tuple[str, ...]) -> None:
@slash.command("resume")
@click.argument("session_id", type=int, required=False)
@click.argument("session_id", type=SESSION_ID, required=False)
@click.pass_obj
def resume_cmd(state: ReplState, session_id: int | None) -> None:
"""Resume session id, or latest for this workspace if omitted."""
@@ -576,6 +729,11 @@ def compact_cmd(state: ReplState, name: str | None) -> None:
return
preview = summary if len(summary) <= _COMPACT_PREVIEW else summary[:_COMPACT_PREVIEW] + ""
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")
@@ -637,8 +795,13 @@ def provider_cmd(state: ReplState, name: str | None) -> None:
@slash.command("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
def models_cmd(state: ReplState, *, refresh: bool) -> None:
def models_cmd(state: ReplState, *, refresh: bool, persist: bool) -> None: # noqa: C901
"""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
@@ -662,6 +825,14 @@ def models_cmd(state: ReplState, *, refresh: bool) -> None:
except (KeyError, ValueError) as exc:
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())
choices = model_choices_for_provider(state.provider, remote_ids=remote)
@@ -691,12 +862,20 @@ def models_cmd(state: ReplState, *, refresh: bool) -> None:
@slash.command("model")
@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
def model_cmd(state: ReplState, model_id: str | None) -> None:
"""Show or switch model (Tab: remote-first plus config; live fetch on pick)."""
if not model_id:
click.echo(f"model={state.model}")
return
def model_cmd(state: ReplState, model_id: str | None, *, persist: bool) -> None:
"""Show or switch model (Tab: remote-first plus config; live fetch on pick).
``/model --persist`` saves the active model id into the provider catalog in
TOML (faster Tab/list on next launch). ``/model <id> --persist`` switches
then saves.
"""
if model_id:
try:
choices = _await(state.merged_model_choices(refresh=True))
state.model = select_model(
@@ -709,6 +888,17 @@ def model_cmd(state: ReplState, model_id: str | None) -> None:
click.echo(f"switched model to {state.model}")
except click.ClickException as 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")
@@ -800,45 +990,160 @@ def markdown_cmd(state: ReplState, enabled: bool | None) -> None: # noqa: FBT00
@slash.command("rounds")
@click.argument("n", required=False, type=int)
@click.argument("n", required=False, type=ROUNDS)
@click.pass_obj
def rounds_cmd(state: ReplState, n: int | None) -> None:
"""Show or set max tool-loop rounds."""
if n is None:
click.echo(f"max_rounds={state.max_rounds}")
return
if n < 1:
msg = "max_rounds must be >= 1"
raise click.UsageError(msg)
state.max_rounds = n
state.agent.max_rounds = n
click.echo(f"max_rounds={state.max_rounds}")
@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
def history_cmd(state: ReplState, n: int | None) -> None:
"""Show last n messages in this session (default 20)."""
limit = _DEFAULT_HISTORY_LINES if n is None else n
if limit < 1:
msg = "n must be >= 1"
def history_cmd(
state: ReplState,
n: int | None,
*,
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)
limit = _DEFAULT_HISTORY_LINES if n is None else n
messages = state.agent.messages
if not messages:
click.echo("(no messages in this session)")
return
start = max(0, len(messages) - limit)
click.echo(f"session={state.session_id} messages={len(messages)} showing={len(messages) - start}")
for offset, message in enumerate(messages[start:]):
click.echo(_format_history_message(start + offset, message))
slice_msgs = messages[start:]
# Full: --full, or single-message window (last / 1) unless --preview.
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:
pending = state.agent.pending_retry_text
if full:
click.secho("(pending retry) user:", fg="yellow")
click.echo(pending)
else:
click.secho(
f"(pending retry) user: {_preview_content(state.agent.pending_retry_text)}",
f"(pending retry) user: {_preview_content(pending)}",
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")
@click.pass_obj
def retry_cmd(state: ReplState) -> None:
@@ -858,9 +1163,71 @@ def _preview_content(text: str | None) -> str:
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:
if isinstance(message, UserChatMessage):
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):
parts: list[str] = []
if isinstance(message.content, str) and message.content:
@@ -876,11 +1243,8 @@ def _format_history_message(index: int, message: AnyChatMessage) -> str:
parts.append(f"tool_calls=[{', '.join(names)}]")
body = " ".join(parts) if parts else "(empty)"
return f"{index}. assistant: {body}"
if isinstance(message, ToolChatMessage):
# ToolChatMessage
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:
+111 -3
View File
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Literal, cast
from plyngent.agent import ChatAgent, ChatClient, ToolRegistry
from plyngent.agent.loop import DEFAULT_MAX_ROUNDS
from plyngent.agent.todo_stack import TodoStack
from plyngent.cli.models_source import (
DEFAULT_MODELS_CACHE_TTL,
client_supports_models,
@@ -17,9 +18,11 @@ from plyngent.cli.models_source import (
)
from plyngent.memory.database.store import normalize_workspace
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:
from collections.abc import Sequence
from plyngent.config.models import Provider
from plyngent.config.store import ConfigStore
from plyngent.memory import MemoryStore
@@ -54,6 +57,10 @@ class ReplState:
client: ChatClient = field(init=False)
agent: ChatAgent = field(init=False)
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_models: list[str] | None = field(default=None, init=False, repr=False)
_remote_models_fetched_at: float | None = field(default=None, init=False, repr=False)
@@ -66,6 +73,7 @@ class ReplState:
self.workspace = Path(self.workspace).expanduser().resolve()
self.agent = self._make_agent()
self.sync_display_flags()
self._bind_todo_tools()
def sync_display_flags(self) -> None:
from plyngent.cli.display import set_markdown_enabled, set_verbose_tool_results
@@ -107,6 +115,44 @@ class ReplState:
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:
if not self.tools_enabled:
return None
@@ -140,18 +186,22 @@ class ReplState:
max_tool_result_chars=agent_cfg.max_tool_result_chars,
parallel_tools=agent_cfg.parallel_tools,
max_context_tokens=agent_cfg.max_context_tokens,
todo_stack=self.todo_stack,
)
def rebuild_client(self) -> None:
"""Recreate client and agent after provider/model/tools change."""
messages = list(self.agent.messages)
persist_from = self.agent.persist_from
# Preserve live stream toggle if agent already exists.
if hasattr(self, "agent"):
self.stream_enabled = self.agent.stream
self.client = cast("ChatClient", cast("object", create_client(self.provider)))
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._bind_todo_tools()
# 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():
self.invalidate_remote_models()
@@ -174,6 +224,22 @@ class ReplState:
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:
"""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:
@@ -290,6 +356,28 @@ class ReplState:
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:
import click
@@ -353,7 +441,11 @@ class ReplState:
model=self.model,
)
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._bind_todo_tools()
async def rename_current_session(self, name: str) -> SessionRow:
if self.session_id is None:
@@ -404,11 +496,14 @@ class ReplState:
raise ValueError(msg) from exc
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):
self.rebuild_client()
else:
self.agent = self._make_agent()
await self.agent.load_history()
await self.load_todo_stack()
async def resume_latest_or_new(self, name: str = "chat") -> str:
"""Resume most recently updated session for this workspace, or create one."""
@@ -423,6 +518,7 @@ class ReplState:
else:
self.agent = self._make_agent()
await self.agent.load_history()
await self.load_todo_stack()
_ = await self.memory.touch_session(latest.sid)
return "resume"
@@ -430,6 +526,7 @@ class ReplState:
"""Soft-compact + model-summarize current history into a new workspace session.
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
@@ -461,6 +558,7 @@ class ReplState:
system_prompt=self.config.agent_config.compact_system_prompt 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}"
await self.new_session(name=session_name)
new_id = self.session_id
@@ -474,7 +572,17 @@ class ReplState:
source_session_id=old_id,
seed_text=self.config.agent_config.compact_seed_text or None,
)
self.agent.messages = list(seed)
for message in seed:
_ = 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
+53
View File
@@ -200,6 +200,59 @@ class ConfigStore:
_ = self._bad_providers.pop(name, None)
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 --
def write(self) -> None:
+2
View File
@@ -32,6 +32,8 @@ class Session(PlyngentBase):
# Last selected provider/model for this session (config provider key + model id).
provider_name: Mapped[str | None] = mapped_column(String(128), 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())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
+38 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Self
from typing import TYPE_CHECKING, Self, cast
import msgspec
from sqlalchemy import delete, select, text
@@ -68,6 +68,7 @@ class MemoryStore:
_ = await conn.run_sync(PlyngentBase.metadata.create_all)
await conn.run_sync(_migrate_session_workspace)
await conn.run_sync(_migrate_session_llm)
await conn.run_sync(_migrate_session_todo_stack)
async def close(self) -> None:
"""Dispose the underlying engine."""
@@ -208,6 +209,31 @@ class MemoryStore:
await session.refresh(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:
"""Rename a session (max 64 characters, non-empty after 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)"))
if "model" not in columns:
_ = 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"))
+17 -1
View File
@@ -21,6 +21,17 @@ from .process import open_pty as open_pty
from .process import read_pty as read_pty
from .process import run_command as run_command
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_branch as vcs_branch
from .vcs import vcs_diff as vcs_diff
@@ -31,14 +42,19 @@ from .workspace import (
DEFAULT_COMMAND_DENYLIST as DEFAULT_COMMAND_DENYLIST,
)
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 clear_workspace_allowlist as clear_workspace_allowlist
from .workspace import clear_workspace_root as clear_workspace_root
from .workspace import get_command_denylist as get_command_denylist
from .workspace import get_path_denylist as get_path_denylist
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 set_command_denylist as set_command_denylist
from .workspace import set_path_denylist as set_path_denylist
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]
+172 -24
View File
@@ -1,11 +1,111 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, cast
from plyngent.tools.workspace import WorkspaceError, resolve_path
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:
@@ -15,32 +115,80 @@ def _write_file_reason(args: Mapping[str, object]) -> str | None:
try:
target = resolve_path(path)
except WorkspaceError:
return None
if target.exists() or target.is_symlink():
return f"overwrite existing file {path!r}"
return None
return f"write file {path!r}"
return f"write file {path!r} ({target})"
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``.
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] = {
"delete_path": (
f"delete path {args.get('path', '')!r} recursively"
if bool(args.get("recursive", False))
else f"delete path {args.get('path', '')!r}"
),
"move_path": f"move {args.get('src', '')!r} -> {args.get('dst', '')!r}",
"copy_path": (
f"copy with overwrite {args.get('src', '')!r} -> {args.get('dst', '')!r}"
if bool(args.get("overwrite", False))
else None
),
"write_file": _write_file_reason(args),
}
if name not in reasons:
if name == "delete_path":
return _delete_path_reason(args)
if name == "move_path":
return _move_path_reason(args)
if name == "copy_path":
return _copy_path_reason(args)
if name == "write_file":
return _write_file_reason(args)
if name == "edit_replace":
return _edit_replace_reason(args)
if name == "edit_lineno":
return _edit_lineno_reason(args)
if name == "run_command":
return _run_command_reason(args)
if name == "open_pty":
return _open_pty_reason(args)
return None
return reasons[name]
+3
View File
@@ -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_replace import edit_replace as edit_replace
from .fs_ops import copy_path as copy_path
@@ -22,4 +24,5 @@ FILE_TOOLS = [
copy_path,
move_path,
delete_path,
new_temporary_workspace,
]
+26 -2
View File
@@ -3,12 +3,33 @@ from __future__ import annotations
from plyngent.agent import tool
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
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.
``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)
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)
if start >= len(lines):
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)
+71 -12
View File
@@ -4,9 +4,10 @@ from dataclasses import dataclass
from typing import TYPE_CHECKING
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:
from collections.abc import Sequence
from pathlib import Path
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)
class _TreeLimits:
max_depth: int
max_entries: int
skip_hidden_dirs: bool
skip_basenames: frozenset[str]
apply_path_denylist: bool
def _skip_directory(name: str, *, skip_hidden_dirs: bool) -> bool:
if name in VCS_DIR_NAMES:
def _skip_directory(name: str, *, limits: _TreeLimits) -> bool:
if name in VCS_DIR_NAMES or name in limits.skip_basenames:
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:
children = list(directory.iterdir())
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()
except OSError:
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
visible.append(child)
# Directories first, then files; alphabetical within each group.
@@ -70,7 +110,7 @@ def _render_tree(
if depth >= limits.max_depth:
return
children = _list_children(directory, skip_hidden_dirs=limits.skip_hidden_dirs)
children = _list_children(directory, limits=limits)
if isinstance(children, str):
lines.append(f"{prefix}{children}")
return
@@ -104,6 +144,13 @@ def _render_tree(
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
def tree(
path: str = ".",
@@ -111,13 +158,22 @@ def tree(
max_depth: int = DEFAULT_MAX_DEPTH,
max_entries: int = DEFAULT_MAX_ENTRIES,
skip_hidden_dirs: bool = True,
skip_dirs: list[str] | None = None,
apply_path_denylist: bool = True,
) -> str:
"""Show a directory tree under the workspace.
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
``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_entries`` caps how many entries are listed per directory.
"""
@@ -135,15 +191,18 @@ def tree(
root_label = path.rstrip("/\\") or "."
lines = [f"{root_label}/"]
_render_tree(
origin,
prefix="",
depth=0,
limits = _TreeLimits(
max_depth=max_depth,
max_entries=max_entries,
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,
)
return "\n".join(lines)
+4 -1
View File
@@ -1,7 +1,10 @@
from .close_pty import close_pty as close_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 .run_command import run_command as run_command
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 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
if TYPE_CHECKING:
@@ -286,6 +287,8 @@ class PtyManager:
truncated = len(data) > max_bytes
if truncated:
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)
return PtyReadResult(
session_id=session_id,
@@ -354,6 +357,8 @@ class PtyManager:
with cls._lock:
_ = 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(
session_id=session_id,
closed=True,
@@ -381,6 +386,7 @@ class PtyManager:
@classmethod
def close_all(cls) -> None:
"""Close every open session (no host TTY reset)."""
with cls._lock:
ids = list(cls._sessions.keys())
for session_id in ids:
+108
View File
@@ -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)
+19 -10
View File
@@ -6,22 +6,31 @@ from plyngent.tools.workspace import WorkspaceError
from .pty_session import PtyManager
@tool
def write_pty(session_id: int, data: str) -> str:
"""Write text to a PTY session (interactive input). Does not append a newline."""
try:
PtyManager.write(session_id, data)
def write_pty_payload(session_id: int, raw: str) -> str:
"""Write raw bytes (as str) to the PTY and format the tool status string."""
PtyManager.write(session_id, raw)
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)
return "\n".join(
[
f"session_id={session_id}",
f"alive={'true' if session.alive else 'false'}",
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}"
+96
View File
@@ -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"
)
+126
View File
@@ -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]
+82 -6
View File
@@ -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):
"""Raised when a path or command violates workspace policy."""
@@ -35,6 +38,14 @@ class _WorkspaceState:
root: Path | None = None
path_denylist: tuple[str, ...] = ()
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()
@@ -59,7 +70,7 @@ def get_workspace_root() -> Path:
def clear_workspace_root() -> None:
"""Clear workspace root (mainly for tests)."""
"""Clear workspace root (mainly for tests). Does not clear allowlist."""
_state.root = None
@@ -82,18 +93,83 @@ def get_command_denylist() -> frozenset[str]:
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:
"""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()
candidate = Path(path)
if not candidate.is_absolute():
candidate = root / candidate
resolved = candidate.expanduser().resolve()
try:
_ = resolved.relative_to(root)
except ValueError as exc:
if not _under_any_root(resolved):
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.
resolved_str = str(resolved).replace("\\", "/")
for pattern in _state.path_denylist:
+141 -2
View File
@@ -30,6 +30,7 @@ from plyngent.lmproto.openai_compatible.model import (
ChatCompletionsParam,
ChunkChoice,
DeltaMessage,
FinishReason,
StreamFunctionDelta,
StreamToolCallDelta,
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
if tool_calls is not UNSET and tool_calls:
deltas: list[StreamToolCallDelta] = []
@@ -152,7 +175,8 @@ class ScriptedClient:
def _response(
message: AssistantChatMessage,
*,
usage: dict[str, int] | None = None,
usage: dict[str, object] | None = None,
finish_reason: FinishReason | None = "stop",
) -> ChatCompletionResponse:
return ChatCompletionResponse(
id="1",
@@ -164,7 +188,7 @@ def _response(
index=0,
message=message,
logprobs={},
finish_reason="stop",
finish_reason=finish_reason,
)
],
system_fingerprint="",
@@ -825,3 +849,118 @@ async def test_tool_result_char_budget() -> None:
assert len(tool_msgs) == 1
assert tool_msgs[0].content.startswith("x" * 20)
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)
+26
View File
@@ -8,6 +8,7 @@ from plyngent.agent.responses_bridge import (
chat_param_to_responses_kwargs,
response_to_assistant_message,
response_to_chat_completion,
responses_status_to_finish_reason,
tool_items_to_response_tools,
)
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 isinstance(completion.usage, dict)
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:
+238
View File
@@ -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)
+52
View File
@@ -99,11 +99,63 @@ async def test_compact_to_new_session(tmp_path: Path) -> None:
assert "compacted goals" in summary
loaded = await memory.list_messages(new_id)
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
sessions = await memory.list_sessions(workspace=tmp_path)
ids = {s.sid for s in sessions}
assert old_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:
await memory.close()
+67 -2
View File
@@ -1,16 +1,22 @@
from __future__ import annotations
import asyncio
import signal
from typing import TYPE_CHECKING
import pytest
from plyngent.cli.interrupt import (
allow_task_cancel,
pause_task_cancel_for_prompt,
run_in_prompt_thread,
set_sigint_reinstall,
)
from plyngent.cli.limits import prompt_continue_limit, prompt_continue_limit_async
from plyngent.cli.retry import run_cancellable
if TYPE_CHECKING:
import pytest
pass
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
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 _confirm(*_a: object, **_k: object) -> bool:
assert allow_task_cancel() is False
@@ -36,7 +52,6 @@ async def test_run_in_prompt_thread_pauses_cancel() -> None:
def work() -> str:
return "ok"
# ContextVar may not propagate to worker threads; assert pause around the call.
result = await run_in_prompt_thread(work)
assert result == "ok"
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)
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)
+17 -1
View File
@@ -1,6 +1,11 @@
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.tools.process.pty_session import PtyManager
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
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:
install_cli_limit_hooks()
assert callable(getattr(PtyManager, "_limit_continue", None))
+17
View File
@@ -183,3 +183,20 @@ def test_complete_slash_args_from_registry(tmp_path: object) -> None:
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"]
+85 -2
View File
@@ -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:
assert await handle_slash(state, "/help history") is True
out = capsys.readouterr().out
assert "Usage: /history [N]" in out
assert "Options:" not in out
assert "Usage: /history" 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
@@ -341,6 +343,36 @@ async def test_verbose_toggle(state: ReplState) -> None:
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
@@ -414,10 +446,61 @@ async def test_history(state: ReplState, capsys: pytest.CaptureFixture[str]) ->
]
assert await handle_slash(state, "/history") is True
out = capsys.readouterr().out
assert "mode=preview" in out
assert "user: hello" 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:
assert await handle_slash(state, "/rounds 40") is True
assert state.max_rounds == 40
+33
View File
@@ -179,6 +179,39 @@ def test_write_new_config() -> None:
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:
file = Path(__file__).parent / "plyngent-edit-2.toml"
_ = shutil.copy(Path(__file__).parent / "plyngent-valid.toml", file)
+7 -1
View File
@@ -4,7 +4,11 @@ from typing import TYPE_CHECKING
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:
from collections.abc import Iterator
@@ -14,6 +18,8 @@ if TYPE_CHECKING:
@pytest.fixture
def workspace(tmp_path: Path) -> Iterator[Path]:
clear_workspace_root()
clear_workspace_allowlist()
root = set_workspace_root(tmp_path)
yield root
clear_workspace_allowlist()
clear_workspace_root()
+61 -8
View File
@@ -11,18 +11,71 @@ def test_classify_delete_and_move() -> None:
assert "move" in (classify_danger("move_path", {"src": "a", "dst": "b"}) or "")
def test_classify_copy_overwrite_only() -> None:
assert classify_danger("copy_path", {"src": "a", "dst": "b"}) is None
assert "overwrite" in (classify_danger("copy_path", {"src": "a", "dst": "b", "overwrite": True}) or "")
def test_classify_copy() -> None:
assert "copy" in (classify_danger("copy_path", {"src": "a", "dst": "b"}) or "")
def test_classify_write_file_overwrite(workspace: object) -> None:
assert isinstance(workspace, Path)
_ = (workspace / "x.txt").write_text("old", encoding="utf-8")
assert "overwrite" in (classify_danger("write_file", {"path": "x.txt", "content": "n"}) or "")
assert classify_danger("write_file", {"path": "new.txt", "content": "n"}) is None
def test_classify_write_and_edits(tmp_path: Path) -> None:
from plyngent.tools.workspace import clear_workspace_root, set_workspace_root
set_workspace_root(tmp_path)
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:
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("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
+12
View File
@@ -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"
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:
del workspace
assert "error" in call_sync(listdir, "nope")
+87 -1
View File
@@ -4,7 +4,14 @@ import asyncio
import sys
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.workspace import set_command_denylist
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()
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:
from plyngent.tools.process.pty_backend import pty_available
+64
View File
@@ -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()
+55
View File
@@ -83,3 +83,58 @@ def test_tree_invalid_limits(workspace: object) -> None:
del workspace
assert "max_depth" in call_sync(tree, ".", max_depth=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)