11 Commits

Author SHA1 Message Date
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
29 changed files with 604 additions and 202 deletions
+1 -1
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` (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.
+1 -1
View File
@@ -270,7 +270,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 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:
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.0"
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" },
+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),
) )
+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),
) )
], ],
) )
+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,
+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():
+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
+30 -8
View File
@@ -8,24 +8,46 @@ 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_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 (
decode_truncate_token,
get_remainder,
truncate_with_token,
)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE) @tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
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 start with a 1-based
line range like ``read_file``. ``L{begin}-{end}`` line range like ``read_file``; 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":
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
text, err = read_raw_text(parsed.location) text, err = read_raw_text(parsed.location)
if err: if err:
return err return err
@@ -41,6 +63,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}"
+10 -7
View File
@@ -3,6 +3,7 @@ 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.agent.budget import DEFAULT_TOOL_RESULT_MAX_CHARS
from plyngent.tools.truncate_token import truncate_with_token from plyngent.tools.truncate_token import truncate_with_token
from plyngent.tools.workspace import resolve_path from plyngent.tools.workspace import resolve_path
@@ -55,7 +56,7 @@ async def read_file(
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.
@@ -64,11 +65,10 @@ async def read_file(
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.
``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)
@@ -88,7 +88,7 @@ async def read_file(
body = _format_with_lineno(slice_lines, start_lineno=start + 1) body = _format_with_lineno(slice_lines, start_lineno=start + 1)
else: else:
body = "".join(slice_lines) body = "".join(slice_lines)
if max_chars is not None and max_chars >= 1: if max_chars >= 1:
char_start = len("".join(lines[:start])) char_start = len("".join(lines[:start]))
body, _ = truncate_with_token( body, _ = truncate_with_token(
body, body,
@@ -98,6 +98,9 @@ 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,
) )
if not body: if not body:
return "" return ""
+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(
+85 -27
View File
@@ -1,25 +1,43 @@
"""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.
""" """
kind: TruncateKind kind: TruncateKind
@@ -28,6 +46,23 @@ class TruncateToken(msgspec.Struct, frozen=True):
limit: int limit: int
# 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:
"""Base64url-encode a token so it is safe inside tool-result text.""" """Base64url-encode a token so it is safe inside tool-result text."""
payload = msgspec.json.encode(token) payload = msgspec.json.encode(token)
@@ -46,11 +81,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 +95,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
+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([])
+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)
+40
View File
@@ -593,3 +593,43 @@ 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
+4 -4
View File
@@ -246,9 +246,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 +269,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:
+20 -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:
+42 -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)
@@ -62,3 +62,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(
+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