mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 23:47:54 +08:00
Compare commits
11 Commits
c2735dada3
...
v0.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
| ea8e64e913 | |||
| 1973ef93bb | |||
| a197a67142 | |||
| 28a191cd79 | |||
| ec6edbcba4 | |||
| bd1053a3bc | |||
| b5a30190f6 | |||
| 26472776fb | |||
| 6fdfb8398e | |||
| ec1ae59a28 | |||
| b5950bdb57 |
@@ -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.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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" },
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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()
|
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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([])
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user