32 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
56 changed files with 2075 additions and 325 deletions
+3 -2
View File
@@ -85,7 +85,7 @@ Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `
Module-level `@tool` handlers. Call `set_workspace_root()` before use. Module-level `@tool` handlers. Call `set_workspace_root()` before use.
- **`workspace`**: path resolve under 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. - **`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`, `max_chars`; results start with a 1-based `L{begin}-{end}` range), `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), `get_truncated` (resume truncated `read_file`/`fetch` via `TRUNCATE_TOKEN`). - **`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). - **`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. - 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. - CLI limit hooks: interactive confirm to raise tool-loop rounds, PTY session cap, or PTY output budget.
@@ -94,7 +94,7 @@ Module-level `@tool` handlers. Call `set_workspace_root()` before use.
- **`chat`**: human prompts as tools — `ask_user_line` / `ask_user_choice` / `ask_user_form` (shared `prompting` core); `wait` (line prompt with timeout; Enter disturbs). - **`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`). - **`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. - **`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`) ### 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`. - **`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. - 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**. - Explicit `/resume` or `--session` from another workspace prompts: **keep** / **update** / **abort**.
- Failed/cancelled turns: user kept; **committed tool rounds kept** (side effects not re-run on `/retry`); only unfinished assistant rolled back; Ctrl+C cancels; TTY confirms off-loop; auto-retry 10s/20s/30s then `/retry`. - Failed/cancelled turns: user kept; **committed tool rounds kept** (side effects not re-run on `/retry`); only unfinished assistant rolled back; Ctrl+C cancels; TTY confirms off-loop; auto-retry 10s/20s/30s then `/retry`.
- **`plyngent providers`**, **`config path|edit`**. 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. - **`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.
+2 -1
View File
@@ -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 | | `/yolo [on\|off\|once]` | Soft destructive confirms: sticky skip, off, or next turn only |
| `/retry` | Re-run incomplete last user turn (after error/cancel) | | `/retry` | Re-run incomplete last user turn (after error/cancel) |
| `/btw [--tools read\|no\|full] [--fresh]` | Side question without changing the main session (read-only tools by default) |
| `/provider` `/model` | Switch without restarting | | `/provider` `/model` | Switch without restarting |
| `/model --persist` | Save current model id into `plyngent.toml` catalog | | `/model --persist` | Save current model id into `plyngent.toml` catalog |
| `/models` | List config + remote `GET /models` (always re-fetches) | | `/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) ## Tools (when enabled)
Default registry: file ops (including `tree` with 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 truncated results via `TRUNCATE_TOKEN`), 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: Safety defaults:
+5
View File
@@ -50,6 +50,11 @@ Notes:
- Optional soft-confirm bits: **`YOLO`** (eligible for YOLO auto-approve), - Optional soft-confirm bits: **`YOLO`** (eligible for YOLO auto-approve),
**`TRUSTABLE`** (grant once per session after approve). Hard denylists are **`TRUSTABLE`** (grant once per session after approve). Hard denylists are
never YOLO-skipped. 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 - Docstring becomes the model-facing description; parameter schemas come from
type hints. type hints.
- Use `register=False` only for unit tests that build a private registry. - Use `register=False` only for unit tests that build a private registry.
Generated
+54 -54
View File
@@ -115,51 +115,51 @@ files = [
[[package]] [[package]]
name = "greenlet" name = "greenlet"
version = "3.5.4" version = "3.5.5"
requires_python = ">=3.10" requires_python = ">=3.10"
summary = "Lightweight in-process concurrent programming" summary = "Lightweight in-process concurrent programming"
groups = ["default"] groups = ["default"]
files = [ files = [
{file = "greenlet-3.5.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7e8afa5eac028f8140ceafe5ceec66e6aa127ddcb21452d2a564dcd2900b5f22"}, {file = "greenlet-3.5.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d4a389a852e392a6366058651a20fa5ba40d979865aa81bea2ccbdc44805070d"},
{file = "greenlet-3.5.4-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73b37afe369021423ea53dd3123e04bffa7e93ac64429b9f50835b2e4fcae7cf"}, {file = "greenlet-3.5.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70b157cd319873e8b544ddc2de158f55bbd0a9b0218c8ce9332039801518e328"},
{file = "greenlet-3.5.4-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ef964f56dfcb6f9bbef2a190d9126795eac408716aeae47b5e7c73c32aafca9"}, {file = "greenlet-3.5.5-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8bdfd1424abcf26832961e766570cae79efdb9599d709088c9cb6ef82b194926"},
{file = "greenlet-3.5.4-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cef589bc65fae02d10bca2ac341191c5b33acc2967892ebf4fcbd10eabb7a74c"}, {file = "greenlet-3.5.5-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d98ef6f92e67c6dbf299dbfd8facc1b0d2d9cedf91e325e73b3d0373fe4309d8"},
{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.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:182de51c6b572a705f2fafaab2e783bcf7d2760940229dfe73086cbae037af3e"},
{file = "greenlet-3.5.4-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:dfc41ae893d9ceaf22c824f2153a88b30651b20e8758c2cd9ac143f23640563c"}, {file = "greenlet-3.5.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:159df1942d88e8f784cbb38d6f18bdb365cd11319cfbb3e89623de2b97892d53"},
{file = "greenlet-3.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecca4d80d55a01ad6b23b33262662956149fbb7b2c6be2910f1705921958cbf3"}, {file = "greenlet-3.5.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8fec3f165dfe332e490c3247c0f6c23b0bfc45f06496ad7f00ddb00e3d35e4dc"},
{file = "greenlet-3.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffbc533e0eaf8e80d8471411646ab88fe58f641d508c0b02b24494479f4d9ec"}, {file = "greenlet-3.5.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c6ce25fee6cabc8bf22cb8b52e642cbb821be5b9aec8094d07ff03378141b8e9"},
{file = "greenlet-3.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:305f69e6c4523d7f6979ed001cff4e5853c063e5da04880296603aa0227e544c"}, {file = "greenlet-3.5.5-cp314-cp314-win_amd64.whl", hash = "sha256:7dffc5c859fe6059974df1e37d7923d654a83e2ae18fdd616994270e001115e1"},
{file = "greenlet-3.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:f260930bbbbcf9caee661211235a5111c86dfe5832fdf6ae4570da1e0995320f"}, {file = "greenlet-3.5.5-cp314-cp314-win_arm64.whl", hash = "sha256:5e2afcfc4d4305dd715809b03da5cbe437c8984f61d8917751eb5fe4aefa3e07"},
{file = "greenlet-3.5.4-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:41ddab54e4b238f4a6c323f39b4e59e176affd5a94d461a9fb7583dac74240a3"}, {file = "greenlet-3.5.5-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:0e5a7de979d764aea1f5b6e95cf92b5b37741b9823702041f34b126e7f690277"},
{file = "greenlet-3.5.4-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3dabe3e2809013052c68bdf0b7fa5f5f2859c43a80803131ad61af9cabd7867"}, {file = "greenlet-3.5.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fef01bd457f11fc158b130ca0027a3c365693280e8e231b65bdaf57999f39f5b"},
{file = "greenlet-3.5.4-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:27d3f00718634d4520a3a150154ac5da36f257869d41321953375b90bfbbc72c"}, {file = "greenlet-3.5.5-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5173a72310725a74afc82c164f0e52cb8ad0de62f2bb623f24f6c0cc07d80272"},
{file = "greenlet-3.5.4-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39169a11d87a6a263afda3e9a27d1df16d0f919d40a4837cc73986c9884c0dd8"}, {file = "greenlet-3.5.5-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5e9ec2e7c98e895fcea0c5cc57b2606cf86ece6d0a56578f3eb225e2af4f0387"},
{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.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44f08341873200ba8a60a8bc14ace3d91f1754f7fa7bc66157714a8cd420a476"},
{file = "greenlet-3.5.4-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:bd3d1145f603b2db19feb9078c2e6855eb7c67e15580c010ed815cee519b86fd"}, {file = "greenlet-3.5.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:102817506f6090b5176c746a82603341a549b40e5c3d5b72a4c672228a918c41"},
{file = "greenlet-3.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f00f910f0e7b35416c63b23ad78b769aeccfc1775f712b43c4ee525624a2eef7"}, {file = "greenlet-3.5.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d246c0db9a2513cd45f019ba178ea4d4d4705bd210ee465e2c15d76a1ab13874"},
{file = "greenlet-3.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:91c26423753b92caf41ab3f98fd547d7374d4d9fc2d85be041886c1579d9255e"}, {file = "greenlet-3.5.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:72507285b5caa1d17904a3f7c322ca780823a54170a0e04ec3f37bcc60d4db71"},
{file = "greenlet-3.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:ee032b91fd8ec29ec6c4cea2b8c561b178435134bd0752c7334b94e9c736c132"}, {file = "greenlet-3.5.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7805655781fb8f28a55d05fe57ed61f5f10f1892fb587673e3bb5264f28041f0"},
{file = "greenlet-3.5.4-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:178111881dd7a6c946471fda85485ec796e1043c2b939f694b096e2ecf986809"}, {file = "greenlet-3.5.5-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:c0db80fcd5b8aece93f66c64f78a786bbb6b96c5fe63ef5a5a4581ecf8bab206"},
{file = "greenlet-3.5.4-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d92df08dd65fede97fc37aad36c2e9dcda3b31c467f8e0c2c096456cb818e927"}, {file = "greenlet-3.5.5-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b241c32f912ada659808d68e308c568baf577eebf757d15471472de0c18cfad"},
{file = "greenlet-3.5.4-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:99e8f8c4ebc4fd80aa26c1280ae9ad43a0976e786349703a181cf0bae60413e5"}, {file = "greenlet-3.5.5-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ef6a08349401d8eaf3cb12688ac8557de95788556b8631ef17555a4a173022c0"},
{file = "greenlet-3.5.4-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1f17e362d78e37559e0506c5a7d066bdd45073c36a0127a543e8a0df27242ff3"}, {file = "greenlet-3.5.5-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37faa97daccb6d9f4c2141ce3118d023c3c5506864a7d8bdf726f665018c1f76"},
{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.5-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2e3d061b8e13aec2f0441689b3c71b244a20e5d274a52cb0f7e31bd1d139552"},
{file = "greenlet-3.5.4-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:cd320d998cbaa032932830448e39abf3c6a12901295e386e8114db926e10cffb"}, {file = "greenlet-3.5.5-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:b18007dc2473a7942fd157366b55f01da6fed7ce85318591005b419e0a439474"},
{file = "greenlet-3.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:c883d61f2282d72c767a14936641b3efcbde9d82f1080712aaea0b1d3126cb88"}, {file = "greenlet-3.5.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9ab5f5b93655e77fe0d6c2dfd22b5eac751bb1f876d8ec21761b7c1fb9266007"},
{file = "greenlet-3.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2a924f15d17957e252a810acefcb5942f5ca712298e8b6fcaed9a307d357522c"}, {file = "greenlet-3.5.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f0e5a21bd4452a88cf032fc43c4a5b307ab1380eacb63b5988f9c0317885e773"},
{file = "greenlet-3.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:ed17e5f3420360d5b459de8462efb52060399a5326a613d4cde31cef63ef95da"}, {file = "greenlet-3.5.5-cp315-cp315-win_amd64.whl", hash = "sha256:469dbb0a78625642f4a626cfd0c6e8bccc0385b5e49189b6308bbe849ec88a8e"},
{file = "greenlet-3.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:f908898d6fa484ce4b6f447ce70ea99b52c503fee419e53cf74d60a16bc9e667"}, {file = "greenlet-3.5.5-cp315-cp315-win_arm64.whl", hash = "sha256:2d57406c3efd32d7a81e17a674314e8bd00792cdab49ea3228a49aa1bfb2e769"},
{file = "greenlet-3.5.4-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:1833637f17d5e7472548a48575c394fe39f1b1890d676d162d86593610f44d8c"}, {file = "greenlet-3.5.5-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:68184dfcf50ccaa8e864770fe0633a7e27250ea9329f8192ef47ee9ecfd78e1c"},
{file = "greenlet-3.5.4-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12cda9122e03341f1cb6b8207a19d7a9d375e52f1b4e9243918375f40fd7b4b9"}, {file = "greenlet-3.5.5-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ec0dc0e59dc9c61af5c47348365ccbbd7addfafe0a93b00336ff3da2907bdc6"},
{file = "greenlet-3.5.4-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d83ae0e32d14957ab7170785a20f582635c8474deab1bfbb552b17e769a6ce25"}, {file = "greenlet-3.5.5-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e604f58e35833fc46ef20302bcb314dddbfd3fcf33a4f936216d51dd678d63ae"},
{file = "greenlet-3.5.4-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:123aa379c962ed5fe90a880327e0c3066124ac64ec99e12a238be9fd8eb3db3d"}, {file = "greenlet-3.5.5-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2888a3a38bc5ee5bb6c438372197152e815837e4fab7ed7a1f86ef18ffd58ad1"},
{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.5-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40239b5384f96da3963585cc6d7eaa9b56f8ae67e8d92cc82dd9e202fc847de3"},
{file = "greenlet-3.5.4-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:adf2244d7f69409925a8f22ed22cc5f93cdfe5c9dc87ff3476be2c2aaae61a05"}, {file = "greenlet-3.5.5-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:1e8d9391fe77f15649589a907cef972dbbd6352ef7ff7dc0492f658c0c26495f"},
{file = "greenlet-3.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0fa53040b78b578120eecdc0265e3f1051487cc425d11a2b7c761daadf4feaa8"}, {file = "greenlet-3.5.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:a1eaccf5c3a1d3e46dead602c72e6836731e8e245c9de6a27764567b6b62d4c0"},
{file = "greenlet-3.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:60e0bc961d367df506660e9ac0177a76bc6d81305300704b0977d1634f76efe2"}, {file = "greenlet-3.5.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:19e4e026fe20691f333b8eb1a3bc9625eceba8c3f9d62ec5a6f8581afbc6b5a5"},
{file = "greenlet-3.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:f680e549edb3eaf21eea4e7fe101e15ec180c74b7879ab46adc080f22d4015d2"}, {file = "greenlet-3.5.5-cp315-cp315t-win_amd64.whl", hash = "sha256:712aee154f648bde84634654bb38bb78c69ac640c37a45c9effed800735049d8"},
{file = "greenlet-3.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:08fc36de8442d5c3e95b044550dbea9bf144d31ec0cc58e36fb241cb6ef6a994"}, {file = "greenlet-3.5.5-cp315-cp315t-win_arm64.whl", hash = "sha256:7f049911ee81a16a03c33d5450d8d5867d27f596ca5fb201b86f4524e874468b"},
{file = "greenlet-3.5.4.tar.gz", hash = "sha256:0232ae1de90a8e07867bb127d7a6ba2301e859145489f25cda8a6096dabe1d20"}, {file = "greenlet-3.5.5.tar.gz", hash = "sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c"},
] ]
[[package]] [[package]]
@@ -315,24 +315,24 @@ files = [
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "26.2" version = "26.3"
requires_python = ">=3.8" requires_python = ">=3.9"
summary = "Core utilities for Python packages" summary = "Core utilities for Python packages"
groups = ["dev"] groups = ["dev"]
files = [ files = [
{file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"}, {file = "packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c"},
{file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"}, {file = "packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79"},
] ]
[[package]] [[package]]
name = "platformdirs" name = "platformdirs"
version = "4.11.0" version = "4.11.2"
requires_python = ">=3.10" requires_python = ">=3.10"
summary = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." summary = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
groups = ["default"] groups = ["default"]
files = [ files = [
{file = "platformdirs-4.11.0-py3-none-any.whl", hash = "sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74"}, {file = "platformdirs-4.11.2-py3-none-any.whl", hash = "sha256:7f89089b6ea71bda7962953edcf784b2e2d9d285b40ad88be2bb75c6e9d82ab4"},
{file = "platformdirs-4.11.0.tar.gz", hash = "sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0"}, {file = "platformdirs-4.11.2.tar.gz", hash = "sha256:3a2ae5fca3520a01ab1be8b45613537f52ddf5b5f6f53d88233892dfbf0cd82d"},
] ]
[[package]] [[package]]
@@ -549,7 +549,7 @@ files = [
[[package]] [[package]]
name = "urllib3-future" name = "urllib3-future"
version = "2.23.900" version = "2.24.900"
requires_python = ">=3.7" requires_python = ">=3.7"
summary = "urllib3.future is a powerful HTTP 1.1, 2, and 3 client with both sync and async interfaces" summary = "urllib3.future is a powerful HTTP 1.1, 2, and 3 client with both sync and async interfaces"
groups = ["default"] 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\"", "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 = [ files = [
{file = "urllib3_future-2.23.900-py3-none-any.whl", hash = "sha256:4303dc9a5d8c4c810f3fcd3b13bd62d69c3a4f63bb7e62089410907c34aeaf6b"}, {file = "urllib3_future-2.24.900-py3-none-any.whl", hash = "sha256:a4fe06b59b0c3ce2fffa821c3de72eaf59b1e61619becc255dee18919ee25260"},
{file = "urllib3_future-2.23.900.tar.gz", hash = "sha256:f488f22983f96cd8c733640b47e6a32def462d33c59bcc4bea6d97a8568b70c8"}, {file = "urllib3_future-2.24.900.tar.gz", hash = "sha256:05c2e9d09293ab29a154fed8f5b60644c7cdaffe4ae2587a07b1c7f00f23a7fb"},
] ]
[[package]] [[package]]
name = "wassima" name = "wassima"
version = "2.1.2" version = "2.1.3"
requires_python = ">=3.7" requires_python = ">=3.7"
summary = "Access your OS root certificates with utmost ease" summary = "Access your OS root certificates with utmost ease"
groups = ["default"] groups = ["default"]
marker = "sys_platform != \"emscripten\"" marker = "sys_platform != \"emscripten\""
files = [ files = [
{file = "wassima-2.1.2-py3-none-any.whl", hash = "sha256:9a5779e8ade39960973c6cc17e095cc998d6345ab3dbdd1ec2d7fde8c356fb82"}, {file = "wassima-2.1.3-py3-none-any.whl", hash = "sha256:f8251c23720dd092117a554b3012756caee3feed6d0122c2458a2aca5d7d92c5"},
{file = "wassima-2.1.2.tar.gz", hash = "sha256:f74b5441151728c54118ece6d747cfe92b2c595a3d062a1955f42a2bc894cd10"}, {file = "wassima-2.1.3.tar.gz", hash = "sha256:fcd6c38be0f909c393da35cb2a993101fcdcff673b8fa8d5da228f73b630d0d0"},
] ]
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "plyngent" name = "plyngent"
version = "0.3.0" version = "0.4.1"
description = "LLM chat and agent toolkit" description = "LLM chat and agent toolkit"
authors = [ authors = [
{ name = "HivertMoZara", email = "worldmozara@163.com" }, { name = "HivertMoZara", email = "worldmozara@163.com" },
+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 get_lineno_read_files as get_lineno_read_files
from .chat import invalidate_lineno_read as invalidate_lineno_read 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_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 mark_lineno_read as mark_lineno_read
from .chat import reset_lineno_tracker as reset_lineno_tracker from .chat import reset_lineno_tracker as reset_lineno_tracker
from .chat import was_lineno_read as was_lineno_read 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( def truncate_tool_result(
text: str, text: str,
max_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS, max_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS,
*,
hint: bool = True,
) -> str: ) -> str:
"""Cap tool output so huge dumps do not flood model context. """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 This is the generic agent-loop cap for arbitrary tool results. It embeds a
it may ask the user to raise the config limit (``max_tool_result_chars``). memory truncate token via :func:`truncate_generic` so the model can always
``hint=False`` keeps the terse marker for internal shrinks whose cap is not resume the remainder with ``get_truncated`` — resumable or not (remainders
a user-facing config knob (e.g. request-time compacting). 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: if max_chars < 1:
return text return text
if len(text) <= max_chars: if len(text) <= max_chars:
return text return text
omitted = len(text) - max_chars from plyngent.tools.truncate_token import truncate_generic
if hint:
return ( return truncate_generic(text, max_chars)
f"{text[:max_chars]}\n"
f"...[Truncated ({max_chars} chars max.; {omitted} chars omitted). "
f"Use `get_truncated` when a `TRUNCATE_TOKEN` is shown.]"
)
return f"{text[:max_chars]}\n...[truncated {omitted} characters]"
def estimate_message_chars(message: AnyChatMessage) -> int: def estimate_message_chars(message: AnyChatMessage) -> int:
@@ -120,7 +115,7 @@ def _shrink_tool(message: ToolChatMessage, max_chars: int) -> ToolChatMessage:
return message return message
return msgspec.structs.replace( return msgspec.structs.replace(
message, 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 __future__ import annotations
from contextvars import ContextVar, Token from contextvars import ContextVar, Token
from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from msgspec import UNSET from msgspec import UNSET
@@ -47,14 +48,18 @@ if TYPE_CHECKING:
# Per-conversation tracking of lines read with ``with_lineno=True``, keyed by # 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 # resolved file path, with the file's mtime at read time (so stale line numbers
# :func:`reset_lineno_tracker`; cleared after the assistant finishes. # after external/process mutations are detected). Set to a fresh ``dict`` at the
# Tool handlers read/write via :func:`get_lineno_read_files` / # start of each user turn via :func:`reset_lineno_tracker`; cleared after the
# :func:`lineno_read_lines` / :func:`mark_lineno_read`. # assistant finishes. Tool handlers read/write via :func:`get_lineno_read_files` /
_lineno_read_files: ContextVar[dict[str, set[int]] | None] = ContextVar("_lineno_read_files", default=None) # :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. """Replace the tracker with a fresh empty mapping; return the previous token.
Call at the start of a new user turn. Restore with 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() val = _lineno_read_files.get()
if val is None: if val is None:
return set() 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: def mark_lineno_read(path: str, lines: set[int]) -> None:
"""Record that *path* was read with line numbers this turn. """Record that *path* was read with line numbers this turn.
``lines`` are 1-based absolute file line numbers (``read_file`` slice). ``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() val = _lineno_read_files.get()
if val is not None: if val is None:
val.setdefault(path, set()).update(lines) 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: def invalidate_lineno_read(path: str) -> None:
@@ -569,7 +595,8 @@ class ChatAgent:
- Tools default **off**. ``tools=True`` clones this agent's registry with - Tools default **off**. ``tools=True`` clones this agent's registry with
a **fresh session** bag (unless *session_state* is passed) and the a **fresh session** bag (unless *session_state* is passed) and the
given *instance_state* (CLI typically shares the host instance for 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() text = user_text.strip()
if not text: if not text:
+3 -2
View File
@@ -22,11 +22,12 @@ use `run_argv_batch` for ordered pipelines.
- `edit_replace`: fix match / `max_replaces`; `edit_lineno` edits only lines read via \ - `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(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). - `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`. - Truncated results carry a `truncate_token=...`; continue with `get_truncated`.
- Prefer `fetch` for HTTP(S); private/LAN hosts need human policy allow (not YOLO). - 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. - PTY secrets only via `ask_into_pty`; denylists and confirms still apply.
- `wait` pauses; pressing Enter disturbs early (optional reason). - `wait` pauses; pressing Enter disturbs early (optional reason).
- Todo stack: open items mean unfinished work. - 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( _BAND_MARKER = re.compile(
+14 -1
View File
@@ -16,6 +16,7 @@ from plyngent.lmproto.openai_compatible.model import (
ChatCompletionChunk, ChatCompletionChunk,
ChatCompletionResponse, ChatCompletionResponse,
ChatCompletionsParam, ChatCompletionsParam,
DeltaMessage,
StreamOptions, StreamOptions,
StreamToolCallDelta, StreamToolCallDelta,
ToolChatMessage, 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( async def _stream_round(
client: AnyLLMClient, client: AnyLLMClient,
param: ChatCompletionsParam, param: ChatCompletionsParam,
@@ -295,7 +308,7 @@ async def _stream_round(
saw_terminal = True saw_terminal = True
delta = choice.delta delta = choice.delta
if isinstance(delta.reasoning_content, str) and delta.reasoning_content: if isinstance(delta.reasoning_content, str) and delta.reasoning_content:
reasoning_parts.append(delta.reasoning_content) reasoning_parts = _append_reasoning_delta(reasoning_parts, delta)
yield ReasoningDeltaEvent(content=delta.reasoning_content) yield ReasoningDeltaEvent(content=delta.reasoning_content)
if isinstance(delta.content, str) and delta.content: if isinstance(delta.content, str) and delta.content:
content_parts.append(delta.content) content_parts.append(delta.content)
+19 -10
View File
@@ -47,13 +47,15 @@ if TYPE_CHECKING:
# Responses stream event types that carry reasoning text deltas. # Responses stream event types that carry reasoning text deltas.
# OpenAI streams ``response.reasoning_summary_text.delta``; DeepSeek streams # OpenAI streams ``response.reasoning_summary_text.delta``; DeepSeek streams
# ``response.reasoning_text.delta`` and emits the full text once via # ``response.reasoning_text.delta`` fragments and emits the FULL chain-of-thought
# ``response.reasoning_text.done``. # 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 = { _REASONING_DELTA_TYPES = {
"response.reasoning_summary_text.delta", "response.reasoning_summary_text.delta",
"response.reasoning_text.delta", "response.reasoning_text.delta",
"response.reasoning_text.done",
} }
_REASONING_FULL_TYPES = {"response.reasoning_text.done"}
# Terminal events that carry the full Response object (status, tool calls, usage). # Terminal events that carry the full Response object (status, tool calls, usage).
# ``response.failed`` also carries a Response but is surfaced as an error. # ``response.failed`` also carries a Response but is surfaced as an error.
_FINAL_EVENT_TYPES = {"response.completed", "response.incomplete"} _FINAL_EVENT_TYPES = {"response.completed", "response.incomplete"}
@@ -98,12 +100,19 @@ def _decode_final_event(event: ResponseStreamEvent) -> ResponseModel | None:
return None return None
def _stream_reasoning_content(event: ResponseStreamEvent) -> str | None: def _stream_reasoning_content(event: ResponseStreamEvent) -> tuple[str | None, bool]:
"""Return reasoning text from a reasoning stream event (delta or full-text done).""" """Return (reasoning_text, is_full) from a reasoning stream event.
if event.type not in _REASONING_DELTA_TYPES:
return None ``.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 content = event.delta if isinstance(event.delta, str) else event.text
return content if isinstance(content, str) and content else None 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]: def _terminal_chunks(final: ResponseModel, *, model: str) -> list[ChatCompletionChunk]:
@@ -148,10 +157,10 @@ async def _stream_as_chat_chunks(
if etype == "response.output_text.delta" and isinstance(event.delta, str) and event.delta: if etype == "response.output_text.delta" and isinstance(event.delta, str) and event.delta:
yield text_delta_chunk(model=model, content=event.delta) yield text_delta_chunk(model=model, content=event.delta)
continue continue
reasoning = _stream_reasoning_content(event) reasoning, reasoning_full = _stream_reasoning_content(event)
if reasoning is not None: if reasoning is not None:
saw_reasoning = True saw_reasoning = True
yield reasoning_delta_chunk(model=model, content=reasoning) yield reasoning_delta_chunk(model=model, content=reasoning, full=reasoning_full)
continue continue
if final is None: if final is None:
+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( return ChatCompletionChunk(
id="bridge-stream", id="bridge-stream",
object="chat.completion.chunk", object="chat.completion.chunk",
@@ -67,7 +73,7 @@ def reasoning_delta_chunk(*, model: str, content: str, created: int = 0) -> Chat
choices=[ choices=[
ChunkChoice( ChunkChoice(
index=0, 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 YOLO = auto() # soft-confirm: eligible for YOLO auto-approve
INSTANCE_STATE = auto() # needs instance-scoped state INSTANCE_STATE = auto() # needs instance-scoped state
SESSION_STATE = auto() # needs session-scoped state SESSION_STATE = auto() # needs session-scoped state
READ_ONLY = auto() # never mutates host state; safe for read-only contexts
class ToolDefinition: class ToolDefinition:
@@ -266,14 +267,22 @@ class ToolRegistry:
session_state: object | None = None, session_state: object | None = None,
yolo: bool | None = None, yolo: bool | None = None,
auto_bind_state: bool | None = None, auto_bind_state: bool | None = None,
read_only_only: bool = False,
) -> ToolRegistry: ) -> ToolRegistry:
"""New registry with the same tools/hooks and rebound host state. """New registry with the same tools/hooks and rebound host state.
*instance_state* / *session_state* are applied as given (use the parent *instance_state* / *session_state* are applied as given (use the parent
registry's handles only when the caller passes them through). 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( return ToolRegistry(
self.definitions(), definitions,
danger=self._danger, danger=self._danger,
on_confirm=self._on_confirm, on_confirm=self._on_confirm,
yolo=self._yolo if yolo is None else yolo, yolo=self._yolo if yolo is None else yolo,
+8
View File
@@ -17,6 +17,7 @@ from plyngent.cli.editor import (
resolve_config_path, resolve_config_path,
) )
from plyngent.cli.exit_codes import EXIT_CANCELLED, EXIT_OK, EXIT_TURN_FAILED 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.limits import install_cli_limit_hooks
from plyngent.cli.repl import run_repl from plyngent.cli.repl import run_repl
from plyngent.cli.retry import run_user_text_with_retries from plyngent.cli.retry import run_user_text_with_retries
@@ -484,6 +485,13 @@ def chat_cmd(
raise click.ClickException(msg) raise click.ClickException(msg)
root = workspace if workspace is not None else Path.cwd() 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( code = asyncio.run(
_run_chat( _run_chat(
config_path=config_path, config_path=config_path,
+329 -26
View File
@@ -3,6 +3,8 @@ from __future__ import annotations
import contextlib import contextlib
import json import json
import os import os
import re
import shlex
import sys import sys
from contextvars import ContextVar from contextvars import ContextVar
from typing import TYPE_CHECKING, Literal, cast from typing import TYPE_CHECKING, Literal, cast
@@ -22,7 +24,7 @@ from plyngent.agent import (
from plyngent.lmproto.openai_compatible.model import AssistantFunctionToolCall from plyngent.lmproto.openai_compatible.model import AssistantFunctionToolCall
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import AsyncIterator from collections.abc import AsyncIterator, Callable
from plyngent.agent import AgentEvent from plyngent.agent import AgentEvent
@@ -31,7 +33,29 @@ _TOOL_ARGS_PREVIEW = 80
# Tool calls that render as a single pretty summary line instead of # Tool calls that render as a single pretty summary line instead of
# ``[tool]`` / ``[tool result]``. # ``[tool]`` / ``[tool result]``.
_PRETTY_TOOLS = frozenset({"read_file", "todo_push", "todo_update"}) _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). # Process/session display flags (set from ReplState / slash).
_verbose_tool_results: ContextVar[bool] = ContextVar("verbose_tool_results", default=False) _verbose_tool_results: ContextVar[bool] = ContextVar("verbose_tool_results", default=False)
@@ -74,47 +98,327 @@ def _preview(text: str, limit: int) -> str:
return text[:limit] + "…" return text[:limit] + "…"
def _json_str_arg(args_json: str, key: str) -> str | None: def _preview_result(content: str, limit: int) -> str:
"""Extract a string argument from a tool-call arguments JSON blob.""" """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: try:
raw = json.loads(args_json) raw = json.loads(args_json)
except ValueError: except ValueError:
return None return None
if isinstance(raw, dict): if isinstance(raw, dict):
value = cast("dict[str, object]", raw).get(key) return cast("dict[str, object]", raw).get(key)
if isinstance(value, str):
return value
return None 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: def _read_file_pretty(args_json: str, result: str) -> str:
"""One-line summary for a ``read_file`` call: ``* Read 'path' L1-4 (done)``.""" """One-line summary for a ``read_file`` call: ``* Read 'path' (done)``."""
path = _json_str_arg(args_json, "path") or "?" path = _json_str_arg(args_json, "path") or "?"
if result.startswith("error: file not found"): if result.startswith("error: file not found"):
return f"* Read '{path}' (file not found)" return _pretty_line(f"* Read '{path}' ", ("(file not found)", "red"))
if result.startswith("error: not a file"): if result.startswith("error: not a file"):
return f"* Read '{path}' (not a file)" return _pretty_line(f"* Read '{path}' ", ("(not a file)", "red"))
if result.startswith("error:"): if result.startswith("error:"):
return f"* Read '{path}' (error)" return _pretty_line(f"* Read '{path}' ", ("(error)", "red"))
first = result.splitlines()[0] if result.splitlines() else "" return _pretty_line(f"* Read '{path}' ", ("(done)", "green"))
if first.startswith("L") and "-" in first:
return f"* Read '{path}' {first} (done)"
return f"* Read '{path}' (done)"
def _todo_pretty(name: str, result: str) -> str: def _todo_pretty(name: str, result: str) -> str:
"""Summary for todo push/update: header + the rendered todo stack.""" """Summary for todo push/update: header + the rendered todo stack."""
label = "Todo Push" if name == "todo_push" else "Todo Update" label = "Todo Push" if name == "todo_push" else "Todo Update"
return f"* {label}:\n{result}" 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: 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.""" """Pretty summary for a known tool call + result; None keeps the old style."""
if name == "read_file": builder = _PRETTY_BUILDERS.get(name)
return _read_file_pretty(args_json, result) if builder is None:
if name in {"todo_push", "todo_update"}: return None
return _todo_pretty(name, result) return builder(args_json, result)
return None
def _echo_stream(text: str) -> None: def _echo_stream(text: str) -> None:
@@ -258,14 +562,13 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
content = event.message.content content = event.message.content
name, args, pretty_tool = pending_tools.pop(0) if pending_tools else ("", "", False) 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 pretty_line = _pretty_tool_result(name, args, content) if pretty_tool else None
if pretty_line is not None: if pretty_line is not None and not show_full:
click.secho(f"\n{pretty_line}", fg="yellow") click.echo(f"\n{pretty_line}")
elif show_full: elif show_full:
click.secho(f"[tool ok]\n{content}", fg="magenta") click.secho(f"[tool ok]\n{content}", fg="magenta")
else: else:
preview = _preview(content, _TOOL_RESULT_PREVIEW) preview = _preview_result(content, _TOOL_RESULT_PREVIEW)
one_line = preview.replace("\n", " ") click.secho(f"[tool ok] {preview}", fg="magenta")
click.secho(f"[tool ok] {one_line}", fg="magenta")
elif isinstance(event, ErrorEvent): elif isinstance(event, ErrorEvent):
flush_assistant() flush_assistant()
suffix = "" suffix = ""
+27
View File
@@ -29,6 +29,33 @@ def set_sigint_reinstall(callback: Callable[[], None] | None) -> None:
_reinstall_holder[0] = callback _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 @contextmanager
def pause_task_cancel_for_prompt() -> Generator[None]: def pause_task_cancel_for_prompt() -> Generator[None]:
"""Disable turn-task cancel during blocking TTY prompts (confirm, etc.). """Disable turn-task cancel during blocking TTY prompts (confirm, etc.).
+33 -30
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import contextlib
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import click import click
@@ -23,9 +24,13 @@ def _echo_user(text: str) -> None:
def _echo_interrupted() -> None: def _echo_interrupted() -> None:
click.echo() # A further Ctrl+C during this output must stay benign (same rule as
click.secho("interrupted", fg="yellow") # retry._echo_cancel_lines): after a turn the SIGINT handler is removed, so
click.echo() # 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: async def run_repl(state: ReplState) -> None:
@@ -49,33 +54,31 @@ async def run_repl(state: ReplState) -> None:
click.echo() click.echo()
break break
if entry is None: # Between turns the turn-task SIGINT handler is removed (run_cancellable),
continue # so a stray Ctrl+C raises KeyboardInterrupt at whatever bytecode is
# executing — echoing user text, expire_yolo_once, gaps between
if entry.startswith("/"): # statements. Swallow it and re-prompt instead of exiting the REPL.
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)
try: 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: except KeyboardInterrupt:
_echo_interrupted() _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) task: asyncio.Task[T] = asyncio.create_task(coro)
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
installed = False 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: def _on_sigint() -> None:
# allow_task_cancel() uses a process-level depth counter (not ContextVar) # 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: if installed:
with contextlib.suppress(NotImplementedError, RuntimeError, ValueError): with contextlib.suppress(NotImplementedError, RuntimeError, ValueError):
_ = loop.remove_signal_handler(signal.SIGINT) _ = 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). # Only cancel if still running (e.g. KeyboardInterrupt path above).
# Do not cancel a finished task — that would mask success. # Do not cancel a finished task — that would mask success.
if not task.done(): if not task.done():
+53 -25
View File
@@ -24,6 +24,7 @@ from plyngent.runtime import ProviderNotSupportedError
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Awaitable, Sequence from collections.abc import Awaitable, Sequence
from plyngent.agent import ToolRegistry
from plyngent.cli.state import ReplState, YoloMode from plyngent.cli.state import ReplState, YoloMode
from plyngent.lmproto.openai_compatible.model import AnyChatMessage 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" "works after resume, not only via readline history). Auto-retry: 10s/20s/30s.\n"
"\n" "\n"
"Side questions: /btw … answers without changing the main session\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" "Use --session ID or /resume to continue a prior chat after restart.\n"
"\n" "\n"
'Multiline: start a message with """ then end a later line with """.\n' 'Multiline: start a message with """ then end a later line with """.\n'
@@ -928,11 +930,12 @@ def _format_tool_tags(tags: object) -> str:
@slash.command("btw") @slash.command("btw")
@click.argument("question", nargs=-1, required=False) @click.argument("question", nargs=-1, required=False)
@click.option( @click.option(
"--tools/--no-tools", "--tools",
"with_tools", "tools_mode",
default=False, type=click.Choice(["no", "read", "full"]),
default="read",
show_default=True, 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( @click.option(
"--fresh", "--fresh",
@@ -945,48 +948,73 @@ def btw_cmd(
state: ReplState, state: ReplState,
question: tuple[str, ...], question: tuple[str, ...],
*, *,
with_tools: bool, tools_mode: str,
fresh: bool, fresh: bool,
) -> None: ) -> None:
"""Ask a side question without changing the main session transcript or DB. """Ask a side question without changing the main session transcript or DB.
Uses the current model and (unless ``--fresh``) a copy of main history. Uses the current model and (unless ``--fresh``) a copy of main history.
Tools are **off** by default; ``--tools`` clones the main tool registry with Tools default to **read-only** (``--tools=read``): file/vcs/ask/fetch-GET
a fresh session bag and the shared instance workspace. 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() text = " ".join(question).strip()
if not text: if not text:
click.echo("usage: /btw [--tools] [--fresh] <question>") click.echo("usage: /btw [--tools read|no|full] [--fresh] <question>")
return return
if state.agent.pending_retry_text is not None: if state.agent.pending_retry_text is not None:
click.echo("error: finish or /retry the main turn before /btw") click.echo("error: finish or /retry the main turn before /btw")
return return
if with_tools and (not state.tools_enabled or state.agent.tools is None):
click.echo("error: --tools requires tools on (/tools on)") tools_on = state.tools_enabled and state.agent.tools is not None
return 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.display import render_events
from plyngent.cli.retry import run_cancellable 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.secho("btw: ", fg="magenta", nl=False)
click.echo(text) click.echo(text)
tools_arg: bool = with_tools if tools_mode == "no":
aside_session = SessionState() if with_tools else None tools_arg: ToolRegistry | bool = False
aside_instance = state.instance_state if with_tools else None 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: async def _run() -> None:
await run_cancellable( side = render_events(
render_events( state.agent.run_aside(
state.agent.run_aside( text,
text, include_history=not fresh,
include_history=not fresh, tools=tools_arg,
tools=tools_arg, instance_state=aside_instance,
instance_state=aside_instance, session_state=aside_session,
session_state=aside_session,
)
) )
) )
if tools_mode == "read":
with read_only_context():
await run_cancellable(side)
else:
await run_cancellable(side)
try: try:
_await(_run()) _await(_run())
+4 -2
View File
@@ -34,8 +34,8 @@ re-read with line numbers after any `edit_*` / `write_file` / copy/move/delete,
since line numbers go stale. since line numbers go stale.
- `read_file` results start with a 1-based line range `L{begin}-{end}` (`offset` \ - `read_file` results start with a 1-based line range `L{begin}-{end}` (`offset` \
is 0-based); `with_lineno` shows per-line numbers instead. is 0-based); `with_lineno` shows per-line numbers instead.
- Truncated `read_file` / `fetch` results carry a `TRUNCATE_TOKEN`; use \ - Truncated results (any tool) carry a `truncate_token=...`; use `get_truncated` \
`get_truncated` with it to keep reading (chains through truncations). with it to keep reading (chains through truncations).
### Commands ### Commands
- Prefer `run_argv` / `run_argv_batch` (argv lists, no shell) over `bash -c` or similar. - Prefer `run_argv` / `run_argv_batch` (argv lists, no shell) over `bash -c` or similar.
@@ -65,6 +65,8 @@ otherwise a small default is used. Never rely on shell to spoof identity.
### Todos ### Todos
- Use the todo stack for multi-step work (LIFO groups: push related items, finish/update, \ - Use the todo stack for multi-step work (LIFO groups: push related items, finish/update, \
pop the group). Open items mean unfinished work. 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. # Providers often send content=null / reasoning_content=null on partial chunks.
content: str | None | Unset = UNSET content: str | None | Unset = UNSET
reasoning_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 tool_calls: list[StreamToolCallDelta] | Unset = UNSET
+1 -1
View File
@@ -4,7 +4,7 @@ from plyngent.agent import ToolTag, tool
from plyngent.prompting import NonInteractiveError, ask_async 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: async def ask_user(question: str, default: str = "") -> str:
"""Ask the human a free-form one-line question and return their answer. """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 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( async def choose_user(
question: str, question: str,
options: list[str] | list[dict[str, object]], 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 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( async def form_user(
title: str, title: str,
fields: list[dict[str, object]], fields: list[dict[str, object]],
+1 -1
View File
@@ -8,7 +8,7 @@ from plyngent.prompting import get_prompt_backend, read_line_with_timeout, run_p
WAIT_PROMPT = "Waiting for {duration}s. Press Enter to disturb. Feel free to ship any reason before pressing Enter. " 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) @tool(name="wait", tags=ToolTag.LOCAL | ToolTag.READ_ONLY)
async def wait(duration: int, *, reason: str | None = None) -> str: async def wait(duration: int, *, reason: str | None = None) -> str:
"""Wait ``duration`` seconds before continuing. """Wait ``duration`` seconds before continuing.
+23
View File
@@ -137,6 +137,29 @@ def bind_session(state: SessionState | None) -> Generator[SessionState | None]:
_session.reset(token) _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 @contextmanager
def bind_tool_context( def bind_tool_context(
*, *,
+40 -8
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING 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 from plyngent.tools.workspace import resolve_path
if TYPE_CHECKING: 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. """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 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: 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) 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}" 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( def _replace_range(
target: Path, target: Path,
path: str, path: str,
@@ -92,7 +101,11 @@ def _replace_range(
new_lines = lines[: start_line - 1] + replacement + lines[end:] new_lines = lines[: start_line - 1] + replacement + lines[end:]
_ = target.write_text("".join(new_lines), encoding="utf-8") _ = target.write_text("".join(new_lines), encoding="utf-8")
removed = end - start_line + 1 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) @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: if err is not None:
return err 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: if unread:
preview = unread[:_UNREAD_PREVIEW] preview = unread[:_UNREAD_PREVIEW]
head = ", ".join(str(i) for i in preview) head = ", ".join(str(i) for i in preview)
if len(unread) > _UNREAD_PREVIEW: if len(unread) > _UNREAD_PREVIEW:
head += f", … ({len(unread)} total)" 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: if start_line == len(lines) + 1:
result = _append_after(target, path, text, lines, new_content) result = _append_after(target, path, text, lines, new_content)
else: else:
result = _replace_range(target, path, lines, start_line, end_line, new_content) 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. # Line numbers are stale after a write; the fresh mtime makes the next
invalidate_lineno_read(str(target)) # edit_lineno reject until the file is re-read.
return result return result
+30 -8
View File
@@ -1,6 +1,6 @@
from __future__ import annotations 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 from plyngent.tools.workspace import resolve_path
@@ -18,15 +18,31 @@ def _count_non_overlapping(text: str, needle: str) -> int:
start = index + len(needle) 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 remaining = found - replaced
unit = "occurrence" if replaced == 1 else "occurrences" unit = "occurrence" if replaced == 1 else "occurrences"
change = f" ('{_excerpt(old_string)}' → '{_excerpt(new_string)}')"
if remaining == 0: if remaining == 0:
if found == 1: if found == 1:
return f"replaced 1 occurrence in {path}" return f"replaced 1 occurrence in {path}{change}"
return f"replaced {replaced} {unit} in {path} (all {found} matches)" return f"replaced {replaced} {unit} in {path}{change} (all {found} matches)"
return ( return (
f"replaced {replaced} {unit} in {path} " f"replaced {replaced} {unit} in {path}{change} "
f"({replaced} of {found} matches; {remaining} remain — " f"({replaced} of {found} matches; {remaining} remain — "
f"raise max_replaces or use a more specific old_string)" 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) n = min(max_replaces, found)
updated = text.replace(old_string, new_string, n) updated = text.replace(old_string, new_string, n)
_ = target.write_text(updated, encoding="utf-8") _ = target.write_text(updated, encoding="utf-8")
# Any lineno read state is stale after a write; force a re-read. # The write changes the file's mtime, so edit_lineno's freshness check
invalidate_lineno_read(str(target)) # rejects stale line numbers until the model re-reads with_lineno.
return _success_message(path, replaced=n, found=found) 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: else:
_ = shutil.copy2(source, dest, follow_symlinks=False) _ = shutil.copy2(source, dest, follow_symlinks=False)
label = _kind(source) 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)) invalidate_lineno_read(str(dest))
return f"copied {label} {src} -> {dest.relative_to(root)}" return f"copied {label} {src} -> {dest.relative_to(root)}"
moved = Path(shutil.move(str(source), str(dest))).resolve() 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(source))
invalidate_lineno_read(str(moved)) invalidate_lineno_read(str(moved))
return f"moved {_kind(moved)} {src} -> {moved.relative_to(root)}" return f"moved {_kind(moved)} {src} -> {moved.relative_to(root)}"
+62 -10
View File
@@ -6,31 +6,83 @@ from typing import cast
from plyngent.agent import ToolTag, tool from plyngent.agent import ToolTag, tool
from plyngent.agent.budget import DEFAULT_TOOL_RESULT_MAX_CHARS from plyngent.agent.budget import DEFAULT_TOOL_RESULT_MAX_CHARS
from plyngent.tools.file.read import line_range_label, read_raw_text from plyngent.tools.file.read import line_range_label, read_file, read_raw_text
from plyngent.tools.net.fetch import fetch from plyngent.tools.net.fetch import fetch
from plyngent.tools.truncate_token import decode_truncate_token, truncate_with_token from plyngent.tools.truncate_token import (
TruncateToken,
decode_truncate_token,
get_remainder,
truncate_with_token,
)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE) 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: 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``. """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``, or Pass the token shown at the end of a truncated ``read_file``, ``fetch``,
earlier ``get_truncated`` result to keep reading without re-requesting the ``run_command``, or earlier ``get_truncated`` result to keep reading without
whole source or raising limits. Truncated output carries a fresh token, so re-requesting the whole source or raising limits. Truncated output carries a
chunks chain indefinitely. File chunks start with a 1-based ``L{begin}-{end}`` fresh token, so chunks chain indefinitely. File chunks resume in the same
line range like ``read_file``. 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) parsed = decode_truncate_token(token)
if parsed is None: if parsed is None:
return "error: invalid truncate token" return "error: invalid truncate token"
if parsed.kind == "http": if parsed.kind == "http":
return await fetch.handler(parsed.location, offset=parsed.offset, max_chars=parsed.limit) 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) text, err = read_raw_text(parsed.location)
if err: if err:
return err return err
text = cast("str", text) text = cast("str", text)
start = parsed.offset start = parsed.offset
if parsed.numbered:
return await _numbered_continuation(text, parsed.location, start, max_chars)
segment = text[start : start + max_chars] segment = text[start : start + max_chars]
chunk, _ = truncate_with_token( chunk, _ = truncate_with_token(
segment, segment,
@@ -41,6 +93,6 @@ async def get_truncated(token: str, *, max_chars: int = DEFAULT_TOOL_RESULT_MAX_
limit=max_chars, limit=max_chars,
total_len=len(text), total_len=len(text),
) )
content_len = len(chunk.split("\n...[", 1)[0]) if "\n...[" in chunk else len(chunk) 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) header = line_range_label(text, start, start + content_len)
return f"{header}\n{chunk}" 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 return root, base
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE) @tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
async def glob_paths( async def glob_paths(
pattern: str, pattern: str,
path: 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}" 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( async def grep_files(
pattern: str, pattern: str,
path: str = ".", path: str = ".",
+1 -1
View File
@@ -4,7 +4,7 @@ from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import resolve_path 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: async def listdir(path: str = ".") -> str:
"""List entries in a directory under the workspace (name and type).""" """List entries in a directory under the workspace (name and type)."""
target = resolve_path(path) target = resolve_path(path)
+47 -17
View File
@@ -3,7 +3,8 @@ from __future__ import annotations
from typing import cast from typing import cast
from plyngent.agent import ToolTag, mark_lineno_read, tool from plyngent.agent import ToolTag, mark_lineno_read, tool
from plyngent.tools.truncate_token import truncate_with_token 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 from plyngent.tools.workspace import resolve_path
_LINENO_WIDTH = 6 _LINENO_WIDTH = 6
@@ -48,14 +49,14 @@ def read_raw_text(path: str) -> tuple[str | None, str]:
return target.read_text(encoding="utf-8", errors="replace"), "" return target.read_text(encoding="utf-8", errors="replace"), ""
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE) @tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
async def read_file( async def read_file(
path: str, path: str,
*, *,
offset: int = 0, offset: int = 0,
limit: int | None = None, limit: int | None = None,
with_lineno: bool = False, with_lineno: bool = False,
max_chars: int | None = None, max_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS,
) -> str: ) -> str:
"""Read a text file under the workspace. """Read a text file under the workspace.
@@ -63,12 +64,13 @@ async def read_file(
(None = rest of file). When ``with_lineno`` is true, each line is prefixed (None = rest of file). When ``with_lineno`` is true, each line is prefixed
with its 1-based file line number (`` N|…``), matching ``edit_lineno`` with its 1-based file line number (`` N|…``), matching ``edit_lineno``
numbering, and those lines are marked readable for ``edit_lineno`` this turn. 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; a ``TRUNCATE_TOKEN`` is appended so ``max_chars`` caps the returned slice (default: the agent's tool-result cap,
``get_truncated`` can continue reading the rest without a new request. ``DEFAULT_TOOL_RESULT_MAX_CHARS``); a ``truncate_token`` is appended when cut
so ``get_truncated`` can continue reading the rest without a new request.
The result starts with a 1-based inclusive line range ``L{begin}-{end}`` so Pass ``max_chars < 1`` to read unbounded.
the caller knows exactly which file lines were read (offset is 0-based).
""" """
target = resolve_path(path) target = resolve_path(path)
text, err = read_raw_text(path) text, err = read_raw_text(path)
@@ -83,14 +85,10 @@ async def read_file(
if start >= len(lines): if start >= len(lines):
return "" return ""
slice_lines = lines[start:end] slice_lines = lines[start:end]
if with_lineno: body = _format_with_lineno(slice_lines, start_lineno=start + 1) if with_lineno else "".join(slice_lines)
mark_lineno_read(str(target), set(range(start + 1, end + 1))) char_start = len("".join(lines[:start]))
body = _format_with_lineno(slice_lines, start_lineno=start + 1) if max_chars >= 1:
else: body, token = truncate_with_token(
body = "".join(slice_lines)
if max_chars is not None and max_chars >= 1:
char_start = len("".join(lines[:start]))
body, _ = truncate_with_token(
body, body,
max_chars, max_chars,
kind="file", kind="file",
@@ -98,11 +96,43 @@ async def read_file(
offset=char_start, offset=char_start,
limit=max_chars, limit=max_chars,
total_len=len(text), 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: if not body:
return "" return ""
if with_lineno: if with_lineno:
return body # per-line numbers already show the range # 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 begin = start + 1
end_line = start + len(slice_lines) end_line = start + len(slice_lines)
return f"L{begin}-{end_line}\n{body}" return f"L{begin}-{end_line}\n{body}"
+102 -6
View File
@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Literal
from plyngent.agent import ToolTag, tool from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import WorkspaceError, get_path_denylist, resolve_path from plyngent.tools.workspace import WorkspaceError, get_path_denylist, resolve_path
@@ -10,6 +10,11 @@ if TYPE_CHECKING:
from collections.abc import Sequence from collections.abc import Sequence
from pathlib import Path 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_DEPTH = 4
DEFAULT_MAX_ENTRIES = 50 DEFAULT_MAX_ENTRIES = 50
@@ -144,6 +149,80 @@ def _render_tree(
lines.append(f"{prefix}└── … ({more} more entries not shown)") 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]: def _resolve_skip_basenames(skip_dirs: Sequence[str] | None) -> frozenset[str]:
"""None → default noise set; explicit list (including empty) replaces defaults.""" """None → default noise set; explicit list (including empty) replaces defaults."""
if skip_dirs is None: 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) 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( async def tree(
path: str = ".", path: str = ".",
*, *,
format: TreeFormat = "markdown", # noqa: A002 — model-facing param name
max_depth: int = DEFAULT_MAX_DEPTH, max_depth: int = DEFAULT_MAX_DEPTH,
max_entries: int = DEFAULT_MAX_ENTRIES, max_entries: int = DEFAULT_MAX_ENTRIES,
skip_hidden_dirs: bool = True, skip_hidden_dirs: bool = True,
@@ -174,8 +254,15 @@ async def tree(
``apply_path_denylist`` (default true) hides entries whose full path matches ``apply_path_denylist`` (default true) hides entries whose full path matches
the agent ``path_denylist`` policy. 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_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: if max_depth < 1:
return "error: max_depth must be >= 1" return "error: max_depth must be >= 1"
@@ -189,8 +276,6 @@ async def tree(
if not origin.is_dir(): if not origin.is_dir():
return f"error: not a directory: {path}" return f"error: not a directory: {path}"
root_label = path.rstrip("/\\") or "."
lines = [f"{root_label}/"]
limits = _TreeLimits( limits = _TreeLimits(
max_depth=max_depth, max_depth=max_depth,
max_entries=max_entries, max_entries=max_entries,
@@ -198,7 +283,18 @@ async def tree(
skip_basenames=_resolve_skip_basenames(skip_dirs), skip_basenames=_resolve_skip_basenames(skip_dirs),
apply_path_denylist=apply_path_denylist, 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, origin,
prefix="", prefix="",
depth=0, depth=0,
+3 -2
View File
@@ -1,6 +1,6 @@
from __future__ import annotations 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 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 = resolve_path(path)
target.parent.mkdir(parents=True, exist_ok=True) target.parent.mkdir(parents=True, exist_ok=True)
_ = target.write_text(content, encoding="utf-8") _ = 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}" return f"wrote {len(content)} characters to {path}"
+6 -1
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from plyngent.agent import ToolTag, tool 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.client import http_fetch
from plyngent.tools.net.policy import ( from plyngent.tools.net.policy import (
DEFAULT_MAX_BODY_CHARS_IN, DEFAULT_MAX_BODY_CHARS_IN,
@@ -66,7 +67,7 @@ def format_fetch_result(
return "\n".join(parts) 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( async def fetch(
url: str, url: str,
*, *,
@@ -92,9 +93,13 @@ async def fetch(
Private/loopback/link-local hosts require an explicit human policy grant Private/loopback/link-local hosts require an explicit human policy grant
(not skipped by YOLO). HTTPS→HTTP redirects are blocked unless (not skipped by YOLO). HTTPS→HTTP redirects are blocked unless
``allow_http_downgrade`` is true. ``allow_http_downgrade`` is true.
Read-only contexts (e.g. ``/btw --tools=read``) accept only ``GET``.
""" """
try: try:
verb = normalize_method(method) 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) parsed = parse_fetch_url(url)
hdrs = normalize_request_headers(headers, user_agent=user_agent) hdrs = normalize_request_headers(headers, user_agent=user_agent)
body_bytes: bytes | None = None body_bytes: bytes | None = None
+12 -2
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: 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
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( def format_command_result(
+1 -1
View File
@@ -48,7 +48,7 @@ async def _with_todo_stack(mutator: Callable[[TodoStack], str]) -> str:
return result 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: async def todo_list() -> str:
"""Show the LIFO stack of **task groups** (TOP group = current breakdown level). """Show the LIFO stack of **task groups** (TOP group = current breakdown level).
+87 -27
View File
@@ -1,31 +1,68 @@
"""Truncate-token cursors for resuming truncated tool results. """Truncate-token cursors for resuming truncated tool results.
``fetch`` and ``read_file`` append a ``[TRUNCATE_TOKEN: ...]`` marker when they Every truncation site (the agent loop's generic tool-result cap,
cut output short; the model passes the token to ``get_truncated`` to read the ``run_command`` / ``run_command_batch`` per-stream caps, ``read_file``,
next chunk without re-requesting the whole source or raising limits. ``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 from __future__ import annotations
import base64 import base64
import uuid
from typing import Literal from typing import Literal
import msgspec import msgspec
type TruncateKind = Literal["file", "http"] type TruncateKind = Literal["file", "http", "memory"]
class TruncateToken(msgspec.Struct, frozen=True): class TruncateToken(msgspec.Struct, frozen=True):
"""Opaque cursor into a truncated source. """Opaque cursor into a truncated source.
``offset``/``limit`` are in characters (http bodies) or lines (files is ``offset``/``limit`` are in characters (http bodies, memory remainders) or
line-based via ``read_file``); ``get_truncated`` interprets by ``kind``. 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 kind: TruncateKind
location: str location: str
offset: int offset: int
limit: 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: def encode_truncate_token(token: TruncateToken) -> str:
@@ -46,11 +83,9 @@ def decode_truncate_token(text: str) -> TruncateToken | None:
return token return token
def _truncated_marker(omitted: int, token: TruncateToken) -> str: def truncation_marker(max_chars: int, omitted: int, token: TruncateToken) -> str:
return ( """The single truncation hint: ``[Truncated (N chars max.; M omitted). truncate_token=...]``."""
f"\n...[truncated {omitted} chars; more via get_truncated token] " return f"\n[Truncated ({max_chars} chars max.; {omitted} omitted). truncate_token={encode_truncate_token(token)}]"
f"[TRUNCATE_TOKEN: {encode_truncate_token(token)}]"
)
def truncate_with_token( def truncate_with_token(
@@ -62,35 +97,60 @@ def truncate_with_token(
offset: int, offset: int,
limit: int, limit: int,
total_len: int, total_len: int,
emit_more_after: bool = True,
) -> tuple[str, TruncateToken | None]: ) -> tuple[str, TruncateToken | None]:
"""Bound ``text`` to ``max_chars``; return (bounded_text, next_token). """Bound ``text`` (a slice of the full source starting at ``offset``) to ``max_chars``.
``text`` is a slice of the full source starting at ``offset``; ``total_len`` Returns ``(bounded_text, next_token)``. A token is embedded whenever the
is the full source length. A token is embedded whenever more content remains returned chunk is cut (overflow) or, with ``emit_more_after`` (default),
after the returned chunk, so ``get_truncated`` chains through truncations whenever more source content remains after the chunk — so ``get_truncated``
and each chunk may itself carry a fresh token. The token advances by the chains through truncations. The token advances by the length actually
length actually returned (no content is skipped), and the marker is budgeted returned (no content is skipped), and the marker is budgeted so the total
so the total returned length stays near ``max_chars`` (the agent loop's own returned length stays near ``max_chars`` (the agent loop's own cap never
cap never re-cuts it and never strips the token). 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: if max_chars < 1:
return text, None return text, None
if len(text) > max_chars: if len(text) > max_chars:
omitted = len(text) - max_chars
# First pass sizes the marker with an offset+max_chars token; re-encode # 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. # with the real cut so the cursor lands exactly where content stops.
first = TruncateToken(kind=kind, location=location, offset=offset + max_chars, limit=limit) first = TruncateToken(kind=kind, location=location, offset=offset + max_chars, limit=limit)
cut = max_chars - len(_truncated_marker(omitted, first)) omitted = total_len - (offset + max_chars)
cut = max_chars - len(truncation_marker(max_chars, omitted, first))
if cut < 1: if cut < 1:
return text[:max_chars] + _truncated_marker(omitted, first), first return text[:max_chars] + truncation_marker(max_chars, omitted, first), first
token = TruncateToken(kind=kind, location=location, offset=offset + cut, limit=limit) token = TruncateToken(kind=kind, location=location, offset=offset + cut, limit=limit)
return text[:cut] + _truncated_marker(omitted, token), token 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 more_after = offset + len(text) < total_len
if not more_after: if not more_after:
return text, None return text, None
omitted = total_len - (offset + len(text))
token = TruncateToken(kind=kind, location=location, offset=offset + len(text), limit=limit) token = TruncateToken(kind=kind, location=location, offset=offset + len(text), limit=limit)
marker = ( return text + truncation_marker(max_chars, omitted, token), token
f"\n...[more content available; use get_truncated with the token] "
f"[TRUNCATE_TOKEN: {encode_truncate_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 text + marker, token return bounded
+5 -5
View File
@@ -22,7 +22,7 @@ def _backend_or_error() -> VcsBackend | str:
return backend return backend
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE) @tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
async def vcs_kind() -> str: async def vcs_kind() -> str:
"""Return the detected VCS kind under the workspace (e.g. ``git``), or an error.""" """Return the detected VCS kind under the workspace (e.g. ``git``), or an error."""
backend = _backend_or_error() backend = _backend_or_error()
@@ -31,7 +31,7 @@ async def vcs_kind() -> str:
return backend.kind 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: async def vcs_status() -> str:
"""Show working-tree status for the detected VCS (read-only).""" """Show working-tree status for the detected VCS (read-only)."""
backend = _backend_or_error() backend = _backend_or_error()
@@ -40,7 +40,7 @@ async def vcs_status() -> str:
return backend.status() 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: async def vcs_diff(path: str = "", *, staged: bool = False) -> str:
"""Show a unified diff for the detected VCS (read-only). """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) 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: async def vcs_log(limit: int = 10) -> str:
"""Show recent commits for the detected VCS (read-only).""" """Show recent commits for the detected VCS (read-only)."""
if limit < 1: if limit < 1:
@@ -65,7 +65,7 @@ async def vcs_log(limit: int = 10) -> str:
return backend.log(limit=limit) 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: async def vcs_branch() -> str:
"""Show the current branch / named head for the detected VCS (read-only).""" """Show the current branch / named head for the detected VCS (read-only)."""
backend = _backend_or_error() backend = _backend_or_error()
+6 -9
View File
@@ -11,20 +11,17 @@ def test_truncate_tool_result_short() -> None:
def test_truncate_tool_result_long() -> None: def test_truncate_tool_result_long() -> None:
text = "a" * 50 text = "a" * 50
out = truncate_tool_result(text, 20) out = truncate_tool_result(text, 20)
assert out.startswith("a" * 20)
assert "Truncated" in out assert "Truncated" in out
assert "20" in out assert "20" in out
assert "30" in out assert "30" in out
assert "get_truncated" in out # Generic tool results now embed a memory token too (resumable via get_truncated).
assert "configuration file" not in out assert "truncate_token=" in out
assert out.startswith(("a" * 10, "a" * 20)) # marker is budgeted
def test_truncate_tool_result_no_hint() -> None: def test_truncate_tool_result_fits_no_marker() -> None:
text = "a" * 50 assert truncate_tool_result("tiny", 100) == "tiny"
out = truncate_tool_result(text, 20, hint=False) assert "Truncated" not in truncate_tool_result("tiny", 100)
assert out.startswith("a" * 20)
assert "truncated" in out
assert "get_truncated" not in out
def test_estimate_message_chars() -> None: def test_estimate_message_chars() -> None:
+10 -10
View File
@@ -40,20 +40,18 @@ def test_truncate_tool_result_short() -> None:
def test_truncate_tool_result_long() -> None: def test_truncate_tool_result_long() -> None:
text = "a" * 50 text = "a" * 50
out = truncate_tool_result(text, 20) out = truncate_tool_result(text, 20)
assert out.startswith("a" * 20)
assert "Truncated" in out assert "Truncated" in out
assert "20" in out assert "20" in out
assert "30" in out assert "30" in out
assert "get_truncated" in out # Generic tool results now embed a memory token too (resumable via get_truncated).
assert "configuration file" not in out assert "truncate_token=" in out
def test_truncate_tool_result_no_hint() -> None: def test_truncate_tool_result_no_hint() -> None:
text = "a" * 50 text = "a" * 50
out = truncate_tool_result(text, 20, hint=False) out = truncate_tool_result(text, 20)
assert out.startswith("a" * 20) assert "Truncated" in out
assert "truncated" in out assert "truncate_token=" in out
assert "get_truncated" not in out
def test_compact_shrinks_old_tool_results() -> None: def test_compact_shrinks_old_tool_results() -> None:
@@ -93,7 +91,9 @@ def test_compact_shrinks_old_tool_results() -> None:
) )
assert isinstance(compacted[2], ToolChatMessage) assert isinstance(compacted[2], ToolChatMessage)
assert len(compacted[2].content) < original_len 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 # Full history unchanged
assert isinstance(messages[2], ToolChatMessage) assert isinstance(messages[2], ToolChatMessage)
assert len(messages[2].content) == original_len assert len(messages[2].content) == original_len
@@ -133,7 +133,7 @@ def test_compact_uses_api_calibration() -> None:
# Raw est under budget → no compact # Raw est under budget → no compact
no_api = compact_messages_for_request(messages, max_tokens=est + 100) no_api = compact_messages_for_request(messages, max_tokens=est + 100)
assert isinstance(no_api[1], ToolChatMessage) 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 # Calibrate so measured size is 5x → over a mid budget → shrink old tool
compacted = compact_messages_for_request( compacted = compact_messages_for_request(
messages, messages,
@@ -146,7 +146,7 @@ def test_compact_uses_api_calibration() -> None:
assert isinstance(compacted[1], ToolChatMessage) assert isinstance(compacted[1], ToolChatMessage)
old = messages[1] old = messages[1]
assert isinstance(old, ToolChatMessage) 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: def _response(message: AssistantChatMessage) -> ChatCompletionResponse:
+22 -2
View File
@@ -186,7 +186,11 @@ async def test_dispatch_responses_stream_text_and_usage() -> None:
async def _streamed_reasoning( async def _streamed_reasoning(
events: list[ResponseStreamEvent], events: list[ResponseStreamEvent],
) -> str: ) -> str:
"""Run one Responses stream and join all streamed ``reasoning_content`` deltas.""" """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 import msgspec
final = _completed_response(text="ok") final = _completed_response(text="ok")
@@ -202,7 +206,10 @@ async def _streamed_reasoning(
continue continue
delta = chunk.choices[0].delta delta = chunk.choices[0].delta
if isinstance(delta.reasoning_content, str) and delta.reasoning_content: if isinstance(delta.reasoning_content, str) and delta.reasoning_content:
parts.append(delta.reasoning_content) if delta.reasoning_full is True:
parts = [delta.reasoning_content]
else:
parts.append(delta.reasoning_content)
return "".join(parts) return "".join(parts)
@@ -224,6 +231,19 @@ async def test_dispatch_responses_stream_reasoning_deltas() -> None:
assert reasoning == "step one 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: async def test_dispatch_responses_stream_terminal_only_reasoning() -> None:
"""DeepSeek sometimes delivers reasoning only on the terminal response.""" """DeepSeek sometimes delivers reasoning only on the terminal response."""
reasoning = await _streamed_reasoning([]) reasoning = await _streamed_reasoning([])
+88
View File
@@ -209,3 +209,91 @@ async def test_run_aside_tools_clones_registry_with_fresh_session() -> None:
pass pass
assert hits == ["ok"] assert hits == ["ok"]
assert "aside" not in main_session.extras 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 == []
+345 -5
View File
@@ -65,7 +65,7 @@ async def test_flush_markdown_on_source_change(
call = AssistantFunctionToolCall( call = AssistantFunctionToolCall(
id="1", id="1",
function=AssistantFunctionTool(name="run_argv", arguments='{"argv": ["echo", "x"]}'), function=AssistantFunctionTool(name="vcs_diff", arguments='{"path": ""}'),
) )
await render_events( await render_events(
_aiter( _aiter(
@@ -98,7 +98,7 @@ def _result(content: str) -> ToolResultEvent:
async def test_pretty_read_file_done(capsys: pytest.CaptureFixture[str]) -> None: 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")])) await render_events(_aiter([_read_call('{"path": "a.txt"}'), _result("L1-4\none\ntwo\n")]))
out = capsys.readouterr().out out = capsys.readouterr().out
assert "* Read 'a.txt' L1-4 (done)" in out assert "* Read 'a.txt' (done)" in out
assert "[tool]" not in out assert "[tool]" not in out
assert "[tool ok]" not in out assert "[tool ok]" not in out
@@ -116,6 +116,44 @@ async def test_pretty_read_file_statuses(capsys: pytest.CaptureFixture[str]) ->
assert f"* Read 'a.txt' {status}" in out, f"status {status} not rendered for {content!r}" 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: async def test_pretty_todo_push(capsys: pytest.CaptureFixture[str]) -> None:
call = ToolCallEvent( call = ToolCallEvent(
tool_call=AssistantFunctionToolCall( tool_call=AssistantFunctionToolCall(
@@ -140,12 +178,12 @@ async def test_non_pretty_tool_keeps_old_style(capsys: pytest.CaptureFixture[str
call = ToolCallEvent( call = ToolCallEvent(
tool_call=AssistantFunctionToolCall( tool_call=AssistantFunctionToolCall(
id="3", id="3",
function=AssistantFunctionTool(name="run_argv", arguments='{"argv": ["ls"]}'), function=AssistantFunctionTool(name="vcs_diff", arguments='{"path": ""}'),
) )
) )
await render_events(_aiter([call, _result("exit_code=0")])) await render_events(_aiter([call, _result("diff --git a/x b/x")]))
out = capsys.readouterr().out out = capsys.readouterr().out
assert "[tool] run_argv(" in out assert "[tool] vcs_diff(" in out
assert "[tool ok]" in out assert "[tool ok]" in out
@@ -210,3 +248,305 @@ def test_markdown_render_available_respects_plain_env(monkeypatch: pytest.Monkey
monkeypatch.setenv("PLYNGENT_PLAIN", "1") monkeypatch.setenv("PLYNGENT_PLAIN", "1")
assert markdown_render_available() is False assert markdown_render_available() is False
monkeypatch.delenv("PLYNGENT_PLAIN", raising=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 ( from plyngent.cli.interrupt import (
allow_task_cancel, allow_task_cancel,
install_keyboard_interrupt_sigint,
pause_task_cancel_for_prompt, pause_task_cancel_for_prompt,
raise_keyboard_interrupt_sigint,
run_in_prompt_thread, run_in_prompt_thread,
set_sigint_reinstall, set_sigint_reinstall,
) )
@@ -113,3 +115,71 @@ async def test_sigint_cancels_after_prompt_pause() -> None:
assert cancelled.is_set() assert cancelled.is_set()
# Cleanup any leftover reinstall hook from run_cancellable # Cleanup any leftover reinstall hook from run_cancellable
set_sigint_reinstall(None) 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)
+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 await run_repl(state) # must return normally (EOF), never raise KeyboardInterrupt
assert calls["reads"] == 2 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
+40 -1
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import inspect 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 import default_tool_definitions, register_builtin_tools
from plyngent.tools.catalog import ToolCatalog, ToolSource, catalog_scope, get_catalog, registration_source from plyngent.tools.catalog import ToolCatalog, ToolSource, catalog_scope, get_catalog, registration_source
from plyngent.tools.chat import CHAT_TOOLS from plyngent.tools.chat import CHAT_TOOLS
@@ -42,6 +42,45 @@ def test_public_surface_is_subset() -> None:
assert "read_file" not in public 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: def test_catalog_scope_empty_isolates_registration() -> None:
with catalog_scope(empty=True) as catalog: with catalog_scope(empty=True) as catalog:
+57 -3
View File
@@ -19,9 +19,38 @@ def test_edit_lineno_replace_middle(workspace: object) -> None:
_ = _read_lineno("a.txt") _ = _read_lineno("a.txt")
out = call_sync(edit_lineno, "a.txt", 2, 3, "TWO\nTHREE\n") out = call_sync(edit_lineno, "a.txt", 2, 3, "TWO\nTHREE\n")
assert "replaced lines 2-3" in out assert "replaced lines 2-3" in out
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" 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: def test_edit_lineno_requires_all_lines_read(workspace: object) -> None:
del workspace del workspace
_ = call_sync(write_file, "r.txt", "a\nb\nc\nd\ne\n") _ = call_sync(write_file, "r.txt", "a\nb\nc\nd\ne\n")
@@ -37,6 +66,16 @@ def test_edit_lineno_requires_all_lines_read(workspace: object) -> None:
assert "replaced lines 2-3" in out 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: def test_edit_lineno_append_requires_last_line_read(workspace: object) -> None:
del workspace del workspace
_ = call_sync(write_file, "ap.txt", "a\nb\nc\n") _ = call_sync(write_file, "ap.txt", "a\nb\nc\n")
@@ -92,7 +131,7 @@ def test_edit_lineno_requires_rerun_after_write(workspace: object) -> None:
_ = _read_lineno("f.txt") _ = _read_lineno("f.txt")
_ = call_sync(write_file, "f.txt", "changed\n") _ = call_sync(write_file, "f.txt", "changed\n")
out = call_sync(edit_lineno, "f.txt", 1, 1, "x\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: 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") _ = _read_lineno("g.txt")
_ = call_sync(edit_replace, "g.txt", "two", "TWO") _ = call_sync(edit_replace, "g.txt", "two", "TWO")
out = call_sync(edit_lineno, "g.txt", 2, 2, "x\n") 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: 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") _ = call_sync(edit_lineno, "h.txt", 2, 2, "B\n")
# After the edit, line numbers are stale — must re-read before editing again. # After the edit, line numbers are stale — must re-read before editing again.
out = call_sync(edit_lineno, "h.txt", 2, 2, "C\n") 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
+22 -4
View File
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, override
import pytest import pytest
from plyngent.tools.context import read_only_context
from plyngent.tools.danger import classify_danger from plyngent.tools.danger import classify_danger
from plyngent.tools.net import fetch, grant_private_host from plyngent.tools.net import fetch, grant_private_host
from plyngent.tools.net.policy import ( from plyngent.tools.net.policy import (
@@ -199,6 +200,23 @@ async def test_fetch_get_post_put_delete(workspace: Path, http_server: str) -> N
assert "status=204" in out_del 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: async def test_fetch_user_agent_passthrough(workspace: Path, http_server: str) -> None:
del workspace del workspace
port = int(http_server.rsplit(":", 1)[1]) port = int(http_server.rsplit(":", 1)[1])
@@ -246,9 +264,9 @@ async def test_fetch_char_truncation_embeds_token(workspace: Path, http_server:
out = await call_async(fetch, f"{http_server}/big", max_chars=1000) out = await call_async(fetch, f"{http_server}/big", max_chars=1000)
assert "truncated=true" in out assert "truncated=true" in out
body = out.split("--- body ---", 1)[-1] body = out.split("--- body ---", 1)[-1]
assert "TRUNCATE_TOKEN:" in body assert "truncate_token=" in body
token_line = next(ln for ln in body.splitlines() if "TRUNCATE_TOKEN:" in ln) 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_str = token_line.split("truncate_token=", 1)[1].rstrip("]")
token = decode_truncate_token(token_str) token = decode_truncate_token(token_str)
assert token is not None assert token is not None
assert token.kind == "http" assert token.kind == "http"
@@ -269,7 +287,7 @@ async def test_fetch_offset_skips_body(workspace: Path, http_server: str) -> Non
out = await call_async(fetch, f"{http_server}/big", max_chars=200, offset=4900) out = await call_async(fetch, f"{http_server}/big", max_chars=200, offset=4900)
body = out.split("--- body ---", 1)[-1] body = out.split("--- body ---", 1)[-1]
assert body.strip() == "x" * 100 assert body.strip() == "x" * 100
assert "TRUNCATE_TOKEN:" not in out assert "truncate_token=" not in out
async def test_fetch_private_denied_without_grant(workspace: Path, http_server: str) -> None: async def test_fetch_private_denied_without_grant(workspace: Path, http_server: str) -> None:
+21 -5
View File
@@ -11,8 +11,8 @@ get_truncated_module = importlib.import_module("plyngent.tools.file.get_truncate
def _extract_token(out: str) -> str | None: def _extract_token(out: str) -> str | None:
for line in out.splitlines(): for line in out.splitlines():
if "TRUNCATE_TOKEN:" in line: if "truncate_token=" in line:
return line.split("[TRUNCATE_TOKEN: ", 1)[1].rstrip("]") return line.split("truncate_token=", 1)[1].rstrip("]")
return None return None
@@ -33,10 +33,10 @@ def test_read_file_max_chars_embeds_token(workspace: Path) -> None:
out = call_sync(read_file, "big.txt", max_chars=200) out = call_sync(read_file, "big.txt", max_chars=200)
header, _, rest = out.partition("\n") header, _, rest = out.partition("\n")
assert header.startswith("L1-") # 1-based range of the raw slice read assert header.startswith("L1-") # 1-based range of the raw slice read
content, _, marker = rest.partition("\n...[") content, _, marker = rest.partition("\n[Truncated")
assert content == "a" * len(content) assert content == "a" * len(content)
assert len(content) < 200 assert len(content) < 200
assert "TRUNCATE_TOKEN:" in marker assert "truncate_token=" in marker
token = _extract_token(out) token = _extract_token(out)
assert token is not None assert token is not None
parsed = get_truncated_module.decode_truncate_token(token) parsed = get_truncated_module.decode_truncate_token(token)
@@ -45,11 +45,26 @@ def test_read_file_max_chars_embeds_token(workspace: Path) -> None:
assert parsed.offset == len(content) # cursor lands exactly where content stops 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: def test_read_file_max_chars_small_file_untouched(workspace: Path) -> None:
(workspace / "small.txt").write_text("tiny", encoding="utf-8") (workspace / "small.txt").write_text("tiny", encoding="utf-8")
out = call_sync(read_file, "small.txt", max_chars=200) out = call_sync(read_file, "small.txt", max_chars=200)
assert out == "L1-1\ntiny" assert out == "L1-1\ntiny"
assert "TRUNCATE_TOKEN:" not in out assert "truncate_token=" not in out
def test_edit_replace_first_only(workspace: object) -> None: def test_edit_replace_first_only(workspace: object) -> None:
@@ -59,6 +74,7 @@ def test_edit_replace_first_only(workspace: object) -> None:
assert call_sync(read_file, "t.txt") == "L1-1\nbb 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 "1 of 2" in result or "1 of 2 matches" in result
assert "remain" 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: def test_edit_replace_max_replaces(workspace: object) -> None:
+76 -3
View File
@@ -12,8 +12,8 @@ get_truncated = get_truncated_module.get_truncated
def _extract_token(out: str) -> str | None: def _extract_token(out: str) -> str | None:
for line in out.splitlines(): for line in out.splitlines():
if "TRUNCATE_TOKEN:" in line: if "truncate_token=" in line:
return line.split("[TRUNCATE_TOKEN: ", 1)[1].rstrip("]") return line.split("truncate_token=", 1)[1].rstrip("]")
return None return None
@@ -29,7 +29,7 @@ async def test_get_truncated_file_chains_without_gap(workspace: Path) -> None:
total_read = 0 total_read = 0
for _ in range(20): for _ in range(20):
out = await call_async(get_truncated, token, max_chars=200) out = await call_async(get_truncated, token, max_chars=200)
body, _, _ = out.partition("\n...[") body, _, _ = out.partition("\n[Truncated")
header, _, content = body.partition("\n") header, _, content = body.partition("\n")
assert header.startswith("L") and "-" in header assert header.startswith("L") and "-" in header
assert content == "a" * len(content) assert content == "a" * len(content)
@@ -44,6 +44,40 @@ async def test_get_truncated_file_chains_without_gap(workspace: Path) -> None:
assert total_read == 1000 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: async def test_get_truncated_file_missing(workspace: Path) -> None:
token = encode_truncate_token(TruncateToken(kind="file", location="nope.txt", offset=0, limit=200)) token = encode_truncate_token(TruncateToken(kind="file", location="nope.txt", offset=0, limit=200))
out = await call_async(get_truncated, token, max_chars=200) out = await call_async(get_truncated, token, max_chars=200)
@@ -62,3 +96,42 @@ async def test_get_truncated_http_passthrough(monkeypatch) -> None:
out = await call_async(get_truncated, token, max_chars=200) out = await call_async(get_truncated, token, max_chars=200)
assert out == "body-from-500" assert out == "body-from-500"
assert calls == [("https://example.com/x", 500, 400)] 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
+17
View File
@@ -190,6 +190,23 @@ async def test_run_argv_timeout_keeps_partial_output(workspace: object) -> None:
assert "partial-out" in out assert "partial-out" in out
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: async def test_run_argv_stdin(workspace: object) -> None:
del workspace del workspace
out = await call_async( out = await call_async(
+53
View File
@@ -67,7 +67,60 @@ def test_tree_origin_subdir(workspace: object) -> None:
assert isinstance(workspace, Path) assert isinstance(workspace, Path)
_build_sample(workspace) _build_sample(workspace)
out = call_sync(tree, "src") 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 out.startswith("src/")
assert "├──" in out
assert "main.py" in out assert "main.py" in out
assert "deep.txt" in out assert "deep.txt" in out
+43 -6
View File
@@ -4,7 +4,10 @@ from plyngent.tools.truncate_token import (
TruncateToken, TruncateToken,
decode_truncate_token, decode_truncate_token,
encode_truncate_token, encode_truncate_token,
get_remainder,
truncate_generic,
truncate_with_token, truncate_with_token,
truncation_marker,
) )
@@ -27,6 +30,14 @@ def test_decode_rejects_invalid_fields() -> None:
assert decode_truncate_token(encode_truncate_token(bad_limit)) is None 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: def test_truncate_with_token_short_text_untouched() -> None:
out, token = truncate_with_token( out, token = truncate_with_token(
"hello", "hello",
@@ -39,6 +50,7 @@ def test_truncate_with_token_short_text_untouched() -> None:
) )
assert out == "hello" assert out == "hello"
assert token is None assert token is None
assert "Truncated" not in out # no hint when no truncation
def test_truncate_with_token_cuts_and_advances() -> None: def test_truncate_with_token_cuts_and_advances() -> None:
@@ -53,10 +65,9 @@ def test_truncate_with_token_cuts_and_advances() -> None:
total_len=500, total_len=500,
) )
assert token is not None assert token is not None
content, _, _ = out.partition("\n...[") content, _, _ = out.partition("\n[Truncated")
assert content.startswith("x") assert content.startswith("x")
assert "truncated 300 chars" in out assert "[Truncated (200 chars max.; 300 omitted). truncate_token=" in out
assert f"[TRUNCATE_TOKEN: {encode_truncate_token(token)}]" in out
assert token.offset == len(content) # cursor lands exactly where content stops assert token.offset == len(content) # cursor lands exactly where content stops
assert len(out) <= 200 # marker fits inside the char budget assert len(out) <= 200 # marker fits inside the char budget
@@ -75,7 +86,7 @@ def test_truncate_with_token_more_after_nonoverflow() -> None:
) )
assert token is not None assert token is not None
assert token.offset == 30 # advanced by returned length only assert token.offset == 30 # advanced by returned length only
assert "more content available" in out assert "[Truncated (40 chars max.; 170 omitted). truncate_token=" in out
assert out.startswith("y" * 30) assert out.startswith("y" * 30)
@@ -92,6 +103,7 @@ def test_truncate_with_token_end_reached_no_token() -> None:
) )
assert token is None assert token is None
assert out == text assert out == text
assert "Truncated" not in out # no hint when the source ends
def test_truncate_with_token_nonzero_start() -> None: def test_truncate_with_token_nonzero_start() -> None:
@@ -107,7 +119,32 @@ def test_truncate_with_token_nonzero_start() -> None:
) )
assert token is not None assert token is not None
assert token.kind == "http" assert token.kind == "http"
content, _, _ = out.partition("\n...[") content, _, _ = out.partition("\n[Truncated")
assert token.offset == 150 + len(content) assert token.offset == 150 + len(content)
assert "truncated 300 chars" in out assert "[Truncated (200 chars max.; 350 omitted). truncate_token=" in out
assert len(out) <= 200 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