52 Commits

Author SHA1 Message Date
NCBM ab8828f7b6 release: plyngent 0.4.1 2026-08-15 18:45:09 +08:00
NCBM 95a48873c1 cli: pretty printed summary for wait tool 2026-08-15 18:42:31 +08:00
NCBM e27effcefe cli: pretty printed summaries for vcs_status and vcs_log 2026-08-15 18:37:12 +08:00
NCBM 8e8a10a05e cli: reduce pretty summaries to minimal done/error semantics 2026-08-15 18:36:21 +08:00
NCBM 5344a20b67 cli: pretty printed summaries for file mutation tools 2026-08-15 18:09:52 +08:00
NCBM aeae99a496 cli: pretty printed summary for fetch with HTTP status coloring 2026-08-15 18:09:05 +08:00
NCBM 259dd87c43 cli: pretty printed summaries for run_argv and run_argv_batch 2026-08-15 18:08:28 +08:00
NCBM 6f939e52b2 cli: pretty printed summaries for listdir, glob_paths and grep_files 2026-08-15 18:06:36 +08:00
NCBM 41ce98bf42 cli: style pretty tool summaries, verbose shows full results, preview shows first line 2026-08-15 18:05:40 +08:00
NCBM 5436457ac0 core/tools: allow edit_lineno append to an empty file
Appending via edit_lineno required the last line to be read, which made
writing to an empty file impossible (line 0 does not exist). An empty
file has nothing to clobber, so the append needs no prior read.
2026-08-15 17:41:06 +08:00
NCBM 2cf66edb60 core/tools: edit results preview the old content
Wrong-target edits (the test_run_aside pass clobber, the edit_replace
mangling) were silent: the success message reported counts only, so the
model had no signal it replaced the wrong thing until later.

edit_lineno now reports the first removed line ("first: 'pass'") and
edit_replace reports the matched pattern ('old' -> 'new'), giving the
model an immediate self-correction signal.
2026-08-15 17:40:26 +08:00
NCBM 38671482f2 core/tools: edit_lineno unread error names the read window
The boundary overguard (read offset=50 shows lines 51+, then editing
line 50 fails) forced blind re-reads. The error now reports the read
window ("this turn read lines 51-86") and advises reading with a margin
around the target range; read_file's description says the same.
2026-08-15 17:38:58 +08:00
NCBM c9815e32bb core/tools: mtime freshness replaces per-write lineno invalidation
edit_lineno now rejects edits when the file changed since the numbered
read (mtime recorded at read time), so stale line numbers are caught
even when the mutation came from run_argv / an external process — the
class the explicit invalidations could never cover.

This lets write_file / edit_replace / edit_lineno drop their explicit
invalidate calls (the write itself changes the mtime, and the error is
now the accurate "changed since it was read" instead of a misleading
"not read"). copy/move keep explicit invalidation because shutil
preserves the source mtime, which the freshness check cannot see.
2026-08-15 17:38:10 +08:00
NCBM d914590677 core/tools: numbered read continuation via get_truncated
Two fixes for the truncated numbered-read flow:

- read_file(with_lineno) tokens were rebased onto numbered-body chars
  (each line carries an N| prefix), so get_truncated over-advanced into
  raw text and silently skipped lines. Tokens now advance by raw chars
  of the complete lines shown and carry a numbered flag.
- get_truncated resumes numbered tokens as numbered lines (via
  read_file with_lineno), marking them readable for edit_lineno, so
  read -> get_truncated -> edit_lineno works end-to-end without gaps.
2026-08-15 17:35:57 +08:00
NCBM 132ac520ec core/tools: mark only displayed lines read on truncated read_file
read_file(with_lineno=true) marked the whole requested slice before
applying the max_chars cap, so a truncated read silently unlocked every
line of the file for edit_lineno even though the model never saw them.
Mark only the complete numbered lines actually returned (excluding the
[Truncated] marker); the unseen tail stays unread.
2026-08-15 17:31:43 +08:00
NCBM 32456c9f47 docs: tree markdown default and flat/decorated formats 2026-08-15 17:10:21 +08:00
NCBM b5014cd3e7 cli: pretty tree tool summary like read_file
Tool results for tree render as a single line: * Tree 'path' (N dirs,
M files, depth D) for markdown/decorated, dropping the depth for flat.
Counts and depth are parsed from the rendered output per format.
2026-08-15 17:09:57 +08:00
NCBM 4fa05e2456 core/tools: tree format markdown|flat|decorated, markdown default
The model picks the representation per call via the format param (no
user config). markdown (default) is a nested bullet list: ASCII-only,
cheapest tokens, indentation-based hierarchy the model reads reliably.
flat lists one relative path per line (dirs with trailing /, empty dirs
included) with max_entries capping the total lines, so paths are directly
usable. decorated keeps the classic box-drawing tree for humans.
2026-08-15 17:06:12 +08:00
NCBM ae93128e02 docs: document READ_ONLY attribute and /btw --tools modes 2026-08-15 16:53:06 +08:00
NCBM b16b2547b8 cli: /btw --tools [no|read|full] with read-only default
/btw now defaults to --tools=read: the side turn gets only READ_ONLY
tools under a read-only context (fetch restricted to GET), with the main
session view so read-only reads (todo_list) see real state. --tools=full
keeps the previous whole-registry clone with a fresh session; --tools=no
turns tools off. When tools are off, the default read silently falls back
to no tools, while an explicit --tools read|full errors as before.
2026-08-15 16:52:02 +08:00
NCBM ddca6e23d3 core/tools: add READ_ONLY tool attribute
New ToolTag.READ_ONLY marks tools that never mutate host state, plus
ToolRegistry.clone(read_only_only=True) to select just those (for safe
side turns / planning). Builtin read-only set: read_file, get_truncated,
glob_paths, grep_files, listdir, tree, todo_list, wait, ask_user_*,
vcs_* and fetch.

fetch is tagged READ_ONLY but can mutate via method; a new
read_only_context ContextVar makes it accept GET only while set.
2026-08-15 16:46:27 +08:00
NCBM ea8e64e913 release: plyngent 0.4.0 2026-08-11 21:18:01 +08:00
NCBM 1973ef93bb cli: keep Ctrl+C a plain KeyboardInterrupt under asyncio.run
asyncio.run (Runner) replaces SIGINT with its own handler when the
current one is signal.default_int_handler: the first Ctrl+C silently
cancels the main task (invisible at the REPL prompt, leaving the task
marked cancelled) and the second raises KeyboardInterrupt, so a Ctrl+D
exit after a cancel was cancelled mid-memory.close() (SQLAlchemy
CancelledError traceback) and the CLI died with "Aborted!".

Install a distinct KeyboardInterrupt-raising handler before asyncio.run
so Runner leaves SIGINT alone, and have run_cancellable restore the
previous handler after each turn (remove_signal_handler leaves SIG_DFL,
which would kill the process on a stray Ctrl+C between turns).
2026-08-11 21:13:17 +08:00
NCBM a197a67142 cli: keep REPL alive on stray Ctrl+C outside turn calls
After a turn the run_cancellable SIGINT handler is removed, so a Ctrl+C
landing between turns (echoing user text, expire_yolo_once, gaps between
statements, or re-entering _echo_interrupted) raised KeyboardInterrupt at
an arbitrary bytecode and escaped run_repl, killing the whole REPL. Wrap
the loop body in one try/except KeyboardInterrupt that re-prompts, and
make _echo_interrupted itself suppress a further interrupt.
2026-08-11 20:54:13 +08:00
NCBM 28a191cd79 core/agent: full reasoning_text.done replaces streamed fragments 2026-08-11 20:08:52 +08:00
NCBM ec6edbcba4 core/config: tell model to clear todo stack when done 2026-08-11 19:42:10 +08:00
NCBM bd1053a3bc core/config: document default truncate_token everywhere 2026-08-11 19:42:10 +08:00
NCBM b5a30190f6 core/tools: default read_file truncation with token (no hint when uncut) 2026-08-11 19:42:10 +08:00
NCBM 26472776fb core/tools: token-ify per-stream caps and resume memory remainders 2026-08-11 19:41:54 +08:00
NCBM 6fdfb8398e core/agent: token-ify generic tool-result cap and compact shrinks 2026-08-11 19:41:54 +08:00
NCBM ec1ae59a28 core/tools: unified truncate marker with memory remainder store 2026-08-11 19:41:54 +08:00
NCBM b5950bdb57 core/agent: drop get_truncated hint from token-less generic truncation 2026-08-11 18:21:45 +08:00
NCBM c2735dada3 core/config: document read_file L-range and prettier summaries 2026-08-11 15:38:44 +08:00
NCBM 65349b3c07 cli: prettier tool call summaries for read_file and todo tools 2026-08-11 15:38:07 +08:00
NCBM e4ed89b391 core/tools: distinguish missing files from non-files on read 2026-08-11 15:37:56 +08:00
NCBM 25e6ec3d44 core/tools: show 1-based line range on read_file results 2026-08-11 15:33:12 +08:00
NCBM 3fbf2dc388 core/agent: fix deepseek responses reasoning_content for tool calls 2026-08-11 15:27:01 +08:00
NCBM 9640f86b89 core/tools: make truncate tokens gap-free (advance by returned length) 2026-08-11 15:16:55 +08:00
NCBM 4c60e9b1b3 docs: document truncate tokens and get_truncated 2026-08-11 15:03:00 +08:00
NCBM 6458a4ad31 core/tools: embed truncate tokens in read_file (max_chars) 2026-08-11 15:02:37 +08:00
NCBM 00db7cdc7f core/tools: add get_truncated tool for chained resume 2026-08-11 14:59:39 +08:00
NCBM c96f669bcb core/tools: embed truncate tokens in fetch (offset param) 2026-08-11 14:55:55 +08:00
NCBM 8cd456171c core/agent: drop increase-limit hint in favor of truncate tokens 2026-08-11 14:53:49 +08:00
NCBM cb137c1dee core/tools: add truncate token module (chainable cursors) 2026-08-11 14:53:15 +08:00
NCBM fa0e68d594 docs: document wait tool 2026-08-11 14:47:53 +08:00
NCBM d3a5200e0f core/tools: add wait tool (Enter to disturb) 2026-08-11 14:46:58 +08:00
NCBM fe7b75aad8 core/prompting: add line input with timeout 2026-08-11 14:44:34 +08:00
NCBM 010173cfc7 docs: use run_argv tool names in docs 2026-08-04 21:41:39 +08:00
NCBM f4c020b761 core/config: point default playbook and reminder at run_argv tools 2026-08-04 21:40:55 +08:00
NCBM df8f6bf959 core/tools: rename run_command_batch to run_argv_batch (steps/argv keys) 2026-08-04 21:40:25 +08:00
NCBM d698fbf06f core/tools: rename run_command to run_argv (argv param) 2026-08-04 21:38:20 +08:00
NCBM 23f7b4e299 core/tools: clear argv error when run_command gets a string 2026-08-04 20:35:22 +08:00
73 changed files with 3188 additions and 399 deletions
+5 -4
View File
@@ -85,16 +85,16 @@ Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `
Module-level `@tool` handlers. Call `set_workspace_root()` before use.
- **`workspace`**: path resolve under primary root **or** temporary allowlist roots; path substring denylist; command basename denylist (CLI: timed human allow override, default deny on timeout; **not** skipped by YOLO); 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` (`max_replaces`, reports remaining matches), `edit_lineno` (1-based range; only lines read via `with_lineno`; read state resets on any file write/edit/copy/move/delete), `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); `run_command_batch` (serial steps, `pipe_out` on provider, `mix_stderr`, `stop_on_error`); PTY `open_pty` / `read_pty` / `write_pty` (literal) / `write_pty_keys` (escapes) / `ask_into_pty` (human→PTY only; `secret` no-echo; answer never in tool result) / `close_pty` (POSIX: `pty`+`fork`; Windows: ConPTY via `pywinpty` env marker dep).
- **`file`**: `read_file` (`with_lineno`, `max_chars` default 32k; results start with a 1-based `L{begin}-{end}` range), `write_file`, `listdir`, `tree` (model picks `format=markdown` default / `flat` paths / `decorated`; VCS + default noise dirs + optional `skip_dirs` / denylist walk), `glob_paths`, `grep_files` (regex, skip VCS/binary), `edit_replace` (`max_replaces`, reports remaining matches), `edit_lineno` (1-based range; only lines read via `with_lineno`; read state resets on any file write/edit/copy/move/delete), `copy_path` / `move_path` / `delete_path`, `new_temporary_workspace` (system temp allowlist; cleanup on chat exit; no session rebind), `get_truncated` (resume any truncated result via its `truncate_token=...`).
- **`process`**: `run_argv` (argv, no shell, timeout, optional stdin/env); `run_argv_batch` (serial steps, `pipe_out` on provider, `mix_stderr`, `stop_on_error`); PTY `open_pty` / `read_pty` / `write_pty` (literal) / `write_pty_keys` (escapes) / `ask_into_pty` (human→PTY only; `secret` no-echo; answer never in tool result) / `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`); secrets via `ask_into_pty` (not `write_pty` data); 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`. 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).
- **`chat`**: human prompts as tools — `ask_user_line` / `ask_user_choice` / `ask_user_form` (shared `prompting` core); `wait` (line prompt with timeout; Enter disturbs).
- **`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; open items = unfinished work (`[TODO OPEN WORK]`); all-terminal non-empty = hygiene (`[TODO HYGIENE]`); nag channel via `[agent] todo_nag_strategy` (`developer`/`user` = prose nag; `synthetic_tool` = forged `todo_list` call + **real** `stack.render()` result + ToolCall/Result events; `none`).
- **`net` / `fetch`**: HTTP GET/POST/PUT/DELETE via niquests (isolated session, not the LLM client). Manual redirects with per-hop SSRF checks (`asyncio` `getaddrinfo`). Private/loopback hosts need instance policy grant (CLI timed confirm; **not** YOLO). Public cleartext HTTP and mutating methods soft-confirm (`YOLO`/`TRUSTABLE`). HTTPS→HTTP redirects blocked unless `allow_http_downgrade`. `user_agent` tool arg (or headers) is never replaced by the default when provided. Caps: body bytes/chars, request body size.
- **`DEFAULT_TOOLS` / catalog**: file + process + vcs + chat + todo + net (`fetch`) for local surface.
- **`DEFAULT_TOOLS` / catalog**: file + process + vcs + chat + todo + net (`fetch`) for local surface. `ToolTag.READ_ONLY` marks non-mutating tools (`read_file`, `get_truncated`, `listdir`, `glob_paths`, `grep_files`, `tree`, `todo_list`, `wait`, `ask_user_*`, `vcs_*`, `fetch`); `ToolRegistry.clone(read_only_only=True)` selects just those for safe side turns / planning, and `read_only_context()` (in `tools/context.py`) makes `fetch` accept GET only.
### Prompting (`prompting.py`)
@@ -106,6 +106,7 @@ 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 when url unset; explicit `url = ":memory:"` kept + warn); 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 `$VISUAL`/`$EDITOR` (blocking only). `/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.
- `/btw` side questions: forked session; main transcript/DB untouched. `--tools=read` (default) exposes only `READ_ONLY` tools under `read_only_context()` (fetch=GET) with the main session view; `--tools=no` off; `--tools=full` whole-registry clone with fresh session. Tools off: default falls back to no tools; explicit `read`/`full` errors.
- 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`**. Config open: `$VISUAL`/`$EDITOR` (wait), else system default (`xdg-open` / `open` / `startfile`, non-blocking). `/edit` stays blocking-only. No providers → optional edit then reload when waited.
+3 -2
View File
@@ -153,7 +153,7 @@ max_context_tokens = 200000
# enable = ["acme"]
```
Per-provider **`timeout`** is passed to the HTTP session for chat/completions, Responses, and `GET /models`. A single number sets one timeout; `{ connect, read }` splits TCP/TLS setup vs idle wait between response bytes (SSE can run longer than `read` while chunks keep arriving). Tool/process timeouts (`run_command`, PTY, policy confirm) are separate.
Per-provider **`timeout`** is passed to the HTTP session for chat/completions, Responses, and `GET /models`. A single number sets one timeout; `{ connect, read }` splits TCP/TLS setup vs idle wait between response bytes (SSE can run longer than `read` while chunks keep arriving). Tool/process timeouts (`run_argv`, PTY, policy confirm) are separate.
Third-party **plugins**: install a package that declares `project.entry-points."plyngent.tools"` (and later other groups), then allowlist the entry-point name under **`[plugins].enable`**. Details: [doc/plugins.md](doc/plugins.md).
@@ -252,6 +252,7 @@ Type `/help` in the REPL for the live list. Common ones:
| `/yolo [on\|off\|once]` | Soft destructive confirms: sticky skip, off, or next turn only |
| `/retry` | Re-run incomplete last user turn (after error/cancel) |
| `/btw [--tools read\|no\|full] [--fresh]` | Side question without changing the main session (read-only tools by default) |
| `/provider` `/model` | Switch without restarting |
| `/model --persist` | Save current model id into `plyngent.toml` catalog |
| `/models` | List config + remote `GET /models` (always re-fetches) |
@@ -270,7 +271,7 @@ User messages are saved immediately. On API error or Ctrl+C, partial assistant/t
## Tools (when enabled)
Default registry: file ops (including `tree` with default noise-dir skips), `run_command` / PTY (POSIX openpty; Windows ConPTY via pywinpty), read-only VCS (git), HTTP `fetch` (GET/POST/PUT/DELETE via niquests; private/loopback hosts need a human policy grant, not YOLO), 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`).
Default registry: file ops (including `tree` with a markdown bullet-list default, `flat` paths or classic `decorated` on demand; default noise-dir skips), `run_argv` / `run_argv_batch` / PTY (POSIX openpty; Windows ConPTY via pywinpty), read-only VCS (git), HTTP `fetch` (GET/POST/PUT/DELETE via niquests; private/loopback hosts need a human policy grant, not YOLO), human prompts (`ask_user_line` / `ask_user_choice` / `ask_user_form`), `wait` (line prompt with timeout; Enter disturbs), `get_truncated` (resume any truncated result via its `truncate_token=...`), and todo stack tools (`todo_list` / `todo_push` / `todo_pop` / `todo_update` / `todo_clear`).
Safety defaults:
+6 -1
View File
@@ -50,6 +50,11 @@ Notes:
- Optional soft-confirm bits: **`YOLO`** (eligible for YOLO auto-approve),
**`TRUSTABLE`** (grant once per session after approve). Hard denylists are
never YOLO-skipped.
- Optional host-policy bit: **`READ_ONLY`** marks tools that never mutate host
state — they become eligible for read-only contexts such as
`/btw --tools=read` side turns or planning. Tools that only sometimes mutate
(e.g. `fetch` with a non-GET method) may still opt in; the handler must
enforce the constraint via the read-only context flag.
- Docstring becomes the model-facing description; parameter schemas come from
type hints.
- Use `register=False` only for unit tests that build a private registry.
@@ -159,7 +164,7 @@ Listed tools show **tags** and **catalog source** (`builtin` vs `plugin:…`).
## Policy checklist for authors
1. Prefer **LOCAL** for host FS, shell, PTY, or arbitrary network side effects.
2. Do not shadow builtin names (`read_file`, `run_command`, todo tools, …).
2. Do not shadow builtin names (`read_file`, `run_argv`, todo tools, …).
3. Do not rely on process-global workspace/todo alone when a host binds
instance/session context — prefer the same patterns as builtins
(`get_workspace_root` already prefers instance policy; session state via
Generated
+54 -54
View File
@@ -115,51 +115,51 @@ files = [
[[package]]
name = "greenlet"
version = "3.5.4"
version = "3.5.5"
requires_python = ">=3.10"
summary = "Lightweight in-process concurrent programming"
groups = ["default"]
files = [
{file = "greenlet-3.5.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7e8afa5eac028f8140ceafe5ceec66e6aa127ddcb21452d2a564dcd2900b5f22"},
{file = "greenlet-3.5.4-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73b37afe369021423ea53dd3123e04bffa7e93ac64429b9f50835b2e4fcae7cf"},
{file = "greenlet-3.5.4-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ef964f56dfcb6f9bbef2a190d9126795eac408716aeae47b5e7c73c32aafca9"},
{file = "greenlet-3.5.4-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cef589bc65fae02d10bca2ac341191c5b33acc2967892ebf4fcbd10eabb7a74c"},
{file = "greenlet-3.5.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c53ff01a5c53a40f2c16820ebc56d7c61a77f5fbe009dadd96292d5682f80f8"},
{file = "greenlet-3.5.4-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:dfc41ae893d9ceaf22c824f2153a88b30651b20e8758c2cd9ac143f23640563c"},
{file = "greenlet-3.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecca4d80d55a01ad6b23b33262662956149fbb7b2c6be2910f1705921958cbf3"},
{file = "greenlet-3.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffbc533e0eaf8e80d8471411646ab88fe58f641d508c0b02b24494479f4d9ec"},
{file = "greenlet-3.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:305f69e6c4523d7f6979ed001cff4e5853c063e5da04880296603aa0227e544c"},
{file = "greenlet-3.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:f260930bbbbcf9caee661211235a5111c86dfe5832fdf6ae4570da1e0995320f"},
{file = "greenlet-3.5.4-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:41ddab54e4b238f4a6c323f39b4e59e176affd5a94d461a9fb7583dac74240a3"},
{file = "greenlet-3.5.4-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3dabe3e2809013052c68bdf0b7fa5f5f2859c43a80803131ad61af9cabd7867"},
{file = "greenlet-3.5.4-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:27d3f00718634d4520a3a150154ac5da36f257869d41321953375b90bfbbc72c"},
{file = "greenlet-3.5.4-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39169a11d87a6a263afda3e9a27d1df16d0f919d40a4837cc73986c9884c0dd8"},
{file = "greenlet-3.5.4-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbd60b5763c6543c1827e48faaf14ea9bfbad245f52b1a4d76a2a2d8884c6c66"},
{file = "greenlet-3.5.4-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:bd3d1145f603b2db19feb9078c2e6855eb7c67e15580c010ed815cee519b86fd"},
{file = "greenlet-3.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f00f910f0e7b35416c63b23ad78b769aeccfc1775f712b43c4ee525624a2eef7"},
{file = "greenlet-3.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:91c26423753b92caf41ab3f98fd547d7374d4d9fc2d85be041886c1579d9255e"},
{file = "greenlet-3.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:ee032b91fd8ec29ec6c4cea2b8c561b178435134bd0752c7334b94e9c736c132"},
{file = "greenlet-3.5.4-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:178111881dd7a6c946471fda85485ec796e1043c2b939f694b096e2ecf986809"},
{file = "greenlet-3.5.4-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d92df08dd65fede97fc37aad36c2e9dcda3b31c467f8e0c2c096456cb818e927"},
{file = "greenlet-3.5.4-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:99e8f8c4ebc4fd80aa26c1280ae9ad43a0976e786349703a181cf0bae60413e5"},
{file = "greenlet-3.5.4-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1f17e362d78e37559e0506c5a7d066bdd45073c36a0127a543e8a0df27242ff3"},
{file = "greenlet-3.5.4-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:394de08dad5ffcb1f50c2159d93e398d9d2da3ed437645eaa54771fa720db9f0"},
{file = "greenlet-3.5.4-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:cd320d998cbaa032932830448e39abf3c6a12901295e386e8114db926e10cffb"},
{file = "greenlet-3.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:c883d61f2282d72c767a14936641b3efcbde9d82f1080712aaea0b1d3126cb88"},
{file = "greenlet-3.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2a924f15d17957e252a810acefcb5942f5ca712298e8b6fcaed9a307d357522c"},
{file = "greenlet-3.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:ed17e5f3420360d5b459de8462efb52060399a5326a613d4cde31cef63ef95da"},
{file = "greenlet-3.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:f908898d6fa484ce4b6f447ce70ea99b52c503fee419e53cf74d60a16bc9e667"},
{file = "greenlet-3.5.4-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:1833637f17d5e7472548a48575c394fe39f1b1890d676d162d86593610f44d8c"},
{file = "greenlet-3.5.4-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12cda9122e03341f1cb6b8207a19d7a9d375e52f1b4e9243918375f40fd7b4b9"},
{file = "greenlet-3.5.4-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d83ae0e32d14957ab7170785a20f582635c8474deab1bfbb552b17e769a6ce25"},
{file = "greenlet-3.5.4-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:123aa379c962ed5fe90a880327e0c3066124ac64ec99e12a238be9fd8eb3db3d"},
{file = "greenlet-3.5.4-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f1467de1bb767f75db0aa34c195e3a496d8d1278c796e70c24ce205d3e99cde"},
{file = "greenlet-3.5.4-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:adf2244d7f69409925a8f22ed22cc5f93cdfe5c9dc87ff3476be2c2aaae61a05"},
{file = "greenlet-3.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0fa53040b78b578120eecdc0265e3f1051487cc425d11a2b7c761daadf4feaa8"},
{file = "greenlet-3.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:60e0bc961d367df506660e9ac0177a76bc6d81305300704b0977d1634f76efe2"},
{file = "greenlet-3.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:f680e549edb3eaf21eea4e7fe101e15ec180c74b7879ab46adc080f22d4015d2"},
{file = "greenlet-3.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:08fc36de8442d5c3e95b044550dbea9bf144d31ec0cc58e36fb241cb6ef6a994"},
{file = "greenlet-3.5.4.tar.gz", hash = "sha256:0232ae1de90a8e07867bb127d7a6ba2301e859145489f25cda8a6096dabe1d20"},
{file = "greenlet-3.5.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d4a389a852e392a6366058651a20fa5ba40d979865aa81bea2ccbdc44805070d"},
{file = "greenlet-3.5.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70b157cd319873e8b544ddc2de158f55bbd0a9b0218c8ce9332039801518e328"},
{file = "greenlet-3.5.5-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8bdfd1424abcf26832961e766570cae79efdb9599d709088c9cb6ef82b194926"},
{file = "greenlet-3.5.5-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d98ef6f92e67c6dbf299dbfd8facc1b0d2d9cedf91e325e73b3d0373fe4309d8"},
{file = "greenlet-3.5.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:182de51c6b572a705f2fafaab2e783bcf7d2760940229dfe73086cbae037af3e"},
{file = "greenlet-3.5.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:159df1942d88e8f784cbb38d6f18bdb365cd11319cfbb3e89623de2b97892d53"},
{file = "greenlet-3.5.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8fec3f165dfe332e490c3247c0f6c23b0bfc45f06496ad7f00ddb00e3d35e4dc"},
{file = "greenlet-3.5.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c6ce25fee6cabc8bf22cb8b52e642cbb821be5b9aec8094d07ff03378141b8e9"},
{file = "greenlet-3.5.5-cp314-cp314-win_amd64.whl", hash = "sha256:7dffc5c859fe6059974df1e37d7923d654a83e2ae18fdd616994270e001115e1"},
{file = "greenlet-3.5.5-cp314-cp314-win_arm64.whl", hash = "sha256:5e2afcfc4d4305dd715809b03da5cbe437c8984f61d8917751eb5fe4aefa3e07"},
{file = "greenlet-3.5.5-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:0e5a7de979d764aea1f5b6e95cf92b5b37741b9823702041f34b126e7f690277"},
{file = "greenlet-3.5.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fef01bd457f11fc158b130ca0027a3c365693280e8e231b65bdaf57999f39f5b"},
{file = "greenlet-3.5.5-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5173a72310725a74afc82c164f0e52cb8ad0de62f2bb623f24f6c0cc07d80272"},
{file = "greenlet-3.5.5-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5e9ec2e7c98e895fcea0c5cc57b2606cf86ece6d0a56578f3eb225e2af4f0387"},
{file = "greenlet-3.5.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44f08341873200ba8a60a8bc14ace3d91f1754f7fa7bc66157714a8cd420a476"},
{file = "greenlet-3.5.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:102817506f6090b5176c746a82603341a549b40e5c3d5b72a4c672228a918c41"},
{file = "greenlet-3.5.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d246c0db9a2513cd45f019ba178ea4d4d4705bd210ee465e2c15d76a1ab13874"},
{file = "greenlet-3.5.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:72507285b5caa1d17904a3f7c322ca780823a54170a0e04ec3f37bcc60d4db71"},
{file = "greenlet-3.5.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7805655781fb8f28a55d05fe57ed61f5f10f1892fb587673e3bb5264f28041f0"},
{file = "greenlet-3.5.5-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:c0db80fcd5b8aece93f66c64f78a786bbb6b96c5fe63ef5a5a4581ecf8bab206"},
{file = "greenlet-3.5.5-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b241c32f912ada659808d68e308c568baf577eebf757d15471472de0c18cfad"},
{file = "greenlet-3.5.5-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ef6a08349401d8eaf3cb12688ac8557de95788556b8631ef17555a4a173022c0"},
{file = "greenlet-3.5.5-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37faa97daccb6d9f4c2141ce3118d023c3c5506864a7d8bdf726f665018c1f76"},
{file = "greenlet-3.5.5-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2e3d061b8e13aec2f0441689b3c71b244a20e5d274a52cb0f7e31bd1d139552"},
{file = "greenlet-3.5.5-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:b18007dc2473a7942fd157366b55f01da6fed7ce85318591005b419e0a439474"},
{file = "greenlet-3.5.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9ab5f5b93655e77fe0d6c2dfd22b5eac751bb1f876d8ec21761b7c1fb9266007"},
{file = "greenlet-3.5.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f0e5a21bd4452a88cf032fc43c4a5b307ab1380eacb63b5988f9c0317885e773"},
{file = "greenlet-3.5.5-cp315-cp315-win_amd64.whl", hash = "sha256:469dbb0a78625642f4a626cfd0c6e8bccc0385b5e49189b6308bbe849ec88a8e"},
{file = "greenlet-3.5.5-cp315-cp315-win_arm64.whl", hash = "sha256:2d57406c3efd32d7a81e17a674314e8bd00792cdab49ea3228a49aa1bfb2e769"},
{file = "greenlet-3.5.5-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:68184dfcf50ccaa8e864770fe0633a7e27250ea9329f8192ef47ee9ecfd78e1c"},
{file = "greenlet-3.5.5-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ec0dc0e59dc9c61af5c47348365ccbbd7addfafe0a93b00336ff3da2907bdc6"},
{file = "greenlet-3.5.5-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e604f58e35833fc46ef20302bcb314dddbfd3fcf33a4f936216d51dd678d63ae"},
{file = "greenlet-3.5.5-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2888a3a38bc5ee5bb6c438372197152e815837e4fab7ed7a1f86ef18ffd58ad1"},
{file = "greenlet-3.5.5-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40239b5384f96da3963585cc6d7eaa9b56f8ae67e8d92cc82dd9e202fc847de3"},
{file = "greenlet-3.5.5-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:1e8d9391fe77f15649589a907cef972dbbd6352ef7ff7dc0492f658c0c26495f"},
{file = "greenlet-3.5.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:a1eaccf5c3a1d3e46dead602c72e6836731e8e245c9de6a27764567b6b62d4c0"},
{file = "greenlet-3.5.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:19e4e026fe20691f333b8eb1a3bc9625eceba8c3f9d62ec5a6f8581afbc6b5a5"},
{file = "greenlet-3.5.5-cp315-cp315t-win_amd64.whl", hash = "sha256:712aee154f648bde84634654bb38bb78c69ac640c37a45c9effed800735049d8"},
{file = "greenlet-3.5.5-cp315-cp315t-win_arm64.whl", hash = "sha256:7f049911ee81a16a03c33d5450d8d5867d27f596ca5fb201b86f4524e874468b"},
{file = "greenlet-3.5.5.tar.gz", hash = "sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c"},
]
[[package]]
@@ -315,24 +315,24 @@ files = [
[[package]]
name = "packaging"
version = "26.2"
requires_python = ">=3.8"
version = "26.3"
requires_python = ">=3.9"
summary = "Core utilities for Python packages"
groups = ["dev"]
files = [
{file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"},
{file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"},
{file = "packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c"},
{file = "packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79"},
]
[[package]]
name = "platformdirs"
version = "4.11.0"
version = "4.11.2"
requires_python = ">=3.10"
summary = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
groups = ["default"]
files = [
{file = "platformdirs-4.11.0-py3-none-any.whl", hash = "sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74"},
{file = "platformdirs-4.11.0.tar.gz", hash = "sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0"},
{file = "platformdirs-4.11.2-py3-none-any.whl", hash = "sha256:7f89089b6ea71bda7962953edcf784b2e2d9d285b40ad88be2bb75c6e9d82ab4"},
{file = "platformdirs-4.11.2.tar.gz", hash = "sha256:3a2ae5fca3520a01ab1be8b45613537f52ddf5b5f6f53d88233892dfbf0cd82d"},
]
[[package]]
@@ -549,7 +549,7 @@ files = [
[[package]]
name = "urllib3-future"
version = "2.23.900"
version = "2.24.900"
requires_python = ">=3.7"
summary = "urllib3.future is a powerful HTTP 1.1, 2, and 3 client with both sync and async interfaces"
groups = ["default"]
@@ -559,18 +559,18 @@ dependencies = [
"qh3<2.0.0,>=1.5.4; (platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and platform_python_implementation == \"PyPy\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and python_version < \"3.12\" or (platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and python_full_version > \"3.7.10\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and platform_python_implementation == \"CPython\"",
]
files = [
{file = "urllib3_future-2.23.900-py3-none-any.whl", hash = "sha256:4303dc9a5d8c4c810f3fcd3b13bd62d69c3a4f63bb7e62089410907c34aeaf6b"},
{file = "urllib3_future-2.23.900.tar.gz", hash = "sha256:f488f22983f96cd8c733640b47e6a32def462d33c59bcc4bea6d97a8568b70c8"},
{file = "urllib3_future-2.24.900-py3-none-any.whl", hash = "sha256:a4fe06b59b0c3ce2fffa821c3de72eaf59b1e61619becc255dee18919ee25260"},
{file = "urllib3_future-2.24.900.tar.gz", hash = "sha256:05c2e9d09293ab29a154fed8f5b60644c7cdaffe4ae2587a07b1c7f00f23a7fb"},
]
[[package]]
name = "wassima"
version = "2.1.2"
version = "2.1.3"
requires_python = ">=3.7"
summary = "Access your OS root certificates with utmost ease"
groups = ["default"]
marker = "sys_platform != \"emscripten\""
files = [
{file = "wassima-2.1.2-py3-none-any.whl", hash = "sha256:9a5779e8ade39960973c6cc17e095cc998d6345ab3dbdd1ec2d7fde8c356fb82"},
{file = "wassima-2.1.2.tar.gz", hash = "sha256:f74b5441151728c54118ece6d747cfe92b2c595a3d062a1955f42a2bc894cd10"},
{file = "wassima-2.1.3-py3-none-any.whl", hash = "sha256:f8251c23720dd092117a554b3012756caee3feed6d0122c2458a2aca5d7d92c5"},
{file = "wassima-2.1.3.tar.gz", hash = "sha256:fcd6c38be0f909c393da35cb2a993101fcdcff673b8fa8d5da228f73b630d0d0"},
]
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "plyngent"
version = "0.3.0"
version = "0.4.1"
description = "LLM chat and agent toolkit"
authors = [
{ name = "HivertMoZara", email = "worldmozara@163.com" },
+1
View File
@@ -2,6 +2,7 @@ from .chat import ChatAgent as ChatAgent
from .chat import get_lineno_read_files as get_lineno_read_files
from .chat import invalidate_lineno_read as invalidate_lineno_read
from .chat import lineno_read_lines as lineno_read_lines
from .chat import lineno_read_mtime as lineno_read_mtime
from .chat import mark_lineno_read as mark_lineno_read
from .chat import reset_lineno_tracker as reset_lineno_tracker
from .chat import was_lineno_read as was_lineno_read
+10 -15
View File
@@ -31,28 +31,23 @@ DEFAULT_CONTEXT_MAX_CHARS = DEFAULT_CONTEXT_MAX_TOKENS * 4
def truncate_tool_result(
text: str,
max_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS,
*,
hint: bool = True,
) -> str:
"""Cap tool output so huge dumps do not flood model context.
With ``hint=True`` the suffix tells the model the result was cut and that
it may ask the user to raise the config limit (``max_tool_result_chars``).
``hint=False`` keeps the terse marker for internal shrinks whose cap is not
a user-facing config knob (e.g. request-time compacting).
This is the generic agent-loop cap for arbitrary tool results. It embeds a
memory truncate token via :func:`truncate_generic` so the model can always
resume the remainder with ``get_truncated`` — resumable or not (remainders
live in the in-memory store for the process lifetime). The compact marker
``[Truncated (N chars max.; M omitted). truncate_token=...]`` is emitted
only when output is actually cut.
"""
if max_chars < 1:
return text
if len(text) <= max_chars:
return text
omitted = len(text) - max_chars
if hint:
return (
f"{text[:max_chars]}\n"
f"...[Truncated ({max_chars} chars max.; {omitted} chars omitted). "
f"You may ask for user to increase the limit via configuration file.]"
)
return f"{text[:max_chars]}\n...[truncated {omitted} characters]"
from plyngent.tools.truncate_token import truncate_generic
return truncate_generic(text, max_chars)
def estimate_message_chars(message: AnyChatMessage) -> int:
@@ -120,7 +115,7 @@ def _shrink_tool(message: ToolChatMessage, max_chars: int) -> ToolChatMessage:
return message
return msgspec.structs.replace(
message,
content=truncate_tool_result(message.content, max_chars, hint=False),
content=truncate_tool_result(message.content, max_chars),
)
+37 -10
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from contextvars import ContextVar, Token
from pathlib import Path
from typing import TYPE_CHECKING
from msgspec import UNSET
@@ -47,14 +48,18 @@ if TYPE_CHECKING:
# Per-conversation tracking of lines read with ``with_lineno=True``, keyed by
# resolved file path. Set to a fresh ``dict`` at the start of each user turn via
# :func:`reset_lineno_tracker`; cleared after the assistant finishes.
# Tool handlers read/write via :func:`get_lineno_read_files` /
# :func:`lineno_read_lines` / :func:`mark_lineno_read`.
_lineno_read_files: ContextVar[dict[str, set[int]] | None] = ContextVar("_lineno_read_files", default=None)
# resolved file path, with the file's mtime at read time (so stale line numbers
# after external/process mutations are detected). Set to a fresh ``dict`` at the
# start of each user turn via :func:`reset_lineno_tracker`; cleared after the
# assistant finishes. Tool handlers read/write via :func:`get_lineno_read_files` /
# :func:`lineno_read_lines` / :func:`lineno_read_mtime` / :func:`mark_lineno_read`.
_lineno_read_files: ContextVar[dict[str, tuple[set[int], int]] | None] = ContextVar(
"_lineno_read_files",
default=None,
)
def reset_lineno_tracker() -> Token[dict[str, set[int]] | None]:
def reset_lineno_tracker() -> Token[dict[str, tuple[set[int], int]] | None]:
"""Replace the tracker with a fresh empty mapping; return the previous token.
Call at the start of a new user turn. Restore with
@@ -80,17 +85,38 @@ def lineno_read_lines(path: str) -> set[int]:
val = _lineno_read_files.get()
if val is None:
return set()
return set(val.get(path, ()))
entry = val.get(path)
return set(entry[0]) if entry is not None else set()
def lineno_read_mtime(path: str) -> int | None:
"""Return the file mtime (ns) recorded when *path* was read, or None."""
val = _lineno_read_files.get()
if val is None:
return None
entry = val.get(path)
return entry[1] if entry is not None else None
def mark_lineno_read(path: str, lines: set[int]) -> None:
"""Record that *path* was read with line numbers this turn.
``lines`` are 1-based absolute file line numbers (``read_file`` slice).
The file's current mtime is recorded too, so edits can detect that the
file changed since the read (external/process mutations included).
"""
val = _lineno_read_files.get()
if val is not None:
val.setdefault(path, set()).update(lines)
if val is None:
return
try:
mtime_ns = Path(path).stat().st_mtime_ns
except OSError:
mtime_ns = 0
entry = val.get(path)
if entry is None:
val[path] = (set(lines), mtime_ns)
else:
val[path] = (entry[0] | lines, mtime_ns)
def invalidate_lineno_read(path: str) -> None:
@@ -569,7 +595,8 @@ class ChatAgent:
- Tools default **off**. ``tools=True`` clones this agent's registry with
a **fresh session** bag (unless *session_state* is passed) and the
given *instance_state* (CLI typically shares the host instance for
workspace identity).
workspace identity). A caller-built :class:`ToolRegistry` (e.g. a
``clone(read_only_only=True)`` subset) is used as-is.
"""
text = user_text.strip()
if not text:
+7 -3
View File
@@ -17,13 +17,17 @@ DEFAULT_DIRECTIVE_REMINDER_TOKENS = 100_000
DEFAULT_DIRECTIVE_REMINDER_TEXT = """\
Tool playbook still applies (see system). Hard constraints:
- Prefer file tools over shell; several `run_command` calls may run in parallel; \
use `run_command_batch` for ordered pipelines.
- Prefer file tools over shell; several `run_argv` calls may run in parallel; \
use `run_argv_batch` for ordered pipelines.
- `edit_replace`: fix match / `max_replaces`; `edit_lineno` edits only lines read via \
`read_file(with_lineno=true)`, and line numbers reset after any file write.
- `read_file` results start with `L{begin}-{end}` (1-based lines; offset is 0-based).
- Truncated results carry a `truncate_token=...`; continue with `get_truncated`.
- Prefer `fetch` for HTTP(S); private/LAN hosts need human policy allow (not YOLO).
- PTY secrets only via `ask_into_pty`; denylists and confirms still apply.
- Todo stack: open items mean unfinished work.
- `wait` pauses; pressing Enter disturbs early (optional reason).
- Todo stack: open items mean unfinished work; the stack does not auto-clear — \
call `todo_clear` (or pop finished TOP groups) when the whole plan is done.
"""
_BAND_MARKER = re.compile(
+14 -1
View File
@@ -16,6 +16,7 @@ from plyngent.lmproto.openai_compatible.model import (
ChatCompletionChunk,
ChatCompletionResponse,
ChatCompletionsParam,
DeltaMessage,
StreamOptions,
StreamToolCallDelta,
ToolChatMessage,
@@ -254,6 +255,18 @@ async def _non_stream_round(
)
def _append_reasoning_delta(
reasoning_parts: list[str],
delta: DeltaMessage,
) -> list[str]:
"""Accumulate a reasoning delta; a ``full`` (``.done``) event replaces fragments."""
if isinstance(delta.reasoning_content, str) and delta.reasoning_content:
if delta.reasoning_full is True:
return [delta.reasoning_content]
return [*reasoning_parts, delta.reasoning_content]
return reasoning_parts
async def _stream_round(
client: AnyLLMClient,
param: ChatCompletionsParam,
@@ -295,7 +308,7 @@ async def _stream_round(
saw_terminal = True
delta = choice.delta
if isinstance(delta.reasoning_content, str) and delta.reasoning_content:
reasoning_parts.append(delta.reasoning_content)
reasoning_parts = _append_reasoning_delta(reasoning_parts, delta)
yield ReasoningDeltaEvent(content=delta.reasoning_content)
if isinstance(delta.content, str) and delta.content:
content_parts.append(delta.content)
+31 -14
View File
@@ -171,7 +171,7 @@ def response_to_assistant_message(response: Response) -> AssistantChatMessage:
)
for call in calls
]
reasoning = _reasoning_summary_text(response)
reasoning = reasoning_summary_text(response)
return AssistantChatMessage(
content=text or None,
tool_calls=tool_calls,
@@ -179,23 +179,40 @@ def response_to_assistant_message(response: Response) -> AssistantChatMessage:
)
def _reasoning_summary_text(response: Response) -> str:
def _reasoning_text_blocks(raw: object) -> list[str]:
"""Collect text from a reasoning block list (``summary``/``content``)."""
parts: list[str] = []
if not isinstance(raw, list):
return parts
for block_obj in cast("list[object]", raw):
if not isinstance(block_obj, dict):
continue
block_map = cast("dict[str, object]", block_obj)
if block_map.get("type") in {"summary_text", "output_text", "reasoning_content"}:
text = block_map.get("text")
if isinstance(text, str) and text:
parts.append(text)
return parts
def reasoning_summary_text(response: Response) -> str:
"""Concatenate reasoning text from the response.
OpenAI puts ``reasoning`` items in ``output`` with a ``summary`` block list.
DeepSeek (Responses convention) additionally returns the full chain-of-thought
in the top-level ``response.reasoning`` (``content``/``summary`` block lists);
prefer it when present so the two sources are not duplicated.
"""
reasoning = response.reasoning
if isinstance(reasoning, dict):
parts = _reasoning_text_blocks(reasoning.get("content")) + _reasoning_text_blocks(reasoning.get("summary"))
if parts:
return "".join(parts)
parts: list[str] = []
for raw in response.output:
if raw.get("type") != "reasoning":
continue
summary = raw.get("summary")
if not isinstance(summary, list):
continue
summary_items = cast("list[object]", summary)
for block_obj in summary_items:
if not isinstance(block_obj, dict):
continue
block_map = cast("dict[str, object]", block_obj)
if block_map.get("type") in {"summary_text", "output_text"}:
text = block_map.get("text")
if isinstance(text, str) and text:
parts.append(text)
parts.extend(_reasoning_text_blocks(raw.get("summary")))
return "".join(parts)
+53 -11
View File
@@ -15,6 +15,7 @@ from msgspec import UNSET
from plyngent.agent.responses_bridge import (
chat_param_to_responses_kwargs,
reasoning_summary_text,
response_to_assistant_message,
response_to_chat_completion,
responses_status_to_finish_reason,
@@ -45,7 +46,16 @@ if TYPE_CHECKING:
)
# Responses stream event types that carry reasoning text deltas.
_REASONING_DELTA_TYPES = {"response.reasoning_summary_text.delta", "response.reasoning_text.delta"}
# OpenAI streams ``response.reasoning_summary_text.delta``; DeepSeek streams
# ``response.reasoning_text.delta`` fragments and emits the FULL chain-of-thought
# once via ``response.reasoning_text.done``. Fragments append; a ``.done`` event
# carries the complete text and must replace the accumulated fragments (otherwise
# the CoT is concatenated twice).
_REASONING_DELTA_TYPES = {
"response.reasoning_summary_text.delta",
"response.reasoning_text.delta",
}
_REASONING_FULL_TYPES = {"response.reasoning_text.done"}
# Terminal events that carry the full Response object (status, tool calls, usage).
# ``response.failed`` also carries a Response but is surfaced as an error.
_FINAL_EVENT_TYPES = {"response.completed", "response.incomplete"}
@@ -90,6 +100,34 @@ def _decode_final_event(event: ResponseStreamEvent) -> ResponseModel | None:
return None
def _stream_reasoning_content(event: ResponseStreamEvent) -> tuple[str | None, bool]:
"""Return (reasoning_text, is_full) from a reasoning stream event.
``.delta`` events carry incremental fragments (``is_full=False``); ``.done``
events carry the full chain-of-thought (``is_full=True``) and must replace
the accumulated fragments.
"""
if event.type not in _REASONING_DELTA_TYPES and event.type not in _REASONING_FULL_TYPES:
return None, False
content = event.delta if isinstance(event.delta, str) else event.text
if not isinstance(content, str) or not content:
return None, False
return content, event.type in _REASONING_FULL_TYPES
def _terminal_chunks(final: ResponseModel, *, model: str) -> list[ChatCompletionChunk]:
"""Build the post-stream chunks (tool calls, finish_reason, usage)."""
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)
chunks: list[ChatCompletionChunk] = list(tool_call_chunks_from_response(final, model=model))
chunks.append(finish_reason_chunk(model=model, finish_reason=finish))
usage = usage_chunk_from_response(final, model=model)
if usage is not None:
chunks.append(usage)
return chunks
async def _stream_as_chat_chunks(
client: OpenAIClient,
create: ResponsesCreateParam,
@@ -108,6 +146,7 @@ async def _stream_as_chat_chunks(
"""
stream = await client.responses(create, stream=True)
final: ResponseModel | None = None
saw_reasoning = False
async for event in stream:
etype = event.type
if etype in {"error", "response.failed"}:
@@ -118,8 +157,10 @@ async def _stream_as_chat_chunks(
if etype == "response.output_text.delta" and isinstance(event.delta, str) and event.delta:
yield text_delta_chunk(model=model, content=event.delta)
continue
if etype in _REASONING_DELTA_TYPES and isinstance(event.delta, str) and event.delta:
yield reasoning_delta_chunk(model=model, content=event.delta)
reasoning, reasoning_full = _stream_reasoning_content(event)
if reasoning is not None:
saw_reasoning = True
yield reasoning_delta_chunk(model=model, content=reasoning, full=reasoning_full)
continue
if final is None:
@@ -127,15 +168,16 @@ async def _stream_as_chat_chunks(
# The 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):
if not saw_reasoning:
# DeepSeek sometimes delivers reasoning only on the terminal response
# (top-level ``reasoning``); surface it so tool-call turns keep their
# reasoning_content when the assistant message goes back to the API.
terminal_reasoning = reasoning_summary_text(final)
if terminal_reasoning:
yield reasoning_delta_chunk(model=model, content=terminal_reasoning)
for chunk in _terminal_chunks(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
async def dispatch_responses(
+8 -2
View File
@@ -58,7 +58,13 @@ def text_delta_chunk(*, model: str, content: str, created: int = 0) -> ChatCompl
)
def reasoning_delta_chunk(*, model: str, content: str, created: int = 0) -> ChatCompletionChunk:
def reasoning_delta_chunk(
*,
model: str,
content: str,
created: int = 0,
full: bool = False,
) -> ChatCompletionChunk:
return ChatCompletionChunk(
id="bridge-stream",
object="chat.completion.chunk",
@@ -67,7 +73,7 @@ def reasoning_delta_chunk(*, model: str, content: str, created: int = 0) -> Chat
choices=[
ChunkChoice(
index=0,
delta=DeltaMessage(reasoning_content=content),
delta=DeltaMessage(reasoning_content=content, reasoning_full=full or UNSET),
)
],
)
+10 -1
View File
@@ -42,6 +42,7 @@ class ToolTag(Flag):
YOLO = auto() # soft-confirm: eligible for YOLO auto-approve
INSTANCE_STATE = auto() # needs instance-scoped state
SESSION_STATE = auto() # needs session-scoped state
READ_ONLY = auto() # never mutates host state; safe for read-only contexts
class ToolDefinition:
@@ -266,14 +267,22 @@ class ToolRegistry:
session_state: object | None = None,
yolo: bool | None = None,
auto_bind_state: bool | None = None,
read_only_only: bool = False,
) -> ToolRegistry:
"""New registry with the same tools/hooks and rebound host state.
*instance_state* / *session_state* are applied as given (use the parent
registry's handles only when the caller passes them through).
With *read_only_only*, only :attr:`ToolTag.READ_ONLY` tools are kept
(e.g. for safe side turns / planning); hooks and policy flags are
preserved.
"""
definitions = self.definitions()
if read_only_only:
definitions = [d for d in definitions if d.tags & ToolTag.READ_ONLY]
return ToolRegistry(
self.definitions(),
definitions,
danger=self._danger,
on_confirm=self._on_confirm,
yolo=self._yolo if yolo is None else yolo,
+8
View File
@@ -17,6 +17,7 @@ from plyngent.cli.editor import (
resolve_config_path,
)
from plyngent.cli.exit_codes import EXIT_CANCELLED, EXIT_OK, EXIT_TURN_FAILED
from plyngent.cli.interrupt import install_keyboard_interrupt_sigint
from plyngent.cli.limits import install_cli_limit_hooks
from plyngent.cli.repl import run_repl
from plyngent.cli.retry import run_user_text_with_retries
@@ -484,6 +485,13 @@ def chat_cmd(
raise click.ClickException(msg)
root = workspace if workspace is not None else Path.cwd()
# asyncio.run installs its own SIGINT handler when the current one is the
# default: the first Ctrl+C then silently cancels the main task (invisible
# at the REPL prompt) and the second raises KeyboardInterrupt, which also
# cancels memory.close() on a Ctrl+D exit ("Aborted!"). Pre-install a plain
# KeyboardInterrupt-raising handler so Ctrl+C stays a normal KeyboardInterrupt
# that the REPL / turn-retry / oneshot code handles deliberately.
install_keyboard_interrupt_sigint()
code = asyncio.run(
_run_chat(
config_path=config_path,
+372 -8
View File
@@ -1,10 +1,13 @@
from __future__ import annotations
import contextlib
import json
import os
import re
import shlex
import sys
from contextvars import ContextVar
from typing import TYPE_CHECKING, Literal
from typing import TYPE_CHECKING, Literal, cast
import click
@@ -21,13 +24,39 @@ from plyngent.agent import (
from plyngent.lmproto.openai_compatible.model import AssistantFunctionToolCall
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from collections.abc import AsyncIterator, Callable
from plyngent.agent import AgentEvent
_TOOL_RESULT_PREVIEW = 120
_TOOL_ARGS_PREVIEW = 80
# Tool calls that render as a single pretty summary line instead of
# ``[tool]`` / ``[tool result]``.
_PRETTY_TOOLS = frozenset(
{
"read_file",
"todo_push",
"todo_update",
"tree",
"listdir",
"glob_paths",
"grep_files",
"run_argv",
"run_argv_batch",
"fetch",
"edit_replace",
"edit_lineno",
"write_file",
"copy_path",
"move_path",
"delete_path",
"vcs_status",
"vcs_log",
"wait",
}
)
# Process/session display flags (set from ReplState / slash).
_verbose_tool_results: ContextVar[bool] = ContextVar("verbose_tool_results", default=False)
_markdown_enabled: ContextVar[bool] = ContextVar("markdown_enabled", default=True)
@@ -69,6 +98,329 @@ def _preview(text: str, limit: int) -> str:
return text[:limit] + "…"
def _preview_result(content: str, limit: int) -> str:
"""Non-verbose tool-result preview: first line + a ``(…N more lines)`` tail."""
lines = content.splitlines()
if not lines:
return "(no output)"
first = _preview(lines[0], limit)
more = len(lines) - 1
if more == 0:
return first
unit = "line" if more == 1 else "lines"
return f"{first} (…{more} more {unit})"
def _json_arg(args_json: str, key: str) -> object | None:
"""Extract a single argument value (any type) from a tool-call args JSON blob."""
try:
raw = json.loads(args_json)
except ValueError:
return None
if isinstance(raw, dict):
return cast("dict[str, object]", raw).get(key)
return None
def _json_str_arg(args_json: str, key: str) -> str | None:
value = _json_arg(args_json, key)
return value if isinstance(value, str) else None
def _json_list_arg(args_json: str, key: str) -> list[str] | None:
value = _json_arg(args_json, key)
if isinstance(value, list):
raw_items = cast("list[object]", value)
items = [item for item in raw_items if isinstance(item, str)]
if len(items) == len(raw_items):
return items
return None
def _pretty_line(prefix: str, *segments: tuple[str, str | None]) -> str:
"""Style a pretty tool summary: yellow ``* Verb`` prefix + colored detail segments.
``segments`` are ``(text, fg)`` pairs; ``fg=None`` leaves a segment uncolored
and ``"dim"`` renders it dim. click.style emits ANSI codes only on a TTY,
so non-TTY output stays plain.
"""
parts = [click.style(prefix, fg="yellow")]
for text, fg in segments:
if fg == "dim":
parts.append(click.style(text, dim=True))
else:
parts.append(click.style(text, fg=fg))
return "".join(parts)
def _read_file_pretty(args_json: str, result: str) -> str:
"""One-line summary for a ``read_file`` call: ``* Read 'path' (done)``."""
path = _json_str_arg(args_json, "path") or "?"
if result.startswith("error: file not found"):
return _pretty_line(f"* Read '{path}' ", ("(file not found)", "red"))
if result.startswith("error: not a file"):
return _pretty_line(f"* Read '{path}' ", ("(not a file)", "red"))
if result.startswith("error:"):
return _pretty_line(f"* Read '{path}' ", ("(error)", "red"))
return _pretty_line(f"* Read '{path}' ", ("(done)", "green"))
def _todo_pretty(name: str, result: str) -> str:
"""Summary for todo push/update: header + the rendered todo stack."""
label = "Todo Push" if name == "todo_push" else "Todo Update"
return _pretty_line(f"* {label}:\n{result}")
def _tree_line_stats(fmt: str, line: str) -> tuple[str, int] | None:
"""Classify one ``tree`` result line as ``(kind, depth)`` or None (not an entry).
``kind`` is ``"dir"`` or ``"file"``; depth is 0 for ``flat`` (no hierarchy).
"""
stripped = line.strip()
if not stripped:
return None
kind: str | None = None
depth = 0
if fmt == "flat":
if not stripped.startswith("…"):
kind = "dir" if stripped.endswith("/") else "file"
elif fmt == "markdown":
if stripped.startswith("- ") and not stripped.startswith(("- …", "- error")):
kind = "dir" if stripped[2:].endswith("/") else "file"
depth = (len(line) - len(line.lstrip(" "))) // 2 + 1
elif "… (" not in stripped:
branch = "├── " if "├── " in line else ("└── " if "└── " in line else None)
if branch is not None:
name = line[line.index(branch) + len(branch) :].rstrip()
kind = "dir" if name.endswith("/") else "file"
depth = line.index(branch) // 4 + 1
if kind is None:
return None
return kind, depth
def _tree_pretty(args_json: str, result: str) -> str:
"""One-line summary for a ``tree`` call: ``* Tree 'src' (3 dirs, 24 files, depth 3)``.
Flat trees have no depth, so the depth part is omitted for ``format=flat``.
"""
path = _json_str_arg(args_json, "path") or "."
fmt = _json_str_arg(args_json, "format") or "markdown"
if result.startswith("error:"):
return f"* Tree '{path}' (error)"
dirs = 0
files = 0
depth = 0
for line in result.splitlines():
stats = _tree_line_stats(fmt, line)
if stats is None:
continue
kind, line_depth = stats
if kind == "dir":
dirs += 1
else:
files += 1
depth = max(depth, line_depth)
if depth:
return _pretty_line(f"* Tree '{path}' ", (f"({dirs} dirs, {files} files, depth {depth})", None))
return _pretty_line(f"* Tree '{path}' ", (f"({dirs} dirs, {files} files)", None))
def _listdir_pretty(args_json: str, result: str) -> str:
"""One-line summary for a ``listdir`` call: ``* List 'path' (3 dirs, 5 files)``."""
path = _json_str_arg(args_json, "path") or "."
if result.startswith("error:"):
return _pretty_line(f"* List '{path}' ", (f"({result})", "red"))
if result == "(empty)":
return _pretty_line(f"* List '{path}' ", ("(empty)", "dim"))
dirs = 0
files = 0
for line in result.splitlines():
if line.startswith("dir\t"):
dirs += 1
elif line.startswith("file\t"):
files += 1
return _pretty_line(f"* List '{path}' ", (f"({dirs} dirs, {files} files)", None))
def _glob_pretty(args_json: str, result: str) -> str:
"""One-line summary for a ``glob_paths`` call: ``* Glob '**/*.py' in '.' (12 paths)``."""
pattern = _json_str_arg(args_json, "pattern") or "?"
path = _json_str_arg(args_json, "path") or "."
if result.startswith("error:"):
return _pretty_line(f"* Glob '{pattern}' in '{path}' ", (f"({result})", "red"))
if result == "(no matches)":
return _pretty_line(f"* Glob '{pattern}' in '{path}' ", ("(no matches)", "dim"))
count = sum(1 for line in result.splitlines() if line and not line.startswith("...[truncated"))
unit = "path" if count == 1 else "paths"
return _pretty_line(f"* Glob '{pattern}' in '{path}' ", (f"({count} {unit})", None))
def _grep_pretty(args_json: str, result: str) -> str:
"""One-line summary for a ``grep_files`` call: ``* Grep 'pat' in '.' (12 matches in 3 files)``."""
pattern = _json_str_arg(args_json, "pattern") or "?"
path = _json_str_arg(args_json, "path") or "."
if result.startswith("error:"):
return _pretty_line(f"* Grep '{pattern}' in '{path}' ", (f"({result})", "red"))
if result == "(no matches)":
return _pretty_line(f"* Grep '{pattern}' in '{path}' ", ("(no matches)", "dim"))
matches = [line for line in result.splitlines() if line and not line.startswith("...[truncated")]
files = len({line.split(":", 1)[0] for line in matches})
match_unit = "match" if len(matches) == 1 else "matches"
file_unit = "file" if files == 1 else "files"
return _pretty_line(
f"* Grep '{pattern}' in '{path}' ",
(f"({len(matches)} {match_unit} in {files} {file_unit})", None),
)
def _run_argv_pretty(args_json: str, result: str) -> str:
"""One-line summary for a ``run_argv`` call: ``* Run $ git status --short (exit code 0)``."""
argv = _json_list_arg(args_json, "argv")
cmd = shlex.join(argv) if argv else "?"
if result.startswith("error:"):
return _pretty_line(f"* Run $ {cmd} ", (f"({result})", "red"))
fields: dict[str, str] = {}
for line in result.splitlines():
if line.startswith("--- "):
break
if "=" in line:
key, _, value = line.partition("=")
fields[key] = value
if fields.get("timed_out") == "true":
return _pretty_line(f"* Run $ {cmd} ", ("(timed out)", "red"))
code = fields.get("exit_code", "")
fg = "green" if code == "0" else "red"
return _pretty_line(f"* Run $ {cmd} ", (f"(exit code {code or 'killed'})", fg))
def _run_argv_batch_pretty(_args_json: str, result: str) -> str:
"""One-line summary for a ``run_argv_batch`` call: ``* Run batch (done)``."""
if result.startswith("error:"):
return _pretty_line("* Run batch ", (f"({result})", "red"))
lines = result.splitlines()
head = lines[0] if lines else ""
stopped_early = False
for part in head.split():
if part == "stopped_early=true":
stopped_early = True
if stopped_early:
return _pretty_line("* Run batch ", ("(stopped early)", "yellow"))
return _pretty_line("* Run batch ", ("(done)", "green"))
def _http_status_fg(status: str) -> str | None:
"""Foreground color by HTTP status class (2xx green, 3xx yellow, 4xx/5xx red)."""
if status and status[0] in {"2", "3", "4", "5"}:
return {"2": "green", "3": "yellow", "4": "red", "5": "red"}[status[0]]
return None
def _fetch_pretty(args_json: str, result: str) -> str:
"""One-line summary for a ``fetch`` call: ``* Fetch GET https://… (200)``."""
method = _json_str_arg(args_json, "method") or "GET"
url = _json_str_arg(args_json, "url") or "?"
if result.startswith("error:"):
return _pretty_line(f"* Fetch {method} {url} ", (f"({result})", "red"))
status = ""
for line in result.splitlines():
if line.startswith("--- "):
break
if line.startswith("status="):
status = line.partition("=")[2]
break
return _pretty_line(f"* Fetch {method} {url} ", (f"({status or 'done'})", _http_status_fg(status)))
_MUTATOR_VERBS: dict[str, str] = {
"edit_replace": "Edit",
"edit_lineno": "Edit",
"write_file": "Write",
"copy_path": "Copy",
"move_path": "Move",
"delete_path": "Delete",
}
def _mutator_pretty(name: str, args_json: str, result: str) -> str:
"""One-line summary for a file mutation: ``* Wrote 'path' (120 chars)`` / ``* Edit 'path' (done)``."""
verb = _MUTATOR_VERBS[name]
if name in {"copy_path", "move_path"}:
src = _json_str_arg(args_json, "src") or "?"
dst = _json_str_arg(args_json, "dst") or "?"
target = f"'{src}' → '{dst}'"
else:
path = _json_str_arg(args_json, "path") or "?"
target = f"'{path}'"
if result.startswith("error:"):
return _pretty_line(f"* {verb} {target} ", (f"({result})", "red"))
if name == "write_file":
# write_file keeps its brief detail: ``wrote 120 characters to path``.
match = re.match(r"wrote (\d+) characters to (.+)$", result)
if match:
chars, path = match.groups()
return _pretty_line(f"* Write '{path}' ", (f"({chars} chars)", None))
return _pretty_line(f"* {verb} {target} ", ("(done)", "green"))
def _vcs_status_pretty(_args_json: str, result: str) -> str:
"""One-line summary for ``vcs_status``: ``* VCS Status (done)``."""
if result.startswith("error:"):
return _pretty_line("* VCS Status ", (f"({result})", "red"))
return _pretty_line("* VCS Status ", ("(done)", "green"))
def _vcs_log_pretty(_args_json: str, result: str) -> str:
"""One-line summary for ``vcs_log``: ``* VCS Log (5 commits)``."""
if result.startswith("error:"):
return _pretty_line("* VCS Log ", (f"({result})", "red"))
if result == "(no commits)":
return _pretty_line("* VCS Log ", ("(no commits)", "dim"))
commits = [line for line in result.splitlines() if line.strip()]
unit = "commit" if len(commits) == 1 else "commits"
return _pretty_line("* VCS Log ", (f"({len(commits)} {unit})", None))
def _wait_pretty(_args_json: str, result: str) -> str:
"""One-line summary for a ``wait`` call: ``* Wait (5s)`` / ``* Wait (disturbed)``."""
if result.startswith("error:"):
return _pretty_line("* Wait ", (f"({result})", "red"))
if result.startswith("waited "):
return _pretty_line("* Wait ", (f"({result.removeprefix('waited ')})", "green"))
return _pretty_line("* Wait ", ("(disturbed)", "yellow"))
_PRETTY_BUILDERS: dict[str, Callable[[str, str], str]] = {
"read_file": _read_file_pretty,
"tree": _tree_pretty,
"todo_push": lambda _args, result: _todo_pretty("todo_push", result),
"todo_update": lambda _args, result: _todo_pretty("todo_update", result),
"listdir": _listdir_pretty,
"glob_paths": _glob_pretty,
"grep_files": _grep_pretty,
"run_argv": _run_argv_pretty,
"run_argv_batch": _run_argv_batch_pretty,
"fetch": _fetch_pretty,
"edit_replace": lambda args, result: _mutator_pretty("edit_replace", args, result),
"edit_lineno": lambda args, result: _mutator_pretty("edit_lineno", args, result),
"write_file": lambda args, result: _mutator_pretty("write_file", args, result),
"copy_path": lambda args, result: _mutator_pretty("copy_path", args, result),
"move_path": lambda args, result: _mutator_pretty("move_path", args, result),
"delete_path": lambda args, result: _mutator_pretty("delete_path", args, result),
"vcs_status": _vcs_status_pretty,
"vcs_log": _vcs_log_pretty,
"wait": _wait_pretty,
}
def _pretty_tool_result(name: str, args_json: str, result: str) -> str | None:
"""Pretty summary for a known tool call + result; None keeps the old style."""
builder = _PRETTY_BUILDERS.get(name)
if builder is None:
return None
return builder(args_json, result)
def _echo_stream(text: str) -> None:
"""Write without newline and flush so assistant text appears token-by-token."""
click.echo(text, nl=False)
@@ -144,6 +496,9 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
assistant_buf: list[str] = []
printed_reasoning = False
printed_assistant = False
# Tool calls buffer so prettified tools can render once their result lands
# (the summary line needs the status/range). FIFO matches loop event order.
pending_tools: list[tuple[str, str, bool]] = []
def flush_assistant() -> None:
nonlocal source, assistant_buf, printed_assistant
@@ -192,19 +547,28 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
flush_assistant()
call = event.tool_call
if isinstance(call, AssistantFunctionToolCall):
args = _preview(call.function.arguments, _TOOL_ARGS_PREVIEW)
click.secho(f"\n[tool] {call.function.name}({args})", fg="yellow")
name = call.function.name
args = call.function.arguments
pretty_tool = name in _PRETTY_TOOLS
pending_tools.append((name, args, pretty_tool))
if not pretty_tool:
preview = _preview(args, _TOOL_ARGS_PREVIEW)
click.secho(f"\n[tool] {name}({preview})", fg="yellow")
else:
pending_tools.append(("custom", call.id, False))
click.secho(f"\n[tool] custom id={call.id}", fg="yellow")
elif isinstance(event, ToolResultEvent):
flush_assistant()
content = event.message.content
if show_full:
name, args, pretty_tool = pending_tools.pop(0) if pending_tools else ("", "", False)
pretty_line = _pretty_tool_result(name, args, content) if pretty_tool else None
if pretty_line is not None and not show_full:
click.echo(f"\n{pretty_line}")
elif show_full:
click.secho(f"[tool ok]\n{content}", fg="magenta")
else:
preview = _preview(content, _TOOL_RESULT_PREVIEW)
one_line = preview.replace("\n", " ")
click.secho(f"[tool ok] {one_line}", fg="magenta")
preview = _preview_result(content, _TOOL_RESULT_PREVIEW)
click.secho(f"[tool ok] {preview}", fg="magenta")
elif isinstance(event, ErrorEvent):
flush_assistant()
suffix = ""
+27
View File
@@ -29,6 +29,33 @@ def set_sigint_reinstall(callback: Callable[[], None] | None) -> None:
_reinstall_holder[0] = callback
def raise_keyboard_interrupt_sigint(signum: int, frame: FrameType | None) -> None:
"""SIGINT handler: raise KeyboardInterrupt (same effect as the default).
A distinct function object — deliberately *not*
``signal.default_int_handler`` — so that :func:`asyncio.run`'s Runner does
not recognise it and install its own silent-cancel handler on top.
"""
del signum, frame
raise KeyboardInterrupt
def install_keyboard_interrupt_sigint() -> None:
"""Install :func:`raise_keyboard_interrupt_sigint` as the SIGINT handler.
``asyncio.run`` (Runner) replaces the SIGINT handler with its own when the
current one *is* ``signal.default_int_handler``: the first Ctrl+C then
silently cancels the main task (invisible at the REPL prompt, leaving the
task marked cancelled) and only the second raises KeyboardInterrupt. A
later Ctrl+D (EOF) is then cancelled mid-``memory.close()`` and the CLI
exits with ``Aborted!``. Installing a plain KeyboardInterrupt-raising
handler first keeps every Ctrl+C a normal KeyboardInterrupt that the CLI
(prompt, turn-retry, oneshot) already handles deliberately.
"""
with contextlib.suppress(ValueError):
_ = signal.signal(signal.SIGINT, raise_keyboard_interrupt_sigint)
@contextmanager
def pause_task_cancel_for_prompt() -> Generator[None]:
"""Disable turn-task cancel during blocking TTY prompts (confirm, etc.).
+33 -30
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import contextlib
from typing import TYPE_CHECKING
import click
@@ -23,9 +24,13 @@ def _echo_user(text: str) -> None:
def _echo_interrupted() -> None:
click.echo()
click.secho("interrupted", fg="yellow")
click.echo()
# A further Ctrl+C during this output must stay benign (same rule as
# retry._echo_cancel_lines): after a turn the SIGINT handler is removed, so
# the next Ctrl+C raises KeyboardInterrupt at an arbitrary bytecode.
with contextlib.suppress(KeyboardInterrupt):
click.echo()
click.secho("interrupted", fg="yellow")
click.echo()
async def run_repl(state: ReplState) -> None:
@@ -49,33 +54,31 @@ async def run_repl(state: ReplState) -> None:
click.echo()
break
if entry is None:
continue
if entry.startswith("/"):
try:
cont = await handle_slash(state, entry)
except KeyboardInterrupt:
_echo_interrupted()
continue
if not cont:
break
if state.pending_user_text is not None:
text = state.pending_user_text
state.pending_user_text = None
_echo_user(text)
try:
_ = await run_user_text_with_retries(state.agent, text)
except KeyboardInterrupt:
_echo_interrupted()
finally:
state.expire_yolo_once()
continue
_echo_user(entry)
# Between turns the turn-task SIGINT handler is removed (run_cancellable),
# so a stray Ctrl+C raises KeyboardInterrupt at whatever bytecode is
# executing — echoing user text, expire_yolo_once, gaps between
# statements. Swallow it and re-prompt instead of exiting the REPL.
try:
_ = await run_user_text_with_retries(state.agent, entry)
if entry is None:
continue
if entry.startswith("/"):
cont = await handle_slash(state, entry)
if not cont:
break
if state.pending_user_text is not None:
text = state.pending_user_text
state.pending_user_text = None
_echo_user(text)
try:
_ = await run_user_text_with_retries(state.agent, text)
finally:
state.expire_yolo_once()
continue
_echo_user(entry)
try:
_ = await run_user_text_with_retries(state.agent, entry)
finally:
state.expire_yolo_once()
except KeyboardInterrupt:
_echo_interrupted()
finally:
state.expire_yolo_once()
+7
View File
@@ -64,6 +64,11 @@ async def run_cancellable[T](coro: Coroutine[object, object, T]) -> T:
task: asyncio.Task[T] = asyncio.create_task(coro)
loop = asyncio.get_running_loop()
installed = False
# What to restore once the turn-cancel handler is removed. remove_signal_handler
# leaves SIG_DFL, which would terminate the process on a stray Ctrl+C between
# turns; restore the handler from before (the CLI installs a
# KeyboardInterrupt-raising one so the REPL can catch it).
previous_sigint = signal.getsignal(signal.SIGINT)
def _on_sigint() -> None:
# allow_task_cancel() uses a process-level depth counter (not ContextVar)
@@ -98,6 +103,8 @@ async def run_cancellable[T](coro: Coroutine[object, object, T]) -> T:
if installed:
with contextlib.suppress(NotImplementedError, RuntimeError, ValueError):
_ = loop.remove_signal_handler(signal.SIGINT)
with contextlib.suppress(ValueError):
_ = signal.signal(signal.SIGINT, previous_sigint)
# Only cancel if still running (e.g. KeyboardInterrupt path above).
# Do not cancel a finished task — that would mask success.
if not task.done():
+53 -25
View File
@@ -24,6 +24,7 @@ from plyngent.runtime import ProviderNotSupportedError
if TYPE_CHECKING:
from collections.abc import Awaitable, Sequence
from plyngent.agent import ToolRegistry
from plyngent.cli.state import ReplState, YoloMode
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
@@ -83,7 +84,8 @@ HELP_FOOTER = (
"works after resume, not only via readline history). Auto-retry: 10s/20s/30s.\n"
"\n"
"Side questions: /btw … answers without changing the main session\n"
"(optional --tools / --fresh). Tab completes slash commands and arguments.\n"
"(--tools=read by default: read-only tools; --tools no/full opt-in; --fresh).\n"
"Tab completes slash commands and arguments.\n"
"Use --session ID or /resume to continue a prior chat after restart.\n"
"\n"
'Multiline: start a message with """ then end a later line with """.\n'
@@ -928,11 +930,12 @@ def _format_tool_tags(tags: object) -> str:
@slash.command("btw")
@click.argument("question", nargs=-1, required=False)
@click.option(
"--tools/--no-tools",
"with_tools",
default=False,
"--tools",
"tools_mode",
type=click.Choice(["no", "read", "full"]),
default="read",
show_default=True,
help="Allow tools on the side turn (shared workspace; forked session grants/todo).",
help=("Tool access on the side turn: read = read-only tools only (default), no = no tools, full = all tools."),
)
@click.option(
"--fresh",
@@ -945,48 +948,73 @@ def btw_cmd(
state: ReplState,
question: tuple[str, ...],
*,
with_tools: bool,
tools_mode: str,
fresh: bool,
) -> None:
"""Ask a side question without changing the main session transcript or DB.
Uses the current model and (unless ``--fresh``) a copy of main history.
Tools are **off** by default; ``--tools`` clones the main tool registry with
a fresh session bag and the shared instance workspace.
Tools default to **read-only** (``--tools=read``): file/vcs/ask/fetch-GET
reads with no state mutation. ``--tools=no`` turns tools off and
``--tools=full`` clones the whole main registry with a fresh session bag
and the shared instance workspace.
"""
text = " ".join(question).strip()
if not text:
click.echo("usage: /btw [--tools] [--fresh] <question>")
click.echo("usage: /btw [--tools read|no|full] [--fresh] <question>")
return
if state.agent.pending_retry_text is not None:
click.echo("error: finish or /retry the main turn before /btw")
return
if with_tools and (not state.tools_enabled or state.agent.tools is None):
click.echo("error: --tools requires tools on (/tools on)")
return
tools_on = state.tools_enabled and state.agent.tools is not None
explicit = click.get_current_context().get_parameter_source("tools_mode") is not click.core.ParameterSource.DEFAULT
if tools_mode != "no" and not tools_on:
# Explicitly requesting tools while they are off is an error; the
# default (read) silently falls back to no tools so bare /btw still
# works under /tools off.
if tools_mode == "full" or explicit:
click.echo("error: --tools requires tools on (/tools on)")
return
tools_mode = "no"
from plyngent.cli.display import render_events
from plyngent.cli.retry import run_cancellable
from plyngent.tools.context import SessionState
from plyngent.tools.context import SessionState, read_only_context
click.secho("btw: ", fg="magenta", nl=False)
click.echo(text)
tools_arg: bool = with_tools
aside_session = SessionState() if with_tools else None
aside_instance = state.instance_state if with_tools else None
if tools_mode == "no":
tools_arg: ToolRegistry | bool = False
aside_session = None
aside_instance = None
elif tools_mode == "full":
tools_arg = True
aside_session = SessionState()
aside_instance = state.instance_state
else: # read
assert state.agent.tools is not None
# Read-only clone: keep the main session view so read-only session
# reads (e.g. todo_list) see the real state (they cannot mutate it).
tools_arg = state.agent.tools.clone(read_only_only=True)
aside_session = state.session_state
aside_instance = state.instance_state
async def _run() -> None:
await run_cancellable(
render_events(
state.agent.run_aside(
text,
include_history=not fresh,
tools=tools_arg,
instance_state=aside_instance,
session_state=aside_session,
)
side = render_events(
state.agent.run_aside(
text,
include_history=not fresh,
tools=tools_arg,
instance_state=aside_instance,
session_state=aside_session,
)
)
if tools_mode == "read":
with read_only_context():
await run_cancellable(side)
else:
await run_cancellable(side)
try:
_await(_run())
+9 -2
View File
@@ -32,10 +32,14 @@ raise `max_replaces` or narrow `old_string`.
- `edit_lineno` edits only lines you read via `read_file(path, with_lineno=true)`; \
re-read with line numbers after any `edit_*` / `write_file` / copy/move/delete, \
since line numbers go stale.
- `read_file` results start with a 1-based line range `L{begin}-{end}` (`offset` \
is 0-based); `with_lineno` shows per-line numbers instead.
- Truncated results (any tool) carry a `truncate_token=...`; use `get_truncated` \
with it to keep reading (chains through truncations).
### Commands
- Prefer `run_command` / `run_command_batch` (argv, no shell) over `bash -c` or similar.
- Several `run_command` calls in one step may run in parallel; use `run_command_batch` \
- Prefer `run_argv` / `run_argv_batch` (argv lists, no shell) over `bash -c` or similar.
- Several `run_argv` calls in one step may run in parallel; use `run_argv_batch` \
for ordered pipelines (`pipe_out` / `mix_stderr` as needed).
- Prefer `vcs_*` for status/diff/log/branch when enough.
@@ -49,6 +53,7 @@ otherwise a small default is used. Never rely on shell to spoof identity.
### Humans & safety
- Prefer `ask_user_line` / `ask_user_choice` / `ask_user_form` over waiting for the next free-form turn.
- Use `wait` to pause; pressing Enter during the wait disturbs it (reason optional; turn continues immediately).
- Overwrites, deletes, shells, and risky ops may require human confirm; hard denylists are not skipped by YOLO.
- Temporary scratch: `new_temporary_workspace`.
@@ -60,6 +65,8 @@ otherwise a small default is used. Never rely on shell to spoof identity.
### Todos
- Use the todo stack for multi-step work (LIFO groups: push related items, finish/update, \
pop the group). Open items mean unfinished work.
- The stack does **not** auto-clear. When every task is done (all items done/cancelled), \
call `todo_clear` (or pop finished TOP groups) so no hygiene noise is left behind.
"""
@@ -239,6 +239,9 @@ class DeltaMessage(Struct):
# Providers often send content=null / reasoning_content=null on partial chunks.
content: str | None | Unset = UNSET
reasoning_content: str | None | Unset = UNSET
# True when reasoning_content is the FULL chain-of-thought (e.g. DeepSeek's
# ``response.reasoning_text.done``), replacing any streamed fragments.
reasoning_full: bool | Unset = UNSET
tool_calls: list[StreamToolCallDelta] | Unset = UNSET
+84 -1
View File
@@ -2,10 +2,12 @@ from __future__ import annotations
import asyncio
import contextlib
import select
import sys
import time
from contextlib import AbstractContextManager
from dataclasses import dataclass
from typing import TYPE_CHECKING, Protocol, runtime_checkable
from typing import TYPE_CHECKING, Protocol, TextIO, cast, runtime_checkable
import click
@@ -98,6 +100,87 @@ def _readline_input(prompt: str, *, completions: Sequence[str] | None = None) ->
readline.set_completer_delims(previous_delims)
type PollFn = Callable[
[list[object], list[object], list[object], float],
tuple[list[object], list[object], list[object]],
]
def _read_line_with_timeout_posix(
prompt: str,
timeout: float,
*,
stream: TextIO,
poll: PollFn,
) -> str | None:
"""Blocking line read on POSIX: poll stdin, then read one line on data.
Returns the line (without trailing newline), ``""`` for an empty Enter, or
``None`` when the timeout elapses before any line is complete (EOF too).
"""
_ = sys.stdout.write(prompt)
_ = sys.stdout.flush()
deadline = time.monotonic() + timeout
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
return None
ready, _, _ = poll([stream], [], [], min(remaining, 0.1))
if not ready:
continue
line = stream.readline()
if line == "":
return None # EOF
return line.rstrip("\n")
def _read_line_with_timeout_windows(prompt: str, timeout: float) -> str | None:
"""Blocking line read on Windows via msvcrt char polling (best-effort)."""
import msvcrt
_ = sys.stdout.write(prompt)
_ = sys.stdout.flush()
deadline = time.monotonic() + timeout
buf: list[str] = []
while time.monotonic() < deadline:
if msvcrt.kbhit():
ch = msvcrt.getwch()
if ch in ("\r", "\n"):
_ = sys.stdout.write("\n")
_ = sys.stdout.flush()
return "".join(buf)
if ch == "\x03":
raise KeyboardInterrupt
if ch in ("\x08", "\x7f") and buf: # backspace / DEL
_ = buf.pop()
_ = sys.stdout.write("\b \b")
_ = sys.stdout.flush()
continue
buf.append(ch)
_ = sys.stdout.write(ch)
_ = sys.stdout.flush()
else:
time.sleep(0.05)
_ = sys.stdout.write("\n")
_ = sys.stdout.flush()
return None
def read_line_with_timeout(prompt: str, timeout: float) -> str | None:
"""Read one line, blocking at most ``timeout`` seconds.
Returns the typed line (without trailing newline), ``""`` for an empty
Enter, or ``None`` when the timeout elapses first (EOF too). Ctrl+C
propagates as :class:`KeyboardInterrupt`. POSIX polls stdin via ``select``;
Windows uses ``msvcrt`` char polling (best-effort).
"""
if timeout < 0:
timeout = 0.0
if sys.platform == "win32":
return _read_line_with_timeout_windows(prompt, timeout)
return _read_line_with_timeout_posix(prompt, timeout, stream=sys.stdin, poll=cast("PollFn", select.select))
class ClickPromptBackend:
"""Click/TTY backend for interactive prompts (readline + Tab when available)."""
+3 -2
View File
@@ -8,6 +8,7 @@ from .chat import CHAT_TOOLS as CHAT_TOOLS
from .chat import ask_user as ask_user
from .chat import choose_user as choose_user
from .chat import form_user as form_user
from .chat import wait as wait
from .context import InstanceState as InstanceState
from .context import SessionState as SessionState
from .context import bind_instance as bind_instance
@@ -41,8 +42,8 @@ from .process import ask_into_pty as ask_into_pty
from .process import close_pty as close_pty
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 run_command_batch as run_command_batch
from .process import run_argv as run_argv
from .process import run_argv_batch as run_argv_batch
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
+2 -1
View File
@@ -1,5 +1,6 @@
from .ask import ask_user as ask_user
from .choose import choose_user as choose_user
from .form import form_user as form_user
from .wait import wait as wait
CHAT_TOOLS = [ask_user, choose_user, form_user]
CHAT_TOOLS = [ask_user, choose_user, form_user, wait]
+1 -1
View File
@@ -4,7 +4,7 @@ from plyngent.agent import ToolTag, tool
from plyngent.prompting import NonInteractiveError, ask_async
@tool(name="ask_user_line", tags=ToolTag.LOCAL)
@tool(name="ask_user_line", tags=ToolTag.LOCAL | ToolTag.READ_ONLY)
async def ask_user(question: str, default: str = "") -> str:
"""Ask the human a free-form one-line question and return their answer.
+1 -1
View File
@@ -36,7 +36,7 @@ def parse_options(options: list[object]) -> list[ChoiceOption]:
return out
@tool(name="ask_user_choice", tags=ToolTag.LOCAL)
@tool(name="ask_user_choice", tags=ToolTag.LOCAL | ToolTag.READ_ONLY)
async def choose_user(
question: str,
options: list[str] | list[dict[str, object]],
+1 -1
View File
@@ -46,7 +46,7 @@ def parse_fields(fields: list[dict[str, object]]) -> list[FormField]:
return out
@tool(name="ask_user_form", tags=ToolTag.LOCAL)
@tool(name="ask_user_form", tags=ToolTag.LOCAL | ToolTag.READ_ONLY)
async def form_user(
title: str,
fields: list[dict[str, object]],
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
import asyncio
from plyngent.agent import ToolTag, tool
from plyngent.prompting import get_prompt_backend, read_line_with_timeout, run_prompt_async
WAIT_PROMPT = "Waiting for {duration}s. Press Enter to disturb. Feel free to ship any reason before pressing Enter. "
@tool(name="wait", tags=ToolTag.LOCAL | ToolTag.READ_ONLY)
async def wait(duration: int, *, reason: str | None = None) -> str:
"""Wait ``duration`` seconds before continuing.
Interactive sessions show a line prompt: pressing Enter (optionally after
typing a reason) "disturbs" the wait so the turn continues immediately.
Non-interactive runs simply sleep the full duration.
"""
if duration < 0:
return "error: duration must not be negative"
if duration == 0:
return "waited 0s"
backend = get_prompt_backend()
if not backend.is_interactive():
await asyncio.sleep(duration)
return f"waited {duration}s"
prompt = WAIT_PROMPT.format(duration=duration)
if reason:
prompt = (
f"Waiting for {duration}s (reason: {reason}). Press Enter to disturb. "
"Feel free to ship any reason before pressing Enter. "
)
line = await run_prompt_async(read_line_with_timeout, prompt, float(duration))
if line is None:
return f"waited {duration}s"
text = line.strip()
if text:
return f"disturbed by user: {text}"
return "disturbed by user (no reason)"
+23
View File
@@ -137,6 +137,29 @@ def bind_session(state: SessionState | None) -> Generator[SessionState | None]:
_session.reset(token)
_read_only: ContextVar[bool] = ContextVar("plyngent_read_only_context", default=False)
@contextmanager
def read_only_context() -> Generator[None]:
"""Mark the current context as read-only for tool execution.
Tools that may mutate depending on arguments (e.g. ``fetch`` with a
non-GET method) check :func:`is_read_only_context` and refuse to mutate
while it is set.
"""
token = _read_only.set(True)
try:
yield
finally:
_read_only.reset(token)
def is_read_only_context() -> bool:
"""Whether tools currently run inside a read-only context."""
return _read_only.get()
@contextmanager
def bind_tool_context(
*,
+20 -20
View File
@@ -59,8 +59,8 @@ def _basename(argv0: str) -> str:
return name.lower()
def _as_argv(args: Mapping[str, object]) -> list[str] | None:
command = args.get("command")
def _as_argv(args: Mapping[str, object], *, key: str = "command") -> list[str] | None:
command = args.get(key)
if not isinstance(command, list) or not command:
return None
out: list[str] = []
@@ -110,7 +110,7 @@ def _shell_or_dash_c_reason(argv: Sequence[str], *, via: str) -> str | None:
"""Confirm interactive shells and ``*-c`` one-liners so the user can inspect argv.
Multi-line reason (shown inside the CLI confirm box). ``via`` is a short
label such as ``run_command`` or ``open_pty``.
label such as ``run_argv`` or ``open_pty``.
For ``-c`` scripts, argv shows ``$(command)`` instead of inlining the body;
the full script is printed below untruncated, with every line indented.
@@ -181,31 +181,31 @@ def _delete_path_reason(args: Mapping[str, object]) -> str | None:
return f"delete path {path!r}{extra}"
def _run_command_reason(args: Mapping[str, object]) -> str | None:
argv = _as_argv(args)
def _run_argv_reason(args: Mapping[str, object]) -> str | None:
argv = _as_argv(args, key="argv")
if argv is None:
return None
return _shell_or_dash_c_reason(argv, via="run_command")
return _shell_or_dash_c_reason(argv, via="run_argv")
def _batch_step_argv(item: object) -> list[str] | None:
if not isinstance(item, dict):
return None
step = cast("dict[str, object]", item)
command = step.get("command")
if not isinstance(command, list) or not command:
argv = step.get("argv")
if not isinstance(argv, list) or not argv:
return None
argv: list[str] = []
for part in cast("list[object]", command):
parts: list[str] = []
for part in cast("list[object]", argv):
if not isinstance(part, str):
return None
argv.append(part)
return argv or None
parts.append(part)
return parts or None
def _run_command_batch_reason(args: Mapping[str, object]) -> str | None:
def _run_argv_batch_reason(args: Mapping[str, object]) -> str | None:
"""One confirm for the whole batch if any step is shell/REPL/-c."""
raw = args.get("commands")
raw = args.get("steps")
if isinstance(raw, str):
try:
loaded: object = json.loads(raw)
@@ -218,11 +218,11 @@ def _run_command_batch_reason(args: Mapping[str, object]) -> str | None:
reason
for index, item in enumerate(cast("list[object]", raw))
if (argv := _batch_step_argv(item)) is not None
and (reason := _shell_or_dash_c_reason(argv, via=f"run_command_batch[{index}]")) is not None
and (reason := _shell_or_dash_c_reason(argv, via=f"run_argv_batch[{index}]")) is not None
]
if not risky:
return None
header = f"run_command_batch: {len(risky)} risky step(s) (review before allow)"
header = f"run_argv_batch: {len(risky)} risky step(s) (review before allow)"
return header + "\n" + "\n".join(risky)
@@ -262,10 +262,10 @@ def classify_danger(name: str, args: Mapping[str, object]) -> str | None: # noq
return _copy_path_reason(args)
if name == "write_file":
return _write_file_reason(args)
if name == "run_command":
return _run_command_reason(args)
if name == "run_command_batch":
return _run_command_batch_reason(args)
if name == "run_argv":
return _run_argv_reason(args)
if name == "run_argv_batch":
return _run_argv_batch_reason(args)
if name == "open_pty":
return _open_pty_reason(args)
if name == "fetch":
+2
View File
@@ -5,6 +5,7 @@ from .edit_replace import edit_replace as edit_replace
from .fs_ops import copy_path as copy_path
from .fs_ops import delete_path as delete_path
from .fs_ops import move_path as move_path
from .get_truncated import get_truncated as get_truncated
from .glob_paths import glob_paths as glob_paths
from .grep_files import grep_files as grep_files
from .listdir import listdir as listdir
@@ -25,4 +26,5 @@ FILE_TOOLS = [
move_path,
delete_path,
new_temporary_workspace,
get_truncated,
]
+40 -8
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from plyngent.agent import ToolTag, invalidate_lineno_read, lineno_read_lines, tool
from plyngent.agent import ToolTag, lineno_read_lines, lineno_read_mtime, tool
from plyngent.tools.workspace import resolve_path
if TYPE_CHECKING:
@@ -60,10 +60,11 @@ def _unread_lines_in_range(
"""Lines in ``start_line..end_line`` that were not read with line numbers.
Appending (``start_line == n + 1``) requires the last line to be read, so
line numbers stay truthful; the append point itself is not a line.
line numbers stay truthful; the append point itself is not a line. An empty
file (``n == 0``) has nothing to clobber, so appending needs no read.
"""
if start_line == n + 1:
return [] if n in read_lines else [n]
return [] if n == 0 or n in read_lines else [n]
return sorted(set(range(start_line, end_line + 1)) - read_lines)
@@ -77,6 +78,14 @@ def _append_after(target: Path, path: str, text: str, lines: list[str], new_cont
return f"appended content after line {n} in {path}"
def _excerpt(text: str, limit: int = 40) -> str:
"""Collapse newlines and truncate for a one-line message excerpt."""
one = text.replace("\r\n", " ").replace("\n", " ")
if len(one) > limit:
return one[:limit] + "…"
return one
def _replace_range(
target: Path,
path: str,
@@ -92,7 +101,11 @@ def _replace_range(
new_lines = lines[: start_line - 1] + replacement + lines[end:]
_ = target.write_text("".join(new_lines), encoding="utf-8")
removed = end - start_line + 1
return f"replaced lines {start_line}-{end} ({removed} lines) with {len(replacement)} lines in {path}"
old_first = _excerpt(lines[start_line - 1].rstrip("\r\n"))
return (
f"replaced lines {start_line}-{end} ({removed} lines; first: {old_first!r}) "
f"with {len(replacement)} lines in {path}"
)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
@@ -111,18 +124,37 @@ async def edit_lineno(path: str, start_line: int, end_line: int, new_content: st
if err is not None:
return err
unread = _unread_lines_in_range(lineno_read_lines(str(target)), start_line, end_line, len(lines))
read_lines = lineno_read_lines(str(target))
unread = _unread_lines_in_range(read_lines, start_line, end_line, len(lines))
if unread:
preview = unread[:_UNREAD_PREVIEW]
head = ", ".join(str(i) for i in preview)
if len(unread) > _UNREAD_PREVIEW:
head += f", … ({len(unread)} total)"
return f"error: lines {head} not read; call read_file(path, with_lineno=true) first to see current line numbers"
window = ""
if read_lines:
window = f" (this turn read lines {min(read_lines)}-{max(read_lines)})"
return (
f"error: lines {head} not read{window}; call read_file(path, with_lineno=true) "
"first — read with a small margin around the lines you plan to edit"
)
recorded_mtime = lineno_read_mtime(str(target))
if recorded_mtime is not None:
try:
current_mtime = target.stat().st_mtime_ns
except OSError:
current_mtime = None
if current_mtime is not None and current_mtime != recorded_mtime:
return (
f"error: {path} changed since it was read (line numbers may be stale); "
"re-read with read_file(path, with_lineno=true)"
)
if start_line == len(lines) + 1:
result = _append_after(target, path, text, lines, new_content)
else:
result = _replace_range(target, path, lines, start_line, end_line, new_content)
# Line numbers are stale after a write; force a re-read before next edit.
invalidate_lineno_read(str(target))
# Line numbers are stale after a write; the fresh mtime makes the next
# edit_lineno reject until the file is re-read.
return result
+30 -8
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from plyngent.agent import ToolTag, invalidate_lineno_read, tool
from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import resolve_path
@@ -18,15 +18,31 @@ def _count_non_overlapping(text: str, needle: str) -> int:
start = index + len(needle)
def _success_message(path: str, *, replaced: int, found: int) -> str:
def _excerpt(text: str, limit: int = 40) -> str:
"""Collapse newlines and truncate for a one-line message excerpt."""
one = text.replace("\r\n", " ").replace("\n", " ")
if len(one) > limit:
return one[:limit] + "…"
return one
def _success_message(
path: str,
*,
replaced: int,
found: int,
old_string: str,
new_string: str,
) -> str:
remaining = found - replaced
unit = "occurrence" if replaced == 1 else "occurrences"
change = f" ('{_excerpt(old_string)}' → '{_excerpt(new_string)}')"
if remaining == 0:
if found == 1:
return f"replaced 1 occurrence in {path}"
return f"replaced {replaced} {unit} in {path} (all {found} matches)"
return f"replaced 1 occurrence in {path}{change}"
return f"replaced {replaced} {unit} in {path}{change} (all {found} matches)"
return (
f"replaced {replaced} {unit} in {path} "
f"replaced {replaced} {unit} in {path}{change} "
f"({replaced} of {found} matches; {remaining} remain — "
f"raise max_replaces or use a more specific old_string)"
)
@@ -54,6 +70,12 @@ async def edit_replace(path: str, old_string: str, new_string: str, max_replaces
n = min(max_replaces, found)
updated = text.replace(old_string, new_string, n)
_ = target.write_text(updated, encoding="utf-8")
# Any lineno read state is stale after a write; force a re-read.
invalidate_lineno_read(str(target))
return _success_message(path, replaced=n, found=found)
# The write changes the file's mtime, so edit_lineno's freshness check
# rejects stale line numbers until the model re-reads with_lineno.
return _success_message(
path,
replaced=n,
found=found,
old_string=old_string,
new_string=new_string,
)
+4 -1
View File
@@ -68,10 +68,13 @@ def _copy_or_move_validated(
else:
_ = shutil.copy2(source, dest, follow_symlinks=False)
label = _kind(source)
# A copy does not change the source, but the destination content is new.
# A copy does not change the source, but the destination content is
# new. copy2/copytree preserve the source mtime, so the freshness
# check alone could miss the change — invalidate explicitly.
invalidate_lineno_read(str(dest))
return f"copied {label} {src} -> {dest.relative_to(root)}"
moved = Path(shutil.move(str(source), str(dest))).resolve()
# shutil.move preserves mtime too; drop read state for both paths.
invalidate_lineno_read(str(source))
invalidate_lineno_read(str(moved))
return f"moved {_kind(moved)} {src} -> {moved.relative_to(root)}"
+98
View File
@@ -0,0 +1,98 @@
"""``get_truncated`` tool: resume truncated tool results via a truncate token."""
from __future__ import annotations
from typing import cast
from plyngent.agent import ToolTag, tool
from plyngent.agent.budget import DEFAULT_TOOL_RESULT_MAX_CHARS
from plyngent.tools.file.read import line_range_label, read_file, read_raw_text
from plyngent.tools.net.fetch import fetch
from plyngent.tools.truncate_token import (
TruncateToken,
decode_truncate_token,
get_remainder,
truncate_with_token,
)
def _memory_chunk(parsed: TruncateToken, max_chars: int) -> str:
"""Serve one chunk from the in-memory remainder store (or an error)."""
remainder = get_remainder(parsed.location)
if remainder is None:
return "error: truncate token expired (no longer in memory); re-run the tool"
start = parsed.offset
segment = remainder[start : start + max_chars]
chunk, _ = truncate_with_token(
segment,
max_chars,
kind="memory",
location=parsed.location,
offset=start,
limit=max_chars,
total_len=len(remainder),
)
return chunk
async def _numbered_continuation(text: str, location: str, char_offset: int, max_chars: int) -> str:
"""Resume a numbered read at the line containing *char_offset*.
Delegates to ``read_file(with_lineno=True)`` so the continuation carries
``N|`` line numbers and marks the resumed lines readable for ``edit_lineno``.
"""
pos = 0
line = 0
for ln in text.splitlines(keepends=True):
if pos + len(ln) > char_offset:
break
pos += len(ln)
line += 1
return await read_file.handler(
location,
offset=line,
with_lineno=True,
max_chars=max_chars,
)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
async def get_truncated(token: str, *, max_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS) -> str:
"""Fetch the next chunk of a truncated result using its ``truncate_token``.
Pass the token shown at the end of a truncated ``read_file``, ``fetch``,
``run_command``, or earlier ``get_truncated`` result to keep reading without
re-requesting the whole source or raising limits. Truncated output carries a
fresh token, so chunks chain indefinitely. File chunks resume in the same
view as the original read: numbered reads continue as numbered lines
(editable via ``edit_lineno``), plain reads as raw text with a 1-based
``L{begin}-{end}`` range. Memory chunks (generic tool output) are served
from a short-lived in-memory store that is forgotten when the agent exits.
"""
parsed = decode_truncate_token(token)
if parsed is None:
return "error: invalid truncate token"
if parsed.kind == "http":
return await fetch.handler(parsed.location, offset=parsed.offset, max_chars=parsed.limit)
if parsed.kind == "memory":
return _memory_chunk(parsed, max_chars)
text, err = read_raw_text(parsed.location)
if err:
return err
text = cast("str", text)
start = parsed.offset
if parsed.numbered:
return await _numbered_continuation(text, parsed.location, start, max_chars)
segment = text[start : start + max_chars]
chunk, _ = truncate_with_token(
segment,
max_chars,
kind="file",
location=parsed.location,
offset=start,
limit=max_chars,
total_len=len(text),
)
content_len = len(chunk.split("\n[Truncated", 1)[0]) if "\n[Truncated" in chunk else len(chunk)
header = line_range_label(text, start, start + content_len)
return f"{header}\n{chunk}"
+1 -1
View File
@@ -65,7 +65,7 @@ def _resolve_glob_base(path: str) -> tuple[Path, Path] | str:
return root, base
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
async def glob_paths(
pattern: str,
path: str = ".",
+1 -1
View File
@@ -127,7 +127,7 @@ def _compile_pattern(pattern: str, *, case_insensitive: bool) -> re.Pattern[str]
return f"error: invalid regex: {exc}"
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
async def grep_files(
pattern: str,
path: str = ".",
+1 -1
View File
@@ -4,7 +4,7 @@ from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import resolve_path
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
async def listdir(path: str = ".") -> str:
"""List entries in a directory under the workspace (name and type)."""
target = resolve_path(path)
+99 -11
View File
@@ -1,6 +1,10 @@
from __future__ import annotations
from typing import cast
from plyngent.agent import ToolTag, mark_lineno_read, tool
from plyngent.agent.budget import DEFAULT_TOOL_RESULT_MAX_CHARS
from plyngent.tools.truncate_token import TruncateToken, truncate_with_token, truncation_marker
from plyngent.tools.workspace import resolve_path
_LINENO_WIDTH = 6
@@ -17,25 +21,62 @@ def _format_with_lineno(lines: list[str], *, start_lineno: int) -> str:
return "".join(out)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
def line_range_label(text: str, start_char: int, end_char: int) -> str:
"""1-based inclusive line range covering ``text[start_char:end_char]``.
Char offsets do not map 1:1 to lines; this converts a contiguous char range
into the 1-based file line numbers it spans (offset is 0-based, so line 1
starts at char 0).
"""
begin = text.count("\n", 0, start_char) + 1
last = max(start_char, end_char - 1)
end = text.count("\n", 0, last) + 1
return f"L{begin}-{end}"
def read_raw_text(path: str) -> tuple[str | None, str]:
"""(text, error) for a workspace file read; ``error`` is ``""`` on success.
Shared by ``read_file`` and ``get_truncated`` so truncate-token char offsets
always refer to the raw file text (never the ``L{begin}-{end}`` header).
Missing paths and directories are distinguished for the caller.
"""
target = resolve_path(path)
if not target.exists():
return None, f"error: file not found: {path}"
if not target.is_file():
return None, f"error: not a file: {path}"
return target.read_text(encoding="utf-8", errors="replace"), ""
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
async def read_file(
path: str,
*,
offset: int = 0,
limit: int | None = None,
with_lineno: bool = False,
max_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS,
) -> 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, and those lines are
marked readable for ``edit_lineno`` this turn.
``offset`` is 0-based line start (0 = first line); ``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, and those lines are marked readable for ``edit_lineno`` this turn.
Only the displayed lines become editable, so read with a small margin around
any range you plan to edit with ``edit_lineno``.
``max_chars`` caps the returned slice (default: the agent's tool-result cap,
``DEFAULT_TOOL_RESULT_MAX_CHARS``); a ``truncate_token`` is appended when cut
so ``get_truncated`` can continue reading the rest without a new request.
Pass ``max_chars < 1`` to read unbounded.
"""
target = resolve_path(path)
if not target.is_file():
return f"error: not a file: {path}"
text = target.read_text(encoding="utf-8", errors="replace")
text, err = read_raw_text(path)
if err:
return err
text = cast("str", text)
lines = text.splitlines(keepends=True)
if offset < 0:
return "error: offset must be >= 0"
@@ -44,7 +85,54 @@ async def read_file(
if start >= len(lines):
return ""
slice_lines = lines[start:end]
body = _format_with_lineno(slice_lines, start_lineno=start + 1) if with_lineno else "".join(slice_lines)
char_start = len("".join(lines[:start]))
if max_chars >= 1:
body, token = truncate_with_token(
body,
max_chars,
kind="file",
location=path, # arg form: short token, resolves to the same file
offset=char_start,
limit=max_chars,
total_len=len(text),
# read_file bounds the read via offset/limit; only mark when the
# returned slice itself was cut by max_chars (not just "file continues").
emit_more_after=False,
)
else:
token = None
if not body:
return ""
if with_lineno:
mark_lineno_read(str(target), set(range(start + 1, end + 1)))
return _format_with_lineno(slice_lines, start_lineno=start + 1)
return "".join(slice_lines)
# Mark only the lines actually displayed: complete numbered lines in the
# returned body (the [Truncated] marker, when present, is not a file line).
# The max_chars-truncated tail was never shown, so it stays unread —
# edit_lineno must not trust line numbers the model never saw.
counted = body
if token is not None:
counted = body.split("\n[Truncated", 1)[0]
shown = counted.count("\n")
# The truncate cursor counts *numbered* chars (each line carries an
# "N|" prefix), which would over-advance into raw text and skip
# lines on continuation. Rebase the token onto raw characters of the
# complete lines shown (at least one, so the cursor always moves)
# and mark it as the numbered view for get_truncated.
consumed_lines = max(shown, 1)
raw_consumed = sum(len(ln) for ln in slice_lines[:consumed_lines])
corrected = TruncateToken(
kind="file",
location=path,
offset=char_start + raw_consumed,
limit=max_chars,
numbered=True,
)
omitted = len(text) - (char_start + raw_consumed)
body = counted + truncation_marker(max_chars, omitted, corrected)
else:
shown = counted.count("\n")
mark_lineno_read(str(target), set(range(start + 1, start + 1 + shown)))
return body
begin = start + 1
end_line = start + len(slice_lines)
return f"L{begin}-{end_line}\n{body}"
+102 -6
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Literal
from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import WorkspaceError, get_path_denylist, resolve_path
@@ -10,6 +10,11 @@ if TYPE_CHECKING:
from collections.abc import Sequence
from pathlib import Path
# Model-facing renderings. ``markdown`` (default) is the cheapest, most
# reliable representation for the agent; ``flat`` gives directly-usable paths;
# ``decorated`` keeps the classic box-drawing tree for human readers.
type TreeFormat = Literal["markdown", "flat", "decorated"]
DEFAULT_MAX_DEPTH = 4
DEFAULT_MAX_ENTRIES = 50
@@ -144,6 +149,80 @@ def _render_tree(
lines.append(f"{prefix}└── … ({more} more entries not shown)")
def _render_tree_markdown(
directory: Path,
*,
prefix: str,
depth: int,
limits: _TreeLimits,
lines: list[str],
) -> None:
"""Nested ``- `` bullet list (2-space indent per level)."""
if depth >= limits.max_depth:
return
children = _list_children(directory, limits=limits)
if isinstance(children, str):
lines.append(f"{prefix}- {children}")
return
truncated = len(children) > limits.max_entries
shown = children[: limits.max_entries]
for child in shown:
try:
is_dir = child.is_dir()
except OSError:
lines.append(f"{prefix}- {child.name} [error: stat failed]")
continue
if is_dir:
lines.append(f"{prefix}- {child.name}/")
_render_tree_markdown(
child,
prefix=prefix + " ",
depth=depth + 1,
limits=limits,
lines=lines,
)
else:
lines.append(f"{prefix}- {child.name}")
if truncated:
more = len(children) - limits.max_entries
lines.append(f"{prefix}- … ({more} more entries not shown)")
def _flat_paths(directory: Path, *, limits: _TreeLimits) -> list[str]:
"""All paths (dirs with trailing ``/``, files plain) in depth-first order.
Includes empty directories; bounded by *max_depth* (``max_entries`` is
applied to the total by the caller).
"""
out: list[str] = []
def visit(child_dir: Path, rel: str, depth: int) -> None:
if depth >= limits.max_depth:
return
children = _list_children(child_dir, limits=limits)
if isinstance(children, str):
out.append(f"{rel or child_dir.name} [error: cannot list]")
return
for child in children:
try:
is_dir = child.is_dir()
except OSError:
out.append(f"{child.name} [error: stat failed]")
continue
child_rel = child.name if not rel else f"{rel}/{child.name}"
if is_dir:
out.append(f"{child_rel}/")
visit(child, child_rel, depth + 1)
else:
out.append(child_rel)
visit(directory, "", 0)
return out
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:
@@ -151,10 +230,11 @@ def _resolve_skip_basenames(skip_dirs: Sequence[str] | None) -> frozenset[str]:
return frozenset(name for name in skip_dirs if name)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
async def tree(
path: str = ".",
*,
format: TreeFormat = "markdown", # noqa: A002 — model-facing param name
max_depth: int = DEFAULT_MAX_DEPTH,
max_entries: int = DEFAULT_MAX_ENTRIES,
skip_hidden_dirs: bool = True,
@@ -174,8 +254,15 @@ async def tree(
``apply_path_denylist`` (default true) hides entries whose full path matches
the agent ``path_denylist`` policy.
``format`` selects the representation (the model chooses per call):
``markdown`` (default) renders a nested ``- `` bullet list — cheapest and
most reliable for the agent; ``flat`` lists one relative path per line
(directories with a trailing ``/``) so every path is directly usable
without reconstructing it; ``decorated`` uses the classic box-drawing tree.
``max_depth`` limits how deep directories are expanded (1 = origin + children).
``max_entries`` caps how many entries are listed per directory.
``max_entries`` caps entries per directory (``markdown``/``decorated``) or
the total number of lines (``flat``).
"""
if max_depth < 1:
return "error: max_depth must be >= 1"
@@ -189,8 +276,6 @@ async def tree(
if not origin.is_dir():
return f"error: not a directory: {path}"
root_label = path.rstrip("/\\") or "."
lines = [f"{root_label}/"]
limits = _TreeLimits(
max_depth=max_depth,
max_entries=max_entries,
@@ -198,7 +283,18 @@ async def tree(
skip_basenames=_resolve_skip_basenames(skip_dirs),
apply_path_denylist=apply_path_denylist,
)
_render_tree(
if format == "flat":
paths = _flat_paths(origin, limits=limits)
lines = paths[:max_entries]
if len(paths) > max_entries:
lines.append(f"… ({len(paths) - max_entries} more paths not shown)")
return "\n".join(lines)
root_label = path.rstrip("/\\") or "."
lines: list[str] = [] if format == "markdown" else [f"{root_label}/"]
renderer = _render_tree_markdown if format == "markdown" else _render_tree
renderer(
origin,
prefix="",
depth=0,
+3 -2
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from plyngent.agent import ToolTag, invalidate_lineno_read, tool
from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import resolve_path
@@ -10,5 +10,6 @@ async def write_file(path: str, content: str) -> str:
target = resolve_path(path)
target.parent.mkdir(parents=True, exist_ok=True)
_ = target.write_text(content, encoding="utf-8")
invalidate_lineno_read(str(target))
# The write changes the file's mtime, so edit_lineno's freshness check
# rejects stale line numbers until the model re-reads with_lineno.
return f"wrote {len(content)} characters to {path}"
+23 -8
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
from plyngent.agent import ToolTag, tool
from plyngent.tools.context import is_read_only_context
from plyngent.tools.net.client import http_fetch
from plyngent.tools.net.policy import (
DEFAULT_MAX_BODY_CHARS_IN,
@@ -16,6 +17,7 @@ from plyngent.tools.net.policy import (
parse_fetch_url,
soft_confirm_reason,
)
from plyngent.tools.truncate_token import truncate_with_token
def format_fetch_result(
@@ -32,13 +34,20 @@ def format_fetch_result(
warnings: list[str],
body_kind: str,
max_chars: int,
offset: int = 0,
) -> str:
text = body_text
char_truncated = False
if max_chars >= 1 and len(text) > max_chars:
omitted = len(text) - max_chars
text = text[:max_chars] + f"\n...[truncated {omitted} characters]"
char_truncated = True
start = max(0, offset)
body = body_text[start:]
body, token = truncate_with_token(
body,
max_chars,
kind="http",
location=final_url,
offset=start,
limit=max_chars,
total_len=len(body_text),
)
char_truncated = token is not None
warn_line = "; ".join(warnings) if warnings else ""
parts = [
f"status={status}",
@@ -54,11 +63,11 @@ def format_fetch_result(
if warn_line:
parts.append(f"warnings={warn_line}")
parts.append("--- body ---")
parts.append(text)
parts.append(body)
return "\n".join(parts)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO | ToolTag.TRUSTABLE)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO | ToolTag.TRUSTABLE | ToolTag.READ_ONLY)
async def fetch(
url: str,
*,
@@ -72,6 +81,7 @@ async def fetch(
max_redirects: int = DEFAULT_MAX_REDIRECTS,
follow_redirects: bool = True,
allow_http_downgrade: bool = False,
offset: int = 0,
) -> str:
"""HTTP request (GET/POST/PUT/DELETE); return status, metadata, and truncated body.
@@ -83,9 +93,13 @@ async def fetch(
Private/loopback/link-local hosts require an explicit human policy grant
(not skipped by YOLO). HTTPS→HTTP redirects are blocked unless
``allow_http_downgrade`` is true.
Read-only contexts (e.g. ``/btw --tools=read``) accept only ``GET``.
"""
try:
verb = normalize_method(method)
if is_read_only_context() and verb != "GET":
return "error: fetch method must be GET in a read-only context (POST/PUT/DELETE mutate)"
parsed = parse_fetch_url(url)
hdrs = normalize_request_headers(headers, user_agent=user_agent)
body_bytes: bytes | None = None
@@ -122,6 +136,7 @@ async def fetch(
warnings=result.warnings,
body_kind=result.body_kind,
max_chars=max_chars,
offset=offset,
)
+4 -4
View File
@@ -4,14 +4,14 @@ 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 .run_command_batch import run_command_batch as run_command_batch
from .run_argv import run_argv as run_argv
from .run_argv_batch import run_argv_batch as run_argv_batch
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,
run_command_batch,
run_argv,
run_argv_batch,
open_pty,
read_pty,
write_pty,
+13 -3
View File
@@ -22,9 +22,19 @@ DEFAULT_MAX_BATCH_STEPS = 20
def truncate_output(text: str, label: str, max_chars: int = DEFAULT_MAX_OUTPUT_CHARS) -> str:
if len(text) <= max_chars:
"""Cap a per-stream output block; embed a truncate token when cut.
``label`` (stdout/stderr/batch) is kept for call-site clarity but the
marker itself is the uniform ``[Truncated (N chars max.; M omitted).
truncate_token=...]`` (no label), matching every other truncation site.
Returns ``text`` unchanged when it fits.
"""
del label
if max_chars < 1 or len(text) <= max_chars:
return text
return text[:max_chars] + f"\n...[{label} truncated]"
from plyngent.tools.truncate_token import truncate_generic
return truncate_generic(text, max_chars)
def format_command_result(
@@ -83,7 +93,7 @@ async def execute_argv(
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
mix_stderr: bool = False,
) -> CommandStepResult:
"""Run one argv command; no shell. Used by run_command and run_command_batch."""
"""Run one argv command; no shell. Used by run_argv and run_argv_batch."""
if not command:
msg = "command argv must not be empty"
raise WorkspaceError(msg)
@@ -1,7 +1,7 @@
from __future__ import annotations
from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import WorkspaceError
from plyngent.tools.workspace import WorkspaceError, argv_shape_error
from .command_exec import (
DEFAULT_TIMEOUT_SECONDS,
@@ -11,18 +11,21 @@ from .command_exec import (
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
async def run_command(
command: list[str],
async def run_argv(
argv: list[str],
*,
cwd: str = ".",
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
stdin: str | None = None,
env: dict[str, str] | None = None,
) -> str:
"""Run ``command`` (argv, no shell) under the workspace; capture stdout/stderr."""
"""Run ``argv`` (argv, no shell) under the workspace; capture stdout/stderr."""
shape_error = argv_shape_error(argv)
if shape_error is not None:
return f"error: `run_argv` {shape_error}"
try:
result = await execute_argv(
command,
argv,
cwd=cwd,
env=env,
stdin=stdin,
@@ -50,23 +50,23 @@ def _as_str_dict(value: object) -> dict[str, str] | None:
def _parse_step(raw: object, index: int) -> dict[str, Any]:
if not isinstance(raw, dict):
msg = f"commands[{index}] must be an object"
msg = f"steps[{index}] must be an object"
raise WorkspaceError(msg)
data = cast("dict[str, object]", raw)
command = data.get("command")
if not isinstance(command, list) or not command:
msg = f"commands[{index}].command must be a non-empty argv list"
argv = data.get("argv")
if not isinstance(argv, list) or not argv:
msg = f"steps[{index}].argv must be a non-empty argv list"
raise WorkspaceError(msg)
command_parts = cast("list[object]", command)
argv = [part for part in command_parts if isinstance(part, str)]
if len(argv) != len(command_parts):
msg = f"commands[{index}].command must be a list of strings"
argv_parts = cast("list[object]", argv)
argv_str = [part for part in argv_parts if isinstance(part, str)]
if len(argv_str) != len(argv_parts):
msg = f"steps[{index}].argv must be a list of strings"
raise WorkspaceError(msg)
cwd = data.get("cwd")
stdin = data.get("stdin")
stop = data.get("stop_on_error", None)
return {
"command": argv,
"argv": argv_str,
"cwd": str(cwd) if isinstance(cwd, str) and cwd else None,
"env": _as_str_dict(data.get("env")),
"stdin": None if stdin is None else str(stdin),
@@ -77,18 +77,18 @@ def _parse_step(raw: object, index: int) -> dict[str, Any]:
}
def _normalize_commands(commands: list[dict[str, object]] | str) -> list[object]:
if isinstance(commands, str):
def _normalize_steps(steps: list[dict[str, object]] | str) -> list[object]:
if isinstance(steps, str):
try:
loaded: object = json.loads(commands)
loaded: object = json.loads(steps)
except json.JSONDecodeError as exc:
msg = f"commands must be a JSON array: {exc}"
msg = f"steps must be a JSON array: {exc}"
raise WorkspaceError(msg) from exc
if not isinstance(loaded, list):
msg = "commands must be a JSON array of step objects"
msg = "steps must be a JSON array of step objects"
raise WorkspaceError(msg)
return cast("list[object]", loaded)
return cast("list[object]", commands)
return cast("list[object]", steps)
def _format_step(index: int, result: CommandStepResult, *, pipe_out: bool) -> str:
@@ -131,7 +131,7 @@ async def _run_batch_steps(
for index, step in enumerate(steps):
stdin_text = prev_capture if prev_piped else step["stdin"]
result = await execute_argv(
step["command"],
step["argv"],
cwd=step["cwd"] if step["cwd"] is not None else cwd,
env=_merge_env(env, step["env"]),
stdin=stdin_text,
@@ -155,8 +155,8 @@ async def _run_batch_steps(
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
async def run_command_batch(
commands: list[dict[str, object]] | str,
async def run_argv_batch(
steps: list[dict[str, object]] | str,
*,
cwd: str = ".",
env: dict[str, str] | None = None,
@@ -164,10 +164,10 @@ async def run_command_batch(
) -> str:
"""Run a serial batch of argv commands (no shell).
Each element of ``commands`` is an object::
Each element of ``steps`` is an object::
{
"command": ["git", "status"], # required argv
"argv": ["git", "status"], # required argv
"cwd": ".", # optional, else batch cwd
"env": {"FOO": "1"}, # optional overlay
"stdin": "...", # optional; unused if previous pipe_out
@@ -181,15 +181,15 @@ async def run_command_batch(
Default ``stop_on_error`` is true. Max 20 steps; total output budget 32k chars.
"""
try:
raw_steps = _normalize_commands(commands)
raw_steps = _normalize_steps(steps)
if not raw_steps:
return "error: commands must not be empty"
return "error: steps must not be empty"
if len(raw_steps) > DEFAULT_MAX_BATCH_STEPS:
return f"error: at most {DEFAULT_MAX_BATCH_STEPS} commands per batch"
return f"error: at most {DEFAULT_MAX_BATCH_STEPS} steps per batch"
steps = [_parse_step(item, i) for i, item in enumerate(raw_steps)]
if steps[-1]["pipe_out"]:
return "error: last command has pipe_out=true but there is no next step"
return "error: last step has pipe_out=true but there is no next step"
results, stopped_early = await _run_batch_steps(steps, cwd=cwd, env=env, stop_on_error=stop_on_error)
body = "\n".join(
+1 -1
View File
@@ -48,7 +48,7 @@ async def _with_todo_stack(mutator: Callable[[TodoStack], str]) -> str:
return result
@tool(name="todo_list", tags=ToolTag.LOCAL | ToolTag.PUBLIC | ToolTag.SESSION_STATE)
@tool(name="todo_list", tags=ToolTag.LOCAL | ToolTag.PUBLIC | ToolTag.SESSION_STATE | ToolTag.READ_ONLY)
async def todo_list() -> str:
"""Show the LIFO stack of **task groups** (TOP group = current breakdown level).
+156
View File
@@ -0,0 +1,156 @@
"""Truncate-token cursors for resuming truncated tool results.
Every truncation site (the agent loop's generic tool-result cap,
``run_command`` / ``run_command_batch`` per-stream caps, ``read_file``,
``fetch``, and request-time compact shrinks) appends the same compact marker
when output is cut short::
[Truncated (32000 chars max.; 500 omitted). truncate_token=...]
The model passes the token to ``get_truncated`` to read the next chunk without
re-requesting the whole source or raising limits. Tokens chain: each truncated
chunk carries a fresh token, so ``get_truncated`` keeps resuming until the
source is exhausted (then no marker is emitted).
Resumable sources (``file`` / ``http``) are re-read by ``location`` +
``offset``. Arbitrary tool output (run_command stdout, todo renders, …) has no
resumable source, so its remainder lives in a short-lived in-memory store
(``kind="memory"``) that is forgotten when the agent process exits — the model
must re-run the tool after that.
"""
from __future__ import annotations
import base64
import uuid
from typing import Literal
import msgspec
type TruncateKind = Literal["file", "http", "memory"]
class TruncateToken(msgspec.Struct, frozen=True):
"""Opaque cursor into a truncated source.
``offset``/``limit`` are in characters (http bodies, memory remainders) or
lines (files is line-based via ``read_file``); ``get_truncated`` interprets
by ``kind``. For ``memory``, ``location`` is a key into the in-memory
remainder store and ``offset``/``limit`` index characters of that stored
remainder. ``numbered`` marks file tokens from ``read_file(with_lineno)``:
the continuation resumes the numbered view instead of raw text.
"""
kind: TruncateKind
location: str
offset: int
limit: int
numbered: bool = False
# In-memory remainders for generic (non-resumable) tool output. Process-lifetime
# only: forgotten when the agent exits. Single-user CLI → one process per agent.
_MEMORY_REMAINDERS: dict[str, str] = {}
def store_remainder(text: str) -> str:
"""Store truncated-away output; return its opaque store key."""
key = f"mem-{uuid.uuid4().hex[:12]}"
_MEMORY_REMAINDERS[key] = text
return key
def get_remainder(key: str) -> str | None:
"""Fetch a stored remainder by key; None when expired/unknown."""
return _MEMORY_REMAINDERS.get(key)
def encode_truncate_token(token: TruncateToken) -> str:
"""Base64url-encode a token so it is safe inside tool-result text."""
payload = msgspec.json.encode(token)
return base64.urlsafe_b64encode(payload).decode("ascii")
def decode_truncate_token(text: str) -> TruncateToken | None:
"""Decode a token; None when malformed (model garbage or tampering)."""
try:
raw = base64.urlsafe_b64decode(text.encode("ascii"))
token = msgspec.json.decode(raw, type=TruncateToken)
except ValueError, msgspec.DecodeError:
return None
if token.offset < 0 or token.limit < 1:
return None
return token
def truncation_marker(max_chars: int, omitted: int, token: TruncateToken) -> str:
"""The single truncation hint: ``[Truncated (N chars max.; M omitted). truncate_token=...]``."""
return f"\n[Truncated ({max_chars} chars max.; {omitted} omitted). truncate_token={encode_truncate_token(token)}]"
def truncate_with_token(
text: str,
max_chars: int,
*,
kind: TruncateKind,
location: str,
offset: int,
limit: int,
total_len: int,
emit_more_after: bool = True,
) -> tuple[str, TruncateToken | None]:
"""Bound ``text`` (a slice of the full source starting at ``offset``) to ``max_chars``.
Returns ``(bounded_text, next_token)``. A token is embedded whenever the
returned chunk is cut (overflow) or, with ``emit_more_after`` (default),
whenever more source content remains after the chunk — so ``get_truncated``
chains through truncations. The token advances by the length actually
returned (no content is skipped), and the marker is budgeted so the total
returned length stays near ``max_chars`` (the agent loop's own cap never
re-cuts it and never strips the token). ``omitted`` in the marker counts
source characters not yet delivered. When nothing was cut and
(``emit_more_after=False`` or the source ends), ``text`` is returned
unchanged with no marker.
"""
if max_chars < 1:
return text, None
if len(text) > max_chars:
# First pass sizes the marker with an offset+max_chars token; re-encode
# with the real cut so the cursor lands exactly where content stops.
first = TruncateToken(kind=kind, location=location, offset=offset + max_chars, limit=limit)
omitted = total_len - (offset + max_chars)
cut = max_chars - len(truncation_marker(max_chars, omitted, first))
if cut < 1:
return text[:max_chars] + truncation_marker(max_chars, omitted, first), first
token = TruncateToken(kind=kind, location=location, offset=offset + cut, limit=limit)
return text[:cut] + truncation_marker(max_chars, omitted, token), token
if not emit_more_after:
return text, None
more_after = offset + len(text) < total_len
if not more_after:
return text, None
omitted = total_len - (offset + len(text))
token = TruncateToken(kind=kind, location=location, offset=offset + len(text), limit=limit)
return text + truncation_marker(max_chars, omitted, token), token
def truncate_generic(text: str, max_chars: int) -> str:
"""Cap arbitrary tool output; embed a memory truncate token (chainable).
The full remainder is kept in the in-memory store so ``get_truncated`` can
resume it. Returns ``text`` unchanged (no marker) when it fits within
``max_chars``.
"""
if max_chars < 1 or len(text) <= max_chars:
return text
key = store_remainder(text)
bounded, _ = truncate_with_token(
text,
max_chars,
kind="memory",
location=key,
offset=0,
limit=max_chars,
total_len=len(text),
)
return bounded
+5 -5
View File
@@ -22,7 +22,7 @@ def _backend_or_error() -> VcsBackend | str:
return backend
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
async def vcs_kind() -> str:
"""Return the detected VCS kind under the workspace (e.g. ``git``), or an error."""
backend = _backend_or_error()
@@ -31,7 +31,7 @@ async def vcs_kind() -> str:
return backend.kind
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
async def vcs_status() -> str:
"""Show working-tree status for the detected VCS (read-only)."""
backend = _backend_or_error()
@@ -40,7 +40,7 @@ async def vcs_status() -> str:
return backend.status()
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
async def vcs_diff(path: str = "", *, staged: bool = False) -> str:
"""Show a unified diff for the detected VCS (read-only).
@@ -54,7 +54,7 @@ async def vcs_diff(path: str = "", *, staged: bool = False) -> str:
return backend.diff(staged=staged, path=rel)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
async def vcs_log(limit: int = 10) -> str:
"""Show recent commits for the detected VCS (read-only)."""
if limit < 1:
@@ -65,7 +65,7 @@ async def vcs_log(limit: int = 10) -> str:
return backend.log(limit=limit)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
async def vcs_branch() -> str:
"""Show the current branch / named head for the detected VCS (read-only)."""
backend = _backend_or_error()
+22 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, cast
if TYPE_CHECKING:
from collections.abc import Callable, Sequence
@@ -268,6 +268,23 @@ def resolve_path(path: str | Path) -> Path:
return resolved
def argv_shape_error(argv: object) -> str | None:
"""Return a model-facing error when ``argv`` is not a non-empty argv list.
Models occasionally pass a shell-style string or a JSON-encoded array as a
string; duck-typing would otherwise let it reach ``create_subprocess_exec``
and splat into single characters. Declared as ``object`` so the isinstance
checks are not flagged as unnecessary.
"""
if isinstance(argv, str):
return 'only accepts an array of args, not a string; pass e.g. ["ls", "-la"]'
if not isinstance(argv, list):
return f"only accepts an array of args, not a {type(argv).__name__}"
if not all(isinstance(part, str) for part in cast("list[object]", argv)):
return "only accepts an array of string args"
return None
def check_command_allowed(argv: list[str]) -> None:
"""Raise if argv is empty or the executable basename is denylisted.
@@ -275,6 +292,10 @@ def check_command_allowed(argv: list[str]) -> None:
installed: the human is asked (with a timeout; default deny). Session grants
skip re-prompting for the same basename. Independent of YOLO soft-confirm.
"""
shape_error = argv_shape_error(argv)
if shape_error is not None:
msg = f"command {shape_error}"
raise WorkspaceError(msg)
if not argv:
msg = "command argv must not be empty"
raise WorkspaceError(msg)
+6 -8
View File
@@ -11,19 +11,17 @@ def test_truncate_tool_result_short() -> None:
def test_truncate_tool_result_long() -> None:
text = "a" * 50
out = truncate_tool_result(text, 20)
assert out.startswith("a" * 20)
assert "Truncated" in out
assert "20" in out
assert "30" in out
assert "configuration file" in out
# Generic tool results now embed a memory token too (resumable via get_truncated).
assert "truncate_token=" in out
assert out.startswith(("a" * 10, "a" * 20)) # marker is budgeted
def test_truncate_tool_result_no_hint() -> None:
text = "a" * 50
out = truncate_tool_result(text, 20, hint=False)
assert out.startswith("a" * 20)
assert "truncated" in out
assert "configuration file" not in out
def test_truncate_tool_result_fits_no_marker() -> None:
assert truncate_tool_result("tiny", 100) == "tiny"
assert "Truncated" not in truncate_tool_result("tiny", 100)
def test_estimate_message_chars() -> None:
+10 -9
View File
@@ -40,19 +40,18 @@ def test_truncate_tool_result_short() -> None:
def test_truncate_tool_result_long() -> None:
text = "a" * 50
out = truncate_tool_result(text, 20)
assert out.startswith("a" * 20)
assert "Truncated" in out
assert "20" in out
assert "30" in out
assert "configuration file" in out
# Generic tool results now embed a memory token too (resumable via get_truncated).
assert "truncate_token=" in out
def test_truncate_tool_result_no_hint() -> None:
text = "a" * 50
out = truncate_tool_result(text, 20, hint=False)
assert out.startswith("a" * 20)
assert "truncated" in out
assert "configuration file" not in out
out = truncate_tool_result(text, 20)
assert "Truncated" in out
assert "truncate_token=" in out
def test_compact_shrinks_old_tool_results() -> None:
@@ -92,7 +91,9 @@ def test_compact_shrinks_old_tool_results() -> None:
)
assert isinstance(compacted[2], ToolChatMessage)
assert len(compacted[2].content) < original_len
assert "truncated" in compacted[2].content
assert "Truncated" in compacted[2].content
# Compact shrinks carry a token too (resumable via get_truncated).
assert "truncate_token=" in compacted[2].content
# Full history unchanged
assert isinstance(messages[2], ToolChatMessage)
assert len(messages[2].content) == original_len
@@ -132,7 +133,7 @@ def test_compact_uses_api_calibration() -> None:
# Raw est under budget → no compact
no_api = compact_messages_for_request(messages, max_tokens=est + 100)
assert isinstance(no_api[1], ToolChatMessage)
assert "truncated" not in no_api[1].content
assert "Truncated" not in no_api[1].content
# Calibrate so measured size is 5x → over a mid budget → shrink old tool
compacted = compact_messages_for_request(
messages,
@@ -145,7 +146,7 @@ def test_compact_uses_api_calibration() -> None:
assert isinstance(compacted[1], ToolChatMessage)
old = messages[1]
assert isinstance(old, ToolChatMessage)
assert "truncated" in compacted[1].content or len(compacted[1].content) < len(old.content)
assert "Truncated" in compacted[1].content or len(compacted[1].content) < len(old.content)
def _response(message: AssistantChatMessage) -> ChatCompletionResponse:
+53
View File
@@ -1,8 +1,13 @@
from __future__ import annotations
from typing import Any
import msgspec
from plyngent.agent.responses_bridge import (
chat_messages_to_responses_input,
chat_param_to_responses_kwargs,
reasoning_summary_text,
response_to_assistant_message,
response_to_chat_completion,
responses_status_to_finish_reason,
@@ -145,6 +150,54 @@ def test_response_to_assistant_with_tools() -> None:
assert completion.choices[0].finish_reason == "tool_calls"
def _reasoning_response(
*,
output_reasoning: bool = False,
top_level: dict[str, Any] | None = None,
) -> Response:
output: list[dict[str, Any]] = []
if output_reasoning:
output.append(
{
"id": "reason_1",
"type": "reasoning",
"summary": [{"type": "summary_text", "text": "openai-style reasoning"}],
}
)
return Response(
id="resp_r",
created_at=1,
model="deepseek-v4-flash",
output=output,
reasoning=top_level,
)
def test_reasoning_summary_text_deepseek_top_level() -> None:
"""DeepSeek Responses returns reasoning in the top-level ``reasoning`` field."""
response = _reasoning_response(top_level={"content": [{"type": "reasoning_content", "text": "think step by step"}]})
assert reasoning_summary_text(response) == "think step by step"
assistant = response_to_assistant_message(response)
assert assistant.reasoning_content == "think step by step"
def test_reasoning_summary_text_prefers_top_level() -> None:
response = _reasoning_response(
output_reasoning=True,
top_level={"summary": [{"type": "summary_text", "text": "top-level reasoning"}]},
)
assert reasoning_summary_text(response) == "top-level reasoning"
def test_reasoning_summary_text_falls_back_to_output_items() -> None:
response = _reasoning_response(output_reasoning=True)
assert reasoning_summary_text(response) == "openai-style reasoning"
def test_reasoning_summary_text_empty_when_absent() -> None:
assert reasoning_summary_text(_reasoning_response()) == ""
def test_responses_status_to_finish_reason_incomplete() -> None:
body = {
"id": "resp_i",
+75 -1
View File
@@ -177,13 +177,87 @@ async def test_dispatch_responses_stream_text_and_usage() -> None:
)
stream = await dispatch_responses(cast("Any", client), param, stream=True)
chunks = [chunk async for chunk in cast("Any", stream)]
# text deltas + finish + usage
texts = [c.choices[0].delta.content for c in chunks if c.choices and isinstance(c.choices[0].delta.content, str)]
assert texts == ["stream", "ed"]
assert any(c.choices and c.choices[0].finish_reason == "stop" for c in chunks)
assert any(token_usage_present(c) for c in chunks)
async def _streamed_reasoning(
events: list[ResponseStreamEvent],
) -> str:
"""Run one Responses stream and join streamed ``reasoning_content`` deltas.
Mirrors the agent loop: ``.done`` (``reasoning_full``) replaces accumulated
fragments instead of appending.
"""
import msgspec
final = _completed_response(text="ok")
final.reasoning = {"content": [{"type": "reasoning_content", "text": "terminal reasoning"}]}
events.append(ResponseStreamEvent(type="response.completed", response=msgspec.to_builtins(final)))
client = ScriptedResponsesClient(stream_events=[events])
param = ChatCompletionsParam(model="gpt-test", messages=[UserChatMessage(content="hi")])
stream = await dispatch_responses(cast("Any", client), param, stream=True)
chunks = [chunk async for chunk in cast("Any", stream)]
parts: list[str] = []
for chunk in chunks:
if not chunk.choices:
continue
delta = chunk.choices[0].delta
if isinstance(delta.reasoning_content, str) and delta.reasoning_content:
if delta.reasoning_full is True:
parts = [delta.reasoning_content]
else:
parts.append(delta.reasoning_content)
return "".join(parts)
async def test_dispatch_responses_stream_reasoning_text_done() -> None:
"""DeepSeek ``response.reasoning_text.done`` carries the full reasoning text."""
reasoning = await _streamed_reasoning(
[ResponseStreamEvent(type="response.reasoning_text.done", delta="full chain of thought")]
)
assert reasoning == "full chain of thought"
async def test_dispatch_responses_stream_reasoning_deltas() -> None:
reasoning = await _streamed_reasoning(
[
ResponseStreamEvent(type="response.reasoning_text.delta", delta="step one "),
ResponseStreamEvent(type="response.reasoning_text.delta", delta="step two"),
]
)
assert reasoning == "step one step two"
async def test_dispatch_responses_stream_reasoning_full_replaces_fragments() -> None:
"""A ``.done`` event with the full CoT must replace streamed fragments, not
append — otherwise the chain-of-thought is concatenated twice."""
reasoning = await _streamed_reasoning(
[
ResponseStreamEvent(type="response.reasoning_text.delta", delta="partial "),
ResponseStreamEvent(type="response.reasoning_text.delta", delta="thinking"),
ResponseStreamEvent(type="response.reasoning_text.done", delta="full chain of thought"),
]
)
assert reasoning == "full chain of thought"
async def test_dispatch_responses_stream_terminal_only_reasoning() -> None:
"""DeepSeek sometimes delivers reasoning only on the terminal response."""
reasoning = await _streamed_reasoning([])
assert reasoning == "terminal reasoning"
async def test_dispatch_responses_stream_reasoning_no_duplicate() -> None:
"""Streamed deltas win; the terminal reasoning is not re-emitted."""
reasoning = await _streamed_reasoning(
[ResponseStreamEvent(type="response.reasoning_text.delta", delta="streamed only")]
)
assert reasoning == "streamed only"
def token_usage_present(chunk: object) -> bool:
usage = getattr(chunk, "usage", UNSET)
return usage is not UNSET and usage is not None
+88
View File
@@ -209,3 +209,91 @@ async def test_run_aside_tools_clones_registry_with_fresh_session() -> None:
pass
assert hits == ["ok"]
assert "aside" not in main_session.extras
async def test_run_aside_read_only_clone_excludes_write_tools() -> None:
"""A read-only-cloned registry passed to run_aside exposes no write tools."""
hits: list[str] = []
@tool(tags=ToolTag.LOCAL | ToolTag.READ_ONLY, register=False)
async def peek() -> str:
hits.append("peek")
return "peeked"
@tool(tags=ToolTag.LOCAL, register=False)
async def write_thing() -> str:
hits.append("write")
return "wrote"
registry = ToolRegistry([peek, write_thing], auto_bind_state=True)
def make_client(first_tool: str | None) -> Any:
calls = 0
class _Client:
@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, param
nonlocal calls
calls += 1
if calls == 1 and first_tool is not None:
from plyngent.lmproto.openai_compatible.model import (
AssistantFunctionTool,
AssistantFunctionToolCall,
)
message = AssistantChatMessage(
content="",
tool_calls=[
AssistantFunctionToolCall(
id="c1",
function=AssistantFunctionTool(name=first_tool, arguments="{}"),
)
],
)
finish = "tool_calls"
else:
message = AssistantChatMessage(content="done")
finish = "stop"
return ChatCompletionResponse(
id="1",
object="chat.completion",
created=0,
model="m",
choices=[
ChatCompletionChoice(
index=0,
message=message,
logprobs={},
finish_reason=finish,
)
],
system_fingerprint="",
usage={},
)
return _Client()
# Read-only tool is available to the side turn.
agent_peek = ChatAgent(make_client("peek"), model="m", tools=registry, stream=False)
async for _ in agent_peek.run_aside("go", tools=registry.clone(read_only_only=True)):
pass
assert hits == ["peek"]
# Write tool is not in the read-only clone: the call errors, never runs.
hits.clear()
agent_write = ChatAgent(make_client("write_thing"), model="m", tools=registry, stream=False)
async for _ in agent_write.run_aside("go", tools=registry.clone(read_only_only=True)):
pass
assert hits == []
+414 -3
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from plyngent.agent import ReasoningDeltaEvent, TextDeltaEvent, ToolResultEvent
from plyngent.agent import ReasoningDeltaEvent, TextDeltaEvent, ToolCallEvent, ToolResultEvent
from plyngent.cli.display import (
get_markdown_enabled,
markdown_render_available,
@@ -11,7 +11,11 @@ from plyngent.cli.display import (
set_markdown_enabled,
set_verbose_tool_results,
)
from plyngent.lmproto.openai_compatible.model import ToolChatMessage
from plyngent.lmproto.openai_compatible.model import (
AssistantFunctionTool,
AssistantFunctionToolCall,
ToolChatMessage,
)
if TYPE_CHECKING:
from collections.abc import AsyncIterator
@@ -61,7 +65,7 @@ async def test_flush_markdown_on_source_change(
call = AssistantFunctionToolCall(
id="1",
function=AssistantFunctionTool(name="read_file", arguments="{}"),
function=AssistantFunctionTool(name="vcs_diff", arguments='{"path": ""}'),
)
await render_events(
_aiter(
@@ -78,6 +82,111 @@ async def test_flush_markdown_on_source_change(
assert "after" in out
def _read_call(args_json: str) -> ToolCallEvent:
return ToolCallEvent(
tool_call=AssistantFunctionToolCall(
id="1",
function=AssistantFunctionTool(name="read_file", arguments=args_json),
)
)
def _result(content: str) -> ToolResultEvent:
return ToolResultEvent(message=ToolChatMessage(content=content, tool_call_id="1"))
async def test_pretty_read_file_done(capsys: pytest.CaptureFixture[str]) -> None:
await render_events(_aiter([_read_call('{"path": "a.txt"}'), _result("L1-4\none\ntwo\n")]))
out = capsys.readouterr().out
assert "* Read 'a.txt' (done)" in out
assert "[tool]" not in out
assert "[tool ok]" not in out
async def test_pretty_read_file_statuses(capsys: pytest.CaptureFixture[str]) -> None:
cases = [
("error: file not found: a.txt", "(file not found)"),
("error: not a file: a.txt", "(not a file)"),
("error: something else", "(error)"),
("no header content", "(done)"),
]
for content, status in cases:
await render_events(_aiter([_read_call('{"path": "a.txt"}'), _result(content)]))
out = capsys.readouterr().out
assert f"* Read 'a.txt' {status}" in out, f"status {status} not rendered for {content!r}"
def _tree_call(args_json: str) -> ToolCallEvent:
return ToolCallEvent(
tool_call=AssistantFunctionToolCall(
id="1",
function=AssistantFunctionTool(name="tree", arguments=args_json),
)
)
async def test_pretty_tree_markdown(capsys: pytest.CaptureFixture[str]) -> None:
result = "- src/\n - main.py\n - nested/\n - deep.txt\n- a.txt\n"
await render_events(_aiter([_tree_call('{"path": "."}'), _result(result)]))
out = capsys.readouterr().out
assert "* Tree '.' (2 dirs, 3 files, depth 3)" in out
assert "[tool]" not in out
async def test_pretty_tree_flat_omits_depth(capsys: pytest.CaptureFixture[str]) -> None:
result = "src/\nsrc/main.py\na.txt\n… (5 more paths not shown)"
await render_events(_aiter([_tree_call('{"path": ".", "format": "flat"}'), _result(result)]))
out = capsys.readouterr().out
assert "* Tree '.' (1 dirs, 2 files)" in out
assert "depth" not in out
async def test_pretty_tree_decorated(capsys: pytest.CaptureFixture[str]) -> None:
result = "src/\n├── main.py\n└── nested/\n └── deep.txt\n"
await render_events(_aiter([_tree_call('{"path": "src", "format": "decorated"}'), _result(result)]))
out = capsys.readouterr().out
assert "* Tree 'src' (1 dirs, 2 files, depth 2)" in out
async def test_pretty_tree_error(capsys: pytest.CaptureFixture[str]) -> None:
await render_events(_aiter([_tree_call('{"path": "nope"}'), _result("error: not a directory: nope")]))
out = capsys.readouterr().out
assert "* Tree 'nope' (error)" in out
async def test_pretty_todo_push(capsys: pytest.CaptureFixture[str]) -> None:
call = ToolCallEvent(
tool_call=AssistantFunctionToolCall(
id="2",
function=AssistantFunctionTool(name="todo_push", arguments='{"titles": ["T1"]}'),
)
)
result = (
"pushed group (depth=1) items=[a1]\n"
"(LIFO of groups: depth=1; TOP group = current breakdown level)\n"
"group d=0 TOP:\n"
" [ ] a1: T1"
)
await render_events(_aiter([call, _result(result)]))
out = capsys.readouterr().out
assert "* Todo Push:" in out
assert " [ ] a1: T1" in out
assert "[tool]" not in out
async def test_non_pretty_tool_keeps_old_style(capsys: pytest.CaptureFixture[str]) -> None:
call = ToolCallEvent(
tool_call=AssistantFunctionToolCall(
id="3",
function=AssistantFunctionTool(name="vcs_diff", arguments='{"path": ""}'),
)
)
await render_events(_aiter([call, _result("diff --git a/x b/x")]))
out = capsys.readouterr().out
assert "[tool] vcs_diff(" in out
assert "[tool ok]" in out
async def test_tool_result_preview_vs_verbose(capsys: pytest.CaptureFixture[str]) -> None:
long = "x" * 200
msg = ToolChatMessage(content=long, tool_call_id="1")
@@ -139,3 +248,305 @@ def test_markdown_render_available_respects_plain_env(monkeypatch: pytest.Monkey
monkeypatch.setenv("PLYNGENT_PLAIN", "1")
assert markdown_render_available() is False
monkeypatch.delenv("PLYNGENT_PLAIN", raising=False)
async def test_pretty_verbose_shows_full_result(capsys: pytest.CaptureFixture[str]) -> None:
"""Verbose mode prints the full tool result even for pretty tools."""
await render_events(
_aiter([_read_call('{"path": "a.txt"}'), _result("L1-4\none\ntwo\n")]),
verbose=True,
)
out = capsys.readouterr().out
assert "[tool ok]" in out
assert "L1-4\none\ntwo" in out
assert "* Read" not in out
async def test_pretty_plain_output_has_no_ansi(capsys: pytest.CaptureFixture[str]) -> None:
"""Pretty summaries are plain text when stdout is not a TTY."""
await render_events(_aiter([_read_call('{"path": "a.txt"}'), _result("L1-4\none\n")]))
out = capsys.readouterr().out
assert "\x1b[" not in out
async def test_tool_result_preview_first_line_and_count(capsys: pytest.CaptureFixture[str]) -> None:
"""Non-verbose preview shows the first line plus a line-count tail."""
set_markdown_enabled(False)
content = "first line\nsecond\nthird"
await render_events(_aiter([ToolResultEvent(message=ToolChatMessage(content=content, tool_call_id="1"))]))
out = capsys.readouterr().out
assert "[tool ok] first line (…2 more lines)" in out
await render_events(_aiter([ToolResultEvent(message=ToolChatMessage(content="only line", tool_call_id="2"))]))
out = capsys.readouterr().out
assert "[tool ok] only line" in out
assert "more" not in out
await render_events(_aiter([ToolResultEvent(message=ToolChatMessage(content="", tool_call_id="3"))]))
out = capsys.readouterr().out
assert "[tool ok] (no output)" in out
set_markdown_enabled(True)
def _pretty_call(name: str, args_json: str) -> ToolCallEvent:
return ToolCallEvent(
tool_call=AssistantFunctionToolCall(
id="1",
function=AssistantFunctionTool(name=name, arguments=args_json),
)
)
async def test_pretty_listdir(capsys: pytest.CaptureFixture[str]) -> None:
result = "dir\tsrc\nfile\tREADME.md\nfile\tpyproject.toml\n"
await render_events(_aiter([_pretty_call("listdir", '{"path": "src"}'), _result(result)]))
out = capsys.readouterr().out
assert "* List 'src' (1 dirs, 2 files)" in out
assert "[tool]" not in out
assert "[tool ok]" not in out
async def test_pretty_listdir_empty(capsys: pytest.CaptureFixture[str]) -> None:
await render_events(_aiter([_pretty_call("listdir", '{"path": "src"}'), _result("(empty)")]))
out = capsys.readouterr().out
assert "* List 'src' (empty)" in out
async def test_pretty_listdir_error(capsys: pytest.CaptureFixture[str]) -> None:
await render_events(_aiter([_pretty_call("listdir", '{"path": "nope"}'), _result("error: not a directory: nope")]))
out = capsys.readouterr().out
assert "* List 'nope' (error: not a directory: nope)" in out
async def test_pretty_glob(capsys: pytest.CaptureFixture[str]) -> None:
result = "src/a.py\nsrc/b.py\n"
await render_events(_aiter([_pretty_call("glob_paths", '{"pattern": "**/*.py"}'), _result(result)]))
out = capsys.readouterr().out
assert "* Glob '**/*.py' in '.' (2 paths)" in out
async def test_pretty_glob_no_matches(capsys: pytest.CaptureFixture[str]) -> None:
await render_events(_aiter([_pretty_call("glob_paths", '{"pattern": "**/*.rs"}'), _result("(no matches)")]))
out = capsys.readouterr().out
assert "* Glob '**/*.rs' in '.' (no matches)" in out
async def test_pretty_glob_truncated(capsys: pytest.CaptureFixture[str]) -> None:
result = "a\nb\n...[truncated at 200 matches]"
await render_events(_aiter([_pretty_call("glob_paths", '{"pattern": "*"}'), _result(result)]))
out = capsys.readouterr().out
assert "* Glob '*' in '.' (2 paths)" in out
async def test_pretty_grep(capsys: pytest.CaptureFixture[str]) -> None:
result = "src/a.py:3: x = 1\nsrc/a.py:9: x = 2\nsrc/b.py:1: y = 3\n"
await render_events(_aiter([_pretty_call("grep_files", '{"pattern": "x"}'), _result(result)]))
out = capsys.readouterr().out
assert "* Grep 'x' in '.' (3 matches in 2 files)" in out
async def test_pretty_grep_no_matches(capsys: pytest.CaptureFixture[str]) -> None:
await render_events(_aiter([_pretty_call("grep_files", '{"pattern": "zzz"}'), _result("(no matches)")]))
out = capsys.readouterr().out
assert "* Grep 'zzz' in '.' (no matches)" in out
async def test_pretty_run_argv_success(capsys: pytest.CaptureFixture[str]) -> None:
result = (
"exit_code=0\ntimed_out=false\ncwd=.\ncmd=git status --short\n--- stdout ---\n M src/foo.py\n--- stderr ---\n"
)
await render_events(_aiter([_pretty_call("run_argv", '{"argv": ["git", "status", "--short"]}'), _result(result)]))
out = capsys.readouterr().out
assert "* Run $ git status --short (exit code 0)" in out
assert "[tool]" not in out
assert "[tool ok]" not in out
async def test_pretty_run_argv_failure(capsys: pytest.CaptureFixture[str]) -> None:
result = (
"exit_code=1\ntimed_out=false\ncwd=.\ncmd=ls /nope\n--- stdout ---\n--- stderr ---\nls: cannot access '/nope'\n"
)
await render_events(_aiter([_pretty_call("run_argv", '{"argv": ["ls", "/nope"]}'), _result(result)]))
out = capsys.readouterr().out
assert "* Run $ ls /nope (exit code 1)" in out
async def test_pretty_run_argv_timed_out(capsys: pytest.CaptureFixture[str]) -> None:
result = "exit_code=\ntimed_out=true\ncwd=.\ncmd=sleep 10\n--- stdout ---\n--- stderr ---\n"
await render_events(_aiter([_pretty_call("run_argv", '{"argv": ["sleep", "10"]}'), _result(result)]))
out = capsys.readouterr().out
assert "* Run $ sleep 10 (timed out)" in out
async def test_pretty_run_argv_error(capsys: pytest.CaptureFixture[str]) -> None:
await render_events(
_aiter([_pretty_call("run_argv", '{"argv": ["nope"]}'), _result("error: executable not found: 'nope'")])
)
out = capsys.readouterr().out
assert "* Run $ nope (error: executable not found: 'nope')" in out
async def test_pretty_run_argv_batch_stopped(capsys: pytest.CaptureFixture[str]) -> None:
result = (
"steps=3 ran=2 stop_on_error=true stopped_early=true\n"
"--- step 1 ---\nexit_code=1\ntimed_out=false\ncwd=.\ncmd=git status\n"
"pipe_out=false\nmix_stderr=false\n--- stdout ---\n--- stderr ---\n"
"--- step 2 ---\nexit_code=0\ntimed_out=false\ncwd=.\ncmd=git diff\n"
"pipe_out=false\nmix_stderr=false\n--- stdout ---\n--- stderr ---\n"
"--- summary ---\nlast_exit=1\n"
)
await render_events(_aiter([_pretty_call("run_argv_batch", '{"steps": []}'), _result(result)]))
out = capsys.readouterr().out
assert "* Run batch (stopped early)" in out
async def test_pretty_run_argv_batch_complete(capsys: pytest.CaptureFixture[str]) -> None:
result = (
"steps=2 ran=2 stop_on_error=true stopped_early=false\n"
"--- step 1 ---\nexit_code=0\n--- stdout ---\n--- stderr ---\n"
"--- step 2 ---\nexit_code=0\n--- stdout ---\n--- stderr ---\n"
"--- summary ---\nlast_exit=0\n"
)
await render_events(_aiter([_pretty_call("run_argv_batch", '{"steps": []}'), _result(result)]))
out = capsys.readouterr().out
assert "* Run batch (done)" in out
async def test_pretty_fetch(capsys: pytest.CaptureFixture[str]) -> None:
result = (
"status=200\nmethod=GET\nfinal_url=https://example.com\ncontent_type=application/json\n"
"body_kind=json\nbytes=1229\ntruncated=false\nredirects=0\nsecurity=public\n--- body ---\n{...}\n"
)
await render_events(_aiter([_pretty_call("fetch", '{"url": "https://example.com"}'), _result(result)]))
out = capsys.readouterr().out
assert "* Fetch GET https://example.com (200)" in out
assert "[tool]" not in out
async def test_pretty_fetch_error_status(capsys: pytest.CaptureFixture[str]) -> None:
result = (
"status=404\nmethod=GET\nfinal_url=https://example.com/nope\ncontent_type=text/html\n"
"body_kind=html\nbytes=45\ntruncated=false\nredirects=0\nsecurity=public\n--- body ---\nNot found\n"
)
await render_events(_aiter([_pretty_call("fetch", '{"url": "https://example.com/nope"}'), _result(result)]))
out = capsys.readouterr().out
assert "* Fetch GET https://example.com/nope (404)" in out
async def test_pretty_fetch_truncated(capsys: pytest.CaptureFixture[str]) -> None:
result = (
"status=200\nmethod=GET\nfinal_url=https://example.com\ncontent_type=text/plain\n"
"body_kind=text\nbytes=50000\ntruncated=true\nredirects=0\nsecurity=public\n--- body ---\n..."
)
await render_events(_aiter([_pretty_call("fetch", '{"url": "https://example.com"}'), _result(result)]))
out = capsys.readouterr().out
assert "* Fetch GET https://example.com (200)" in out
async def test_pretty_fetch_error(capsys: pytest.CaptureFixture[str]) -> None:
await render_events(
_aiter([_pretty_call("fetch", '{"url": "https://example.com"}'), _result("error: fetch failed: boom")])
)
out = capsys.readouterr().out
assert "* Fetch GET https://example.com (error: fetch failed: boom)" in out
async def test_pretty_mutators(capsys: pytest.CaptureFixture[str]) -> None:
cases = [
("write_file", '{"path": "src/x.py"}', "wrote 120 characters to src/x.py", "* Write 'src/x.py' (120 chars)"),
(
"edit_replace",
'{"path": "src/a.py"}',
"replaced 1 occurrence in src/a.py ('x' → 'y')",
"* Edit 'src/a.py' (done)",
),
(
"edit_lineno",
'{"path": "src/a.py"}',
"replaced lines 3-5 (3 lines; first: 'pass') with 2 lines in src/a.py",
"* Edit 'src/a.py' (done)",
),
(
"copy_path",
'{"src": "a.txt", "dst": "b.txt"}',
"copied file a.txt -> b.txt",
"* Copy 'a.txt' → 'b.txt' (done)",
),
("move_path", '{"src": "src", "dst": "dst"}', "moved directory src -> dst", "* Move 'src' → 'dst' (done)"),
("delete_path", '{"path": "tmp/x.txt"}', "deleted file tmp/x.txt", "* Delete 'tmp/x.txt' (done)"),
]
for name, args, content, expected in cases:
await render_events(_aiter([_pretty_call(name, args), _result(content)]))
out = capsys.readouterr().out
assert expected in out, f"{name}: expected {expected!r} in {out!r}"
assert "[tool ok]" not in out
async def test_pretty_mutator_error(capsys: pytest.CaptureFixture[str]) -> None:
await render_events(
_aiter([_pretty_call("copy_path", '{"src": "x", "dst": "y"}'), _result("error: source does not exist: x")])
)
out = capsys.readouterr().out
assert "* Copy 'x' → 'y' (error: source does not exist: x)" in out
async def test_pretty_vcs_status_done(capsys: pytest.CaptureFixture[str]) -> None:
await render_events(_aiter([_pretty_call("vcs_status", "{}"), _result("(clean)")]))
out = capsys.readouterr().out
assert "* VCS Status (done)" in out
assert "[tool]" not in out
async def test_pretty_vcs_status_error(capsys: pytest.CaptureFixture[str]) -> None:
await render_events(
_aiter(
[
_pretty_call("vcs_status", "{}"),
_result("error: no supported VCS detected under workspace (currently: git)"),
]
)
)
out = capsys.readouterr().out
assert "* VCS Status (error: no supported VCS detected under workspace (currently: git))" in out
async def test_pretty_vcs_log_count(capsys: pytest.CaptureFixture[str]) -> None:
result = (
"5436457 2026-08-15 worldmozara core/tools: allow edit_lineno append\nabc1234 2026-08-14 worldmozara cli: fix\n"
)
await render_events(_aiter([_pretty_call("vcs_log", '{"limit": 2}'), _result(result)]))
out = capsys.readouterr().out
assert "* VCS Log (2 commits)" in out
async def test_pretty_vcs_log_single(capsys: pytest.CaptureFixture[str]) -> None:
content = "abc1234 2026-08-14 worldmozara x\n"
await render_events(_aiter([_pretty_call("vcs_log", '{"limit": 1}'), _result(content)]))
out = capsys.readouterr().out
assert "* VCS Log (1 commit)" in out
async def test_pretty_vcs_log_no_commits(capsys: pytest.CaptureFixture[str]) -> None:
await render_events(_aiter([_pretty_call("vcs_log", "{}"), _result("(no commits)")]))
out = capsys.readouterr().out
assert "* VCS Log (no commits)" in out
async def test_pretty_wait_waited(capsys: pytest.CaptureFixture[str]) -> None:
await render_events(_aiter([_pretty_call("wait", '{"duration": 5}'), _result("waited 5s")]))
out = capsys.readouterr().out
assert "* Wait (5s)" in out
assert "[tool]" not in out
async def test_pretty_wait_disturbed(capsys: pytest.CaptureFixture[str]) -> None:
await render_events(_aiter([_pretty_call("wait", '{"duration": 30}'), _result("disturbed by user: ship it")]))
out = capsys.readouterr().out
assert "* Wait (disturbed)" in out
async def test_pretty_wait_error(capsys: pytest.CaptureFixture[str]) -> None:
content = "error: duration must not be negative"
await render_events(_aiter([_pretty_call("wait", '{"duration": -1}'), _result(content)]))
out = capsys.readouterr().out
assert "* Wait (error: duration must not be negative)" in out
+70
View File
@@ -8,7 +8,9 @@ import pytest
from plyngent.cli.interrupt import (
allow_task_cancel,
install_keyboard_interrupt_sigint,
pause_task_cancel_for_prompt,
raise_keyboard_interrupt_sigint,
run_in_prompt_thread,
set_sigint_reinstall,
)
@@ -113,3 +115,71 @@ async def test_sigint_cancels_after_prompt_pause() -> None:
assert cancelled.is_set()
# Cleanup any leftover reinstall hook from run_cancellable
set_sigint_reinstall(None)
def test_install_keyboard_interrupt_sigint() -> None:
"""Installs a non-default SIGINT handler that raises KeyboardInterrupt.
The handler must be a distinct function object: ``asyncio.run`` only skips
installing its own silent-cancel handler when the current handler is not
``signal.default_int_handler``.
"""
install_keyboard_interrupt_sigint()
try:
handler = signal.getsignal(signal.SIGINT)
assert handler is not signal.default_int_handler
assert handler is not signal.SIG_DFL
assert callable(handler)
with pytest.raises(KeyboardInterrupt):
handler(signal.SIGINT, None) # type: ignore[call-arg]
finally:
signal.signal(signal.SIGINT, signal.default_int_handler)
def test_asyncio_run_keeps_keyboard_interrupt_sigint() -> None:
"""Regression: Runner must not replace our handler with its own.
Previously the first Ctrl+C under ``asyncio.run`` silently cancelled the
main task (invisible at the REPL prompt) and the second raised
KeyboardInterrupt, which also cancelled ``memory.close()`` on a Ctrl+D exit.
With a non-default handler installed first, Runner leaves SIGINT alone.
"""
import asyncio
install_keyboard_interrupt_sigint()
try:
async def quick() -> int:
return 7
assert asyncio.run(quick()) == 7
assert signal.getsignal(signal.SIGINT) is raise_keyboard_interrupt_sigint
finally:
signal.signal(signal.SIGINT, signal.default_int_handler)
async def test_run_cancellable_restores_previous_sigint() -> None:
"""Regression: after a turn SIGINT is restored, not left as SIG_DFL.
``loop.remove_signal_handler`` leaves SIG_DFL, which would terminate the
process on a stray Ctrl+C between turns instead of letting the REPL catch
a KeyboardInterrupt.
"""
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")
previous = signal.getsignal(signal.SIGINT)
async def quick() -> None:
return None
try:
await run_cancellable(quick())
assert signal.getsignal(signal.SIGINT) is previous
assert signal.getsignal(signal.SIGINT) is not signal.SIG_DFL
finally:
signal.signal(signal.SIGINT, signal.default_int_handler)
+3 -3
View File
@@ -30,10 +30,10 @@ def test_prompt_confirm_tool_default_deny() -> None:
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)
reason = "run_argv: python3 -c (review code before allow)\nargv:\n python3 -c print(1)\n-c code:\n print(1)"
box = format_tool_confirm_box("run_argv", reason)
assert "┌" in box and "└" in box
assert "confirm · tool 'run_command'" in box
assert "confirm · tool 'run_argv'" in box
assert "python3 -c" in box
assert "print(1)" in box
# Distinct lines, not one jammed row
+145
View File
@@ -593,3 +593,148 @@ async def test_repl_ctrl_c_from_turn_returns_to_prompt(
await run_repl(state) # must return normally (EOF), never raise KeyboardInterrupt
assert calls["reads"] == 2
async def test_repl_second_ctrl_c_after_cancel_returns_to_prompt(
state: ReplState,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""A second Ctrl+C landing outside the turn call (e.g. while echoing the
next user input, right after a cancel) must return to the prompt, not exit."""
from plyngent.cli import repl as repl_mod
from plyngent.cli.repl import run_repl
reads = ["first", "second", "third"]
calls: dict[str, int] = {"reads": 0, "echoes": 0}
def fake_read_entry() -> str:
calls["reads"] += 1
if calls["reads"] > len(reads):
raise EOFError
return reads[calls["reads"] - 1]
async def cancel_turn(_agent: object, _text: str) -> bool:
raise KeyboardInterrupt # first Ctrl+C: cancels every turn
def echo_user(text: str) -> None:
calls["echoes"] += 1
if calls["echoes"] == 2:
# second Ctrl+C lands while echoing user text — outside the turn
# call, previously unguarded (used to exit the whole REPL).
raise KeyboardInterrupt
monkeypatch.setattr(repl_mod, "setup_readline", lambda _state: None)
monkeypatch.setattr(repl_mod, "read_repl_entry", fake_read_entry)
monkeypatch.setattr(repl_mod, "run_user_text_with_retries", cancel_turn)
monkeypatch.setattr(repl_mod, "_echo_user", echo_user)
await run_repl(state) # must return normally (EOF), never raise KeyboardInterrupt
assert calls["reads"] == 4 # three entries + EOF
assert calls["echoes"] == 3 # the interrupting echo still counted, loop survived
assert "interrupted" in capsys.readouterr().out
async def test_btw_default_read_falls_back_when_tools_off(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
"""Bare /btw (default --tools=read) still works under /tools off: no tools."""
assert state.tools_enabled is False
state.agent.stream = False # DummyClient only completes via non-stream path
assert await handle_slash(state, "/btw hello") is True
out = capsys.readouterr().out
assert "btw: hello" in out
assert "btw done" in out
assert "error" not in out
async def test_btw_explicit_tools_errors_when_off(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
"""Explicitly requesting read/full tools while they are off is an error."""
state.agent.stream = False # DummyClient only completes via non-stream path
assert await handle_slash(state, "/btw --tools read q") is True
assert "error: --tools requires tools on" in capsys.readouterr().out
assert await handle_slash(state, "/btw --tools full q") is True
assert "error: --tools requires tools on" in capsys.readouterr().out
# Explicit no is fine.
assert await handle_slash(state, "/btw --tools no q") is True
out = capsys.readouterr().out
assert "btw done" in out
assert "error" not in out
async def test_btw_read_mode_wires_read_only_clone(
state: ReplState,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""--tools=read passes a read-only clone + read-only context + main session."""
from plyngent.agent import ToolRegistry, ToolTag, tool
from plyngent.tools.context import is_read_only_context
@tool(tags=ToolTag.LOCAL | ToolTag.READ_ONLY, register=False)
async def ro_tool() -> str:
return "ro"
@tool(tags=ToolTag.LOCAL, register=False)
async def w_tool() -> str:
return "w"
state.tools_enabled = True
state.agent.tools = ToolRegistry([ro_tool, w_tool], auto_bind_state=True)
captured: dict[str, object] = {}
async def fake_run_aside(_text: str, **kwargs: object) -> AsyncIterator[object]:
captured["tools"] = kwargs.get("tools")
captured["session_state"] = kwargs.get("session_state")
captured["read_only"] = is_read_only_context()
if False:
yield None
monkeypatch.setattr(state.agent, "run_aside", fake_run_aside)
assert await handle_slash(state, "/btw --tools read hi") is True
assert "btw done" in capsys.readouterr().out
tools = captured["tools"]
assert isinstance(tools, ToolRegistry)
assert {t.name for t in tools.definitions()} == {"ro_tool"}
assert captured["session_state"] is state.session_state
assert captured["read_only"] is True
async def test_btw_full_mode_clones_full_registry(
state: ReplState,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""--tools=full keeps the whole registry with a fresh session, no context."""
from plyngent.agent import ToolRegistry, ToolTag, tool
from plyngent.tools.context import is_read_only_context
@tool(tags=ToolTag.LOCAL | ToolTag.READ_ONLY, register=False)
async def ro_tool() -> str:
return "ro"
@tool(tags=ToolTag.LOCAL, register=False)
async def w_tool() -> str:
return "w"
state.tools_enabled = True
state.agent.tools = ToolRegistry([ro_tool, w_tool], auto_bind_state=True)
captured: dict[str, object] = {}
async def fake_run_aside(_text: str, **kwargs: object) -> AsyncIterator[object]:
captured["tools"] = kwargs.get("tools")
captured["session_state"] = kwargs.get("session_state")
captured["read_only"] = is_read_only_context()
if False:
yield None
monkeypatch.setattr(state.agent, "run_aside", fake_run_aside)
assert await handle_slash(state, "/btw --tools full hi") is True
assert "btw done" in capsys.readouterr().out
assert captured["tools"] is True
assert captured["session_state"] is not state.session_state
assert captured["read_only"] is False
+36
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
import io
import pytest
from plyngent.prompting import (
@@ -7,14 +9,48 @@ from plyngent.prompting import (
FormField,
NonInteractiveBackend,
NonInteractiveError,
_read_line_with_timeout_posix,
ask,
choose,
confirm,
form,
read_line_with_timeout,
temporary_backend,
)
def _ready_poll(r, w, x, t):
return (r, w, x)
def _never_poll(r, w, x, t):
return ([], [], [])
def test_read_line_with_timeout_reads_line() -> None:
stream = io.StringIO("hello\n")
assert _read_line_with_timeout_posix("p> ", 5.0, stream=stream, poll=_ready_poll) == "hello"
def test_read_line_with_timeout_empty_enter() -> None:
stream = io.StringIO("\n")
assert _read_line_with_timeout_posix("p> ", 5.0, stream=stream, poll=_ready_poll) == ""
def test_read_line_with_timeout_timeout_returns_none() -> None:
stream = io.StringIO()
assert _read_line_with_timeout_posix("p> ", 0.01, stream=stream, poll=_never_poll) is None
def test_read_line_with_timeout_eof_returns_none() -> None:
stream = io.StringIO("")
assert _read_line_with_timeout_posix("p> ", 5.0, stream=stream, poll=_ready_poll) is None
def test_read_line_with_timeout_negative_timeout() -> None:
assert read_line_with_timeout("p> ", -1) is None
class ScriptedBackend:
"""Deterministic backend for unit tests."""
+40 -1
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import inspect
from plyngent.agent import ToolTag, tool
from plyngent.agent import ToolRegistry, ToolTag, tool
from plyngent.tools import default_tool_definitions, register_builtin_tools
from plyngent.tools.catalog import ToolCatalog, ToolSource, catalog_scope, get_catalog, registration_source
from plyngent.tools.chat import CHAT_TOOLS
@@ -42,6 +42,45 @@ def test_public_surface_is_subset() -> None:
assert "read_file" not in public
def test_read_only_classification() -> None:
"""READ_ONLY marks exactly the non-mutating builtins."""
register_builtin_tools()
read_only = {t.name for t in default_tool_definitions(surface="local") if t.tags & ToolTag.READ_ONLY}
assert read_only == {
"read_file",
"get_truncated",
"glob_paths",
"grep_files",
"listdir",
"tree",
"todo_list",
"wait",
"ask_user_line",
"ask_user_choice",
"ask_user_form",
"vcs_kind",
"vcs_status",
"vcs_diff",
"vcs_log",
"vcs_branch",
"fetch",
}
def test_registry_clone_read_only_only() -> None:
"""clone(read_only_only=True) keeps only READ_ONLY tools, nothing else."""
register_builtin_tools()
full = ToolRegistry(default_tool_definitions(surface="local"))
ro = full.clone(read_only_only=True)
assert 0 < len(ro) < len(full)
assert {t.name for t in ro.definitions()} == {t.name for t in full.definitions() if t.tags & ToolTag.READ_ONLY}
assert all(t.tags & ToolTag.READ_ONLY for t in ro.definitions())
assert "run_argv" not in ro
assert "edit_replace" not in ro
# Plain clone keeps every tool.
assert len(full.clone()) == len(full)
def test_catalog_scope_empty_isolates_registration() -> None:
with catalog_scope(empty=True) as catalog:
+61 -2
View File
@@ -1,12 +1,15 @@
from __future__ import annotations
import importlib
import json
from plyngent.agent import ToolRegistry
from plyngent.prompting import NonInteractiveBackend, temporary_backend
from plyngent.tools.chat import CHAT_TOOLS, ask_user, choose_user, form_user
from plyngent.tools.chat import CHAT_TOOLS, ask_user, choose_user, form_user, wait
from tests.test_prompting import ScriptedBackend
wait_module = importlib.import_module("plyngent.tools.chat.wait")
async def test_ask_user_tool() -> None:
backend = ScriptedBackend(["42"])
@@ -77,7 +80,7 @@ async def test_form_user_tool() -> None:
async def test_chat_tools_in_default_list() -> None:
names = {t.name for t in CHAT_TOOLS}
assert names == {"ask_user_line", "ask_user_choice", "ask_user_form"}
assert names == {"ask_user_line", "ask_user_choice", "ask_user_form", "wait"}
async def test_ask_user_non_interactive_error() -> None:
@@ -85,3 +88,59 @@ async def test_ask_user_non_interactive_error() -> None:
registry = ToolRegistry([ask_user])
out = await registry.execute("ask_user_line", '{"question": "hi"}')
assert out.startswith("error:")
async def test_wait_tool_zero_seconds(monkeypatch) -> None:
with temporary_backend(NonInteractiveBackend()):
registry = ToolRegistry([wait])
out = await registry.execute("wait", '{"duration": 0}')
assert out == "waited 0s"
async def test_wait_tool_negative_duration() -> None:
with temporary_backend(NonInteractiveBackend()):
registry = ToolRegistry([wait])
out = await registry.execute("wait", '{"duration": -1}')
assert out.startswith("error:")
async def test_wait_tool_noninteractive_sleeps(monkeypatch) -> None:
slept: list[float] = []
async def fake_sleep(seconds: float) -> None:
slept.append(seconds)
monkeypatch.setattr(wait_module.asyncio, "sleep", fake_sleep)
with temporary_backend(NonInteractiveBackend()):
registry = ToolRegistry([wait])
out = await registry.execute("wait", '{"duration": 5}')
assert out == "waited 5s"
assert slept == [5]
async def test_wait_tool_disturbed_with_reason(monkeypatch) -> None:
monkeypatch.setattr(
wait_module,
"read_line_with_timeout",
lambda prompt, timeout: "tests are slow",
)
with temporary_backend(ScriptedBackend([])):
registry = ToolRegistry([wait])
out = await registry.execute("wait", '{"duration": 3}')
assert out == "disturbed by user: tests are slow"
async def test_wait_tool_disturbed_empty_enter(monkeypatch) -> None:
monkeypatch.setattr(wait_module, "read_line_with_timeout", lambda prompt, timeout: "")
with temporary_backend(ScriptedBackend([])):
registry = ToolRegistry([wait])
out = await registry.execute("wait", '{"duration": 3}')
assert out == "disturbed by user (no reason)"
async def test_wait_tool_times_out(monkeypatch) -> None:
monkeypatch.setattr(wait_module, "read_line_with_timeout", lambda prompt, timeout: None)
with temporary_backend(ScriptedBackend([])):
registry = ToolRegistry([wait])
out = await registry.execute("wait", '{"duration": 3}')
assert out == "waited 3s"
+15 -15
View File
@@ -43,46 +43,46 @@ def test_classify_write_overwrite_only(tmp_path: Path) -> None:
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("run_argv", {"argv": ["echo", "hi"]}) is None
assert classify_danger("open_pty", {"command": ["true"]}) is None
assert classify_danger("run_command", {"command": ["ls", "-la"]}) is None
assert classify_danger("run_argv", {"argv": ["ls", "-la"]}) is None
def test_classify_run_command_batch_risky() -> None:
def test_classify_run_argv_batch_risky() -> None:
reason = classify_danger(
"run_command_batch",
"run_argv_batch",
{
"commands": [
{"command": ["echo", "ok"]},
{"command": ["bash", "-c", "echo risky"]},
"steps": [
{"argv": ["echo", "ok"]},
{"argv": ["bash", "-c", "echo risky"]},
]
},
)
assert reason is not None
assert "run_command_batch" in reason
assert "run_argv_batch" in reason
assert "risky" in reason or "bash" in reason
assert (
classify_danger(
"run_command_batch",
{"commands": [{"command": ["echo", "ok"]}]},
"run_argv_batch",
{"steps": [{"argv": ["echo", "ok"]}]},
)
is None
)
def test_classify_shell_and_dash_c() -> None:
r = classify_danger("run_command", {"command": ["bash", "-c", "rm -rf /"]})
r = classify_danger("run_argv", {"argv": ["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)"]})
r2 = classify_danger("run_argv", {"argv": ["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"]})
r_py = classify_danger("run_argv", {"argv": ["python", "-c", "import os"]})
assert r_py is not None and "python -c" in r_py
r3 = classify_danger("open_pty", {"command": ["bash"]})
@@ -118,8 +118,8 @@ async def test_confirm_deny_with_comment() -> None:
def test_shell_confirm_formats_command_placeholder() -> None:
script = chr(10).join(["line1", "line2", "line3"])
reason = classify_danger(
"run_command",
{"command": ["bash", "-c", script]},
"run_argv",
{"argv": ["bash", "-c", script]},
)
assert reason is not None
assert "$(command)" in reason
+61 -7
View File
@@ -19,7 +19,36 @@ def test_edit_lineno_replace_middle(workspace: object) -> None:
_ = _read_lineno("a.txt")
out = call_sync(edit_lineno, "a.txt", 2, 3, "TWO\nTHREE\n")
assert "replaced lines 2-3" in out
assert call_sync(read_file, "a.txt") == "one\nTWO\nTHREE\nfour\n"
assert "first: 'two'" in out # old-content excerpt for self-correction
assert call_sync(read_file, "a.txt") == "L1-4\none\nTWO\nTHREE\nfour\n"
def test_edit_lineno_read_truncated_marks_only_displayed_lines(workspace: object) -> None:
"""A max_chars-truncated numbered read must not mark the unseen tail."""
del workspace
_ = call_sync(write_file, "t.txt", "".join(f"line {i}\n" for i in range(1, 101)))
# Displayed complete lines are editable...
_ = reset_lineno_tracker()
_ = call_sync(read_file, "t.txt", with_lineno=True, max_chars=80)
out = call_sync(edit_lineno, "t.txt", 5, 5, "FIVE\n")
assert "replaced lines 5-5" in out
# ...but the truncated tail (never shown) stays unread.
_ = reset_lineno_tracker()
_ = call_sync(read_file, "t.txt", with_lineno=True, max_chars=80)
out = call_sync(edit_lineno, "t.txt", 90, 90, "X\n")
assert "not read" in out and "90" in out
def test_edit_lineno_error_shows_read_window(workspace: object) -> None:
"""The unread error names the read window so the model can re-read precisely."""
del workspace
_ = call_sync(write_file, "j.txt", "a\nb\nc\nd\n")
_ = reset_lineno_tracker()
_ = call_sync(read_file, "j.txt", offset=1, limit=2, with_lineno=True) # lines 2-3
out = call_sync(edit_lineno, "j.txt", 1, 1, "X\n")
assert "not read" in out
assert "read lines 2-3" in out
assert "margin" in out
def test_edit_lineno_requires_all_lines_read(workspace: object) -> None:
@@ -37,6 +66,16 @@ def test_edit_lineno_requires_all_lines_read(workspace: object) -> None:
assert "replaced lines 2-3" in out
def test_edit_lineno_append_to_empty_file(workspace: object) -> None:
"""An empty file has nothing to clobber, so appending needs no read."""
del workspace
_ = call_sync(write_file, "empty.txt", "")
_ = reset_lineno_tracker()
out = call_sync(edit_lineno, "empty.txt", 1, 1, "first\n")
assert "appended" in out
assert call_sync(read_file, "empty.txt") == "L1-1\nfirst\n"
def test_edit_lineno_append_requires_last_line_read(workspace: object) -> None:
del workspace
_ = call_sync(write_file, "ap.txt", "a\nb\nc\n")
@@ -49,7 +88,7 @@ def test_edit_lineno_append_requires_last_line_read(workspace: object) -> None:
_ = call_sync(read_file, "ap.txt", offset=2, limit=1, with_lineno=True)
out = call_sync(edit_lineno, "ap.txt", 4, 4, "d\n")
assert "appended" in out
assert call_sync(read_file, "ap.txt") == "a\nb\nc\nd\n"
assert call_sync(read_file, "ap.txt") == "L1-4\na\nb\nc\nd\n"
def test_edit_lineno_delete_range(workspace: object) -> None:
@@ -58,7 +97,7 @@ def test_edit_lineno_delete_range(workspace: object) -> None:
_ = _read_lineno("b.txt")
out = call_sync(edit_lineno, "b.txt", 2, 2, "")
assert "replaced" in out
assert call_sync(read_file, "b.txt") == "a\nc\n"
assert call_sync(read_file, "b.txt") == "L1-2\na\nc\n"
def test_edit_lineno_append(workspace: object) -> None:
@@ -67,7 +106,7 @@ def test_edit_lineno_append(workspace: object) -> None:
_ = _read_lineno("c.txt")
out = call_sync(edit_lineno, "c.txt", 2, 2, "more\n")
assert "appended" in out
assert call_sync(read_file, "c.txt") == "only\nmore\n"
assert call_sync(read_file, "c.txt") == "L1-2\nonly\nmore\n"
def test_edit_lineno_invalid(workspace: object) -> None:
@@ -92,7 +131,7 @@ def test_edit_lineno_requires_rerun_after_write(workspace: object) -> None:
_ = _read_lineno("f.txt")
_ = call_sync(write_file, "f.txt", "changed\n")
out = call_sync(edit_lineno, "f.txt", 1, 1, "x\n")
assert "not read" in out
assert "changed" in out and "re-read" in out
def test_edit_lineno_requires_rerun_after_edit_replace(workspace: object) -> None:
@@ -101,7 +140,7 @@ def test_edit_lineno_requires_rerun_after_edit_replace(workspace: object) -> Non
_ = _read_lineno("g.txt")
_ = call_sync(edit_replace, "g.txt", "two", "TWO")
out = call_sync(edit_lineno, "g.txt", 2, 2, "x\n")
assert "not read" in out
assert "changed" in out and "re-read" in out
def test_edit_lineno_invalidates_after_successful_edit(workspace: object) -> None:
@@ -111,4 +150,19 @@ def test_edit_lineno_invalidates_after_successful_edit(workspace: object) -> Non
_ = call_sync(edit_lineno, "h.txt", 2, 2, "B\n")
# After the edit, line numbers are stale — must re-read before editing again.
out = call_sync(edit_lineno, "h.txt", 2, 2, "C\n")
assert "not read" in out
assert "changed" in out and "re-read" in out
def test_edit_lineno_detects_external_change(workspace: object) -> None:
"""A mutation outside the file tools (run_argv, another process) is caught."""
import os
import time
assert isinstance(workspace, Path)
_ = call_sync(write_file, "i.txt", "one\ntwo\nthree\n")
_ = _read_lineno("i.txt")
# Simulate an external/process-tool mutation: rewrite content + new mtime.
_ = call_sync(write_file, "i.txt", "one\ntwo\nthree\nCHANGED\n")
os.utime(workspace / "i.txt", (time.time() + 1, time.time() + 1))
out = call_sync(edit_lineno, "i.txt", 1, 1, "x\n")
assert "changed since it was read" in out
+53
View File
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, override
import pytest
from plyngent.tools.context import read_only_context
from plyngent.tools.danger import classify_danger
from plyngent.tools.net import fetch, grant_private_host
from plyngent.tools.net.policy import (
@@ -20,6 +21,7 @@ from plyngent.tools.net.policy import (
parse_fetch_url,
soft_confirm_reason,
)
from plyngent.tools.truncate_token import decode_truncate_token
from tests.test_tools.helpers import call_async
if TYPE_CHECKING:
@@ -198,6 +200,23 @@ async def test_fetch_get_post_put_delete(workspace: Path, http_server: str) -> N
assert "status=204" in out_del
async def test_fetch_read_only_context_blocks_non_get(workspace: Path, http_server: str) -> None:
"""In a read-only context fetch only allows GET; outside it POST works."""
del workspace
port = int(http_server.rsplit(":", 1)[1])
grant_private_host("127.0.0.1", port)
with read_only_context():
out = await call_async(fetch, f"{http_server}/hello")
assert "status=200" in out # GET is fine in a read-only context
blocked = await call_async(fetch, f"{http_server}/echo", method="POST", body="hi")
assert blocked.startswith("error:")
assert "GET" in blocked
out_post = await call_async(fetch, f"{http_server}/echo", method="POST", body="hi")
assert "POST:hi" in out_post
async def test_fetch_user_agent_passthrough(workspace: Path, http_server: str) -> None:
del workspace
port = int(http_server.rsplit(":", 1)[1])
@@ -237,6 +256,40 @@ async def test_fetch_truncation_and_binary(workspace: Path, http_server: str) ->
assert "binary body omitted" in out_bin
async def test_fetch_char_truncation_embeds_token(workspace: Path, http_server: str) -> None:
del workspace
port = int(http_server.rsplit(":", 1)[1])
grant_private_host("127.0.0.1", port)
out = await call_async(fetch, f"{http_server}/big", max_chars=1000)
assert "truncated=true" in out
body = out.split("--- body ---", 1)[-1]
assert "truncate_token=" in body
token_line = next(ln for ln in body.splitlines() if "truncate_token=" in ln)
token_str = token_line.split("truncate_token=", 1)[1].rstrip("]")
token = decode_truncate_token(token_str)
assert token is not None
assert token.kind == "http"
assert token.limit == 1000
assert 0 < token.offset < 1000
# Continuation resumes exactly where the chunk stopped (no gap).
out2 = await call_async(fetch, f"{http_server}/big", max_chars=1000, offset=token.offset)
body2 = out2.split("--- body ---", 1)[-1].strip()
assert body2.startswith("x")
async def test_fetch_offset_skips_body(workspace: Path, http_server: str) -> None:
del workspace
port = int(http_server.rsplit(":", 1)[1])
grant_private_host("127.0.0.1", port)
# /big body is 5000 chars; offset 4900 leaves 100 chars, no token needed.
out = await call_async(fetch, f"{http_server}/big", max_chars=200, offset=4900)
body = out.split("--- body ---", 1)[-1]
assert body.strip() == "x" * 100
assert "truncate_token=" not in out
async def test_fetch_private_denied_without_grant(workspace: Path, http_server: str) -> None:
del workspace
# No grant, no policy hook → hard deny (even though server is up).
+65 -6
View File
@@ -1,35 +1,87 @@
from __future__ import annotations
import importlib
from pathlib import Path
from plyngent.tools.file import edit_replace, listdir, read_file, write_file
from tests.test_tools.helpers import call_sync
get_truncated_module = importlib.import_module("plyngent.tools.file.get_truncated")
def _extract_token(out: str) -> str | None:
for line in out.splitlines():
if "truncate_token=" in line:
return line.split("truncate_token=", 1)[1].rstrip("]")
return None
def test_write_read_listdir_edit(workspace: object) -> None:
del workspace
assert "wrote" in call_sync(write_file, "notes/a.txt", "hello world")
assert call_sync(read_file, "notes/a.txt") == "hello world"
assert call_sync(read_file, "notes/a.txt") == "L1-1\nhello world"
listing = call_sync(listdir, "notes")
assert "a.txt" in listing
assert "file" in listing
result = call_sync(edit_replace, "notes/a.txt", "world", "there")
assert "replaced" in result
assert call_sync(read_file, "notes/a.txt") == "hello there"
assert call_sync(read_file, "notes/a.txt") == "L1-1\nhello there"
def test_read_file_max_chars_embeds_token(workspace: Path) -> None:
(workspace / "big.txt").write_text("a" * 1000, encoding="utf-8")
out = call_sync(read_file, "big.txt", max_chars=200)
header, _, rest = out.partition("\n")
assert header.startswith("L1-") # 1-based range of the raw slice read
content, _, marker = rest.partition("\n[Truncated")
assert content == "a" * len(content)
assert len(content) < 200
assert "truncate_token=" in marker
token = _extract_token(out)
assert token is not None
parsed = get_truncated_module.decode_truncate_token(token)
assert parsed is not None
assert parsed.kind == "file"
assert parsed.offset == len(content) # cursor lands exactly where content stops
def test_read_file_default_truncates_at_cap(workspace: Path) -> None:
"""Truncation is the default: a large read embeds a token without max_chars."""
(workspace / "huge.txt").write_text("\n".join(f"line-{i}" for i in range(10_000)), encoding="utf-8")
out = call_sync(read_file, "huge.txt")
header, _, rest = out.partition("\n")
assert header == "L1-10000" # the requested slice (whole file)
assert "truncate_token=" in rest
# The slice is capped near the default tool-result cap.
assert len(rest) <= 32_000
# No marker when the file is small (no truncation occurred).
small = call_sync(read_file, "huge.txt", max_chars=0)
assert "Truncated" not in small
assert small.startswith("L1-10000\n")
def test_read_file_max_chars_small_file_untouched(workspace: Path) -> None:
(workspace / "small.txt").write_text("tiny", encoding="utf-8")
out = call_sync(read_file, "small.txt", max_chars=200)
assert out == "L1-1\ntiny"
assert "truncate_token=" not in out
def test_edit_replace_first_only(workspace: object) -> None:
del workspace
_ = call_sync(write_file, "t.txt", "aa aa")
result = call_sync(edit_replace, "t.txt", "aa", "bb")
assert call_sync(read_file, "t.txt") == "bb aa"
assert call_sync(read_file, "t.txt") == "L1-1\nbb aa"
assert "1 of 2" in result or "1 of 2 matches" in result
assert "remain" in result
assert "'aa' → 'bb'" in result # old→new excerpt for self-correction
def test_edit_replace_max_replaces(workspace: object) -> None:
del workspace
_ = call_sync(write_file, "t.txt", "aa aa aa")
result = call_sync(edit_replace, "t.txt", "aa", "bb", max_replaces=2)
assert call_sync(read_file, "t.txt") == "bb bb aa"
assert call_sync(read_file, "t.txt") == "L1-1\nbb bb aa"
assert "2 of 3" in result
assert "1 remain" in result
@@ -38,7 +90,7 @@ def test_edit_replace_all_matches(workspace: object) -> None:
del workspace
_ = call_sync(write_file, "t.txt", "aa aa")
result = call_sync(edit_replace, "t.txt", "aa", "bb", max_replaces=10)
assert call_sync(read_file, "t.txt") == "bb bb"
assert call_sync(read_file, "t.txt") == "L1-1\nbb bb"
assert "all 2 matches" in result
@@ -57,7 +109,14 @@ def test_edit_missing_old_string(workspace: object) -> None:
def test_read_offset_limit(workspace: object) -> None:
del workspace
_ = call_sync(write_file, "lines.txt", "a\nb\nc\nd\n")
assert call_sync(read_file, "lines.txt", offset=1, limit=2) == "b\nc\n"
# offset is 0-based: offset=1 skips line 1, so the read starts at 1-based line 2.
assert call_sync(read_file, "lines.txt", offset=1, limit=2) == "L2-3\nb\nc\n"
def test_read_file_missing_and_directory(workspace: Path) -> None:
(workspace / "sub").mkdir()
assert call_sync(read_file, "nope.txt") == "error: file not found: nope.txt"
assert call_sync(read_file, "sub") == "error: not a file: sub"
def test_read_with_lineno(workspace: object) -> None:
+6 -6
View File
@@ -11,7 +11,7 @@ def test_copy_file(workspace: object) -> None:
_ = call_sync(write_file, "a.txt", "hello")
out = call_sync(copy_path, "a.txt", "b.txt")
assert "copied" in out
assert call_sync(read_file, "b.txt") == "hello"
assert call_sync(read_file, "b.txt") == "L1-1\nhello"
assert (workspace / "a.txt").is_file()
@@ -21,8 +21,8 @@ def test_copy_directory(workspace: object) -> None:
_ = call_sync(write_file, "src/nested/y.txt", "y")
out = call_sync(copy_path, "src", "dst")
assert "directory" in out
assert call_sync(read_file, "dst/x.txt") == "x"
assert call_sync(read_file, "dst/nested/y.txt") == "y"
assert call_sync(read_file, "dst/x.txt") == "L1-1\nx"
assert call_sync(read_file, "dst/nested/y.txt") == "L1-1\ny"
def test_copy_overwrite(workspace: object) -> None:
@@ -32,7 +32,7 @@ def test_copy_overwrite(workspace: object) -> None:
assert "exists" in call_sync(copy_path, "b.txt", "a.txt")
out = call_sync(copy_path, "b.txt", "a.txt", overwrite=True)
assert "copied" in out
assert call_sync(read_file, "a.txt") == "new"
assert call_sync(read_file, "a.txt") == "L1-1\nnew"
def test_move_file(workspace: object) -> None:
@@ -41,7 +41,7 @@ def test_move_file(workspace: object) -> None:
out = call_sync(move_path, "old.txt", "renamed.txt")
assert "moved" in out
assert not (workspace / "old.txt").exists()
assert call_sync(read_file, "renamed.txt") == "data"
assert call_sync(read_file, "renamed.txt") == "L1-1\ndata"
def test_move_directory(workspace: object) -> None:
@@ -50,7 +50,7 @@ def test_move_directory(workspace: object) -> None:
out = call_sync(move_path, "d", "e")
assert "moved" in out
assert not (workspace / "d").exists()
assert call_sync(read_file, "e/a.txt") == "a"
assert call_sync(read_file, "e/a.txt") == "L1-1\na"
def test_delete_file(workspace: object) -> None:
+137
View File
@@ -0,0 +1,137 @@
from __future__ import annotations
import importlib
from pathlib import Path
from plyngent.tools.truncate_token import TruncateToken, decode_truncate_token, encode_truncate_token
from tests.test_tools.helpers import call_async
get_truncated_module = importlib.import_module("plyngent.tools.file.get_truncated")
get_truncated = get_truncated_module.get_truncated
def _extract_token(out: str) -> str | None:
for line in out.splitlines():
if "truncate_token=" in line:
return line.split("truncate_token=", 1)[1].rstrip("]")
return None
async def test_get_truncated_invalid_token() -> None:
out = await call_async(get_truncated, "garbage-token")
assert out.startswith("error:")
assert "invalid" in out
async def test_get_truncated_file_chains_without_gap(workspace: Path) -> None:
(workspace / "big.txt").write_text("a" * 1000, encoding="utf-8")
token = encode_truncate_token(TruncateToken(kind="file", location="big.txt", offset=0, limit=200))
total_read = 0
for _ in range(20):
out = await call_async(get_truncated, token, max_chars=200)
body, _, _ = out.partition("\n[Truncated")
header, _, content = body.partition("\n")
assert header.startswith("L") and "-" in header
assert content == "a" * len(content)
total_read += len(content)
next_token = _extract_token(out)
if next_token is None:
break
parsed = decode_truncate_token(next_token)
assert parsed is not None
assert parsed.offset == total_read # no gap, no overlap
token = next_token
assert total_read == 1000
async def test_get_truncated_numbered_continues_and_marks(workspace: Path) -> None:
"""A numbered read's token resumes the numbered view, gap-free and editable."""
from plyngent.agent import reset_lineno_tracker
from plyngent.tools.file import edit_lineno, read_file
(workspace / "big.txt").write_text("".join(f"line {i}\n" for i in range(1, 101)), encoding="utf-8")
reset_lineno_tracker()
first = await call_async(read_file, "big.txt", with_lineno=True, max_chars=120)
token = _extract_token(first)
assert token is not None
parsed = decode_truncate_token(token)
assert parsed is not None
assert parsed.kind == "file"
assert parsed.numbered is True
# The token points at the raw start of the next line (no skip, no overlap).
body = first.split("\n[Truncated", 1)[0]
shown = body.count("\n")
expected_raw = sum(len(f"line {i}\n") for i in range(1, shown + 1))
assert parsed.offset == expected_raw
# Continuation is numbered and starts right after the displayed lines.
cont = await call_async(get_truncated, token, max_chars=200)
numbered = [ln for ln in cont.splitlines() if "|" in ln and ln.strip().split("|", 1)[0].strip().isdigit()]
assert numbered
first_num = int(numbered[0].split("|", 1)[0].strip())
assert first_num == shown + 1
# Resumed lines are editable via edit_lineno; unseen lines stay unread.
out = await call_async(edit_lineno, "big.txt", first_num, first_num, "EDITED\n")
assert "replaced lines" in out
out2 = await call_async(edit_lineno, "big.txt", 90, 90, "X\n")
assert "not read" in out2
async def test_get_truncated_file_missing(workspace: Path) -> None:
token = encode_truncate_token(TruncateToken(kind="file", location="nope.txt", offset=0, limit=200))
out = await call_async(get_truncated, token, max_chars=200)
assert out.startswith("error:")
async def test_get_truncated_http_passthrough(monkeypatch) -> None:
calls: list[tuple[str, int, int]] = []
async def fake_fetch(url: str, *, offset: int, max_chars: int) -> str:
calls.append((url, offset, max_chars))
return f"body-from-{offset}"
monkeypatch.setattr(get_truncated_module.fetch, "handler", fake_fetch)
token = encode_truncate_token(TruncateToken(kind="http", location="https://example.com/x", offset=500, limit=400))
out = await call_async(get_truncated, token, max_chars=200)
assert out == "body-from-500"
assert calls == [("https://example.com/x", 500, 400)]
async def test_get_truncated_memory_chains() -> None:
from plyngent.tools.truncate_token import truncate_generic
bounded = truncate_generic("m" * 1000, 200)
assert "truncate_token=" in bounded
token = bounded.split("truncate_token=", 1)[1].rstrip("]")
parsed = decode_truncate_token(token)
assert parsed is not None
assert parsed.kind == "memory"
cursor = parsed.offset # chars already delivered by truncate_generic
delivered = cursor
for _ in range(20):
out = await call_async(get_truncated, token, max_chars=200)
content, _, _ = out.partition("\n[Truncated")
assert content == "m" * len(content)
delivered += len(content)
next_token = _extract_token(out)
if next_token is None:
break # source exhausted → no token, no marker
parsed = decode_truncate_token(next_token)
assert parsed is not None
assert parsed.kind == "memory"
# No gap: the next cursor is exactly where this chunk ended.
assert parsed.offset == delivered
token = next_token
assert delivered == 1000
async def test_get_truncated_memory_expired() -> None:
from plyngent.tools.truncate_token import _MEMORY_REMAINDERS, store_remainder
key = store_remainder("x" * 500)
del _MEMORY_REMAINDERS[key]
token = encode_truncate_token(TruncateToken(kind="memory", location=key, offset=0, limit=200))
out = await call_async(get_truncated, token, max_chars=200)
assert out.startswith("error:")
assert "expired" in out
+82 -35
View File
@@ -9,8 +9,8 @@ from plyngent.tools.process import (
close_pty,
open_pty,
read_pty,
run_command,
run_command_batch,
run_argv,
run_argv_batch,
write_pty,
write_pty_keys,
)
@@ -41,20 +41,50 @@ def _py(code: str) -> list[str]:
return [sys.executable, "-c", code]
async def test_run_command_echo(workspace: object) -> None:
async def test_run_argv_echo(workspace: object) -> None:
del workspace
out = await call_async(run_command, _py("print('hi')"))
out = await call_async(run_argv, _py("print('hi')"))
assert "exit_code=0" in out
assert "hi" in out
async def test_run_command_batch_serial(workspace: object) -> None:
async def test_run_argv_string_command_error(workspace: object) -> None:
del workspace
out = await call_async(run_argv, "ls -la")
assert "error:" in out
assert "only accepts an array" in out
assert "executable not found" not in out
async def test_run_argv_stringified_json_array_error(workspace: object) -> None:
del workspace
out = await call_async(run_argv, '["ls", "-la"]')
assert "error:" in out
assert "only accepts an array" in out
assert "executable not found" not in out
async def test_run_argv_non_string_args_error(workspace: object) -> None:
del workspace
out = await call_async(run_argv, ["ls", 1])
assert "error:" in out
assert "array of string args" in out
async def test_open_pty_string_command_error(workspace: object) -> None:
del workspace
out = await call_async(open_pty, "echo hi")
assert "error:" in out
assert "only accepts an array" in out
async def test_run_argv_batch_serial(workspace: object) -> None:
del workspace
out = await call_async(
run_command_batch,
run_argv_batch,
[
{"command": _py("print('a')")},
{"command": _py("print('b')")},
{"argv": _py("print('a')")},
{"argv": _py("print('b')")},
],
)
assert "steps=2" in out
@@ -65,13 +95,13 @@ async def test_run_command_batch_serial(workspace: object) -> None:
assert "a" in out and "b" in out
async def test_run_command_batch_stop_on_error(workspace: object) -> None:
async def test_run_argv_batch_stop_on_error(workspace: object) -> None:
del workspace
out = await call_async(
run_command_batch,
run_argv_batch,
[
{"command": _py("import sys; sys.exit(2)")},
{"command": _py("print('should-not-run')")},
{"argv": _py("import sys; sys.exit(2)")},
{"argv": _py("print('should-not-run')")},
],
stop_on_error=True,
)
@@ -80,17 +110,17 @@ async def test_run_command_batch_stop_on_error(workspace: object) -> None:
assert "should-not-run" not in out
async def test_run_command_batch_pipe_out(workspace: object) -> None:
async def test_run_argv_batch_pipe_out(workspace: object) -> None:
del workspace
out = await call_async(
run_command_batch,
run_argv_batch,
[
{
"command": _py("print('piped-payload', end='')"),
"argv": _py("print('piped-payload', end='')"),
"pipe_out": True,
},
{
"command": _py("import sys; print(sys.stdin.read())"),
"argv": _py("import sys; print(sys.stdin.read())"),
},
],
)
@@ -99,27 +129,27 @@ async def test_run_command_batch_pipe_out(workspace: object) -> None:
assert "pipe_out=true" in out
async def test_run_command_batch_last_pipe_out_error(workspace: object) -> None:
async def test_run_argv_batch_last_pipe_out_error(workspace: object) -> None:
del workspace
out = await call_async(
run_command_batch,
[{"command": _py("print(1)"), "pipe_out": True}],
run_argv_batch,
[{"argv": _py("print(1)"), "pipe_out": True}],
)
assert "error:" in out
assert "pipe_out" in out
async def test_run_command_batch_mix_stderr(workspace: object) -> None:
async def test_run_argv_batch_mix_stderr(workspace: object) -> None:
del workspace
out = await call_async(
run_command_batch,
run_argv_batch,
[
{
"command": _py("import sys; sys.stdout.write('out'); sys.stderr.write('err')"),
"argv": _py("import sys; sys.stdout.write('out'); sys.stderr.write('err')"),
"mix_stderr": True,
"pipe_out": True,
},
{"command": _py("import sys; print(repr(sys.stdin.read()))")},
{"argv": _py("import sys; print(repr(sys.stdin.read()))")},
],
)
assert "ran=2" in out
@@ -127,32 +157,32 @@ async def test_run_command_batch_mix_stderr(workspace: object) -> None:
assert "out" in out and "err" in out
async def test_run_command_denied(workspace: object) -> None:
async def test_run_argv_denied(workspace: object) -> None:
del workspace
out = await call_async(run_command, ["rm", "-rf", "."])
out = await call_async(run_argv, ["rm", "-rf", "."])
assert "denied" in out
async def test_run_command_cwd(workspace: object) -> None:
async def test_run_argv_cwd(workspace: object) -> None:
assert isinstance(workspace, Path)
sub = workspace / "sub"
sub.mkdir()
_ = (sub / "f.txt").write_text("z", encoding="utf-8")
out = await call_async(run_command, _py("import os; print('\\n'.join(os.listdir()))"), cwd="sub")
out = await call_async(run_argv, _py("import os; print('\\n'.join(os.listdir()))"), cwd="sub")
assert "f.txt" in out
async def test_run_command_timeout(workspace: object) -> None:
async def test_run_argv_timeout(workspace: object) -> None:
del workspace
out = await call_async(run_command, _py("import time; time.sleep(5)"), timeout_seconds=0.2)
out = await call_async(run_argv, _py("import time; time.sleep(5)"), timeout_seconds=0.2)
assert "timed_out=true" in out
async def test_run_command_timeout_keeps_partial_output(workspace: object) -> None:
async def test_run_argv_timeout_keeps_partial_output(workspace: object) -> None:
del workspace
# Print then sleep past the timeout so communicate has partial stdout after kill.
out = await call_async(
run_command,
run_argv,
_py("import sys, time; sys.stdout.write('partial-out'); sys.stdout.flush(); time.sleep(5)"),
timeout_seconds=0.3,
)
@@ -160,10 +190,27 @@ async def test_run_command_timeout_keeps_partial_output(workspace: object) -> No
assert "partial-out" in out
async def test_run_command_stdin(workspace: object) -> None:
async def test_run_argv_stdout_cap_embeds_token(workspace: object) -> None:
del workspace
# Per-stream cap default is 32000 chars: a bigger stdout embeds a token.
out = await call_async(run_argv, _py("print('x' * 40000)"))
assert "truncate_token=" in out
token_line = next(ln for ln in out.splitlines() if "truncate_token=" in ln)
assert "[Truncated (32000 chars max.;" in token_line
assert "[Truncated" in out
async def test_run_argv_small_stdout_no_marker(workspace: object) -> None:
del workspace
out = await call_async(run_argv, _py("print('tiny')"))
assert "Truncated" not in out
assert "truncate_token=" not in out
async def test_run_argv_stdin(workspace: object) -> None:
del workspace
out = await call_async(
run_command,
run_argv,
_py("import sys; print(sys.stdin.read(), end='')"),
stdin="hello-stdin\n",
)
@@ -171,10 +218,10 @@ async def test_run_command_stdin(workspace: object) -> None:
assert "hello-stdin" in out
async def test_run_command_env(workspace: object) -> None:
async def test_run_argv_env(workspace: object) -> None:
del workspace
out = await call_async(
run_command,
run_argv,
_py("import os; print(os.environ.get('PLYNGENT_TEST_VAR', ''), end='')"),
env={"PLYNGENT_TEST_VAR": "from-env"},
)
+2 -2
View File
@@ -33,10 +33,10 @@ def test_new_temporary_workspace_allowlist(workspace: object) -> None:
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"
assert call_sync(read_file, str(target)) == "L1-1\nhello-temp"
_ = call_sync(write_file, "project.txt", "proj")
assert call_sync(read_file, "project.txt") == "proj"
assert call_sync(read_file, "project.txt") == "L1-1\nproj"
# Sibling under system temp that we did not allowlist still fails.
outside = temp.parent / "not-ours-should-fail"
+53
View File
@@ -67,7 +67,60 @@ def test_tree_origin_subdir(workspace: object) -> None:
assert isinstance(workspace, Path)
_build_sample(workspace)
out = call_sync(tree, "src")
assert out.startswith("- ")
assert "- nested/" in out
assert "main.py" in out
assert "deep.txt" in out
def test_tree_default_is_markdown(workspace: object) -> None:
assert isinstance(workspace, Path)
_build_sample(workspace)
out = call_sync(tree, ".")
assert out.startswith("- ")
assert "- src/" in out
assert "├──" not in out
def test_tree_markdown_format(workspace: object) -> None:
assert isinstance(workspace, Path)
_build_sample(workspace)
out = call_sync(tree, ".", format="markdown", max_depth=3, max_entries=10)
assert "- src/" in out
# nested bullets are indented by 2 spaces per level
assert " - main.py" in out
assert " - nested/" in out
assert " - deep.txt" in out
assert "more entries not shown" in out
def test_tree_flat_format(workspace: object) -> None:
assert isinstance(workspace, Path)
_build_sample(workspace)
out = call_sync(tree, ".", format="flat", max_entries=200)
assert "src/" in out # dirs keep a trailing slash
assert "src/main.py" in out
assert "src/nested/deep.txt" in out
assert "a.txt" in out
assert "- " not in out
assert "├──" not in out
def test_tree_flat_total_cap(workspace: object) -> None:
assert isinstance(workspace, Path)
_build_sample(workspace)
out = call_sync(tree, "pkg", format="flat", max_depth=2, max_entries=10)
paths = [line for line in out.splitlines() if not line.startswith("…")]
assert len(paths) == 10
assert "more paths not shown" in out
def test_tree_decorated_format(workspace: object) -> None:
assert isinstance(workspace, Path)
_build_sample(workspace)
out = call_sync(tree, "src", format="decorated")
assert out.startswith("src/")
assert "├──" in out
assert "main.py" in out
assert "deep.txt" in out
+150
View File
@@ -0,0 +1,150 @@
from __future__ import annotations
from plyngent.tools.truncate_token import (
TruncateToken,
decode_truncate_token,
encode_truncate_token,
get_remainder,
truncate_generic,
truncate_with_token,
truncation_marker,
)
def test_token_roundtrip() -> None:
token = TruncateToken(kind="file", location="src/a.py", offset=100, limit=32_000)
encoded = encode_truncate_token(token)
assert decode_truncate_token(encoded) == token
def test_decode_garbage_returns_none() -> None:
assert decode_truncate_token("not-a-token") is None
assert decode_truncate_token("") is None
assert decode_truncate_token("!!!") is None
def test_decode_rejects_invalid_fields() -> None:
bad = TruncateToken(kind="file", location="x", offset=-1, limit=10)
assert decode_truncate_token(encode_truncate_token(bad)) is None
bad_limit = TruncateToken(kind="http", location="u", offset=0, limit=0)
assert decode_truncate_token(encode_truncate_token(bad_limit)) is None
def test_marker_format() -> None:
token = TruncateToken(kind="file", location="a.txt", offset=100, limit=32_000)
marker = truncation_marker(32_000, 500, token)
assert marker.startswith("\n[Truncated (32000 chars max.; 500 omitted). truncate_token=")
assert marker.endswith("]")
assert encode_truncate_token(token) in marker
def test_truncate_with_token_short_text_untouched() -> None:
out, token = truncate_with_token(
"hello",
32_000,
kind="file",
location="a.txt",
offset=0,
limit=32_000,
total_len=5,
)
assert out == "hello"
assert token is None
assert "Truncated" not in out # no hint when no truncation
def test_truncate_with_token_cuts_and_advances() -> None:
text = "x" * 500
out, token = truncate_with_token(
text,
200,
kind="file",
location="a.txt",
offset=0,
limit=200,
total_len=500,
)
assert token is not None
content, _, _ = out.partition("\n[Truncated")
assert content.startswith("x")
assert "[Truncated (200 chars max.; 300 omitted). truncate_token=" in out
assert token.offset == len(content) # cursor lands exactly where content stops
assert len(out) <= 200 # marker fits inside the char budget
def test_truncate_with_token_more_after_nonoverflow() -> None:
# Segment fits the chunk, but the source continues: still emit a token.
text = "y" * 30
out, token = truncate_with_token(
text,
40,
kind="file",
location="a.txt",
offset=0,
limit=40,
total_len=200,
)
assert token is not None
assert token.offset == 30 # advanced by returned length only
assert "[Truncated (40 chars max.; 170 omitted). truncate_token=" in out
assert out.startswith("y" * 30)
def test_truncate_with_token_end_reached_no_token() -> None:
text = "z" * 30
out, token = truncate_with_token(
text,
40,
kind="http",
location="https://example.com/x",
offset=0,
limit=40,
total_len=30,
)
assert token is None
assert out == text
assert "Truncated" not in out # no hint when the source ends
def test_truncate_with_token_nonzero_start() -> None:
text = "y" * 500
out, token = truncate_with_token(
text,
200,
kind="http",
location="https://example.com/x",
offset=150,
limit=200,
total_len=700,
)
assert token is not None
assert token.kind == "http"
content, _, _ = out.partition("\n[Truncated")
assert token.offset == 150 + len(content)
assert "[Truncated (200 chars max.; 350 omitted). truncate_token=" in out
assert len(out) <= 200
def test_truncate_generic_short_untouched() -> None:
assert truncate_generic("tiny", 200) == "tiny"
def test_truncate_generic_embeds_memory_token() -> None:
text = "a" * 500
out = truncate_generic(text, 200)
assert len(out) <= 200
assert "[Truncated (200 chars max." in out
token_str = out.split("truncate_token=", 1)[1].rstrip("]")
token = decode_truncate_token(token_str)
assert token is not None
assert token.kind == "memory"
# Remainder is the full original text; token offset points into it.
remainder = get_remainder(token.location)
assert remainder == text
assert token.offset < len(text)
def test_truncate_generic_no_marker_when_fits() -> None:
out = truncate_generic("a" * 50, 200)
assert out == "a" * 50
assert "Truncated" not in out