mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-09 08:57:55 +08:00
Compare commits
46 Commits
c2735dada3
..
v0.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 9961bdea6f | |||
| a1455989f2 | |||
| 4f142f1db9 | |||
| 5f28a49bc1 | |||
| a4baf38a52 | |||
| c638f6a5da | |||
| ede614aa9e | |||
| 41ba0f9dda | |||
| 4916494e44 | |||
| d603615d6a | |||
| 7967095dbc | |||
| dd70d02491 | |||
| 7c377a1a53 | |||
| 0a0f05ad14 | |||
| ab8828f7b6 | |||
| 95a48873c1 | |||
| e27effcefe | |||
| 8e8a10a05e | |||
| 5344a20b67 | |||
| aeae99a496 | |||
| 259dd87c43 | |||
| 6f939e52b2 | |||
| 41ce98bf42 | |||
| 5436457ac0 | |||
| 2cf66edb60 | |||
| 38671482f2 | |||
| c9815e32bb | |||
| d914590677 | |||
| 132ac520ec | |||
| 32456c9f47 | |||
| b5014cd3e7 | |||
| 4fa05e2456 | |||
| ae93128e02 | |||
| b16b2547b8 | |||
| ddca6e23d3 | |||
| ea8e64e913 | |||
| 1973ef93bb | |||
| a197a67142 | |||
| 28a191cd79 | |||
| ec6edbcba4 | |||
| bd1053a3bc | |||
| b5a30190f6 | |||
| 26472776fb | |||
| 6fdfb8398e | |||
| ec1ae59a28 | |||
| b5950bdb57 |
@@ -214,3 +214,6 @@ __marimo__/
|
|||||||
|
|
||||||
# Streamlit
|
# Streamlit
|
||||||
.streamlit/secrets.toml
|
.streamlit/secrets.toml
|
||||||
|
|
||||||
|
# codegraph (local code index)
|
||||||
|
.codegraph/
|
||||||
|
|||||||
+12
-8
@@ -1,4 +1,4 @@
|
|||||||
# CLAUDE.md
|
# AGENTS.md
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
@@ -56,12 +56,14 @@ lmproto/deepseek/anthropic/ ← DeepSeek Anthropic client (extends Anthropic
|
|||||||
|
|
||||||
### Config (`config/`)
|
### Config (`config/`)
|
||||||
|
|
||||||
TOML load/store (`ConfigStore`): `[providers]` tagged union presets, `[database]` section. Default path via platformdirs. Providers may set `timeout` as a float or `{ connect, read }` (`HttpTimeoutConfig`); omitted → product defaults (10s connect / 600s read). `DeepseekProvider.convention` (Literal: `""`/`"openai"`/`"anthropic"`/`"responses"`) selects the DeepSeek API surface — `"responses"` → OpenAI Responses, `"anthropic"` → Anthropic Messages on `https://api.deepseek.com/anthropic` (default url follows the convention); a per-model `ModelConfig.convention` overrides it (empty = inherit).
|
TOML load/store (`ConfigStore`): `[providers]` tagged union presets, `[database]` section. Default path via platformdirs. Providers may set `timeout` as a float or `{ connect, read }` (`HttpTimeoutConfig`); omitted → product defaults (10s connect / 600s read). `DeepseekProvider.convention` (Literal: `""`/`"openai"`/`"anthropic"`/`"responses"`) selects the DeepSeek API surface — `"responses"` → OpenAI Responses, `"anthropic"` → Anthropic Messages on `https://api.deepseek.com/anthropic` (default url follows the convention); a per-model `ModelConfig.convention` overrides it (empty = inherit). `[mcp]` (`McpConfig`): `servers.<name>` stdio server definitions (`McpServerConfig`: `command`/`args`/`env`/`cwd`/`timeout`/`read_only`, `url` reserved for streamable HTTP) + `disable` gate; defining a server opts it in.
|
||||||
|
|
||||||
### Runtime (`runtime/`)
|
### Runtime (`runtime/`)
|
||||||
|
|
||||||
`create_client(provider)` maps config `Provider` → protocol client by effective preset (model-level `preset`/`url` overrides apply). `normalize_http_timeout` feeds OpenAI-compatible configs and Anthropic. Mapping: `openai` → `OpenAIClient` (agent uses Responses), `openai-compatible` / `deepseek` → chat-completions clients (deepseek with effective `convention = "responses"` → `DeepseekResponsesClient`, `"anthropic"` → `DeepseekAnthropicClient`), `anthropic` → `AnthropicClient`.
|
`create_client(provider)` maps config `Provider` → protocol client by effective preset (model-level `preset`/`url` overrides apply). `normalize_http_timeout` feeds OpenAI-compatible configs and Anthropic. Mapping: `openai` → `OpenAIClient` (agent uses Responses), `openai-compatible` / `deepseek` → chat-completions clients (deepseek with effective `convention = "responses"` → `DeepseekResponsesClient`, `"anthropic"` → `DeepseekAnthropicClient`), `anthropic` → `AnthropicClient`.
|
||||||
|
|
||||||
|
MCP (`runtime/mcp_client.py`): `McpManager` owns one `McpServerConnection` per `[mcp]` server — a spawned stdio subprocess speaking newline-delimited JSON-RPC 2.0: initialize handshake, `tools/list` (cursor pagination), `tools/call`, `notifications/tools/list_changed` → `tools_stale`. Spawn/handshake failures record `connection.error` (status string), never raise; `ensure_started` / `restart(config)` / `aclose` / `refresh_tools`; `statuses()` → `(server, status, tool_count)` rows; `instructions()` → `(server, text)` for every connected server that returned optional `InitializeResult.instructions` (usage guidance captured per connection); per-request timeout from config (default 30s).
|
||||||
|
|
||||||
### Memory (`memory/`)
|
### Memory (`memory/`)
|
||||||
|
|
||||||
Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `ALTER` for new columns), default local user, sessions (bound to `workspace` path; optional `provider_name`/`model`), messages stored as msgspec chat message JSON.
|
Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `ALTER` for new columns), default local user, sessions (bound to `workspace` path; optional `provider_name`/`model`), messages stored as msgspec chat message JSON.
|
||||||
@@ -78,14 +80,14 @@ Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `
|
|||||||
- **`/compact`**: soft-compact tool dumps → model summary (no tools) via the same kind dispatch → **new** session seeded with summary message. Soft-compact keeps the last **12** tool results full-size by default (`DEFAULT_RECENT_TOOL_RESULTS`); older tool payloads shrink first.
|
- **`/compact`**: soft-compact tool dumps → model summary (no tools) via the same kind dispatch → **new** session seeded with summary message. Soft-compact keeps the last **12** tool results full-size by default (`DEFAULT_RECENT_TOOL_RESULTS`); older tool payloads shrink first.
|
||||||
- Events: text_delta, **reasoning_delta**, assistant_message, tool_call/result, max_rounds, **error** (`retryable`/`source`), **cancelled** (`reason`), **usage** (`TokenUsage`).
|
- Events: text_delta, **reasoning_delta**, assistant_message, tool_call/result, max_rounds, **error** (`retryable`/`source`), **cancelled** (`reason`), **usage** (`TokenUsage`).
|
||||||
- Usage: API `usage` from completions (stream with `include_usage`); **char≈token fallback** (~4 chars/token) when omitted; **context size** = last request ``prompt_tokens`` (API preferred); `last_turn_usage` / `session_usage` are **billed sums** (tool rounds re-send history); CLI end-of-turn + `/status`.
|
- Usage: API `usage` from completions (stream with `include_usage`); **char≈token fallback** (~4 chars/token) when omitted; **context size** = last request ``prompt_tokens`` (API preferred); `last_turn_usage` / `session_usage` are **billed sums** (tool rounds re-send history); CLI end-of-turn + `/status`.
|
||||||
- Config ``[agent]``: `system_prompt` (persona; default `DEFAULT_SYSTEM_PROMPT`; `""` omits persona), `tool_directives` (tool playbook; default `DEFAULT_TOOL_DIRECTIVES`; `""` omits playbook; both empty → no system), `max_tool_result_chars`, `parallel_tools`, `confirm_destructive`, `path_denylist`, `max_context_tokens` (default 200k est. tokens). Truncated tool results carry a model hint (`[Truncated (N chars max; M omitted). You may ask for user to increase the limit via configuration file.]`) so the model can ask the user to raise `max_tool_result_chars`; request-time compact shrinks (`hint=False`) keep the terse `...[truncated N characters]` marker.
|
- Config ``[agent]``: `system_prompt` (persona; default `DEFAULT_SYSTEM_PROMPT`; `""` omits persona), `tool_directives` (tool playbook; default `DEFAULT_TOOL_DIRECTIVES`; `""` omits playbook; both empty → no system), `mcp_instructions` (fold connected MCP servers' initialize ``instructions`` into the system prompt when tools on; default true), `max_tool_result_chars`, `parallel_tools`, `confirm_destructive`, `path_denylist`, `max_context_tokens` (default 200k est. tokens). Truncated tool results carry a model hint (`[Truncated (N chars max; M omitted). You may ask for user to increase the limit via configuration file.]`) so the model can ask the user to raise `max_tool_result_chars`; request-time compact shrinks (`hint=False`) keep the terse `...[truncated N characters]` marker.
|
||||||
|
|
||||||
### Tools (`tools/`)
|
### Tools (`tools/`)
|
||||||
|
|
||||||
Module-level `@tool` handlers. Call `set_workspace_root()` before use.
|
Module-level `@tool` handlers. Call `set_workspace_root()` before use.
|
||||||
|
|
||||||
- **`workspace`**: path resolve under primary root **or** temporary allowlist roots; path substring denylist; command basename denylist (CLI: timed human allow override, default deny on timeout; **not** skipped by YOLO); clearer deny messages.
|
- **`workspace`**: path resolve under primary root **or** temporary allowlist roots; path substring denylist; command basename denylist (CLI: timed human allow override, default deny on timeout; **not** skipped by YOLO); clearer deny messages.
|
||||||
- **`file`**: `read_file` (`with_lineno`, `max_chars`; results start with a 1-based `L{begin}-{end}` range), `write_file`, `listdir`, `tree` (VCS + default noise dirs + optional `skip_dirs` / denylist walk), `glob_paths`, `grep_files` (regex, skip VCS/binary), `edit_replace` (`max_replaces`, reports remaining matches), `edit_lineno` (1-based range; only lines read via `with_lineno`; read state resets on any file write/edit/copy/move/delete), `copy_path` / `move_path` / `delete_path`, `new_temporary_workspace` (system temp allowlist; cleanup on chat exit; no session rebind), `get_truncated` (resume truncated `read_file`/`fetch` via `TRUNCATE_TOKEN`).
|
- **`file`**: `read_file` (`with_lineno`, `max_chars` default 32k; results start with a 1-based `L{begin}-{end}` range), `write_file`, `listdir`, `tree` (model picks `format=markdown` default / `flat` paths / `decorated`; VCS + default noise dirs + optional `skip_dirs` / denylist walk), `glob_paths`, `grep_files` (regex, skip VCS/binary), `edit_replace` (`max_replaces`, reports remaining matches), `edit_lineno` (1-based range; only lines read via `with_lineno`; read state resets on any file write/edit/copy/move/delete), `copy_path` / `move_path` / `delete_path`, `new_temporary_workspace` (system temp allowlist; cleanup on chat exit; no session rebind), `get_truncated` (resume any truncated result via its `truncate_token=...`).
|
||||||
- **`process`**: `run_argv` (argv, no shell, timeout, optional stdin/env); `run_argv_batch` (serial steps, `pipe_out` on provider, `mix_stderr`, `stop_on_error`); PTY `open_pty` / `read_pty` / `write_pty` (literal) / `write_pty_keys` (escapes) / `ask_into_pty` (human→PTY only; `secret` no-echo; answer never in tool result) / `close_pty` (POSIX: `pty`+`fork`; Windows: ConPTY via `pywinpty` env marker dep).
|
- **`process`**: `run_argv` (argv, no shell, timeout, optional stdin/env); `run_argv_batch` (serial steps, `pipe_out` on provider, `mix_stderr`, `stop_on_error`); PTY `open_pty` / `read_pty` / `write_pty` (literal) / `write_pty_keys` (escapes) / `ask_into_pty` (human→PTY only; `secret` no-echo; answer never in tool result) / `close_pty` (POSIX: `pty`+`fork`; Windows: ConPTY via `pywinpty` env marker dep).
|
||||||
- PTY: backend in `pty_backend.py`; structured status (`alive`/`exit_code`/`data`); `read_pty(..., until=)` (**CSI sanitized** so host TTY is never reset); keys via `write_pty_keys` only (`\xHH`, `ctrl+x`, `key=esc`); secrets via `ask_into_pty` (not `write_pty` data); session limit/idle TTL/output budget; close terminate→kill.
|
- PTY: backend in `pty_backend.py`; structured status (`alive`/`exit_code`/`data`); `read_pty(..., until=)` (**CSI sanitized** so host TTY is never reset); keys via `write_pty_keys` only (`\xHH`, `ctrl+x`, `key=esc`); secrets via `ask_into_pty` (not `write_pty` data); session limit/idle TTL/output budget; close terminate→kill.
|
||||||
- CLI limit hooks: interactive confirm to raise tool-loop rounds, PTY session cap, or PTY output budget.
|
- CLI limit hooks: interactive confirm to raise tool-loop rounds, PTY session cap, or PTY output budget.
|
||||||
@@ -94,7 +96,8 @@ Module-level `@tool` handlers. Call `set_workspace_root()` before use.
|
|||||||
- **`chat`**: human prompts as tools — `ask_user_line` / `ask_user_choice` / `ask_user_form` (shared `prompting` core); `wait` (line prompt with timeout; Enter disturbs).
|
- **`chat`**: human prompts as tools — `ask_user_line` / `ask_user_choice` / `ask_user_form` (shared `prompting` core); `wait` (line prompt with timeout; Enter disturbs).
|
||||||
- **`todo`**: LIFO stack of **task groups** (not a queue of tasks) — `todo_push` creates one group of siblings; `todo_pop` removes the whole top group; `todo_update` by id; DFS breakdown push[T1,T2]→push[T1.1…]→pop→push[T2.1…]; stored on session row; open items = unfinished work (`[TODO OPEN WORK]`); all-terminal non-empty = hygiene (`[TODO HYGIENE]`); nag channel via `[agent] todo_nag_strategy` (`developer`/`user` = prose nag; `synthetic_tool` = forged `todo_list` call + **real** `stack.render()` result + ToolCall/Result events; `none`).
|
- **`todo`**: LIFO stack of **task groups** (not a queue of tasks) — `todo_push` creates one group of siblings; `todo_pop` removes the whole top group; `todo_update` by id; DFS breakdown push[T1,T2]→push[T1.1…]→pop→push[T2.1…]; stored on session row; open items = unfinished work (`[TODO OPEN WORK]`); all-terminal non-empty = hygiene (`[TODO HYGIENE]`); nag channel via `[agent] todo_nag_strategy` (`developer`/`user` = prose nag; `synthetic_tool` = forged `todo_list` call + **real** `stack.render()` result + ToolCall/Result events; `none`).
|
||||||
- **`net` / `fetch`**: HTTP GET/POST/PUT/DELETE via niquests (isolated session, not the LLM client). Manual redirects with per-hop SSRF checks (`asyncio` `getaddrinfo`). Private/loopback hosts need instance policy grant (CLI timed confirm; **not** YOLO). Public cleartext HTTP and mutating methods soft-confirm (`YOLO`/`TRUSTABLE`). HTTPS→HTTP redirects blocked unless `allow_http_downgrade`. `user_agent` tool arg (or headers) is never replaced by the default when provided. Caps: body bytes/chars, request body size.
|
- **`net` / `fetch`**: HTTP GET/POST/PUT/DELETE via niquests (isolated session, not the LLM client). Manual redirects with per-hop SSRF checks (`asyncio` `getaddrinfo`). Private/loopback hosts need instance policy grant (CLI timed confirm; **not** YOLO). Public cleartext HTTP and mutating methods soft-confirm (`YOLO`/`TRUSTABLE`). HTTPS→HTTP redirects blocked unless `allow_http_downgrade`. `user_agent` tool arg (or headers) is never replaced by the default when provided. Caps: body bytes/chars, request body size.
|
||||||
- **`DEFAULT_TOOLS` / catalog**: file + process + vcs + chat + todo + net (`fetch`) for local surface.
|
- **`mcp`** (`tools/mcp.py`): register one server's advertised tools into the catalog as namespaced `mcp__<server>__<tool>` definitions with `ToolSource(kind="mcp", plugin_id=server)`; tags LOCAL (+ READ_ONLY when the server config sets `read_only`) — never YOLO/TRUSTABLE. `register_mcp_tools` purges prior MCP entries then re-registers every connected server (hosts call it on tool-registry rebuild); connections must already be started by `McpManager`.
|
||||||
|
- **`DEFAULT_TOOLS` / catalog**: file + process + vcs + chat + todo + net (`fetch`) for local surface. `ToolTag.READ_ONLY` marks non-mutating tools (`read_file`, `get_truncated`, `listdir`, `glob_paths`, `grep_files`, `tree`, `todo_list`, `wait`, `ask_user_*`, `vcs_*`, `fetch`); `ToolRegistry.clone(read_only_only=True)` selects just those for safe side turns / planning, and `read_only_context()` (in `tools/context.py`) makes `fetch` accept GET only.
|
||||||
|
|
||||||
### Prompting (`prompting.py`)
|
### Prompting (`prompting.py`)
|
||||||
|
|
||||||
@@ -104,8 +107,9 @@ Shared interactive I/O: `ask` / `choose` / `form` / `confirm` with pluggable bac
|
|||||||
|
|
||||||
Click app + readline REPL. Entry: `plyngent` / `python -m plyngent`.
|
Click app + readline REPL. Entry: `plyngent` / `python -m plyngent`.
|
||||||
|
|
||||||
- **`plyngent chat`**: provider/model (flags or interactive; Tab via readline in `prompting`); sessions store `provider_name`/`model` and restore on resume; SQLite via `[database]` (file DB under user data when url unset; explicit `url = ":memory:"` kept + warn); workspace-bound; resume latest for cwd/`--workspace` by default (`--new` / `--session`). One-shot: `-p/--prompt` and non-TTY stdin; exit codes 0/1/2/3; `--yes` (YOLO on), `--stream/--no-stream`, `--quiet`. Root `--log-level`.
|
- **`plyngent chat`**: provider/model (flags or interactive; Tab via readline in `prompting`); sessions store `provider_name`/`model` and restore on resume; SQLite via `[database]` (file DB under user data when url unset; explicit `url = ":memory:"` kept + warn); workspace-bound; resume latest for cwd/`--workspace` by default (`--new` / `--session`). One-shot: `-p/--prompt` and non-TTY stdin; exit codes 0/1/2/3; `--yes` (YOLO on), `--stream/--no-stream`, `--quiet`. Root `--log-level`. Enabled `[mcp]` servers start before the REPL (`_start_mcp_manager`, per-server failures warn on stderr) and are closed on exit (`McpManager.aclose`).
|
||||||
- Slash: Click group in `cli/slash.py` + `awaitlet` for async work; Tab completer from registry + ParamType `shell_complete`. Multiline `"""` … `"""`; `/edit` via `$VISUAL`/`$EDITOR` (blocking only). `/yolo on|off|once` for soft destructive confirms. `/model --persist` / `/models --persist` write model catalog entries into TOML. `/todos` for human show/push/pop/clear of the todo stack.
|
- Slash: Click group in `cli/slash.py` + `awaitlet` for async work; Tab completer from registry + ParamType `shell_complete`. Multiline `"""` … `"""`; `/edit` via `$VISUAL`/`$EDITOR` (blocking only). `/yolo on|off|once` for soft destructive confirms. `/model --persist` / `/models --persist` write model catalog entries into TOML. `/todos` for human show/push/pop/clear of the todo stack. `/mcp` (`list` default) prints per-server status/tool counts + an `instructions` preview when the server sent usage guidance, + last stderr lines; `/mcp reconnect` re-reads config, restarts every enabled server, and rebuilds the tool registry.
|
||||||
|
- `/btw` side questions: forked session; main transcript/DB untouched. `--tools=read` (default) exposes only `READ_ONLY` tools under `read_only_context()` (fetch=GET) with the main session view; `--tools=no` off; `--tools=full` whole-registry clone with fresh session. Tools off: default falls back to no tools; explicit `read`/`full` errors.
|
||||||
- Explicit `/resume` or `--session` from another workspace prompts: **keep** / **update** / **abort**.
|
- Explicit `/resume` or `--session` from another workspace prompts: **keep** / **update** / **abort**.
|
||||||
- Failed/cancelled turns: user kept; **committed tool rounds kept** (side effects not re-run on `/retry`); only unfinished assistant rolled back; Ctrl+C cancels; TTY confirms off-loop; auto-retry 10s/20s/30s then `/retry`.
|
- Failed/cancelled turns: user kept; **committed tool rounds kept** (side effects not re-run on `/retry`); only unfinished assistant rolled back; Ctrl+C cancels; TTY confirms off-loop; auto-retry 10s/20s/30s then `/retry`.
|
||||||
- **`plyngent providers`**, **`config path|edit`**. Config open: `$VISUAL`/`$EDITOR` (wait), else system default (`xdg-open` / `open` / `startfile`, non-blocking). `/edit` stays blocking-only. No providers → optional edit then reload when waited.
|
- **`plyngent providers`**, **`config path|edit`**. Config open: `$VISUAL`/`$EDITOR` (wait), else system default (`xdg-open` / `open` / `startfile`, non-blocking). `/edit` stays blocking-only. No providers → optional edit then reload when waited.
|
||||||
@@ -135,7 +139,7 @@ Basedpyright `recommended`. Ruff includes `ANN` (private return types `ANN202` i
|
|||||||
- G3: multiline `"""` … `"""` input (`cli/input_text.py`); `/edit` via `$EDITOR` (`edit_text_in_editor`)
|
- G3: multiline `"""` … `"""` input (`cli/input_text.py`); `/edit` via `$EDITOR` (`edit_text_in_editor`)
|
||||||
- G4: `plyngent chat -p/--prompt` (+ non-TTY stdin); exit codes 0/1/2/3; `--yes` / non-interactive confirm deny; `--stream/--no-stream`, `--quiet`
|
- G4: `plyngent chat -p/--prompt` (+ non-TTY stdin); exit codes 0/1/2/3; `--yes` / non-interactive confirm deny; `--stream/--no-stream`, `--quiet`
|
||||||
- G5: PTY master FD non-inheritable; `read_pty`/`close_pty` via `to_thread`; `PtyManager.close_all()` on chat exit; `--log-level`; clearer invalid TOML errors; export/status stay secret-free
|
- G5: PTY master FD non-inheritable; `read_pty`/`close_pty` via `to_thread`; `PtyManager.close_all()` on chat exit; `--log-level`; clearer invalid TOML errors; export/status stay secret-free
|
||||||
- G6: README, `doc/plyngent.example.toml`, CLAUDE overview/CLI notes
|
- G6: README, `doc/plyngent.example.toml`, AGENTS overview/CLI notes
|
||||||
|
|
||||||
Phase G complete for single-user CLI polish. Next roadmap work is Phase H or optional F (cost/tokenizer).
|
Phase G complete for single-user CLI polish. Next roadmap work is Phase H or optional F (cost/tokenizer).
|
||||||
|
|
||||||
@@ -147,6 +147,9 @@ access_key_or_token = "sk-..."
|
|||||||
# tool_directives = '''### Workspace ...'''
|
# tool_directives = '''### Workspace ...'''
|
||||||
confirm_destructive = true
|
confirm_destructive = true
|
||||||
max_context_tokens = 200000
|
max_context_tokens = 200000
|
||||||
|
# Fold MCP server initialize ``instructions`` (usage guidance) into the
|
||||||
|
# system prompt when tools are on (default true; false ignores server text).
|
||||||
|
# mcp_instructions = true
|
||||||
|
|
||||||
# Optional plugins (entry-point names); default load none. See doc/plugins.md.
|
# Optional plugins (entry-point names); default load none. See doc/plugins.md.
|
||||||
# [plugins]
|
# [plugins]
|
||||||
@@ -157,6 +160,8 @@ Per-provider **`timeout`** is passed to the HTTP session for chat/completions, R
|
|||||||
|
|
||||||
Third-party **plugins**: install a package that declares `project.entry-points."plyngent.tools"` (and later other groups), then allowlist the entry-point name under **`[plugins].enable`**. Details: [doc/plugins.md](doc/plugins.md).
|
Third-party **plugins**: install a package that declares `project.entry-points."plyngent.tools"` (and later other groups), then allowlist the entry-point name under **`[plugins].enable`**. Details: [doc/plugins.md](doc/plugins.md).
|
||||||
|
|
||||||
|
**MCP servers** (Model Context Protocol over stdio): define servers under `[mcp.servers.<name>]` (`command` + `args`, optional `env`/`cwd`/`timeout`/`read_only`) and their tools become agent tools namespaced `mcp__<server>__<tool>` (LOCAL tags; `read_only = true` also marks them READ_ONLY, eligible for `/btw --tools=read`). Names in `[mcp].disable` stay disconnected. A server may also return optional `instructions` (usage guidance) in its MCP `initialize` response; when tools are on those are folded into the agent system prompt as `MCP server <name> instructions:` blocks (`[agent] mcp_instructions = false` disables), and `/mcp` previews them. In the REPL, `/mcp` lists per-server status and tool counts; `/mcp reconnect` re-reads the config file and restarts every enabled server (adopts newly added ones).
|
||||||
|
|
||||||
Supported provider presets today:
|
Supported provider presets today:
|
||||||
|
|
||||||
| Preset | API used by the agent | Notes |
|
| Preset | API used by the agent | Notes |
|
||||||
@@ -252,6 +257,7 @@ Type `/help` in the REPL for the live list. Common ones:
|
|||||||
| `/yolo [on\|off\|once]` | Soft destructive confirms: sticky skip, off, or next turn only |
|
| `/yolo [on\|off\|once]` | Soft destructive confirms: sticky skip, off, or next turn only |
|
||||||
|
|
||||||
| `/retry` | Re-run incomplete last user turn (after error/cancel) |
|
| `/retry` | Re-run incomplete last user turn (after error/cancel) |
|
||||||
|
| `/btw [--tools read\|no\|full] [--fresh]` | Side question without changing the main session (read-only tools by default) |
|
||||||
| `/provider` `/model` | Switch without restarting |
|
| `/provider` `/model` | Switch without restarting |
|
||||||
| `/model --persist` | Save current model id into `plyngent.toml` catalog |
|
| `/model --persist` | Save current model id into `plyngent.toml` catalog |
|
||||||
| `/models` | List config + remote `GET /models` (always re-fetches) |
|
| `/models` | List config + remote `GET /models` (always re-fetches) |
|
||||||
@@ -270,7 +276,7 @@ User messages are saved immediately. On API error or Ctrl+C, partial assistant/t
|
|||||||
|
|
||||||
## Tools (when enabled)
|
## Tools (when enabled)
|
||||||
|
|
||||||
Default registry: file ops (including `tree` with default noise-dir skips), `run_argv` / `run_argv_batch` / PTY (POSIX openpty; Windows ConPTY via pywinpty), read-only VCS (git), HTTP `fetch` (GET/POST/PUT/DELETE via niquests; private/loopback hosts need a human policy grant, not YOLO), human prompts (`ask_user_line` / `ask_user_choice` / `ask_user_form`), `wait` (line prompt with timeout; Enter disturbs), `get_truncated` (resume truncated results via `TRUNCATE_TOKEN`), and todo stack tools (`todo_list` / `todo_push` / `todo_pop` / `todo_update` / `todo_clear`).
|
Default registry: file ops (including `tree` with a markdown bullet-list default, `flat` paths or classic `decorated` on demand; default noise-dir skips), `run_argv` / `run_argv_batch` / PTY (POSIX openpty; Windows ConPTY via pywinpty), read-only VCS (git), HTTP `fetch` (GET/POST/PUT/DELETE via niquests; private/loopback hosts need a human policy grant, not YOLO), human prompts (`ask_user_line` / `ask_user_choice` / `ask_user_form`), `wait` (line prompt with timeout; Enter disturbs), `get_truncated` (resume any truncated result via its `truncate_token=...`), and todo stack tools (`todo_list` / `todo_push` / `todo_pop` / `todo_update` / `todo_clear`).
|
||||||
|
|
||||||
Safety defaults:
|
Safety defaults:
|
||||||
|
|
||||||
@@ -299,7 +305,7 @@ plyngent --log-level INFO chat ...
|
|||||||
|
|
||||||
## Architecture (short)
|
## Architecture (short)
|
||||||
|
|
||||||
See [doc/architecture.md](doc/architecture.md) and [CLAUDE.md](CLAUDE.md) for developers.
|
See [doc/architecture.md](doc/architecture.md) and [AGENTS.md](AGENTS.md) for developers.
|
||||||
|
|
||||||
- **`lmproto/`** — OpenAI-compatible, OpenAI Responses, Anthropic Messages, DeepSeek msgspec models and async SSE clients
|
- **`lmproto/`** — OpenAI-compatible, OpenAI Responses, Anthropic Messages, DeepSeek msgspec models and async SSE clients
|
||||||
- **`agent/`** — kind-based tool loop (`chat_completions` / `responses` / `messages`), streaming, usage, compact
|
- **`agent/`** — kind-based tool loop (`chat_completions` / `responses` / `messages`), streaming, usage, compact
|
||||||
|
|||||||
@@ -50,6 +50,11 @@ Notes:
|
|||||||
- Optional soft-confirm bits: **`YOLO`** (eligible for YOLO auto-approve),
|
- Optional soft-confirm bits: **`YOLO`** (eligible for YOLO auto-approve),
|
||||||
**`TRUSTABLE`** (grant once per session after approve). Hard denylists are
|
**`TRUSTABLE`** (grant once per session after approve). Hard denylists are
|
||||||
never YOLO-skipped.
|
never YOLO-skipped.
|
||||||
|
- Optional host-policy bit: **`READ_ONLY`** marks tools that never mutate host
|
||||||
|
state — they become eligible for read-only contexts such as
|
||||||
|
`/btw --tools=read` side turns or planning. Tools that only sometimes mutate
|
||||||
|
(e.g. `fetch` with a non-GET method) may still opt in; the handler must
|
||||||
|
enforce the constraint via the read-only context flag.
|
||||||
- Docstring becomes the model-facing description; parameter schemas come from
|
- Docstring becomes the model-facing description; parameter schemas come from
|
||||||
type hints.
|
type hints.
|
||||||
- Use `register=False` only for unit tests that build a private registry.
|
- Use `register=False` only for unit tests that build a private registry.
|
||||||
|
|||||||
@@ -26,6 +26,22 @@
|
|||||||
# [networking]
|
# [networking]
|
||||||
# ssrf_assume_public_cidrs = ["198.18.0.0/15"]
|
# ssrf_assume_public_cidrs = ["198.18.0.0/15"]
|
||||||
|
|
||||||
|
# MCP (Model Context Protocol) servers exposed as agent tools.
|
||||||
|
# Defining a server opts it in; tools appear as mcp__<server>__<tool> with
|
||||||
|
# LOCAL tags (confirm flow applies). Names in disable stay disconnected.
|
||||||
|
# A server's initialize ``instructions`` (usage guidance) is folded into the
|
||||||
|
# agent system prompt when tools are on ([agent] mcp_instructions = false
|
||||||
|
# disables; /mcp previews what each server sent).
|
||||||
|
# [mcp.servers.docs]
|
||||||
|
# command = "npx"
|
||||||
|
# args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
||||||
|
# env = { RUST_LOG = "info" }
|
||||||
|
# cwd = "/path" # spawn working directory (empty = inherit)
|
||||||
|
# timeout = 30.0 # per-request timeout (seconds)
|
||||||
|
# read_only = true # mark every tool READ_ONLY (eligible for /btw --tools=read)
|
||||||
|
# [mcp]
|
||||||
|
# disable = ["docs"] # defined but never connected
|
||||||
|
|
||||||
[agent]
|
[agent]
|
||||||
# Persona (omit → built-in coding-agent line). tool_directives is the tool playbook.
|
# Persona (omit → built-in coding-agent line). tool_directives is the tool playbook.
|
||||||
# Override with multi-line literals (prefer ''' so nested " is fine):
|
# Override with multi-line literals (prefer ''' so nested " is fine):
|
||||||
@@ -44,6 +60,9 @@ confirm_destructive = true
|
|||||||
path_denylist = ["/secrets/", ".ssh/", ".gnupg/"]
|
path_denylist = ["/secrets/", ".ssh/", ".gnupg/"]
|
||||||
# Soft context budget in estimated tokens (~4 chars/token when API usage missing).
|
# Soft context budget in estimated tokens (~4 chars/token when API usage missing).
|
||||||
max_context_tokens = 200000
|
max_context_tokens = 200000
|
||||||
|
# Fold connected MCP servers' initialize ``instructions`` (usage guidance)
|
||||||
|
# into the agent system prompt when tools are on. false = ignore server text.
|
||||||
|
# mcp_instructions = true
|
||||||
# How open-todo nags are injected into model context:
|
# How open-todo nags are injected into model context:
|
||||||
# developer (default) | user | synthetic_tool | none
|
# developer (default) | user | synthetic_tool | none
|
||||||
# (legacy system is treated as developer)
|
# (legacy system is treated as developer)
|
||||||
|
|||||||
@@ -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.5.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" },
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ DEFAULT_WIN_PYTHON = "3.14"
|
|||||||
DEFAULT_WIN_UV_VERSION = "0.11.28"
|
DEFAULT_WIN_UV_VERSION = "0.11.28"
|
||||||
# typings/ is required for Wine basedpyright: pywinpty's shipped types are incomplete
|
# typings/ is required for Wine basedpyright: pywinpty's shipped types are incomplete
|
||||||
# (e.g. kill(sig) required); pyright default stubPath is ``typings/``.
|
# (e.g. kill(sig) required); pyright default stubPath is ``typings/``.
|
||||||
VIEW_LINKS = ("src", "tests", "scripts", "typings", "README.md", "LICENSE", "CLAUDE.md", "doc")
|
VIEW_LINKS = ("src", "tests", "scripts", "typings", "README.md", "LICENSE", "AGENTS.md", "doc")
|
||||||
VIEW_COPIES = ("pyproject.toml", "pdm.lock")
|
VIEW_COPIES = ("pyproject.toml", "pdm.lock")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from .chat import ChatAgent as ChatAgent
|
|||||||
from .chat import get_lineno_read_files as get_lineno_read_files
|
from .chat import get_lineno_read_files as get_lineno_read_files
|
||||||
from .chat import invalidate_lineno_read as invalidate_lineno_read
|
from .chat import invalidate_lineno_read as invalidate_lineno_read
|
||||||
from .chat import lineno_read_lines as lineno_read_lines
|
from .chat import lineno_read_lines as lineno_read_lines
|
||||||
|
from .chat import lineno_read_mtime as lineno_read_mtime
|
||||||
from .chat import mark_lineno_read as mark_lineno_read
|
from .chat import mark_lineno_read as mark_lineno_read
|
||||||
from .chat import reset_lineno_tracker as reset_lineno_tracker
|
from .chat import reset_lineno_tracker as reset_lineno_tracker
|
||||||
from .chat import was_lineno_read as was_lineno_read
|
from .chat import was_lineno_read as was_lineno_read
|
||||||
|
|||||||
@@ -31,28 +31,23 @@ DEFAULT_CONTEXT_MAX_CHARS = DEFAULT_CONTEXT_MAX_TOKENS * 4
|
|||||||
def truncate_tool_result(
|
def truncate_tool_result(
|
||||||
text: str,
|
text: str,
|
||||||
max_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS,
|
max_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS,
|
||||||
*,
|
|
||||||
hint: bool = True,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Cap tool output so huge dumps do not flood model context.
|
"""Cap tool output so huge dumps do not flood model context.
|
||||||
|
|
||||||
With ``hint=True`` the suffix tells the model the result was cut and that
|
This is the generic agent-loop cap for arbitrary tool results. It embeds a
|
||||||
it may ask the user to raise the config limit (``max_tool_result_chars``).
|
memory truncate token via :func:`truncate_generic` so the model can always
|
||||||
``hint=False`` keeps the terse marker for internal shrinks whose cap is not
|
resume the remainder with ``get_truncated`` — resumable or not (remainders
|
||||||
a user-facing config knob (e.g. request-time compacting).
|
live in the in-memory store for the process lifetime). The compact marker
|
||||||
|
``[Truncated (N chars max.; M omitted). truncate_token=...]`` is emitted
|
||||||
|
only when output is actually cut.
|
||||||
"""
|
"""
|
||||||
if max_chars < 1:
|
if max_chars < 1:
|
||||||
return text
|
return text
|
||||||
if len(text) <= max_chars:
|
if len(text) <= max_chars:
|
||||||
return text
|
return text
|
||||||
omitted = len(text) - max_chars
|
from plyngent.tools.truncate_token import truncate_generic
|
||||||
if hint:
|
|
||||||
return (
|
return truncate_generic(text, max_chars)
|
||||||
f"{text[:max_chars]}\n"
|
|
||||||
f"...[Truncated ({max_chars} chars max.; {omitted} chars omitted). "
|
|
||||||
f"Use `get_truncated` when a `TRUNCATE_TOKEN` is shown.]"
|
|
||||||
)
|
|
||||||
return f"{text[:max_chars]}\n...[truncated {omitted} characters]"
|
|
||||||
|
|
||||||
|
|
||||||
def estimate_message_chars(message: AnyChatMessage) -> int:
|
def estimate_message_chars(message: AnyChatMessage) -> int:
|
||||||
@@ -120,7 +115,7 @@ def _shrink_tool(message: ToolChatMessage, max_chars: int) -> ToolChatMessage:
|
|||||||
return message
|
return message
|
||||||
return msgspec.structs.replace(
|
return msgspec.structs.replace(
|
||||||
message,
|
message,
|
||||||
content=truncate_tool_result(message.content, max_chars, hint=False),
|
content=truncate_tool_result(message.content, max_chars),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+37
-10
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from contextvars import ContextVar, Token
|
from contextvars import ContextVar, Token
|
||||||
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from msgspec import UNSET
|
from msgspec import UNSET
|
||||||
@@ -47,14 +48,18 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
|
|
||||||
# Per-conversation tracking of lines read with ``with_lineno=True``, keyed by
|
# Per-conversation tracking of lines read with ``with_lineno=True``, keyed by
|
||||||
# resolved file path. Set to a fresh ``dict`` at the start of each user turn via
|
# resolved file path, with the file's mtime at read time (so stale line numbers
|
||||||
# :func:`reset_lineno_tracker`; cleared after the assistant finishes.
|
# after external/process mutations are detected). Set to a fresh ``dict`` at the
|
||||||
# Tool handlers read/write via :func:`get_lineno_read_files` /
|
# start of each user turn via :func:`reset_lineno_tracker`; cleared after the
|
||||||
# :func:`lineno_read_lines` / :func:`mark_lineno_read`.
|
# assistant finishes. Tool handlers read/write via :func:`get_lineno_read_files` /
|
||||||
_lineno_read_files: ContextVar[dict[str, set[int]] | None] = ContextVar("_lineno_read_files", default=None)
|
# :func:`lineno_read_lines` / :func:`lineno_read_mtime` / :func:`mark_lineno_read`.
|
||||||
|
_lineno_read_files: ContextVar[dict[str, tuple[set[int], int]] | None] = ContextVar(
|
||||||
|
"_lineno_read_files",
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def reset_lineno_tracker() -> Token[dict[str, set[int]] | None]:
|
def reset_lineno_tracker() -> Token[dict[str, tuple[set[int], int]] | None]:
|
||||||
"""Replace the tracker with a fresh empty mapping; return the previous token.
|
"""Replace the tracker with a fresh empty mapping; return the previous token.
|
||||||
|
|
||||||
Call at the start of a new user turn. Restore with
|
Call at the start of a new user turn. Restore with
|
||||||
@@ -80,17 +85,38 @@ def lineno_read_lines(path: str) -> set[int]:
|
|||||||
val = _lineno_read_files.get()
|
val = _lineno_read_files.get()
|
||||||
if val is None:
|
if val is None:
|
||||||
return set()
|
return set()
|
||||||
return set(val.get(path, ()))
|
entry = val.get(path)
|
||||||
|
return set(entry[0]) if entry is not None else set()
|
||||||
|
|
||||||
|
|
||||||
|
def lineno_read_mtime(path: str) -> int | None:
|
||||||
|
"""Return the file mtime (ns) recorded when *path* was read, or None."""
|
||||||
|
val = _lineno_read_files.get()
|
||||||
|
if val is None:
|
||||||
|
return None
|
||||||
|
entry = val.get(path)
|
||||||
|
return entry[1] if entry is not None else None
|
||||||
|
|
||||||
|
|
||||||
def mark_lineno_read(path: str, lines: set[int]) -> None:
|
def mark_lineno_read(path: str, lines: set[int]) -> None:
|
||||||
"""Record that *path* was read with line numbers this turn.
|
"""Record that *path* was read with line numbers this turn.
|
||||||
|
|
||||||
``lines`` are 1-based absolute file line numbers (``read_file`` slice).
|
``lines`` are 1-based absolute file line numbers (``read_file`` slice).
|
||||||
|
The file's current mtime is recorded too, so edits can detect that the
|
||||||
|
file changed since the read (external/process mutations included).
|
||||||
"""
|
"""
|
||||||
val = _lineno_read_files.get()
|
val = _lineno_read_files.get()
|
||||||
if val is not None:
|
if val is None:
|
||||||
val.setdefault(path, set()).update(lines)
|
return
|
||||||
|
try:
|
||||||
|
mtime_ns = Path(path).stat().st_mtime_ns
|
||||||
|
except OSError:
|
||||||
|
mtime_ns = 0
|
||||||
|
entry = val.get(path)
|
||||||
|
if entry is None:
|
||||||
|
val[path] = (set(lines), mtime_ns)
|
||||||
|
else:
|
||||||
|
val[path] = (entry[0] | lines, mtime_ns)
|
||||||
|
|
||||||
|
|
||||||
def invalidate_lineno_read(path: str) -> None:
|
def invalidate_lineno_read(path: str) -> None:
|
||||||
@@ -569,7 +595,8 @@ class ChatAgent:
|
|||||||
- Tools default **off**. ``tools=True`` clones this agent's registry with
|
- Tools default **off**. ``tools=True`` clones this agent's registry with
|
||||||
a **fresh session** bag (unless *session_state* is passed) and the
|
a **fresh session** bag (unless *session_state* is passed) and the
|
||||||
given *instance_state* (CLI typically shares the host instance for
|
given *instance_state* (CLI typically shares the host instance for
|
||||||
workspace identity).
|
workspace identity). A caller-built :class:`ToolRegistry` (e.g. a
|
||||||
|
``clone(read_only_only=True)`` subset) is used as-is.
|
||||||
"""
|
"""
|
||||||
text = user_text.strip()
|
text = user_text.strip()
|
||||||
if not text:
|
if not text:
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class ToolTag(Flag):
|
|||||||
YOLO = auto() # soft-confirm: eligible for YOLO auto-approve
|
YOLO = auto() # soft-confirm: eligible for YOLO auto-approve
|
||||||
INSTANCE_STATE = auto() # needs instance-scoped state
|
INSTANCE_STATE = auto() # needs instance-scoped state
|
||||||
SESSION_STATE = auto() # needs session-scoped state
|
SESSION_STATE = auto() # needs session-scoped state
|
||||||
|
READ_ONLY = auto() # never mutates host state; safe for read-only contexts
|
||||||
|
|
||||||
|
|
||||||
class ToolDefinition:
|
class ToolDefinition:
|
||||||
@@ -266,14 +267,22 @@ class ToolRegistry:
|
|||||||
session_state: object | None = None,
|
session_state: object | None = None,
|
||||||
yolo: bool | None = None,
|
yolo: bool | None = None,
|
||||||
auto_bind_state: bool | None = None,
|
auto_bind_state: bool | None = None,
|
||||||
|
read_only_only: bool = False,
|
||||||
) -> ToolRegistry:
|
) -> ToolRegistry:
|
||||||
"""New registry with the same tools/hooks and rebound host state.
|
"""New registry with the same tools/hooks and rebound host state.
|
||||||
|
|
||||||
*instance_state* / *session_state* are applied as given (use the parent
|
*instance_state* / *session_state* are applied as given (use the parent
|
||||||
registry's handles only when the caller passes them through).
|
registry's handles only when the caller passes them through).
|
||||||
|
|
||||||
|
With *read_only_only*, only :attr:`ToolTag.READ_ONLY` tools are kept
|
||||||
|
(e.g. for safe side turns / planning); hooks and policy flags are
|
||||||
|
preserved.
|
||||||
"""
|
"""
|
||||||
|
definitions = self.definitions()
|
||||||
|
if read_only_only:
|
||||||
|
definitions = [d for d in definitions if d.tags & ToolTag.READ_ONLY]
|
||||||
return ToolRegistry(
|
return ToolRegistry(
|
||||||
self.definitions(),
|
definitions,
|
||||||
danger=self._danger,
|
danger=self._danger,
|
||||||
on_confirm=self._on_confirm,
|
on_confirm=self._on_confirm,
|
||||||
yolo=self._yolo if yolo is None else yolo,
|
yolo=self._yolo if yolo is None else yolo,
|
||||||
|
|||||||
+31
-1
@@ -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
|
||||||
@@ -25,7 +26,7 @@ from plyngent.cli.state import ReplState
|
|||||||
from plyngent.config.models import DatabaseConfig
|
from plyngent.config.models import DatabaseConfig
|
||||||
from plyngent.memory import MemoryStore
|
from plyngent.memory import MemoryStore
|
||||||
from plyngent.prompting import NonInteractiveBackend, configure_prompting
|
from plyngent.prompting import NonInteractiveBackend, configure_prompting
|
||||||
from plyngent.runtime import ProviderNotSupportedError, create_client
|
from plyngent.runtime import McpManager, ProviderNotSupportedError, create_client
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
@@ -133,6 +134,24 @@ def _setup_hooks(*, interactive: bool) -> None:
|
|||||||
PtyManager.set_limit_continue_hook(None)
|
PtyManager.set_limit_continue_hook(None)
|
||||||
|
|
||||||
|
|
||||||
|
async def _start_mcp_manager(store: ConfigStore) -> McpManager | None:
|
||||||
|
"""Start configured MCP servers before the REPL state is built.
|
||||||
|
|
||||||
|
Returns ``None`` when nothing is enabled. Failures degrade to a warning
|
||||||
|
per server (the rest of the session, including LLM traffic, continues).
|
||||||
|
"""
|
||||||
|
manager = McpManager(store.mcp_config)
|
||||||
|
if not manager.enabled_servers():
|
||||||
|
return None
|
||||||
|
await manager.ensure_started()
|
||||||
|
for name, status, count in manager.statuses():
|
||||||
|
if status.startswith("error"):
|
||||||
|
click.secho(f"warning: mcp server {name!r} failed: {status}", fg="yellow", err=True)
|
||||||
|
else:
|
||||||
|
click.secho(f"mcp: {name} ({status}, tools={count})", fg="bright_black", err=True)
|
||||||
|
return manager
|
||||||
|
|
||||||
|
|
||||||
async def _bind_session(
|
async def _bind_session(
|
||||||
state: ReplState,
|
state: ReplState,
|
||||||
*,
|
*,
|
||||||
@@ -284,6 +303,7 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
|
|||||||
except ProviderNotSupportedError as exc:
|
except ProviderNotSupportedError as exc:
|
||||||
raise click.ClickException(str(exc)) from exc
|
raise click.ClickException(str(exc)) from exc
|
||||||
|
|
||||||
|
mcp_manager = await _start_mcp_manager(store)
|
||||||
state = ReplState(
|
state = ReplState(
|
||||||
config=store,
|
config=store,
|
||||||
memory=memory,
|
memory=memory,
|
||||||
@@ -296,6 +316,7 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
|
|||||||
stream_enabled=stream,
|
stream_enabled=stream,
|
||||||
interactive_limits=interactive,
|
interactive_limits=interactive,
|
||||||
yolo=yolo,
|
yolo=yolo,
|
||||||
|
mcp_manager=mcp_manager,
|
||||||
)
|
)
|
||||||
# Path denylist and policy confirm live on instance.workspace (no process bag).
|
# Path denylist and policy confirm live on instance.workspace (no process bag).
|
||||||
state.instance_state.workspace.path_denylist = tuple(store.agent_config.path_denylist or ())
|
state.instance_state.workspace.path_denylist = tuple(store.agent_config.path_denylist or ())
|
||||||
@@ -352,6 +373,8 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
|
|||||||
set_fetch_policy_confirm_hook(None, instance=state_obj.instance_state)
|
set_fetch_policy_confirm_hook(None, instance=state_obj.instance_state)
|
||||||
clear_private_grants(instance=state_obj.instance_state)
|
clear_private_grants(instance=state_obj.instance_state)
|
||||||
clear_ssrf_assume_public_cidrs()
|
clear_ssrf_assume_public_cidrs()
|
||||||
|
if state_obj.mcp_manager is not None:
|
||||||
|
await state_obj.mcp_manager.aclose()
|
||||||
await state_obj.instance_state.shutdown()
|
await state_obj.instance_state.shutdown()
|
||||||
else:
|
else:
|
||||||
# No ReplState: only PTY class cleanup (temps require instance allowlist).
|
# No ReplState: only PTY class cleanup (temps require instance allowlist).
|
||||||
@@ -484,6 +507,13 @@ def chat_cmd(
|
|||||||
raise click.ClickException(msg)
|
raise click.ClickException(msg)
|
||||||
|
|
||||||
root = workspace if workspace is not None else Path.cwd()
|
root = workspace if workspace is not None else Path.cwd()
|
||||||
|
# asyncio.run installs its own SIGINT handler when the current one is the
|
||||||
|
# default: the first Ctrl+C then silently cancels the main task (invisible
|
||||||
|
# at the REPL prompt) and the second raises KeyboardInterrupt, which also
|
||||||
|
# cancels memory.close() on a Ctrl+D exit ("Aborted!"). Pre-install a plain
|
||||||
|
# KeyboardInterrupt-raising handler so Ctrl+C stays a normal KeyboardInterrupt
|
||||||
|
# that the REPL / turn-retry / oneshot code handles deliberately.
|
||||||
|
install_keyboard_interrupt_sigint()
|
||||||
code = asyncio.run(
|
code = asyncio.run(
|
||||||
_run_chat(
|
_run_chat(
|
||||||
config_path=config_path,
|
config_path=config_path,
|
||||||
|
|||||||
+329
-23
@@ -3,6 +3,8 @@ from __future__ import annotations
|
|||||||
import contextlib
|
import contextlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
import sys
|
import sys
|
||||||
from contextvars import ContextVar
|
from contextvars import ContextVar
|
||||||
from typing import TYPE_CHECKING, Literal, cast
|
from typing import TYPE_CHECKING, Literal, cast
|
||||||
@@ -22,7 +24,7 @@ from plyngent.agent import (
|
|||||||
from plyngent.lmproto.openai_compatible.model import AssistantFunctionToolCall
|
from plyngent.lmproto.openai_compatible.model import AssistantFunctionToolCall
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator, Callable
|
||||||
|
|
||||||
from plyngent.agent import AgentEvent
|
from plyngent.agent import AgentEvent
|
||||||
|
|
||||||
@@ -31,7 +33,29 @@ _TOOL_ARGS_PREVIEW = 80
|
|||||||
|
|
||||||
# Tool calls that render as a single pretty summary line instead of
|
# Tool calls that render as a single pretty summary line instead of
|
||||||
# ``[tool]`` / ``[tool result]``.
|
# ``[tool]`` / ``[tool result]``.
|
||||||
_PRETTY_TOOLS = frozenset({"read_file", "todo_push", "todo_update"})
|
_PRETTY_TOOLS = frozenset(
|
||||||
|
{
|
||||||
|
"read_file",
|
||||||
|
"todo_push",
|
||||||
|
"todo_update",
|
||||||
|
"tree",
|
||||||
|
"listdir",
|
||||||
|
"glob_paths",
|
||||||
|
"grep_files",
|
||||||
|
"run_argv",
|
||||||
|
"run_argv_batch",
|
||||||
|
"fetch",
|
||||||
|
"edit_replace",
|
||||||
|
"edit_lineno",
|
||||||
|
"write_file",
|
||||||
|
"copy_path",
|
||||||
|
"move_path",
|
||||||
|
"delete_path",
|
||||||
|
"vcs_status",
|
||||||
|
"vcs_log",
|
||||||
|
"wait",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Process/session display flags (set from ReplState / slash).
|
# Process/session display flags (set from ReplState / slash).
|
||||||
_verbose_tool_results: ContextVar[bool] = ContextVar("verbose_tool_results", default=False)
|
_verbose_tool_results: ContextVar[bool] = ContextVar("verbose_tool_results", default=False)
|
||||||
@@ -74,47 +98,330 @@ def _preview(text: str, limit: int) -> str:
|
|||||||
return text[:limit] + "…"
|
return text[:limit] + "…"
|
||||||
|
|
||||||
|
|
||||||
def _json_str_arg(args_json: str, key: str) -> str | None:
|
def _preview_result(content: str, limit: int) -> str:
|
||||||
"""Extract a string argument from a tool-call arguments JSON blob."""
|
"""Non-verbose tool-result preview: first line + a ``(…N more lines)`` tail."""
|
||||||
|
lines = content.splitlines()
|
||||||
|
if not lines:
|
||||||
|
return "(no output)"
|
||||||
|
first = _preview(lines[0], limit)
|
||||||
|
more = len(lines) - 1
|
||||||
|
if more == 0:
|
||||||
|
return first
|
||||||
|
unit = "line" if more == 1 else "lines"
|
||||||
|
return f"{first} (…{more} more {unit})"
|
||||||
|
|
||||||
|
|
||||||
|
def _json_arg(args_json: str, key: str) -> object | None:
|
||||||
|
"""Extract a single argument value (any type) from a tool-call args JSON blob."""
|
||||||
try:
|
try:
|
||||||
raw = json.loads(args_json)
|
raw = json.loads(args_json)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return None
|
return None
|
||||||
if isinstance(raw, dict):
|
if isinstance(raw, dict):
|
||||||
value = cast("dict[str, object]", raw).get(key)
|
return cast("dict[str, object]", raw).get(key)
|
||||||
if isinstance(value, str):
|
|
||||||
return value
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _json_str_arg(args_json: str, key: str) -> str | None:
|
||||||
|
value = _json_arg(args_json, key)
|
||||||
|
return value if isinstance(value, str) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _json_list_arg(args_json: str, key: str) -> list[str] | None:
|
||||||
|
value = _json_arg(args_json, key)
|
||||||
|
if isinstance(value, list):
|
||||||
|
raw_items = cast("list[object]", value)
|
||||||
|
items = [item for item in raw_items if isinstance(item, str)]
|
||||||
|
if len(items) == len(raw_items):
|
||||||
|
return items
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _pretty_line(prefix: str, *segments: tuple[str, str | None]) -> str:
|
||||||
|
"""Style a pretty tool summary: yellow ``* Verb`` prefix + colored detail segments.
|
||||||
|
|
||||||
|
``segments`` are ``(text, fg)`` pairs; ``fg=None`` leaves a segment uncolored
|
||||||
|
and ``"dim"`` renders it dim. click.style emits ANSI codes only on a TTY,
|
||||||
|
so non-TTY output stays plain.
|
||||||
|
"""
|
||||||
|
parts = [click.style(prefix, fg="yellow")]
|
||||||
|
for text, fg in segments:
|
||||||
|
if fg == "dim":
|
||||||
|
parts.append(click.style(text, dim=True))
|
||||||
|
else:
|
||||||
|
parts.append(click.style(text, fg=fg))
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
def _read_file_pretty(args_json: str, result: str) -> str:
|
def _read_file_pretty(args_json: str, result: str) -> str:
|
||||||
"""One-line summary for a ``read_file`` call: ``* Read 'path' L1-4 (done)``."""
|
"""One-line summary for a ``read_file`` call: ``* Read 'path' L1-4 (done)``."""
|
||||||
path = _json_str_arg(args_json, "path") or "?"
|
path = _json_str_arg(args_json, "path") or "?"
|
||||||
if result.startswith("error: file not found"):
|
if result.startswith("error: file not found"):
|
||||||
return f"* Read '{path}' (file not found)"
|
return _pretty_line(f"* Read '{path}' ", ("(file not found)", "red"))
|
||||||
if result.startswith("error: not a file"):
|
if result.startswith("error: not a file"):
|
||||||
return f"* Read '{path}' (not a file)"
|
return _pretty_line(f"* Read '{path}' ", ("(not a file)", "red"))
|
||||||
if result.startswith("error:"):
|
if result.startswith("error:"):
|
||||||
return f"* Read '{path}' (error)"
|
return _pretty_line(f"* Read '{path}' ", ("(error)", "red"))
|
||||||
first = result.splitlines()[0] if result.splitlines() else ""
|
first = result.splitlines()[0] if result.splitlines() else ""
|
||||||
if first.startswith("L") and "-" in first:
|
if first.startswith("L") and "-" in first:
|
||||||
return f"* Read '{path}' {first} (done)"
|
return _pretty_line(f"* Read '{path}' ", (f"{first} ", "dim"), ("(done)", "green"))
|
||||||
return f"* Read '{path}' (done)"
|
return _pretty_line(f"* Read '{path}' ", ("(done)", "green"))
|
||||||
|
|
||||||
|
|
||||||
def _todo_pretty(name: str, result: str) -> str:
|
def _todo_pretty(name: str, result: str) -> str:
|
||||||
"""Summary for todo push/update: header + the rendered todo stack."""
|
"""Summary for todo push/update: header + the rendered todo stack."""
|
||||||
label = "Todo Push" if name == "todo_push" else "Todo Update"
|
label = "Todo Push" if name == "todo_push" else "Todo Update"
|
||||||
return f"* {label}:\n{result}"
|
return _pretty_line(f"* {label}:\n{result}")
|
||||||
|
|
||||||
|
|
||||||
|
def _tree_line_stats(fmt: str, line: str) -> tuple[str, int] | None:
|
||||||
|
"""Classify one ``tree`` result line as ``(kind, depth)`` or None (not an entry).
|
||||||
|
|
||||||
|
``kind`` is ``"dir"`` or ``"file"``; depth is 0 for ``flat`` (no hierarchy).
|
||||||
|
"""
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped:
|
||||||
|
return None
|
||||||
|
kind: str | None = None
|
||||||
|
depth = 0
|
||||||
|
if fmt == "flat":
|
||||||
|
if not stripped.startswith("…"):
|
||||||
|
kind = "dir" if stripped.endswith("/") else "file"
|
||||||
|
elif fmt == "markdown":
|
||||||
|
if stripped.startswith("- ") and not stripped.startswith(("- …", "- error")):
|
||||||
|
kind = "dir" if stripped[2:].endswith("/") else "file"
|
||||||
|
depth = (len(line) - len(line.lstrip(" "))) // 2 + 1
|
||||||
|
elif "… (" not in stripped:
|
||||||
|
branch = "├── " if "├── " in line else ("└── " if "└── " in line else None)
|
||||||
|
if branch is not None:
|
||||||
|
name = line[line.index(branch) + len(branch) :].rstrip()
|
||||||
|
kind = "dir" if name.endswith("/") else "file"
|
||||||
|
depth = line.index(branch) // 4 + 1
|
||||||
|
if kind is None:
|
||||||
|
return None
|
||||||
|
return kind, depth
|
||||||
|
|
||||||
|
|
||||||
|
def _tree_pretty(args_json: str, result: str) -> str:
|
||||||
|
"""One-line summary for a ``tree`` call: ``* Tree 'src' (3 dirs, 24 files, depth 3)``.
|
||||||
|
|
||||||
|
Flat trees have no depth, so the depth part is omitted for ``format=flat``.
|
||||||
|
"""
|
||||||
|
path = _json_str_arg(args_json, "path") or "."
|
||||||
|
fmt = _json_str_arg(args_json, "format") or "markdown"
|
||||||
|
if result.startswith("error:"):
|
||||||
|
return f"* Tree '{path}' (error)"
|
||||||
|
dirs = 0
|
||||||
|
files = 0
|
||||||
|
depth = 0
|
||||||
|
for line in result.splitlines():
|
||||||
|
stats = _tree_line_stats(fmt, line)
|
||||||
|
if stats is None:
|
||||||
|
continue
|
||||||
|
kind, line_depth = stats
|
||||||
|
if kind == "dir":
|
||||||
|
dirs += 1
|
||||||
|
else:
|
||||||
|
files += 1
|
||||||
|
depth = max(depth, line_depth)
|
||||||
|
if depth:
|
||||||
|
return _pretty_line(f"* Tree '{path}' ", (f"({dirs} dirs, {files} files, depth {depth})", None))
|
||||||
|
return _pretty_line(f"* Tree '{path}' ", (f"({dirs} dirs, {files} files)", None))
|
||||||
|
|
||||||
|
|
||||||
|
def _listdir_pretty(args_json: str, result: str) -> str:
|
||||||
|
"""One-line summary for a ``listdir`` call: ``* List 'path' (3 dirs, 5 files)``."""
|
||||||
|
path = _json_str_arg(args_json, "path") or "."
|
||||||
|
if result.startswith("error:"):
|
||||||
|
return _pretty_line(f"* List '{path}' ", (f"({result})", "red"))
|
||||||
|
if result == "(empty)":
|
||||||
|
return _pretty_line(f"* List '{path}' ", ("(empty)", "dim"))
|
||||||
|
dirs = 0
|
||||||
|
files = 0
|
||||||
|
for line in result.splitlines():
|
||||||
|
if line.startswith("dir\t"):
|
||||||
|
dirs += 1
|
||||||
|
elif line.startswith("file\t"):
|
||||||
|
files += 1
|
||||||
|
return _pretty_line(f"* List '{path}' ", (f"({dirs} dirs, {files} files)", None))
|
||||||
|
|
||||||
|
|
||||||
|
def _glob_pretty(args_json: str, result: str) -> str:
|
||||||
|
"""One-line summary for a ``glob_paths`` call: ``* Glob '**/*.py' in '.' (12 paths)``."""
|
||||||
|
pattern = _json_str_arg(args_json, "pattern") or "?"
|
||||||
|
path = _json_str_arg(args_json, "path") or "."
|
||||||
|
if result.startswith("error:"):
|
||||||
|
return _pretty_line(f"* Glob '{pattern}' in '{path}' ", (f"({result})", "red"))
|
||||||
|
if result == "(no matches)":
|
||||||
|
return _pretty_line(f"* Glob '{pattern}' in '{path}' ", ("(no matches)", "dim"))
|
||||||
|
count = sum(1 for line in result.splitlines() if line and not line.startswith("...[truncated"))
|
||||||
|
unit = "path" if count == 1 else "paths"
|
||||||
|
return _pretty_line(f"* Glob '{pattern}' in '{path}' ", (f"({count} {unit})", None))
|
||||||
|
|
||||||
|
|
||||||
|
def _grep_pretty(args_json: str, result: str) -> str:
|
||||||
|
"""One-line summary for a ``grep_files`` call: ``* Grep 'pat' in '.' (12 matches in 3 files)``."""
|
||||||
|
pattern = _json_str_arg(args_json, "pattern") or "?"
|
||||||
|
path = _json_str_arg(args_json, "path") or "."
|
||||||
|
if result.startswith("error:"):
|
||||||
|
return _pretty_line(f"* Grep '{pattern}' in '{path}' ", (f"({result})", "red"))
|
||||||
|
if result == "(no matches)":
|
||||||
|
return _pretty_line(f"* Grep '{pattern}' in '{path}' ", ("(no matches)", "dim"))
|
||||||
|
matches = [line for line in result.splitlines() if line and not line.startswith("...[truncated")]
|
||||||
|
files = len({line.split(":", 1)[0] for line in matches})
|
||||||
|
match_unit = "match" if len(matches) == 1 else "matches"
|
||||||
|
file_unit = "file" if files == 1 else "files"
|
||||||
|
return _pretty_line(
|
||||||
|
f"* Grep '{pattern}' in '{path}' ",
|
||||||
|
(f"({len(matches)} {match_unit} in {files} {file_unit})", None),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_argv_pretty(args_json: str, result: str) -> str:
|
||||||
|
"""One-line summary for a ``run_argv`` call: ``* Run $ git status --short (exit code 0)``."""
|
||||||
|
argv = _json_list_arg(args_json, "argv")
|
||||||
|
cmd = shlex.join(argv) if argv else "?"
|
||||||
|
if result.startswith("error:"):
|
||||||
|
return _pretty_line(f"* Run $ {cmd} ", (f"({result})", "red"))
|
||||||
|
fields: dict[str, str] = {}
|
||||||
|
for line in result.splitlines():
|
||||||
|
if line.startswith("--- "):
|
||||||
|
break
|
||||||
|
if "=" in line:
|
||||||
|
key, _, value = line.partition("=")
|
||||||
|
fields[key] = value
|
||||||
|
if fields.get("timed_out") == "true":
|
||||||
|
return _pretty_line(f"* Run $ {cmd} ", ("(timed out)", "red"))
|
||||||
|
code = fields.get("exit_code", "")
|
||||||
|
fg = "green" if code == "0" else "red"
|
||||||
|
return _pretty_line(f"* Run $ {cmd} ", (f"(exit code {code or 'killed'})", fg))
|
||||||
|
|
||||||
|
|
||||||
|
def _run_argv_batch_pretty(_args_json: str, result: str) -> str:
|
||||||
|
"""One-line summary for a ``run_argv_batch`` call: ``* Run batch (done)``."""
|
||||||
|
if result.startswith("error:"):
|
||||||
|
return _pretty_line("* Run batch ", (f"({result})", "red"))
|
||||||
|
lines = result.splitlines()
|
||||||
|
head = lines[0] if lines else ""
|
||||||
|
stopped_early = False
|
||||||
|
for part in head.split():
|
||||||
|
if part == "stopped_early=true":
|
||||||
|
stopped_early = True
|
||||||
|
if stopped_early:
|
||||||
|
return _pretty_line("* Run batch ", ("(stopped early)", "yellow"))
|
||||||
|
return _pretty_line("* Run batch ", ("(done)", "green"))
|
||||||
|
|
||||||
|
|
||||||
|
def _http_status_fg(status: str) -> str | None:
|
||||||
|
"""Foreground color by HTTP status class (2xx green, 3xx yellow, 4xx/5xx red)."""
|
||||||
|
if status and status[0] in {"2", "3", "4", "5"}:
|
||||||
|
return {"2": "green", "3": "yellow", "4": "red", "5": "red"}[status[0]]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_pretty(args_json: str, result: str) -> str:
|
||||||
|
"""One-line summary for a ``fetch`` call: ``* Fetch GET https://… (200)``."""
|
||||||
|
method = _json_str_arg(args_json, "method") or "GET"
|
||||||
|
url = _json_str_arg(args_json, "url") or "?"
|
||||||
|
if result.startswith("error:"):
|
||||||
|
return _pretty_line(f"* Fetch {method} {url} ", (f"({result})", "red"))
|
||||||
|
status = ""
|
||||||
|
for line in result.splitlines():
|
||||||
|
if line.startswith("--- "):
|
||||||
|
break
|
||||||
|
if line.startswith("status="):
|
||||||
|
status = line.partition("=")[2]
|
||||||
|
break
|
||||||
|
return _pretty_line(f"* Fetch {method} {url} ", (f"({status or 'done'})", _http_status_fg(status)))
|
||||||
|
|
||||||
|
|
||||||
|
_MUTATOR_VERBS: dict[str, str] = {
|
||||||
|
"edit_replace": "Edit",
|
||||||
|
"edit_lineno": "Edit",
|
||||||
|
"write_file": "Write",
|
||||||
|
"copy_path": "Copy",
|
||||||
|
"move_path": "Move",
|
||||||
|
"delete_path": "Delete",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _mutator_pretty(name: str, args_json: str, result: str) -> str:
|
||||||
|
"""One-line summary for a file mutation: ``* Wrote 'path' (120 chars)`` / ``* Edit 'path' (done)``."""
|
||||||
|
verb = _MUTATOR_VERBS[name]
|
||||||
|
if name in {"copy_path", "move_path"}:
|
||||||
|
src = _json_str_arg(args_json, "src") or "?"
|
||||||
|
dst = _json_str_arg(args_json, "dst") or "?"
|
||||||
|
target = f"'{src}' → '{dst}'"
|
||||||
|
else:
|
||||||
|
path = _json_str_arg(args_json, "path") or "?"
|
||||||
|
target = f"'{path}'"
|
||||||
|
if result.startswith("error:"):
|
||||||
|
return _pretty_line(f"* {verb} {target} ", (f"({result})", "red"))
|
||||||
|
if name == "write_file":
|
||||||
|
# write_file keeps its brief detail: ``wrote 120 characters to path``.
|
||||||
|
match = re.match(r"wrote (\d+) characters to (.+)$", result)
|
||||||
|
if match:
|
||||||
|
chars, path = match.groups()
|
||||||
|
return _pretty_line(f"* Write '{path}' ", (f"({chars} chars)", None))
|
||||||
|
return _pretty_line(f"* {verb} {target} ", ("(done)", "green"))
|
||||||
|
|
||||||
|
|
||||||
|
def _vcs_status_pretty(_args_json: str, result: str) -> str:
|
||||||
|
"""One-line summary for ``vcs_status``: ``* VCS Status (done)``."""
|
||||||
|
if result.startswith("error:"):
|
||||||
|
return _pretty_line("* VCS Status ", (f"({result})", "red"))
|
||||||
|
return _pretty_line("* VCS Status ", ("(done)", "green"))
|
||||||
|
|
||||||
|
|
||||||
|
def _vcs_log_pretty(_args_json: str, result: str) -> str:
|
||||||
|
"""One-line summary for ``vcs_log``: ``* VCS Log (5 commits)``."""
|
||||||
|
if result.startswith("error:"):
|
||||||
|
return _pretty_line("* VCS Log ", (f"({result})", "red"))
|
||||||
|
if result == "(no commits)":
|
||||||
|
return _pretty_line("* VCS Log ", ("(no commits)", "dim"))
|
||||||
|
commits = [line for line in result.splitlines() if line.strip()]
|
||||||
|
unit = "commit" if len(commits) == 1 else "commits"
|
||||||
|
return _pretty_line("* VCS Log ", (f"({len(commits)} {unit})", None))
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_pretty(_args_json: str, result: str) -> str:
|
||||||
|
"""One-line summary for a ``wait`` call: ``* Wait (5s)`` / ``* Wait (disturbed)``."""
|
||||||
|
if result.startswith("error:"):
|
||||||
|
return _pretty_line("* Wait ", (f"({result})", "red"))
|
||||||
|
if result.startswith("waited "):
|
||||||
|
return _pretty_line("* Wait ", (f"({result.removeprefix('waited ')})", "green"))
|
||||||
|
return _pretty_line("* Wait ", ("(disturbed)", "yellow"))
|
||||||
|
|
||||||
|
|
||||||
|
_PRETTY_BUILDERS: dict[str, Callable[[str, str], str]] = {
|
||||||
|
"read_file": _read_file_pretty,
|
||||||
|
"tree": _tree_pretty,
|
||||||
|
"todo_push": lambda _args, result: _todo_pretty("todo_push", result),
|
||||||
|
"todo_update": lambda _args, result: _todo_pretty("todo_update", result),
|
||||||
|
"listdir": _listdir_pretty,
|
||||||
|
"glob_paths": _glob_pretty,
|
||||||
|
"grep_files": _grep_pretty,
|
||||||
|
"run_argv": _run_argv_pretty,
|
||||||
|
"run_argv_batch": _run_argv_batch_pretty,
|
||||||
|
"fetch": _fetch_pretty,
|
||||||
|
"edit_replace": lambda args, result: _mutator_pretty("edit_replace", args, result),
|
||||||
|
"edit_lineno": lambda args, result: _mutator_pretty("edit_lineno", args, result),
|
||||||
|
"write_file": lambda args, result: _mutator_pretty("write_file", args, result),
|
||||||
|
"copy_path": lambda args, result: _mutator_pretty("copy_path", args, result),
|
||||||
|
"move_path": lambda args, result: _mutator_pretty("move_path", args, result),
|
||||||
|
"delete_path": lambda args, result: _mutator_pretty("delete_path", args, result),
|
||||||
|
"vcs_status": _vcs_status_pretty,
|
||||||
|
"vcs_log": _vcs_log_pretty,
|
||||||
|
"wait": _wait_pretty,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _pretty_tool_result(name: str, args_json: str, result: str) -> str | None:
|
def _pretty_tool_result(name: str, args_json: str, result: str) -> str | None:
|
||||||
"""Pretty summary for a known tool call + result; None keeps the old style."""
|
"""Pretty summary for a known tool call + result; None keeps the old style."""
|
||||||
if name == "read_file":
|
builder = _PRETTY_BUILDERS.get(name)
|
||||||
return _read_file_pretty(args_json, result)
|
if builder is None:
|
||||||
if name in {"todo_push", "todo_update"}:
|
return None
|
||||||
return _todo_pretty(name, result)
|
return builder(args_json, result)
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _echo_stream(text: str) -> None:
|
def _echo_stream(text: str) -> None:
|
||||||
@@ -258,14 +565,13 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
|
|||||||
content = event.message.content
|
content = event.message.content
|
||||||
name, args, pretty_tool = pending_tools.pop(0) if pending_tools else ("", "", False)
|
name, args, pretty_tool = pending_tools.pop(0) if pending_tools else ("", "", False)
|
||||||
pretty_line = _pretty_tool_result(name, args, content) if pretty_tool else None
|
pretty_line = _pretty_tool_result(name, args, content) if pretty_tool else None
|
||||||
if pretty_line is not None:
|
if pretty_line is not None and not show_full:
|
||||||
click.secho(f"\n{pretty_line}", fg="yellow")
|
click.echo(f"\n{pretty_line}")
|
||||||
elif show_full:
|
elif show_full:
|
||||||
click.secho(f"[tool ok]\n{content}", fg="magenta")
|
click.secho(f"[tool ok]\n{content}", fg="magenta")
|
||||||
else:
|
else:
|
||||||
preview = _preview(content, _TOOL_RESULT_PREVIEW)
|
preview = _preview_result(content, _TOOL_RESULT_PREVIEW)
|
||||||
one_line = preview.replace("\n", " ")
|
click.secho(f"[tool ok] {preview}", fg="magenta")
|
||||||
click.secho(f"[tool ok] {one_line}", fg="magenta")
|
|
||||||
elif isinstance(event, ErrorEvent):
|
elif isinstance(event, ErrorEvent):
|
||||||
flush_assistant()
|
flush_assistant()
|
||||||
suffix = ""
|
suffix = ""
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
+111
-25
@@ -24,6 +24,7 @@ from plyngent.runtime import ProviderNotSupportedError
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Awaitable, Sequence
|
from collections.abc import Awaitable, Sequence
|
||||||
|
|
||||||
|
from plyngent.agent import ToolRegistry
|
||||||
from plyngent.cli.state import ReplState, YoloMode
|
from plyngent.cli.state import ReplState, YoloMode
|
||||||
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
|
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
|
||||||
|
|
||||||
@@ -83,7 +84,8 @@ HELP_FOOTER = (
|
|||||||
"works after resume, not only via readline history). Auto-retry: 10s/20s/30s.\n"
|
"works after resume, not only via readline history). Auto-retry: 10s/20s/30s.\n"
|
||||||
"\n"
|
"\n"
|
||||||
"Side questions: /btw … answers without changing the main session\n"
|
"Side questions: /btw … answers without changing the main session\n"
|
||||||
"(optional --tools / --fresh). Tab completes slash commands and arguments.\n"
|
"(--tools=read by default: read-only tools; --tools no/full opt-in; --fresh).\n"
|
||||||
|
"Tab completes slash commands and arguments.\n"
|
||||||
"Use --session ID or /resume to continue a prior chat after restart.\n"
|
"Use --session ID or /resume to continue a prior chat after restart.\n"
|
||||||
"\n"
|
"\n"
|
||||||
'Multiline: start a message with """ then end a later line with """.\n'
|
'Multiline: start a message with """ then end a later line with """.\n'
|
||||||
@@ -928,11 +930,12 @@ def _format_tool_tags(tags: object) -> str:
|
|||||||
@slash.command("btw")
|
@slash.command("btw")
|
||||||
@click.argument("question", nargs=-1, required=False)
|
@click.argument("question", nargs=-1, required=False)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--tools/--no-tools",
|
"--tools",
|
||||||
"with_tools",
|
"tools_mode",
|
||||||
default=False,
|
type=click.Choice(["no", "read", "full"]),
|
||||||
|
default="read",
|
||||||
show_default=True,
|
show_default=True,
|
||||||
help="Allow tools on the side turn (shared workspace; forked session grants/todo).",
|
help=("Tool access on the side turn: read = read-only tools only (default), no = no tools, full = all tools."),
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--fresh",
|
"--fresh",
|
||||||
@@ -945,48 +948,73 @@ def btw_cmd(
|
|||||||
state: ReplState,
|
state: ReplState,
|
||||||
question: tuple[str, ...],
|
question: tuple[str, ...],
|
||||||
*,
|
*,
|
||||||
with_tools: bool,
|
tools_mode: str,
|
||||||
fresh: bool,
|
fresh: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Ask a side question without changing the main session transcript or DB.
|
"""Ask a side question without changing the main session transcript or DB.
|
||||||
|
|
||||||
Uses the current model and (unless ``--fresh``) a copy of main history.
|
Uses the current model and (unless ``--fresh``) a copy of main history.
|
||||||
Tools are **off** by default; ``--tools`` clones the main tool registry with
|
Tools default to **read-only** (``--tools=read``): file/vcs/ask/fetch-GET
|
||||||
a fresh session bag and the shared instance workspace.
|
reads with no state mutation. ``--tools=no`` turns tools off and
|
||||||
|
``--tools=full`` clones the whole main registry with a fresh session bag
|
||||||
|
and the shared instance workspace.
|
||||||
"""
|
"""
|
||||||
text = " ".join(question).strip()
|
text = " ".join(question).strip()
|
||||||
if not text:
|
if not text:
|
||||||
click.echo("usage: /btw [--tools] [--fresh] <question>")
|
click.echo("usage: /btw [--tools read|no|full] [--fresh] <question>")
|
||||||
return
|
return
|
||||||
if state.agent.pending_retry_text is not None:
|
if state.agent.pending_retry_text is not None:
|
||||||
click.echo("error: finish or /retry the main turn before /btw")
|
click.echo("error: finish or /retry the main turn before /btw")
|
||||||
return
|
return
|
||||||
if with_tools and (not state.tools_enabled or state.agent.tools is None):
|
|
||||||
click.echo("error: --tools requires tools on (/tools on)")
|
tools_on = state.tools_enabled and state.agent.tools is not None
|
||||||
return
|
explicit = click.get_current_context().get_parameter_source("tools_mode") is not click.core.ParameterSource.DEFAULT
|
||||||
|
if tools_mode != "no" and not tools_on:
|
||||||
|
# Explicitly requesting tools while they are off is an error; the
|
||||||
|
# default (read) silently falls back to no tools so bare /btw still
|
||||||
|
# works under /tools off.
|
||||||
|
if tools_mode == "full" or explicit:
|
||||||
|
click.echo("error: --tools requires tools on (/tools on)")
|
||||||
|
return
|
||||||
|
tools_mode = "no"
|
||||||
|
|
||||||
from plyngent.cli.display import render_events
|
from plyngent.cli.display import render_events
|
||||||
from plyngent.cli.retry import run_cancellable
|
from plyngent.cli.retry import run_cancellable
|
||||||
from plyngent.tools.context import SessionState
|
from plyngent.tools.context import SessionState, read_only_context
|
||||||
|
|
||||||
click.secho("btw: ", fg="magenta", nl=False)
|
click.secho("btw: ", fg="magenta", nl=False)
|
||||||
click.echo(text)
|
click.echo(text)
|
||||||
tools_arg: bool = with_tools
|
if tools_mode == "no":
|
||||||
aside_session = SessionState() if with_tools else None
|
tools_arg: ToolRegistry | bool = False
|
||||||
aside_instance = state.instance_state if with_tools else None
|
aside_session = None
|
||||||
|
aside_instance = None
|
||||||
|
elif tools_mode == "full":
|
||||||
|
tools_arg = True
|
||||||
|
aside_session = SessionState()
|
||||||
|
aside_instance = state.instance_state
|
||||||
|
else: # read
|
||||||
|
assert state.agent.tools is not None
|
||||||
|
# Read-only clone: keep the main session view so read-only session
|
||||||
|
# reads (e.g. todo_list) see the real state (they cannot mutate it).
|
||||||
|
tools_arg = state.agent.tools.clone(read_only_only=True)
|
||||||
|
aside_session = state.session_state
|
||||||
|
aside_instance = state.instance_state
|
||||||
|
|
||||||
async def _run() -> None:
|
async def _run() -> None:
|
||||||
await run_cancellable(
|
side = render_events(
|
||||||
render_events(
|
state.agent.run_aside(
|
||||||
state.agent.run_aside(
|
text,
|
||||||
text,
|
include_history=not fresh,
|
||||||
include_history=not fresh,
|
tools=tools_arg,
|
||||||
tools=tools_arg,
|
instance_state=aside_instance,
|
||||||
instance_state=aside_instance,
|
session_state=aside_session,
|
||||||
session_state=aside_session,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if tools_mode == "read":
|
||||||
|
with read_only_context():
|
||||||
|
await run_cancellable(side)
|
||||||
|
else:
|
||||||
|
await run_cancellable(side)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_await(_run())
|
_await(_run())
|
||||||
@@ -1119,6 +1147,64 @@ def plugins_slash_cmd(state: ReplState, action: str | None, name: str | None) ->
|
|||||||
raise click.UsageError(str(exc)) from exc
|
raise click.UsageError(str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
_MCP_PREVIEW_MAX_CHARS = 120
|
||||||
|
|
||||||
|
|
||||||
|
def _print_mcp_status(state: ReplState) -> None:
|
||||||
|
manager = state.mcp_manager
|
||||||
|
if manager is None:
|
||||||
|
click.echo("mcp: no servers configured (or none enabled)")
|
||||||
|
return
|
||||||
|
rows = manager.statuses()
|
||||||
|
if not rows:
|
||||||
|
click.echo("mcp: no servers configured (or none enabled)")
|
||||||
|
return
|
||||||
|
for name, status, count in rows:
|
||||||
|
click.echo(f" {name}\t{status}\ttools={count}")
|
||||||
|
connection = next(
|
||||||
|
(conn for conn in manager.connections() if conn.name == name),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if connection is not None and connection.instructions:
|
||||||
|
preview = " ".join(connection.instructions.split())
|
||||||
|
if len(preview) > _MCP_PREVIEW_MAX_CHARS:
|
||||||
|
preview = f"{preview[: _MCP_PREVIEW_MAX_CHARS - 3]}..."
|
||||||
|
click.secho(
|
||||||
|
f" instructions ({len(connection.instructions)} chars): {preview}",
|
||||||
|
fg="bright_black",
|
||||||
|
)
|
||||||
|
if connection is not None and connection.error is not None:
|
||||||
|
for line in connection.stderr_tail()[-3:]:
|
||||||
|
click.secho(f" {line}", fg="bright_black")
|
||||||
|
|
||||||
|
|
||||||
|
@slash.command("mcp")
|
||||||
|
@click.argument("action", required=False, type=click.Choice(["list", "reconnect"]))
|
||||||
|
@click.pass_obj
|
||||||
|
def mcp_slash_cmd(state: ReplState, action: str | None) -> None:
|
||||||
|
"""List MCP server statuses, or reconnect after config edits.
|
||||||
|
|
||||||
|
``/mcp`` or ``/mcp list`` — per-server connection status and tool count.
|
||||||
|
``/mcp reconnect`` — re-read the config file, restart every enabled
|
||||||
|
server, and rebuild the tool registry (adopts newly added servers too).
|
||||||
|
"""
|
||||||
|
act = (action or "list").lower()
|
||||||
|
if act == "list":
|
||||||
|
_print_mcp_status(state)
|
||||||
|
return
|
||||||
|
from plyngent.runtime import McpManager
|
||||||
|
|
||||||
|
state.config.reload()
|
||||||
|
if state.mcp_manager is None:
|
||||||
|
state.mcp_manager = McpManager(state.config.mcp_config)
|
||||||
|
else:
|
||||||
|
_await(state.mcp_manager.restart(state.config.mcp_config))
|
||||||
|
_await(state.mcp_manager.ensure_started())
|
||||||
|
_slash_rebuild_tools_if_on(state)
|
||||||
|
click.echo(f"mcp reconnected; tools={'on' if state.tools_enabled else 'off'}")
|
||||||
|
_print_mcp_status(state)
|
||||||
|
|
||||||
|
|
||||||
@slash.command("yolo")
|
@slash.command("yolo")
|
||||||
@click.argument("mode", required=False, type=YOLO_MODE, metavar="[on|off|once]")
|
@click.argument("mode", required=False, type=YOLO_MODE, metavar="[on|off|once]")
|
||||||
@click.pass_obj
|
@click.pass_obj
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ if TYPE_CHECKING:
|
|||||||
from plyngent.config.store import ConfigStore
|
from plyngent.config.store import ConfigStore
|
||||||
from plyngent.memory import MemoryStore
|
from plyngent.memory import MemoryStore
|
||||||
from plyngent.memory.database.schema import Session as SessionRow
|
from plyngent.memory.database.schema import Session as SessionRow
|
||||||
|
from plyngent.runtime.mcp_client import McpManager
|
||||||
|
|
||||||
type YoloMode = Literal["off", "on", "once"]
|
type YoloMode = Literal["off", "on", "once"]
|
||||||
|
|
||||||
@@ -65,6 +66,8 @@ class ReplState:
|
|||||||
todo_stack: TodoStack = field(default_factory=TodoStack)
|
todo_stack: TodoStack = field(default_factory=TodoStack)
|
||||||
instance_state: InstanceState = field(default_factory=InstanceState)
|
instance_state: InstanceState = field(default_factory=InstanceState)
|
||||||
session_state: SessionState = field(default_factory=SessionState)
|
session_state: SessionState = field(default_factory=SessionState)
|
||||||
|
# MCP connections started by the host app before this state was built.
|
||||||
|
mcp_manager: McpManager | None = None
|
||||||
_todo_persist_tasks: set[object] = field(default_factory=set, init=False, repr=False)
|
_todo_persist_tasks: set[object] = field(default_factory=set, init=False, repr=False)
|
||||||
# Session ids for Tab complete (updated when listing/creating/resuming).
|
# Session ids for Tab complete (updated when listing/creating/resuming).
|
||||||
_session_id_cache: list[int] = field(default_factory=list, init=False, repr=False)
|
_session_id_cache: list[int] = field(default_factory=list, init=False, repr=False)
|
||||||
@@ -215,6 +218,11 @@ class ReplState:
|
|||||||
plugins_cfg.enable,
|
plugins_cfg.enable,
|
||||||
disable=plugins_cfg.disable,
|
disable=plugins_cfg.disable,
|
||||||
)
|
)
|
||||||
|
if self.mcp_manager is not None:
|
||||||
|
from plyngent.tools.mcp import register_mcp_tools
|
||||||
|
|
||||||
|
# Connections were started by the host; registration is sync-only.
|
||||||
|
_ = register_mcp_tools(self.mcp_manager, self.config.mcp_config)
|
||||||
# Local surface: builtins + allowlisted plugins (import registers into catalog).
|
# Local surface: builtins + allowlisted plugins (import registers into catalog).
|
||||||
tools = catalog.select(surface="local")
|
tools = catalog.select(surface="local")
|
||||||
yolo = self.effective_yolo() != "off"
|
yolo = self.effective_yolo() != "off"
|
||||||
@@ -229,14 +237,28 @@ class ReplState:
|
|||||||
session_state=self.session_state,
|
session_state=self.session_state,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _mcp_instructions_text(self) -> str:
|
||||||
|
"""Compose connected MCP servers' initialize ``instructions`` into one block.
|
||||||
|
|
||||||
|
Only meaningful when tools are on (MCP tools are registered under the
|
||||||
|
same condition); the text is attributed per server so the model can
|
||||||
|
weigh its provenance. Gated by ``[agent] mcp_instructions`` in callers.
|
||||||
|
"""
|
||||||
|
if self.mcp_manager is None or not self.tools_enabled:
|
||||||
|
return ""
|
||||||
|
parts = [f"MCP server {name!r} instructions:\n{text}" for name, text in self.mcp_manager.instructions()]
|
||||||
|
return "\n\n".join(parts)
|
||||||
|
|
||||||
def _make_agent(self) -> ChatAgent:
|
def _make_agent(self) -> ChatAgent:
|
||||||
from plyngent.cli.limits import prompt_continue_limit_async
|
from plyngent.cli.limits import prompt_continue_limit_async
|
||||||
from plyngent.config import compose_agent_system_content
|
from plyngent.config import compose_agent_system_content
|
||||||
|
|
||||||
agent_cfg = self.config.agent_config
|
agent_cfg = self.config.agent_config
|
||||||
|
mcp_text = self._mcp_instructions_text() if agent_cfg.mcp_instructions else ""
|
||||||
system_prompt = compose_agent_system_content(
|
system_prompt = compose_agent_system_content(
|
||||||
agent_cfg.system_prompt,
|
agent_cfg.system_prompt,
|
||||||
agent_cfg.tool_directives,
|
agent_cfg.tool_directives,
|
||||||
|
mcp_text,
|
||||||
)
|
)
|
||||||
on_limit = prompt_continue_limit_async if self.interactive_limits else None
|
on_limit = prompt_continue_limit_async if self.interactive_limits else None
|
||||||
peak = 0
|
peak = 0
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ from .models import DatabaseConfig as DatabaseConfig
|
|||||||
from .models import DeepSeekConvention as DeepSeekConvention
|
from .models import DeepSeekConvention as DeepSeekConvention
|
||||||
from .models import DeepseekProvider as DeepseekProvider
|
from .models import DeepseekProvider as DeepseekProvider
|
||||||
from .models import HttpTimeoutConfig as HttpTimeoutConfig
|
from .models import HttpTimeoutConfig as HttpTimeoutConfig
|
||||||
|
from .models import McpConfig as McpConfig
|
||||||
|
from .models import McpServerConfig as McpServerConfig
|
||||||
from .models import ModelConfig as ModelConfig
|
from .models import ModelConfig as ModelConfig
|
||||||
from .models import NetworkingConfig as NetworkingConfig
|
from .models import NetworkingConfig as NetworkingConfig
|
||||||
from .models import OpenAICompatibleProvider as OpenAICompatibleProvider
|
from .models import OpenAICompatibleProvider as OpenAICompatibleProvider
|
||||||
|
|||||||
@@ -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,22 +65,20 @@ 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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def compose_agent_system_content(
|
def compose_agent_system_content(*parts: str) -> str | None:
|
||||||
system_prompt: str,
|
"""Join persona + tool playbook + extra blocks into one system body.
|
||||||
tool_directives: str,
|
|
||||||
) -> str | None:
|
|
||||||
"""Join persona + tool playbook into one system body, or ``None`` if both empty.
|
|
||||||
|
|
||||||
Non-empty parts are stripped and joined with a blank line. Either field may
|
Returns ``None`` when every part is empty. Non-empty parts are stripped and
|
||||||
be ``""`` to disable that part while keeping the other.
|
joined with a blank line. Any part may be ``""`` to disable that block
|
||||||
|
(e.g. ``system_prompt = ""`` keeps the tool playbook).
|
||||||
"""
|
"""
|
||||||
parts = [part.strip() for part in (system_prompt, tool_directives) if part and part.strip()]
|
joined = "\n\n".join(part.strip() for part in parts if part and part.strip())
|
||||||
if not parts:
|
return joined or None
|
||||||
return None
|
|
||||||
return "\n\n".join(parts)
|
|
||||||
|
|
||||||
|
|
||||||
class DatabaseConfig(Struct, omit_defaults=True):
|
class DatabaseConfig(Struct, omit_defaults=True):
|
||||||
@@ -104,6 +102,9 @@ class AgentConfig(Struct, omit_defaults=True):
|
|||||||
system_prompt: str = DEFAULT_SYSTEM_PROMPT
|
system_prompt: str = DEFAULT_SYSTEM_PROMPT
|
||||||
# Tool how-to (omit → DEFAULT_TOOL_DIRECTIVES; "" disables playbook only).
|
# Tool how-to (omit → DEFAULT_TOOL_DIRECTIVES; "" disables playbook only).
|
||||||
tool_directives: str = DEFAULT_TOOL_DIRECTIVES
|
tool_directives: str = DEFAULT_TOOL_DIRECTIVES
|
||||||
|
# Fold connected MCP servers' initialize ``instructions`` (usage guidance)
|
||||||
|
# into the agent system prompt when tools are on. false = ignore server text.
|
||||||
|
mcp_instructions: bool = True
|
||||||
max_tool_result_chars: int = 32_000
|
max_tool_result_chars: int = 32_000
|
||||||
parallel_tools: bool = True
|
parallel_tools: bool = True
|
||||||
confirm_destructive: bool = True
|
confirm_destructive: bool = True
|
||||||
@@ -141,6 +142,44 @@ class PluginsConfig(Struct, omit_defaults=True):
|
|||||||
disable: list[str] = field(default_factory=list)
|
disable: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class McpServerConfig(Struct, omit_defaults=True):
|
||||||
|
"""One MCP server definition (stdio transport today).
|
||||||
|
|
||||||
|
TOML example::
|
||||||
|
|
||||||
|
[mcp.servers.docs]
|
||||||
|
command = "npx"
|
||||||
|
args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
||||||
|
env = { RUST_LOG = "info" }
|
||||||
|
# cwd = "/path" # spawn working directory (empty = inherit)
|
||||||
|
# timeout = 30.0 # per-request timeout in seconds
|
||||||
|
# read_only = true # mark every tool READ_ONLY (safe side turns)
|
||||||
|
|
||||||
|
``url`` is reserved for the streamable-HTTP transport; empty = stdio.
|
||||||
|
A defined server is connected unless its name appears in ``[mcp].disable``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
command: str = ""
|
||||||
|
args: list[str] = field(default_factory=list)
|
||||||
|
env: dict[str, str] = field(default_factory=dict)
|
||||||
|
cwd: str = ""
|
||||||
|
timeout: float = 30.0
|
||||||
|
read_only: bool = False
|
||||||
|
url: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class McpConfig(Struct, omit_defaults=True):
|
||||||
|
"""Model Context Protocol servers exposed as agent tools.
|
||||||
|
|
||||||
|
Defining a server under ``[mcp.servers.<name>]`` is the opt-in; tools are
|
||||||
|
namespaced ``mcp__<server>__<tool>`` and registered with LOCAL tags. Names
|
||||||
|
in ``disable`` stay disconnected even when defined.
|
||||||
|
"""
|
||||||
|
|
||||||
|
servers: dict[str, McpServerConfig] = field(default_factory=dict)
|
||||||
|
disable: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class NetworkingConfig(Struct, omit_defaults=True):
|
class NetworkingConfig(Struct, omit_defaults=True):
|
||||||
"""Host-side network policy for tools such as ``fetch``.
|
"""Host-side network policy for tools such as ``fetch``.
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import tomlkit
|
|||||||
from .models import (
|
from .models import (
|
||||||
AgentConfig,
|
AgentConfig,
|
||||||
DatabaseConfig,
|
DatabaseConfig,
|
||||||
|
McpConfig,
|
||||||
ModelConfig,
|
ModelConfig,
|
||||||
NetworkingConfig,
|
NetworkingConfig,
|
||||||
PluginsConfig,
|
PluginsConfig,
|
||||||
@@ -56,6 +57,29 @@ def _parse_networking(raw: dict[str, object]) -> NetworkingConfig:
|
|||||||
return NetworkingConfig()
|
return NetworkingConfig()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_mcp(raw: dict[str, object]) -> McpConfig:
|
||||||
|
"""Parse the ``[mcp]`` section, falling back to defaults.
|
||||||
|
|
||||||
|
Unknown keys (typos like ``cmds``) are rejected the same way as providers:
|
||||||
|
a malformed section silently degrades to "no MCP servers" instead of a
|
||||||
|
half-parsed one.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
config = msgspec.convert(raw, McpConfig)
|
||||||
|
except msgspec.ValidationError:
|
||||||
|
return McpConfig()
|
||||||
|
known = {"servers", "disable"}
|
||||||
|
if set(raw) - known:
|
||||||
|
return McpConfig()
|
||||||
|
known_server = {"command", "args", "env", "cwd", "timeout", "read_only", "url"}
|
||||||
|
servers_raw = cast("dict[str, object]", raw.get("servers", {}))
|
||||||
|
for name, raw_entry in servers_raw.items():
|
||||||
|
entry = cast("dict[str, object]", raw_entry)
|
||||||
|
if not isinstance(raw_entry, dict) or not name.strip() or set(entry) - known_server:
|
||||||
|
return McpConfig()
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
def _parse_providers(
|
def _parse_providers(
|
||||||
document: tomlkit.TOMLDocument,
|
document: tomlkit.TOMLDocument,
|
||||||
) -> tuple[dict[str, Provider], dict[str, object], dict[str, Provider]]:
|
) -> tuple[dict[str, Provider], dict[str, object], dict[str, Provider]]:
|
||||||
@@ -125,6 +149,7 @@ class ConfigStore:
|
|||||||
_agent: AgentConfig
|
_agent: AgentConfig
|
||||||
_plugins: PluginsConfig
|
_plugins: PluginsConfig
|
||||||
_networking: NetworkingConfig
|
_networking: NetworkingConfig
|
||||||
|
_mcp: McpConfig
|
||||||
_providers: dict[str, Provider]
|
_providers: dict[str, Provider]
|
||||||
_bad_providers: dict[str, object]
|
_bad_providers: dict[str, object]
|
||||||
_recoverable_providers: dict[str, Provider]
|
_recoverable_providers: dict[str, Provider]
|
||||||
@@ -137,6 +162,7 @@ class ConfigStore:
|
|||||||
self._agent = _parse_agent(cast("dict[str, object]", raw.get("agent", {})))
|
self._agent = _parse_agent(cast("dict[str, object]", raw.get("agent", {})))
|
||||||
self._plugins = _parse_plugins(cast("dict[str, object]", raw.get("plugins", {})))
|
self._plugins = _parse_plugins(cast("dict[str, object]", raw.get("plugins", {})))
|
||||||
self._networking = _parse_networking(cast("dict[str, object]", raw.get("networking", {})))
|
self._networking = _parse_networking(cast("dict[str, object]", raw.get("networking", {})))
|
||||||
|
self._mcp = _parse_mcp(cast("dict[str, object]", raw.get("mcp", {})))
|
||||||
self._providers, self._bad_providers, self._recoverable_providers = _parse_providers(document)
|
self._providers, self._bad_providers, self._recoverable_providers = _parse_providers(document)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -187,6 +213,13 @@ class ConfigStore:
|
|||||||
"""Typed networking section (e.g. Fake-IP SSRF exemptions)."""
|
"""Typed networking section (e.g. Fake-IP SSRF exemptions)."""
|
||||||
return self._networking
|
return self._networking
|
||||||
|
|
||||||
|
# -- mcp (read-only) --
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mcp_config(self) -> McpConfig:
|
||||||
|
"""Typed MCP section (server definitions + disable list)."""
|
||||||
|
return self._mcp
|
||||||
|
|
||||||
def set_plugins_enable(self, names: Sequence[str]) -> PluginsConfig:
|
def set_plugins_enable(self, names: Sequence[str]) -> PluginsConfig:
|
||||||
"""Replace the plugin enable list (in-memory; call :meth:`write` to persist)."""
|
"""Replace the plugin enable list (in-memory; call :meth:`write` to persist)."""
|
||||||
cleaned = [name.strip() for name in names if name.strip()]
|
cleaned = [name.strip() for name in names if name.strip()]
|
||||||
@@ -390,6 +423,7 @@ class ConfigStore:
|
|||||||
self._agent = _parse_agent(cast("dict[str, object]", raw.get("agent", {})))
|
self._agent = _parse_agent(cast("dict[str, object]", raw.get("agent", {})))
|
||||||
self._plugins = _parse_plugins(cast("dict[str, object]", raw.get("plugins", {})))
|
self._plugins = _parse_plugins(cast("dict[str, object]", raw.get("plugins", {})))
|
||||||
self._networking = _parse_networking(cast("dict[str, object]", raw.get("networking", {})))
|
self._networking = _parse_networking(cast("dict[str, object]", raw.get("networking", {})))
|
||||||
|
self._mcp = _parse_mcp(cast("dict[str, object]", raw.get("mcp", {})))
|
||||||
self._providers, self._bad_providers, self._recoverable_providers = _parse_providers(self._document)
|
self._providers, self._bad_providers, self._recoverable_providers = _parse_providers(self._document)
|
||||||
|
|
||||||
# -- internal sync helpers --
|
# -- internal sync helpers --
|
||||||
|
|||||||
@@ -239,6 +239,9 @@ class DeltaMessage(Struct):
|
|||||||
# Providers often send content=null / reasoning_content=null on partial chunks.
|
# Providers often send content=null / reasoning_content=null on partial chunks.
|
||||||
content: str | None | Unset = UNSET
|
content: str | None | Unset = UNSET
|
||||||
reasoning_content: str | None | Unset = UNSET
|
reasoning_content: str | None | Unset = UNSET
|
||||||
|
# True when reasoning_content is the FULL chain-of-thought (e.g. DeepSeek's
|
||||||
|
# ``response.reasoning_text.done``), replacing any streamed fragments.
|
||||||
|
reasoning_full: bool | Unset = UNSET
|
||||||
tool_calls: list[StreamToolCallDelta] | Unset = UNSET
|
tool_calls: list[StreamToolCallDelta] | Unset = UNSET
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
|
from plyngent.config.models import McpConfig as McpConfig
|
||||||
|
|
||||||
from .client_factory import InvalidHttpTimeoutError as InvalidHttpTimeoutError
|
from .client_factory import InvalidHttpTimeoutError as InvalidHttpTimeoutError
|
||||||
from .client_factory import ProviderNotSupportedError as ProviderNotSupportedError
|
from .client_factory import ProviderNotSupportedError as ProviderNotSupportedError
|
||||||
from .client_factory import create_client as create_client
|
from .client_factory import create_client as create_client
|
||||||
from .client_factory import normalize_http_timeout as normalize_http_timeout
|
from .client_factory import normalize_http_timeout as normalize_http_timeout
|
||||||
from .client_factory import provider_to_openai_config as provider_to_openai_config
|
from .client_factory import provider_to_openai_config as provider_to_openai_config
|
||||||
|
from .mcp_client import McpConnectionError as McpConnectionError
|
||||||
|
from .mcp_client import McpManager as McpManager
|
||||||
|
from .mcp_client import McpServerConnection as McpServerConnection
|
||||||
|
from .mcp_client import McpTool as McpTool
|
||||||
|
|||||||
@@ -0,0 +1,383 @@
|
|||||||
|
"""MCP client: Model Context Protocol over stdio (newline-delimited JSON-RPC).
|
||||||
|
|
||||||
|
One :class:`McpServerConnection` owns one spawned server subprocess and its
|
||||||
|
initialize handshake. :class:`McpManager` owns all configured connections for
|
||||||
|
the process: start, tool listing, tool calls, reconnect, shutdown.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import importlib.metadata
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from collections import deque
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import TYPE_CHECKING, cast
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from plyngent.config.models import McpConfig, McpServerConfig
|
||||||
|
|
||||||
|
MCP_PROTOCOL_VERSION = "2025-06-18"
|
||||||
|
MCP_CLIENT_NAME = "plyngent"
|
||||||
|
DEFAULT_MCP_TIMEOUT = 30.0
|
||||||
|
_STDERR_TAIL_LINES = 32
|
||||||
|
_CLOSE_GRACE_SECONDS = 2.0
|
||||||
|
_MAX_TOOL_PAGES = 1024
|
||||||
|
|
||||||
|
type Json = dict[str, object]
|
||||||
|
|
||||||
|
|
||||||
|
def _json_dict(raw: object) -> Json:
|
||||||
|
"""Narrow an untyped JSON payload to a string-keyed object mapping."""
|
||||||
|
return cast("Json", raw) if isinstance(raw, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _json_list(raw: object) -> list[object]:
|
||||||
|
"""Narrow an untyped JSON payload to a list of objects."""
|
||||||
|
return cast("list[object]", raw) if isinstance(raw, list) else []
|
||||||
|
|
||||||
|
|
||||||
|
def _client_version() -> str:
|
||||||
|
try:
|
||||||
|
return importlib.metadata.version("plyngent")
|
||||||
|
except importlib.metadata.PackageNotFoundError:
|
||||||
|
return "0.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
class McpConnectionError(RuntimeError):
|
||||||
|
"""Server process failed to spawn, handshake, or answer in time."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class McpTool:
|
||||||
|
"""One tool advertised by a server (``tools/list`` entry)."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
input_schema: Json
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_raw(cls, raw: object) -> McpTool:
|
||||||
|
entry = _json_dict(raw)
|
||||||
|
return cls(
|
||||||
|
name=str(entry.get("name", "")),
|
||||||
|
description=str(entry.get("description", "") or ""),
|
||||||
|
input_schema=_json_dict(entry.get("inputSchema")) or {"type": "object"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_content(result: Json) -> str:
|
||||||
|
"""Flatten a ``tools/call`` result payload into model-facing text."""
|
||||||
|
parts: list[str] = []
|
||||||
|
for item in _json_list(result.get("content")):
|
||||||
|
entry = _json_dict(item)
|
||||||
|
kind = str(entry.get("type", ""))
|
||||||
|
if kind == "text":
|
||||||
|
text = entry.get("text")
|
||||||
|
if isinstance(text, str):
|
||||||
|
parts.append(text)
|
||||||
|
elif kind == "resource":
|
||||||
|
parts.append(f"[resource] {json.dumps(entry, ensure_ascii=False)}")
|
||||||
|
else:
|
||||||
|
mime = entry.get("mimeType", "?")
|
||||||
|
parts.append(f"[{kind or 'unknown'}: {mime}]")
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
class McpServerConnection:
|
||||||
|
"""One server subprocess with an active MCP session (stdio transport)."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
error: str | None
|
||||||
|
tools: list[McpTool]
|
||||||
|
tools_stale: bool
|
||||||
|
# Server usage guidance from the initialize result (InitializeResult.instructions).
|
||||||
|
instructions: str | None
|
||||||
|
|
||||||
|
_config: McpServerConfig
|
||||||
|
_proc: asyncio.subprocess.Process | None
|
||||||
|
_reader_task: asyncio.Task[None] | None
|
||||||
|
_pending: dict[int, asyncio.Future[Json]]
|
||||||
|
_next_id: int
|
||||||
|
_stderr_tail: deque[str]
|
||||||
|
|
||||||
|
def __init__(self, name: str, config: McpServerConfig) -> None:
|
||||||
|
self.name = name
|
||||||
|
self._config = config
|
||||||
|
self._proc = None
|
||||||
|
self._reader_task = None
|
||||||
|
self._pending = {}
|
||||||
|
self._next_id = 1
|
||||||
|
self._stderr_tail = deque(maxlen=_STDERR_TAIL_LINES)
|
||||||
|
self.error = None
|
||||||
|
self.tools = []
|
||||||
|
self.tools_stale = False
|
||||||
|
self.instructions = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status(self) -> str:
|
||||||
|
if self.error is not None:
|
||||||
|
return f"error: {self.error}"
|
||||||
|
if self._proc is not None:
|
||||||
|
return "connected (tools changed; /mcp reconnect)" if self.tools_stale else "connected"
|
||||||
|
return "not started"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def connected(self) -> bool:
|
||||||
|
return self._proc is not None and self.error is None
|
||||||
|
|
||||||
|
def stderr_tail(self) -> list[str]:
|
||||||
|
return list(self._stderr_tail)
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
"""Spawn the server process and run the initialize handshake."""
|
||||||
|
if self._proc is not None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._proc = await asyncio.create_subprocess_exec(
|
||||||
|
self._config.command,
|
||||||
|
*self._config.args,
|
||||||
|
stdin=asyncio.subprocess.PIPE,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
env=os.environ | self._config.env,
|
||||||
|
cwd=self._config.cwd or None,
|
||||||
|
)
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
self.error = f"spawn {self._config.command!r}: {exc}"
|
||||||
|
self._proc = None
|
||||||
|
return
|
||||||
|
self._reader_task = asyncio.create_task(self._read_loop())
|
||||||
|
try:
|
||||||
|
await self._initialize()
|
||||||
|
self.tools = await self.list_tools()
|
||||||
|
except (McpConnectionError, TimeoutError) as exc:
|
||||||
|
self.error = f"initialize: {exc}"
|
||||||
|
await self.aclose()
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
"""Terminate the server subprocess and fail pending requests."""
|
||||||
|
proc = self._proc
|
||||||
|
self._proc = None
|
||||||
|
if self._reader_task is not None:
|
||||||
|
_ = self._reader_task.cancel()
|
||||||
|
self._reader_task = None
|
||||||
|
if proc is not None and proc.returncode is None:
|
||||||
|
_ = proc.terminate()
|
||||||
|
try:
|
||||||
|
_ = await asyncio.wait_for(proc.wait(), _CLOSE_GRACE_SECONDS)
|
||||||
|
except TimeoutError:
|
||||||
|
proc.kill()
|
||||||
|
_ = await proc.wait()
|
||||||
|
for future in self._pending.values():
|
||||||
|
if not future.done():
|
||||||
|
future.set_exception(McpConnectionError("connection closed"))
|
||||||
|
self._pending.clear()
|
||||||
|
|
||||||
|
async def _read_loop(self) -> None:
|
||||||
|
proc = self._proc
|
||||||
|
if proc is None or proc.stdout is None or proc.stderr is None:
|
||||||
|
return
|
||||||
|
stderr_task = asyncio.create_task(self._drain_stderr(proc.stderr))
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
line = await proc.stdout.readline()
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
self._dispatch(line)
|
||||||
|
finally:
|
||||||
|
_ = stderr_task.cancel()
|
||||||
|
for future in self._pending.values():
|
||||||
|
if not future.done():
|
||||||
|
future.set_exception(McpConnectionError("server closed the stream"))
|
||||||
|
self._pending.clear()
|
||||||
|
|
||||||
|
async def _drain_stderr(self, stderr: asyncio.StreamReader) -> None:
|
||||||
|
while True:
|
||||||
|
line = await stderr.readline()
|
||||||
|
if not line:
|
||||||
|
return
|
||||||
|
self._stderr_tail.append(line.decode(errors="replace").rstrip())
|
||||||
|
|
||||||
|
def _dispatch(self, line: bytes) -> None:
|
||||||
|
try:
|
||||||
|
payload = _json_dict(cast("object", json.loads(line)))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return
|
||||||
|
message_id = payload.get("id")
|
||||||
|
if message_id is not None:
|
||||||
|
if isinstance(message_id, bool) or not isinstance(message_id, (int, str)):
|
||||||
|
return
|
||||||
|
request_id = int(message_id)
|
||||||
|
future = self._pending.pop(request_id, None)
|
||||||
|
if future is None or future.done():
|
||||||
|
return
|
||||||
|
error = payload.get("error")
|
||||||
|
if error is not None:
|
||||||
|
err = _json_dict(error)
|
||||||
|
detail = str(err.get("message", "unknown error")) if err else str(error)
|
||||||
|
future.set_exception(McpConnectionError(detail))
|
||||||
|
else:
|
||||||
|
future.set_result(_json_dict(payload.get("result")))
|
||||||
|
return
|
||||||
|
# Notifications carry no id; only tool-list changes matter to the host.
|
||||||
|
if payload.get("method") == "notifications/tools/list_changed":
|
||||||
|
self.tools_stale = True
|
||||||
|
|
||||||
|
def _send(self, payload: Json) -> None:
|
||||||
|
proc = self._proc
|
||||||
|
stdin = proc.stdin if proc is not None else None
|
||||||
|
if stdin is None:
|
||||||
|
msg = "server process is not running"
|
||||||
|
raise McpConnectionError(msg)
|
||||||
|
_ = stdin.write(json.dumps(payload, ensure_ascii=False).encode() + b"\n")
|
||||||
|
|
||||||
|
async def _request(self, method: str, params: Json | None = None) -> Json:
|
||||||
|
timeout = self._config.timeout or DEFAULT_MCP_TIMEOUT
|
||||||
|
future: asyncio.Future[Json] = asyncio.get_running_loop().create_future()
|
||||||
|
request_id = self._next_id
|
||||||
|
self._next_id += 1
|
||||||
|
self._pending[request_id] = future
|
||||||
|
payload: Json = {"jsonrpc": "2.0", "id": request_id, "method": method}
|
||||||
|
if params is not None:
|
||||||
|
payload["params"] = params
|
||||||
|
try:
|
||||||
|
self._send(payload)
|
||||||
|
except McpConnectionError:
|
||||||
|
_ = self._pending.pop(request_id, None)
|
||||||
|
raise
|
||||||
|
try:
|
||||||
|
return await asyncio.wait_for(future, timeout)
|
||||||
|
except TimeoutError:
|
||||||
|
_ = self._pending.pop(request_id, None)
|
||||||
|
msg = f"{method} timed out after {timeout}s"
|
||||||
|
raise McpConnectionError(msg) from None
|
||||||
|
|
||||||
|
async def _notify(self, method: str, params: Json | None = None) -> None:
|
||||||
|
payload: Json = {"jsonrpc": "2.0", "method": method}
|
||||||
|
if params is not None:
|
||||||
|
payload["params"] = params
|
||||||
|
self._send(payload)
|
||||||
|
|
||||||
|
async def _initialize(self) -> None:
|
||||||
|
result = await self._request(
|
||||||
|
"initialize",
|
||||||
|
{
|
||||||
|
"protocolVersion": MCP_PROTOCOL_VERSION,
|
||||||
|
"capabilities": {},
|
||||||
|
"clientInfo": {"name": MCP_CLIENT_NAME, "version": _client_version()},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# Optional server usage guidance (MCP spec: InitializeResult.instructions).
|
||||||
|
# Hosts surface this to the model when the server's tools are in play.
|
||||||
|
instructions = result.get("instructions")
|
||||||
|
self.instructions = instructions.strip() if isinstance(instructions, str) and instructions.strip() else None
|
||||||
|
await self._notify("notifications/initialized")
|
||||||
|
|
||||||
|
async def list_tools(self) -> list[McpTool]:
|
||||||
|
"""Fetch the full tool list, following ``nextCursor`` pagination."""
|
||||||
|
tools: list[McpTool] = []
|
||||||
|
cursor: str | None = None
|
||||||
|
for _ in range(_MAX_TOOL_PAGES):
|
||||||
|
params: Json = {} if cursor is None else {"cursor": cursor}
|
||||||
|
result = await self._request("tools/list", params)
|
||||||
|
tools.extend(McpTool.from_raw(item) for item in _json_list(result.get("tools")))
|
||||||
|
next_cursor = result.get("nextCursor")
|
||||||
|
if not isinstance(next_cursor, str) or not next_cursor:
|
||||||
|
return tools
|
||||||
|
cursor = next_cursor
|
||||||
|
return tools
|
||||||
|
|
||||||
|
async def call_tool(self, name: str, arguments: Json) -> str:
|
||||||
|
"""Run ``tools/call`` and return the flattened text result.
|
||||||
|
|
||||||
|
Tool-level failures (``isError``) come back as ``error: …`` text, not
|
||||||
|
exceptions: the protocol round-trip itself succeeded.
|
||||||
|
"""
|
||||||
|
result = await self._request("tools/call", {"name": name, "arguments": arguments})
|
||||||
|
text = _format_content(result)
|
||||||
|
if result.get("isError") is True:
|
||||||
|
return f"error: {text or 'tool reported failure without content'}"
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
class McpManager:
|
||||||
|
"""All configured MCP server connections for this process."""
|
||||||
|
|
||||||
|
_config: McpConfig
|
||||||
|
_connections: dict[str, McpServerConnection]
|
||||||
|
|
||||||
|
def __init__(self, config: McpConfig) -> None:
|
||||||
|
self._config = config
|
||||||
|
self._connections = {}
|
||||||
|
|
||||||
|
def enabled_servers(self) -> dict[str, McpServerConfig]:
|
||||||
|
"""Configured servers minus the ``disable`` list, in config order."""
|
||||||
|
disabled = {name.strip() for name in self._config.disable}
|
||||||
|
return {name: cfg for name, cfg in self._config.servers.items() if name not in disabled}
|
||||||
|
|
||||||
|
async def ensure_started(self) -> None:
|
||||||
|
"""Connect every enabled server that has no live connection yet."""
|
||||||
|
for name, cfg in self.enabled_servers().items():
|
||||||
|
connection = self._connections.get(name)
|
||||||
|
if connection is not None and connection.connected:
|
||||||
|
continue
|
||||||
|
if connection is not None:
|
||||||
|
await connection.aclose()
|
||||||
|
connection = McpServerConnection(name, cfg)
|
||||||
|
self._connections[name] = connection
|
||||||
|
await connection.start()
|
||||||
|
|
||||||
|
async def restart(self, config: McpConfig) -> None:
|
||||||
|
"""Drop every connection and adopt a new config (e.g. /mcp reconnect)."""
|
||||||
|
await self.aclose()
|
||||||
|
self._config = config
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
for connection in self._connections.values():
|
||||||
|
await connection.aclose()
|
||||||
|
self._connections.clear()
|
||||||
|
|
||||||
|
def statuses(self) -> list[tuple[str, str, int]]:
|
||||||
|
"""(server, status, tool_count) rows for diagnostics surfaces."""
|
||||||
|
rows: list[tuple[str, str, int]] = []
|
||||||
|
for name in self.enabled_servers():
|
||||||
|
connection = self._connections.get(name)
|
||||||
|
if connection is None:
|
||||||
|
rows.append((name, "not started", 0))
|
||||||
|
else:
|
||||||
|
rows.append((name, connection.status, len(connection.tools)))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def instructions(self) -> list[tuple[str, str]]:
|
||||||
|
"""(server, text) initialize ``instructions`` for every connected server.
|
||||||
|
|
||||||
|
Only non-empty guidance is returned, in config order. Hosts may fold
|
||||||
|
these into the agent system context (see cli.state.ReplState).
|
||||||
|
"""
|
||||||
|
rows: list[tuple[str, str]] = []
|
||||||
|
for name in self.enabled_servers():
|
||||||
|
connection = self._connections.get(name)
|
||||||
|
if connection is not None and connection.connected:
|
||||||
|
text = (connection.instructions or "").strip()
|
||||||
|
if text:
|
||||||
|
rows.append((name, text))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def connections(self) -> list[McpServerConnection]:
|
||||||
|
return list(self._connections.values())
|
||||||
|
|
||||||
|
async def refresh_tools(self) -> None:
|
||||||
|
"""Re-run tools/list on every connected server."""
|
||||||
|
for connection in self._connections.values():
|
||||||
|
if connection.connected:
|
||||||
|
connection.tools = await connection.list_tools()
|
||||||
|
connection.tools_stale = False
|
||||||
|
|
||||||
|
async def call(self, server: str, tool: str, arguments: Json) -> str:
|
||||||
|
connection = self._connections.get(server)
|
||||||
|
if connection is None or not connection.connected:
|
||||||
|
msg = f"server {server!r} is not connected"
|
||||||
|
raise McpConnectionError(msg)
|
||||||
|
return await connection.call_tool(tool, arguments)
|
||||||
@@ -12,7 +12,7 @@ from plyngent.agent.tools import ToolDefinition, ToolTag
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Container, Generator
|
from collections.abc import Container, Generator
|
||||||
|
|
||||||
type ToolSourceKind = Literal["builtin", "plugin"]
|
type ToolSourceKind = Literal["builtin", "plugin", "mcp"]
|
||||||
type ToolSurface = Literal["local", "public"]
|
type ToolSurface = Literal["local", "public"]
|
||||||
|
|
||||||
|
|
||||||
@@ -29,8 +29,8 @@ class ToolSource:
|
|||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
if self.kind == "builtin":
|
if self.kind == "builtin":
|
||||||
return "builtin"
|
return "builtin"
|
||||||
plugin = self.plugin_id or "?"
|
id_part = self.plugin_id or "?"
|
||||||
return f"plugin:{plugin}"
|
return f"{self.kind}:{id_part}"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -152,6 +152,13 @@ class ToolCatalog:
|
|||||||
def clear(self) -> None:
|
def clear(self) -> None:
|
||||||
self._by_name.clear()
|
self._by_name.clear()
|
||||||
|
|
||||||
|
def purge_kind(self, kind: ToolSourceKind) -> int:
|
||||||
|
"""Remove every entry registered from *kind*; return the count removed."""
|
||||||
|
doomed = [name for name, entry in self._by_name.items() if entry.source.kind == kind]
|
||||||
|
for name in doomed:
|
||||||
|
del self._by_name[name]
|
||||||
|
return len(doomed)
|
||||||
|
|
||||||
def snapshot(self) -> dict[str, RegisteredTool]:
|
def snapshot(self) -> dict[str, RegisteredTool]:
|
||||||
return dict(self._by_name)
|
return dict(self._by_name)
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from plyngent.agent import ToolTag, tool
|
|||||||
from plyngent.prompting import NonInteractiveError, ask_async
|
from plyngent.prompting import NonInteractiveError, ask_async
|
||||||
|
|
||||||
|
|
||||||
@tool(name="ask_user_line", tags=ToolTag.LOCAL)
|
@tool(name="ask_user_line", tags=ToolTag.LOCAL | ToolTag.READ_ONLY)
|
||||||
async def ask_user(question: str, default: str = "") -> str:
|
async def ask_user(question: str, default: str = "") -> str:
|
||||||
"""Ask the human a free-form one-line question and return their answer.
|
"""Ask the human a free-form one-line question and return their answer.
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ def parse_options(options: list[object]) -> list[ChoiceOption]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@tool(name="ask_user_choice", tags=ToolTag.LOCAL)
|
@tool(name="ask_user_choice", tags=ToolTag.LOCAL | ToolTag.READ_ONLY)
|
||||||
async def choose_user(
|
async def choose_user(
|
||||||
question: str,
|
question: str,
|
||||||
options: list[str] | list[dict[str, object]],
|
options: list[str] | list[dict[str, object]],
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ def parse_fields(fields: list[dict[str, object]]) -> list[FormField]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@tool(name="ask_user_form", tags=ToolTag.LOCAL)
|
@tool(name="ask_user_form", tags=ToolTag.LOCAL | ToolTag.READ_ONLY)
|
||||||
async def form_user(
|
async def form_user(
|
||||||
title: str,
|
title: str,
|
||||||
fields: list[dict[str, object]],
|
fields: list[dict[str, object]],
|
||||||
|
|||||||
@@ -1,20 +1,44 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from click import style
|
||||||
|
|
||||||
from plyngent.agent import ToolTag, tool
|
from plyngent.agent import ToolTag, tool
|
||||||
from plyngent.prompting import get_prompt_backend, read_line_with_timeout, run_prompt_async
|
from plyngent.prompting import get_prompt_backend, read_line_with_timeout, run_prompt_async
|
||||||
|
|
||||||
WAIT_PROMPT = "Waiting for {duration}s. Press Enter to disturb. Feel free to ship any reason before pressing Enter. "
|
_ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*m")
|
||||||
|
|
||||||
|
|
||||||
@tool(name="wait", tags=ToolTag.LOCAL)
|
def _wait_prompt(duration: int, *, reason: str | None) -> str:
|
||||||
|
"""Two-line wait prompt: status line, then the optional-reason input prompt.
|
||||||
|
|
||||||
|
``read_line_with_timeout`` writes the prompt verbatim to stdout (no click
|
||||||
|
echo ANSI stripping), so styling is applied here and stripped when stdout
|
||||||
|
is not a TTY. On a terminal: cyan status, dimmed detail, yellow input
|
||||||
|
prompt; plain text otherwise (tests, redirected output).
|
||||||
|
"""
|
||||||
|
status = style(f"Waiting for {duration}s", fg="cyan")
|
||||||
|
if reason:
|
||||||
|
status += style(f" ({reason})", fg="bright_black")
|
||||||
|
status += style(". Press Enter to disturb.", fg="bright_black")
|
||||||
|
input_prompt = style("Reason (optional): ", fg="yellow")
|
||||||
|
rendered = f"{status}\n{input_prompt}"
|
||||||
|
if not sys.stdout.isatty():
|
||||||
|
return _ANSI_ESCAPE_RE.sub("", rendered)
|
||||||
|
return rendered
|
||||||
|
|
||||||
|
|
||||||
|
@tool(name="wait", tags=ToolTag.LOCAL | ToolTag.READ_ONLY)
|
||||||
async def wait(duration: int, *, reason: str | None = None) -> str:
|
async def wait(duration: int, *, reason: str | None = None) -> str:
|
||||||
"""Wait ``duration`` seconds before continuing.
|
"""Wait ``duration`` seconds before continuing.
|
||||||
|
|
||||||
Interactive sessions show a line prompt: pressing Enter (optionally after
|
Interactive sessions show a two-line prompt: a status line, then an
|
||||||
typing a reason) "disturbs" the wait so the turn continues immediately.
|
optional-reason input — pressing Enter (optionally after typing a reason)
|
||||||
Non-interactive runs simply sleep the full duration.
|
"disturbs" the wait so the turn continues immediately. Non-interactive runs
|
||||||
|
simply sleep the full duration.
|
||||||
"""
|
"""
|
||||||
if duration < 0:
|
if duration < 0:
|
||||||
return "error: duration must not be negative"
|
return "error: duration must not be negative"
|
||||||
@@ -24,12 +48,7 @@ async def wait(duration: int, *, reason: str | None = None) -> str:
|
|||||||
if not backend.is_interactive():
|
if not backend.is_interactive():
|
||||||
await asyncio.sleep(duration)
|
await asyncio.sleep(duration)
|
||||||
return f"waited {duration}s"
|
return f"waited {duration}s"
|
||||||
prompt = WAIT_PROMPT.format(duration=duration)
|
prompt = _wait_prompt(duration, reason=reason)
|
||||||
if reason:
|
|
||||||
prompt = (
|
|
||||||
f"Waiting for {duration}s (reason: {reason}). Press Enter to disturb. "
|
|
||||||
"Feel free to ship any reason before pressing Enter. "
|
|
||||||
)
|
|
||||||
line = await run_prompt_async(read_line_with_timeout, prompt, float(duration))
|
line = await run_prompt_async(read_line_with_timeout, prompt, float(duration))
|
||||||
if line is None:
|
if line is None:
|
||||||
return f"waited {duration}s"
|
return f"waited {duration}s"
|
||||||
|
|||||||
@@ -137,6 +137,29 @@ def bind_session(state: SessionState | None) -> Generator[SessionState | None]:
|
|||||||
_session.reset(token)
|
_session.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
_read_only: ContextVar[bool] = ContextVar("plyngent_read_only_context", default=False)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def read_only_context() -> Generator[None]:
|
||||||
|
"""Mark the current context as read-only for tool execution.
|
||||||
|
|
||||||
|
Tools that may mutate depending on arguments (e.g. ``fetch`` with a
|
||||||
|
non-GET method) check :func:`is_read_only_context` and refuse to mutate
|
||||||
|
while it is set.
|
||||||
|
"""
|
||||||
|
token = _read_only.set(True)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
_read_only.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
def is_read_only_context() -> bool:
|
||||||
|
"""Whether tools currently run inside a read-only context."""
|
||||||
|
return _read_only.get()
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def bind_tool_context(
|
def bind_tool_context(
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from plyngent.agent import ToolTag, invalidate_lineno_read, lineno_read_lines, tool
|
from plyngent.agent import ToolTag, lineno_read_lines, lineno_read_mtime, tool
|
||||||
from plyngent.tools.workspace import resolve_path
|
from plyngent.tools.workspace import resolve_path
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -60,10 +60,11 @@ def _unread_lines_in_range(
|
|||||||
"""Lines in ``start_line..end_line`` that were not read with line numbers.
|
"""Lines in ``start_line..end_line`` that were not read with line numbers.
|
||||||
|
|
||||||
Appending (``start_line == n + 1``) requires the last line to be read, so
|
Appending (``start_line == n + 1``) requires the last line to be read, so
|
||||||
line numbers stay truthful; the append point itself is not a line.
|
line numbers stay truthful; the append point itself is not a line. An empty
|
||||||
|
file (``n == 0``) has nothing to clobber, so appending needs no read.
|
||||||
"""
|
"""
|
||||||
if start_line == n + 1:
|
if start_line == n + 1:
|
||||||
return [] if n in read_lines else [n]
|
return [] if n == 0 or n in read_lines else [n]
|
||||||
return sorted(set(range(start_line, end_line + 1)) - read_lines)
|
return sorted(set(range(start_line, end_line + 1)) - read_lines)
|
||||||
|
|
||||||
|
|
||||||
@@ -77,6 +78,14 @@ def _append_after(target: Path, path: str, text: str, lines: list[str], new_cont
|
|||||||
return f"appended content after line {n} in {path}"
|
return f"appended content after line {n} in {path}"
|
||||||
|
|
||||||
|
|
||||||
|
def _excerpt(text: str, limit: int = 40) -> str:
|
||||||
|
"""Collapse newlines and truncate for a one-line message excerpt."""
|
||||||
|
one = text.replace("\r\n", " ").replace("\n", " ")
|
||||||
|
if len(one) > limit:
|
||||||
|
return one[:limit] + "…"
|
||||||
|
return one
|
||||||
|
|
||||||
|
|
||||||
def _replace_range(
|
def _replace_range(
|
||||||
target: Path,
|
target: Path,
|
||||||
path: str,
|
path: str,
|
||||||
@@ -92,7 +101,11 @@ def _replace_range(
|
|||||||
new_lines = lines[: start_line - 1] + replacement + lines[end:]
|
new_lines = lines[: start_line - 1] + replacement + lines[end:]
|
||||||
_ = target.write_text("".join(new_lines), encoding="utf-8")
|
_ = target.write_text("".join(new_lines), encoding="utf-8")
|
||||||
removed = end - start_line + 1
|
removed = end - start_line + 1
|
||||||
return f"replaced lines {start_line}-{end} ({removed} lines) with {len(replacement)} lines in {path}"
|
old_first = _excerpt(lines[start_line - 1].rstrip("\r\n"))
|
||||||
|
return (
|
||||||
|
f"replaced lines {start_line}-{end} ({removed} lines; first: {old_first!r}) "
|
||||||
|
f"with {len(replacement)} lines in {path}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
|
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
|
||||||
@@ -111,18 +124,37 @@ async def edit_lineno(path: str, start_line: int, end_line: int, new_content: st
|
|||||||
if err is not None:
|
if err is not None:
|
||||||
return err
|
return err
|
||||||
|
|
||||||
unread = _unread_lines_in_range(lineno_read_lines(str(target)), start_line, end_line, len(lines))
|
read_lines = lineno_read_lines(str(target))
|
||||||
|
unread = _unread_lines_in_range(read_lines, start_line, end_line, len(lines))
|
||||||
if unread:
|
if unread:
|
||||||
preview = unread[:_UNREAD_PREVIEW]
|
preview = unread[:_UNREAD_PREVIEW]
|
||||||
head = ", ".join(str(i) for i in preview)
|
head = ", ".join(str(i) for i in preview)
|
||||||
if len(unread) > _UNREAD_PREVIEW:
|
if len(unread) > _UNREAD_PREVIEW:
|
||||||
head += f", … ({len(unread)} total)"
|
head += f", … ({len(unread)} total)"
|
||||||
return f"error: lines {head} not read; call read_file(path, with_lineno=true) first to see current line numbers"
|
window = ""
|
||||||
|
if read_lines:
|
||||||
|
window = f" (this turn read lines {min(read_lines)}-{max(read_lines)})"
|
||||||
|
return (
|
||||||
|
f"error: lines {head} not read{window}; call read_file(path, with_lineno=true) "
|
||||||
|
"first — read with a small margin around the lines you plan to edit"
|
||||||
|
)
|
||||||
|
|
||||||
|
recorded_mtime = lineno_read_mtime(str(target))
|
||||||
|
if recorded_mtime is not None:
|
||||||
|
try:
|
||||||
|
current_mtime = target.stat().st_mtime_ns
|
||||||
|
except OSError:
|
||||||
|
current_mtime = None
|
||||||
|
if current_mtime is not None and current_mtime != recorded_mtime:
|
||||||
|
return (
|
||||||
|
f"error: {path} changed since it was read (line numbers may be stale); "
|
||||||
|
"re-read with read_file(path, with_lineno=true)"
|
||||||
|
)
|
||||||
|
|
||||||
if start_line == len(lines) + 1:
|
if start_line == len(lines) + 1:
|
||||||
result = _append_after(target, path, text, lines, new_content)
|
result = _append_after(target, path, text, lines, new_content)
|
||||||
else:
|
else:
|
||||||
result = _replace_range(target, path, lines, start_line, end_line, new_content)
|
result = _replace_range(target, path, lines, start_line, end_line, new_content)
|
||||||
# Line numbers are stale after a write; force a re-read before next edit.
|
# Line numbers are stale after a write; the fresh mtime makes the next
|
||||||
invalidate_lineno_read(str(target))
|
# edit_lineno reject until the file is re-read.
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from plyngent.agent import ToolTag, invalidate_lineno_read, tool
|
from plyngent.agent import ToolTag, tool
|
||||||
from plyngent.tools.workspace import resolve_path
|
from plyngent.tools.workspace import resolve_path
|
||||||
|
|
||||||
|
|
||||||
@@ -18,15 +18,31 @@ def _count_non_overlapping(text: str, needle: str) -> int:
|
|||||||
start = index + len(needle)
|
start = index + len(needle)
|
||||||
|
|
||||||
|
|
||||||
def _success_message(path: str, *, replaced: int, found: int) -> str:
|
def _excerpt(text: str, limit: int = 40) -> str:
|
||||||
|
"""Collapse newlines and truncate for a one-line message excerpt."""
|
||||||
|
one = text.replace("\r\n", " ").replace("\n", " ")
|
||||||
|
if len(one) > limit:
|
||||||
|
return one[:limit] + "…"
|
||||||
|
return one
|
||||||
|
|
||||||
|
|
||||||
|
def _success_message(
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
replaced: int,
|
||||||
|
found: int,
|
||||||
|
old_string: str,
|
||||||
|
new_string: str,
|
||||||
|
) -> str:
|
||||||
remaining = found - replaced
|
remaining = found - replaced
|
||||||
unit = "occurrence" if replaced == 1 else "occurrences"
|
unit = "occurrence" if replaced == 1 else "occurrences"
|
||||||
|
change = f" ('{_excerpt(old_string)}' → '{_excerpt(new_string)}')"
|
||||||
if remaining == 0:
|
if remaining == 0:
|
||||||
if found == 1:
|
if found == 1:
|
||||||
return f"replaced 1 occurrence in {path}"
|
return f"replaced 1 occurrence in {path}{change}"
|
||||||
return f"replaced {replaced} {unit} in {path} (all {found} matches)"
|
return f"replaced {replaced} {unit} in {path}{change} (all {found} matches)"
|
||||||
return (
|
return (
|
||||||
f"replaced {replaced} {unit} in {path} "
|
f"replaced {replaced} {unit} in {path}{change} "
|
||||||
f"({replaced} of {found} matches; {remaining} remain — "
|
f"({replaced} of {found} matches; {remaining} remain — "
|
||||||
f"raise max_replaces or use a more specific old_string)"
|
f"raise max_replaces or use a more specific old_string)"
|
||||||
)
|
)
|
||||||
@@ -54,6 +70,12 @@ async def edit_replace(path: str, old_string: str, new_string: str, max_replaces
|
|||||||
n = min(max_replaces, found)
|
n = min(max_replaces, found)
|
||||||
updated = text.replace(old_string, new_string, n)
|
updated = text.replace(old_string, new_string, n)
|
||||||
_ = target.write_text(updated, encoding="utf-8")
|
_ = target.write_text(updated, encoding="utf-8")
|
||||||
# Any lineno read state is stale after a write; force a re-read.
|
# The write changes the file's mtime, so edit_lineno's freshness check
|
||||||
invalidate_lineno_read(str(target))
|
# rejects stale line numbers until the model re-reads with_lineno.
|
||||||
return _success_message(path, replaced=n, found=found)
|
return _success_message(
|
||||||
|
path,
|
||||||
|
replaced=n,
|
||||||
|
found=found,
|
||||||
|
old_string=old_string,
|
||||||
|
new_string=new_string,
|
||||||
|
)
|
||||||
|
|||||||
@@ -68,10 +68,13 @@ def _copy_or_move_validated(
|
|||||||
else:
|
else:
|
||||||
_ = shutil.copy2(source, dest, follow_symlinks=False)
|
_ = shutil.copy2(source, dest, follow_symlinks=False)
|
||||||
label = _kind(source)
|
label = _kind(source)
|
||||||
# A copy does not change the source, but the destination content is new.
|
# A copy does not change the source, but the destination content is
|
||||||
|
# new. copy2/copytree preserve the source mtime, so the freshness
|
||||||
|
# check alone could miss the change — invalidate explicitly.
|
||||||
invalidate_lineno_read(str(dest))
|
invalidate_lineno_read(str(dest))
|
||||||
return f"copied {label} {src} -> {dest.relative_to(root)}"
|
return f"copied {label} {src} -> {dest.relative_to(root)}"
|
||||||
moved = Path(shutil.move(str(source), str(dest))).resolve()
|
moved = Path(shutil.move(str(source), str(dest))).resolve()
|
||||||
|
# shutil.move preserves mtime too; drop read state for both paths.
|
||||||
invalidate_lineno_read(str(source))
|
invalidate_lineno_read(str(source))
|
||||||
invalidate_lineno_read(str(moved))
|
invalidate_lineno_read(str(moved))
|
||||||
return f"moved {_kind(moved)} {src} -> {moved.relative_to(root)}"
|
return f"moved {_kind(moved)} {src} -> {moved.relative_to(root)}"
|
||||||
|
|||||||
@@ -6,31 +6,83 @@ from typing import cast
|
|||||||
|
|
||||||
from plyngent.agent import ToolTag, tool
|
from plyngent.agent import ToolTag, tool
|
||||||
from plyngent.agent.budget import DEFAULT_TOOL_RESULT_MAX_CHARS
|
from plyngent.agent.budget import DEFAULT_TOOL_RESULT_MAX_CHARS
|
||||||
from plyngent.tools.file.read import line_range_label, read_raw_text
|
from plyngent.tools.file.read import line_range_label, read_file, read_raw_text
|
||||||
from plyngent.tools.net.fetch import fetch
|
from plyngent.tools.net.fetch import fetch
|
||||||
from plyngent.tools.truncate_token import decode_truncate_token, truncate_with_token
|
from plyngent.tools.truncate_token import (
|
||||||
|
TruncateToken,
|
||||||
|
decode_truncate_token,
|
||||||
|
get_remainder,
|
||||||
|
truncate_with_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
|
def _memory_chunk(parsed: TruncateToken, max_chars: int) -> str:
|
||||||
|
"""Serve one chunk from the in-memory remainder store (or an error)."""
|
||||||
|
remainder = get_remainder(parsed.location)
|
||||||
|
if remainder is None:
|
||||||
|
return "error: truncate token expired (no longer in memory); re-run the tool"
|
||||||
|
start = parsed.offset
|
||||||
|
segment = remainder[start : start + max_chars]
|
||||||
|
chunk, _ = truncate_with_token(
|
||||||
|
segment,
|
||||||
|
max_chars,
|
||||||
|
kind="memory",
|
||||||
|
location=parsed.location,
|
||||||
|
offset=start,
|
||||||
|
limit=max_chars,
|
||||||
|
total_len=len(remainder),
|
||||||
|
)
|
||||||
|
return chunk
|
||||||
|
|
||||||
|
|
||||||
|
async def _numbered_continuation(text: str, location: str, char_offset: int, max_chars: int) -> str:
|
||||||
|
"""Resume a numbered read at the line containing *char_offset*.
|
||||||
|
|
||||||
|
Delegates to ``read_file(with_lineno=True)`` so the continuation carries
|
||||||
|
``N|`` line numbers and marks the resumed lines readable for ``edit_lineno``.
|
||||||
|
"""
|
||||||
|
pos = 0
|
||||||
|
line = 0
|
||||||
|
for ln in text.splitlines(keepends=True):
|
||||||
|
if pos + len(ln) > char_offset:
|
||||||
|
break
|
||||||
|
pos += len(ln)
|
||||||
|
line += 1
|
||||||
|
return await read_file.handler(
|
||||||
|
location,
|
||||||
|
offset=line,
|
||||||
|
with_lineno=True,
|
||||||
|
max_chars=max_chars,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
|
||||||
async def get_truncated(token: str, *, max_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS) -> str:
|
async def get_truncated(token: str, *, max_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS) -> str:
|
||||||
"""Fetch the next chunk of a truncated result using its ``TRUNCATE_TOKEN``.
|
"""Fetch the next chunk of a truncated result using its ``truncate_token``.
|
||||||
|
|
||||||
Pass the token shown at the end of a truncated ``read_file``, ``fetch``, or
|
Pass the token shown at the end of a truncated ``read_file``, ``fetch``,
|
||||||
earlier ``get_truncated`` result to keep reading without re-requesting the
|
``run_command``, or earlier ``get_truncated`` result to keep reading without
|
||||||
whole source or raising limits. Truncated output carries a fresh token, so
|
re-requesting the whole source or raising limits. Truncated output carries a
|
||||||
chunks chain indefinitely. File chunks start with a 1-based ``L{begin}-{end}``
|
fresh token, so chunks chain indefinitely. File chunks resume in the same
|
||||||
line range like ``read_file``.
|
view as the original read: numbered reads continue as numbered lines
|
||||||
|
(editable via ``edit_lineno``), plain reads as raw text with a 1-based
|
||||||
|
``L{begin}-{end}`` range. Memory chunks (generic tool output) are served
|
||||||
|
from a short-lived in-memory store that is forgotten when the agent exits.
|
||||||
"""
|
"""
|
||||||
parsed = decode_truncate_token(token)
|
parsed = decode_truncate_token(token)
|
||||||
if parsed is None:
|
if parsed is None:
|
||||||
return "error: invalid truncate token"
|
return "error: invalid truncate token"
|
||||||
if parsed.kind == "http":
|
if parsed.kind == "http":
|
||||||
return await fetch.handler(parsed.location, offset=parsed.offset, max_chars=parsed.limit)
|
return await fetch.handler(parsed.location, offset=parsed.offset, max_chars=parsed.limit)
|
||||||
|
if parsed.kind == "memory":
|
||||||
|
return _memory_chunk(parsed, max_chars)
|
||||||
text, err = read_raw_text(parsed.location)
|
text, err = read_raw_text(parsed.location)
|
||||||
if err:
|
if err:
|
||||||
return err
|
return err
|
||||||
text = cast("str", text)
|
text = cast("str", text)
|
||||||
start = parsed.offset
|
start = parsed.offset
|
||||||
|
if parsed.numbered:
|
||||||
|
return await _numbered_continuation(text, parsed.location, start, max_chars)
|
||||||
segment = text[start : start + max_chars]
|
segment = text[start : start + max_chars]
|
||||||
chunk, _ = truncate_with_token(
|
chunk, _ = truncate_with_token(
|
||||||
segment,
|
segment,
|
||||||
@@ -41,6 +93,6 @@ async def get_truncated(token: str, *, max_chars: int = DEFAULT_TOOL_RESULT_MAX_
|
|||||||
limit=max_chars,
|
limit=max_chars,
|
||||||
total_len=len(text),
|
total_len=len(text),
|
||||||
)
|
)
|
||||||
content_len = len(chunk.split("\n...[", 1)[0]) if "\n...[" in chunk else len(chunk)
|
content_len = len(chunk.split("\n[Truncated", 1)[0]) if "\n[Truncated" in chunk else len(chunk)
|
||||||
header = line_range_label(text, start, start + content_len)
|
header = line_range_label(text, start, start + content_len)
|
||||||
return f"{header}\n{chunk}"
|
return f"{header}\n{chunk}"
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ def _resolve_glob_base(path: str) -> tuple[Path, Path] | str:
|
|||||||
return root, base
|
return root, base
|
||||||
|
|
||||||
|
|
||||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
|
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
|
||||||
async def glob_paths(
|
async def glob_paths(
|
||||||
pattern: str,
|
pattern: str,
|
||||||
path: str = ".",
|
path: str = ".",
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ def _compile_pattern(pattern: str, *, case_insensitive: bool) -> re.Pattern[str]
|
|||||||
return f"error: invalid regex: {exc}"
|
return f"error: invalid regex: {exc}"
|
||||||
|
|
||||||
|
|
||||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
|
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
|
||||||
async def grep_files(
|
async def grep_files(
|
||||||
pattern: str,
|
pattern: str,
|
||||||
path: str = ".",
|
path: str = ".",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from plyngent.agent import ToolTag, tool
|
|||||||
from plyngent.tools.workspace import resolve_path
|
from plyngent.tools.workspace import resolve_path
|
||||||
|
|
||||||
|
|
||||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
|
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
|
||||||
async def listdir(path: str = ".") -> str:
|
async def listdir(path: str = ".") -> str:
|
||||||
"""List entries in a directory under the workspace (name and type)."""
|
"""List entries in a directory under the workspace (name and type)."""
|
||||||
target = resolve_path(path)
|
target = resolve_path(path)
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ from __future__ import annotations
|
|||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
from plyngent.agent import ToolTag, mark_lineno_read, tool
|
from plyngent.agent import ToolTag, mark_lineno_read, tool
|
||||||
from plyngent.tools.truncate_token import truncate_with_token
|
from plyngent.agent.budget import DEFAULT_TOOL_RESULT_MAX_CHARS
|
||||||
|
from plyngent.tools.truncate_token import TruncateToken, truncate_with_token, truncation_marker
|
||||||
from plyngent.tools.workspace import resolve_path
|
from plyngent.tools.workspace import resolve_path
|
||||||
|
|
||||||
_LINENO_WIDTH = 6
|
_LINENO_WIDTH = 6
|
||||||
@@ -48,14 +49,14 @@ def read_raw_text(path: str) -> tuple[str | None, str]:
|
|||||||
return target.read_text(encoding="utf-8", errors="replace"), ""
|
return target.read_text(encoding="utf-8", errors="replace"), ""
|
||||||
|
|
||||||
|
|
||||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
|
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
|
||||||
async def read_file(
|
async def read_file(
|
||||||
path: str,
|
path: str,
|
||||||
*,
|
*,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
limit: int | None = None,
|
limit: int | None = None,
|
||||||
with_lineno: bool = False,
|
with_lineno: bool = False,
|
||||||
max_chars: int | None = None,
|
max_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Read a text file under the workspace.
|
"""Read a text file under the workspace.
|
||||||
|
|
||||||
@@ -63,12 +64,13 @@ async def read_file(
|
|||||||
(None = rest of file). When ``with_lineno`` is true, each line is prefixed
|
(None = rest of file). When ``with_lineno`` is true, each line is prefixed
|
||||||
with its 1-based file line number (`` N|…``), matching ``edit_lineno``
|
with its 1-based file line number (`` N|…``), matching ``edit_lineno``
|
||||||
numbering, and those lines are marked readable for ``edit_lineno`` this turn.
|
numbering, and those lines are marked readable for ``edit_lineno`` this turn.
|
||||||
|
Only the displayed lines become editable, so read with a small margin around
|
||||||
|
any range you plan to edit with ``edit_lineno``.
|
||||||
|
|
||||||
``max_chars`` caps the returned slice; a ``TRUNCATE_TOKEN`` is appended so
|
``max_chars`` caps the returned slice (default: the agent's tool-result cap,
|
||||||
``get_truncated`` can continue reading the rest without a new request.
|
``DEFAULT_TOOL_RESULT_MAX_CHARS``); a ``truncate_token`` is appended when cut
|
||||||
|
so ``get_truncated`` can continue reading the rest without a new request.
|
||||||
The result starts with a 1-based inclusive line range ``L{begin}-{end}`` so
|
Pass ``max_chars < 1`` to read unbounded.
|
||||||
the caller knows exactly which file lines were read (offset is 0-based).
|
|
||||||
"""
|
"""
|
||||||
target = resolve_path(path)
|
target = resolve_path(path)
|
||||||
text, err = read_raw_text(path)
|
text, err = read_raw_text(path)
|
||||||
@@ -83,14 +85,10 @@ async def read_file(
|
|||||||
if start >= len(lines):
|
if start >= len(lines):
|
||||||
return ""
|
return ""
|
||||||
slice_lines = lines[start:end]
|
slice_lines = lines[start:end]
|
||||||
if with_lineno:
|
body = _format_with_lineno(slice_lines, start_lineno=start + 1) if with_lineno else "".join(slice_lines)
|
||||||
mark_lineno_read(str(target), set(range(start + 1, end + 1)))
|
char_start = len("".join(lines[:start]))
|
||||||
body = _format_with_lineno(slice_lines, start_lineno=start + 1)
|
if max_chars >= 1:
|
||||||
else:
|
body, token = truncate_with_token(
|
||||||
body = "".join(slice_lines)
|
|
||||||
if max_chars is not None and max_chars >= 1:
|
|
||||||
char_start = len("".join(lines[:start]))
|
|
||||||
body, _ = truncate_with_token(
|
|
||||||
body,
|
body,
|
||||||
max_chars,
|
max_chars,
|
||||||
kind="file",
|
kind="file",
|
||||||
@@ -98,11 +96,43 @@ async def read_file(
|
|||||||
offset=char_start,
|
offset=char_start,
|
||||||
limit=max_chars,
|
limit=max_chars,
|
||||||
total_len=len(text),
|
total_len=len(text),
|
||||||
|
# read_file bounds the read via offset/limit; only mark when the
|
||||||
|
# returned slice itself was cut by max_chars (not just "file continues").
|
||||||
|
emit_more_after=False,
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
token = None
|
||||||
if not body:
|
if not body:
|
||||||
return ""
|
return ""
|
||||||
if with_lineno:
|
if with_lineno:
|
||||||
return body # per-line numbers already show the range
|
# Mark only the lines actually displayed: complete numbered lines in the
|
||||||
|
# returned body (the [Truncated] marker, when present, is not a file line).
|
||||||
|
# The max_chars-truncated tail was never shown, so it stays unread —
|
||||||
|
# edit_lineno must not trust line numbers the model never saw.
|
||||||
|
counted = body
|
||||||
|
if token is not None:
|
||||||
|
counted = body.split("\n[Truncated", 1)[0]
|
||||||
|
shown = counted.count("\n")
|
||||||
|
# The truncate cursor counts *numbered* chars (each line carries an
|
||||||
|
# "N|" prefix), which would over-advance into raw text and skip
|
||||||
|
# lines on continuation. Rebase the token onto raw characters of the
|
||||||
|
# complete lines shown (at least one, so the cursor always moves)
|
||||||
|
# and mark it as the numbered view for get_truncated.
|
||||||
|
consumed_lines = max(shown, 1)
|
||||||
|
raw_consumed = sum(len(ln) for ln in slice_lines[:consumed_lines])
|
||||||
|
corrected = TruncateToken(
|
||||||
|
kind="file",
|
||||||
|
location=path,
|
||||||
|
offset=char_start + raw_consumed,
|
||||||
|
limit=max_chars,
|
||||||
|
numbered=True,
|
||||||
|
)
|
||||||
|
omitted = len(text) - (char_start + raw_consumed)
|
||||||
|
body = counted + truncation_marker(max_chars, omitted, corrected)
|
||||||
|
else:
|
||||||
|
shown = counted.count("\n")
|
||||||
|
mark_lineno_read(str(target), set(range(start + 1, start + 1 + shown)))
|
||||||
|
return body
|
||||||
begin = start + 1
|
begin = start + 1
|
||||||
end_line = start + len(slice_lines)
|
end_line = start + len(slice_lines)
|
||||||
return f"L{begin}-{end_line}\n{body}"
|
return f"L{begin}-{end_line}\n{body}"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Literal
|
||||||
|
|
||||||
from plyngent.agent import ToolTag, tool
|
from plyngent.agent import ToolTag, tool
|
||||||
from plyngent.tools.workspace import WorkspaceError, get_path_denylist, resolve_path
|
from plyngent.tools.workspace import WorkspaceError, get_path_denylist, resolve_path
|
||||||
@@ -10,6 +10,11 @@ if TYPE_CHECKING:
|
|||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Model-facing renderings. ``markdown`` (default) is the cheapest, most
|
||||||
|
# reliable representation for the agent; ``flat`` gives directly-usable paths;
|
||||||
|
# ``decorated`` keeps the classic box-drawing tree for human readers.
|
||||||
|
type TreeFormat = Literal["markdown", "flat", "decorated"]
|
||||||
|
|
||||||
DEFAULT_MAX_DEPTH = 4
|
DEFAULT_MAX_DEPTH = 4
|
||||||
DEFAULT_MAX_ENTRIES = 50
|
DEFAULT_MAX_ENTRIES = 50
|
||||||
|
|
||||||
@@ -144,6 +149,80 @@ def _render_tree(
|
|||||||
lines.append(f"{prefix}└── … ({more} more entries not shown)")
|
lines.append(f"{prefix}└── … ({more} more entries not shown)")
|
||||||
|
|
||||||
|
|
||||||
|
def _render_tree_markdown(
|
||||||
|
directory: Path,
|
||||||
|
*,
|
||||||
|
prefix: str,
|
||||||
|
depth: int,
|
||||||
|
limits: _TreeLimits,
|
||||||
|
lines: list[str],
|
||||||
|
) -> None:
|
||||||
|
"""Nested ``- `` bullet list (2-space indent per level)."""
|
||||||
|
if depth >= limits.max_depth:
|
||||||
|
return
|
||||||
|
|
||||||
|
children = _list_children(directory, limits=limits)
|
||||||
|
if isinstance(children, str):
|
||||||
|
lines.append(f"{prefix}- {children}")
|
||||||
|
return
|
||||||
|
|
||||||
|
truncated = len(children) > limits.max_entries
|
||||||
|
shown = children[: limits.max_entries]
|
||||||
|
for child in shown:
|
||||||
|
try:
|
||||||
|
is_dir = child.is_dir()
|
||||||
|
except OSError:
|
||||||
|
lines.append(f"{prefix}- {child.name} [error: stat failed]")
|
||||||
|
continue
|
||||||
|
if is_dir:
|
||||||
|
lines.append(f"{prefix}- {child.name}/")
|
||||||
|
_render_tree_markdown(
|
||||||
|
child,
|
||||||
|
prefix=prefix + " ",
|
||||||
|
depth=depth + 1,
|
||||||
|
limits=limits,
|
||||||
|
lines=lines,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lines.append(f"{prefix}- {child.name}")
|
||||||
|
|
||||||
|
if truncated:
|
||||||
|
more = len(children) - limits.max_entries
|
||||||
|
lines.append(f"{prefix}- … ({more} more entries not shown)")
|
||||||
|
|
||||||
|
|
||||||
|
def _flat_paths(directory: Path, *, limits: _TreeLimits) -> list[str]:
|
||||||
|
"""All paths (dirs with trailing ``/``, files plain) in depth-first order.
|
||||||
|
|
||||||
|
Includes empty directories; bounded by *max_depth* (``max_entries`` is
|
||||||
|
applied to the total by the caller).
|
||||||
|
"""
|
||||||
|
out: list[str] = []
|
||||||
|
|
||||||
|
def visit(child_dir: Path, rel: str, depth: int) -> None:
|
||||||
|
if depth >= limits.max_depth:
|
||||||
|
return
|
||||||
|
children = _list_children(child_dir, limits=limits)
|
||||||
|
if isinstance(children, str):
|
||||||
|
out.append(f"{rel or child_dir.name} [error: cannot list]")
|
||||||
|
return
|
||||||
|
for child in children:
|
||||||
|
try:
|
||||||
|
is_dir = child.is_dir()
|
||||||
|
except OSError:
|
||||||
|
out.append(f"{child.name} [error: stat failed]")
|
||||||
|
continue
|
||||||
|
child_rel = child.name if not rel else f"{rel}/{child.name}"
|
||||||
|
if is_dir:
|
||||||
|
out.append(f"{child_rel}/")
|
||||||
|
visit(child, child_rel, depth + 1)
|
||||||
|
else:
|
||||||
|
out.append(child_rel)
|
||||||
|
|
||||||
|
visit(directory, "", 0)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _resolve_skip_basenames(skip_dirs: Sequence[str] | None) -> frozenset[str]:
|
def _resolve_skip_basenames(skip_dirs: Sequence[str] | None) -> frozenset[str]:
|
||||||
"""None → default noise set; explicit list (including empty) replaces defaults."""
|
"""None → default noise set; explicit list (including empty) replaces defaults."""
|
||||||
if skip_dirs is None:
|
if skip_dirs is None:
|
||||||
@@ -151,10 +230,11 @@ def _resolve_skip_basenames(skip_dirs: Sequence[str] | None) -> frozenset[str]:
|
|||||||
return frozenset(name for name in skip_dirs if name)
|
return frozenset(name for name in skip_dirs if name)
|
||||||
|
|
||||||
|
|
||||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
|
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
|
||||||
async def tree(
|
async def tree(
|
||||||
path: str = ".",
|
path: str = ".",
|
||||||
*,
|
*,
|
||||||
|
format: TreeFormat = "markdown", # noqa: A002 — model-facing param name
|
||||||
max_depth: int = DEFAULT_MAX_DEPTH,
|
max_depth: int = DEFAULT_MAX_DEPTH,
|
||||||
max_entries: int = DEFAULT_MAX_ENTRIES,
|
max_entries: int = DEFAULT_MAX_ENTRIES,
|
||||||
skip_hidden_dirs: bool = True,
|
skip_hidden_dirs: bool = True,
|
||||||
@@ -174,8 +254,15 @@ async def tree(
|
|||||||
``apply_path_denylist`` (default true) hides entries whose full path matches
|
``apply_path_denylist`` (default true) hides entries whose full path matches
|
||||||
the agent ``path_denylist`` policy.
|
the agent ``path_denylist`` policy.
|
||||||
|
|
||||||
|
``format`` selects the representation (the model chooses per call):
|
||||||
|
``markdown`` (default) renders a nested ``- `` bullet list — cheapest and
|
||||||
|
most reliable for the agent; ``flat`` lists one relative path per line
|
||||||
|
(directories with a trailing ``/``) so every path is directly usable
|
||||||
|
without reconstructing it; ``decorated`` uses the classic box-drawing tree.
|
||||||
|
|
||||||
``max_depth`` limits how deep directories are expanded (1 = origin + children).
|
``max_depth`` limits how deep directories are expanded (1 = origin + children).
|
||||||
``max_entries`` caps how many entries are listed per directory.
|
``max_entries`` caps entries per directory (``markdown``/``decorated``) or
|
||||||
|
the total number of lines (``flat``).
|
||||||
"""
|
"""
|
||||||
if max_depth < 1:
|
if max_depth < 1:
|
||||||
return "error: max_depth must be >= 1"
|
return "error: max_depth must be >= 1"
|
||||||
@@ -189,8 +276,6 @@ async def tree(
|
|||||||
if not origin.is_dir():
|
if not origin.is_dir():
|
||||||
return f"error: not a directory: {path}"
|
return f"error: not a directory: {path}"
|
||||||
|
|
||||||
root_label = path.rstrip("/\\") or "."
|
|
||||||
lines = [f"{root_label}/"]
|
|
||||||
limits = _TreeLimits(
|
limits = _TreeLimits(
|
||||||
max_depth=max_depth,
|
max_depth=max_depth,
|
||||||
max_entries=max_entries,
|
max_entries=max_entries,
|
||||||
@@ -198,7 +283,18 @@ async def tree(
|
|||||||
skip_basenames=_resolve_skip_basenames(skip_dirs),
|
skip_basenames=_resolve_skip_basenames(skip_dirs),
|
||||||
apply_path_denylist=apply_path_denylist,
|
apply_path_denylist=apply_path_denylist,
|
||||||
)
|
)
|
||||||
_render_tree(
|
|
||||||
|
if format == "flat":
|
||||||
|
paths = _flat_paths(origin, limits=limits)
|
||||||
|
lines = paths[:max_entries]
|
||||||
|
if len(paths) > max_entries:
|
||||||
|
lines.append(f"… ({len(paths) - max_entries} more paths not shown)")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
root_label = path.rstrip("/\\") or "."
|
||||||
|
lines: list[str] = [] if format == "markdown" else [f"{root_label}/"]
|
||||||
|
renderer = _render_tree_markdown if format == "markdown" else _render_tree
|
||||||
|
renderer(
|
||||||
origin,
|
origin,
|
||||||
prefix="",
|
prefix="",
|
||||||
depth=0,
|
depth=0,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from plyngent.agent import ToolTag, invalidate_lineno_read, tool
|
from plyngent.agent import ToolTag, tool
|
||||||
from plyngent.tools.workspace import resolve_path
|
from plyngent.tools.workspace import resolve_path
|
||||||
|
|
||||||
|
|
||||||
@@ -10,5 +10,6 @@ async def write_file(path: str, content: str) -> str:
|
|||||||
target = resolve_path(path)
|
target = resolve_path(path)
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
_ = target.write_text(content, encoding="utf-8")
|
_ = target.write_text(content, encoding="utf-8")
|
||||||
invalidate_lineno_read(str(target))
|
# The write changes the file's mtime, so edit_lineno's freshness check
|
||||||
|
# rejects stale line numbers until the model re-reads with_lineno.
|
||||||
return f"wrote {len(content)} characters to {path}"
|
return f"wrote {len(content)} characters to {path}"
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""Register MCP server tools into the tool catalog (namespaced, LOCAL tags).
|
||||||
|
|
||||||
|
Mirrors :mod:`plyngent.tools.plugins`: the catalog only *registers* what the
|
||||||
|
model may see; hosts still select into a :class:`ToolRegistry`. Connections
|
||||||
|
must already be started (see :class:`~plyngent.runtime.mcp_client.McpManager`);
|
||||||
|
this module is sync-only so registry rebuilds never need an event loop.
|
||||||
|
|
||||||
|
Tool names are namespaced ``mcp__<server>__<tool>`` so two servers (or a
|
||||||
|
server and a builtin) can never collide on sanitized names; the catalog still
|
||||||
|
refuses true clashes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from plyngent.agent.tools import ToolDefinition, ToolTag
|
||||||
|
from plyngent.tools.catalog import ToolSource, get_catalog
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
|
||||||
|
from plyngent.config.models import McpConfig
|
||||||
|
from plyngent.runtime.mcp_client import McpManager, McpTool
|
||||||
|
|
||||||
|
_MCP_PREFIX = "mcp__"
|
||||||
|
_NAME_SEGMENT_MAX = 64
|
||||||
|
_SANITIZE_PATTERN = re.compile(r"[^A-Za-z0-9_-]")
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_segment(part: str) -> str:
|
||||||
|
"""Reduce a server/tool name to a model-safe, bounded name segment."""
|
||||||
|
return _SANITIZE_PATTERN.sub("_", part)[:_NAME_SEGMENT_MAX] or "srv"
|
||||||
|
|
||||||
|
|
||||||
|
def mcp_tool_name(server: str, tool: str) -> str:
|
||||||
|
"""Namespaced catalog name for one MCP tool (collision-free per server)."""
|
||||||
|
return f"{_MCP_PREFIX}{_sanitize_segment(server)}__{_sanitize_segment(tool)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _make_handler(
|
||||||
|
manager: McpManager,
|
||||||
|
server: str,
|
||||||
|
tool: str,
|
||||||
|
) -> Callable[..., Awaitable[str]]:
|
||||||
|
async def handler(**arguments: object) -> str:
|
||||||
|
return await manager.call(server, tool, dict(arguments))
|
||||||
|
|
||||||
|
return handler
|
||||||
|
|
||||||
|
|
||||||
|
def register_mcp_server_tools(
|
||||||
|
server: str,
|
||||||
|
tools: list[McpTool],
|
||||||
|
*,
|
||||||
|
manager: McpManager,
|
||||||
|
read_only: bool = False,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Register one server's advertised tools; returns the registered names.
|
||||||
|
|
||||||
|
Tags: LOCAL (plus READ_ONLY when the server config opts in) — never YOLO or
|
||||||
|
TRUSTABLE, so host-side danger classification always sees every call.
|
||||||
|
"""
|
||||||
|
catalog = get_catalog()
|
||||||
|
source = ToolSource(kind="mcp", plugin_id=server)
|
||||||
|
names: list[str] = []
|
||||||
|
for entry in tools:
|
||||||
|
if not entry.name:
|
||||||
|
continue
|
||||||
|
tags = ToolTag.LOCAL
|
||||||
|
if read_only:
|
||||||
|
tags |= ToolTag.READ_ONLY
|
||||||
|
description = entry.description or f"MCP tool {entry.name!r} from server {server!r}"
|
||||||
|
definition = ToolDefinition(
|
||||||
|
name=mcp_tool_name(server, entry.name),
|
||||||
|
description=f"[mcp:{server}] {description}",
|
||||||
|
parameters=dict(entry.input_schema),
|
||||||
|
handler=_make_handler(manager, server, entry.name),
|
||||||
|
tags=tags,
|
||||||
|
)
|
||||||
|
catalog.register(definition, source=source)
|
||||||
|
names.append(definition.name)
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def purge_mcp_tools() -> int:
|
||||||
|
"""Remove previously registered MCP tools (call before re-registering)."""
|
||||||
|
return get_catalog().purge_kind("mcp")
|
||||||
|
|
||||||
|
|
||||||
|
def register_mcp_tools(manager: McpManager, config: McpConfig) -> list[str]:
|
||||||
|
"""Register every connected server's tools; replaces prior MCP entries."""
|
||||||
|
_ = purge_mcp_tools()
|
||||||
|
names: list[str] = []
|
||||||
|
for connection in manager.connections():
|
||||||
|
if not connection.connected or not connection.tools:
|
||||||
|
continue
|
||||||
|
server_cfg = config.servers.get(connection.name)
|
||||||
|
names.extend(
|
||||||
|
register_mcp_server_tools(
|
||||||
|
connection.name,
|
||||||
|
connection.tools,
|
||||||
|
manager=manager,
|
||||||
|
read_only=server_cfg.read_only if server_cfg is not None else False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return names
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from plyngent.agent import ToolTag, tool
|
from plyngent.agent import ToolTag, tool
|
||||||
|
from plyngent.tools.context import is_read_only_context
|
||||||
from plyngent.tools.net.client import http_fetch
|
from plyngent.tools.net.client import http_fetch
|
||||||
from plyngent.tools.net.policy import (
|
from plyngent.tools.net.policy import (
|
||||||
DEFAULT_MAX_BODY_CHARS_IN,
|
DEFAULT_MAX_BODY_CHARS_IN,
|
||||||
@@ -66,7 +67,7 @@ def format_fetch_result(
|
|||||||
return "\n".join(parts)
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO | ToolTag.TRUSTABLE)
|
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO | ToolTag.TRUSTABLE | ToolTag.READ_ONLY)
|
||||||
async def fetch(
|
async def fetch(
|
||||||
url: str,
|
url: str,
|
||||||
*,
|
*,
|
||||||
@@ -92,9 +93,13 @@ async def fetch(
|
|||||||
Private/loopback/link-local hosts require an explicit human policy grant
|
Private/loopback/link-local hosts require an explicit human policy grant
|
||||||
(not skipped by YOLO). HTTPS→HTTP redirects are blocked unless
|
(not skipped by YOLO). HTTPS→HTTP redirects are blocked unless
|
||||||
``allow_http_downgrade`` is true.
|
``allow_http_downgrade`` is true.
|
||||||
|
|
||||||
|
Read-only contexts (e.g. ``/btw --tools=read``) accept only ``GET``.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
verb = normalize_method(method)
|
verb = normalize_method(method)
|
||||||
|
if is_read_only_context() and verb != "GET":
|
||||||
|
return "error: fetch method must be GET in a read-only context (POST/PUT/DELETE mutate)"
|
||||||
parsed = parse_fetch_url(url)
|
parsed = parse_fetch_url(url)
|
||||||
hdrs = normalize_request_headers(headers, user_agent=user_agent)
|
hdrs = normalize_request_headers(headers, user_agent=user_agent)
|
||||||
body_bytes: bytes | None = None
|
body_bytes: bytes | None = None
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ async def _with_todo_stack(mutator: Callable[[TodoStack], str]) -> str:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@tool(name="todo_list", tags=ToolTag.LOCAL | ToolTag.PUBLIC | ToolTag.SESSION_STATE)
|
@tool(name="todo_list", tags=ToolTag.LOCAL | ToolTag.PUBLIC | ToolTag.SESSION_STATE | ToolTag.READ_ONLY)
|
||||||
async def todo_list() -> str:
|
async def todo_list() -> str:
|
||||||
"""Show the LIFO stack of **task groups** (TOP group = current breakdown level).
|
"""Show the LIFO stack of **task groups** (TOP group = current breakdown level).
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +1,68 @@
|
|||||||
"""Truncate-token cursors for resuming truncated tool results.
|
"""Truncate-token cursors for resuming truncated tool results.
|
||||||
|
|
||||||
``fetch`` and ``read_file`` append a ``[TRUNCATE_TOKEN: ...]`` marker when they
|
Every truncation site (the agent loop's generic tool-result cap,
|
||||||
cut output short; the model passes the token to ``get_truncated`` to read the
|
``run_command`` / ``run_command_batch`` per-stream caps, ``read_file``,
|
||||||
next chunk without re-requesting the whole source or raising limits.
|
``fetch``, and request-time compact shrinks) appends the same compact marker
|
||||||
|
when output is cut short::
|
||||||
|
|
||||||
|
[Truncated (32000 chars max.; 500 omitted). truncate_token=...]
|
||||||
|
|
||||||
|
The model passes the token to ``get_truncated`` to read the next chunk without
|
||||||
|
re-requesting the whole source or raising limits. Tokens chain: each truncated
|
||||||
|
chunk carries a fresh token, so ``get_truncated`` keeps resuming until the
|
||||||
|
source is exhausted (then no marker is emitted).
|
||||||
|
|
||||||
|
Resumable sources (``file`` / ``http``) are re-read by ``location`` +
|
||||||
|
``offset``. Arbitrary tool output (run_command stdout, todo renders, …) has no
|
||||||
|
resumable source, so its remainder lives in a short-lived in-memory store
|
||||||
|
(``kind="memory"``) that is forgotten when the agent process exits — the model
|
||||||
|
must re-run the tool after that.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
import uuid
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
|
|
||||||
type TruncateKind = Literal["file", "http"]
|
type TruncateKind = Literal["file", "http", "memory"]
|
||||||
|
|
||||||
|
|
||||||
class TruncateToken(msgspec.Struct, frozen=True):
|
class TruncateToken(msgspec.Struct, frozen=True):
|
||||||
"""Opaque cursor into a truncated source.
|
"""Opaque cursor into a truncated source.
|
||||||
|
|
||||||
``offset``/``limit`` are in characters (http bodies) or lines (files is
|
``offset``/``limit`` are in characters (http bodies, memory remainders) or
|
||||||
line-based via ``read_file``); ``get_truncated`` interprets by ``kind``.
|
lines (files is line-based via ``read_file``); ``get_truncated`` interprets
|
||||||
|
by ``kind``. For ``memory``, ``location`` is a key into the in-memory
|
||||||
|
remainder store and ``offset``/``limit`` index characters of that stored
|
||||||
|
remainder. ``numbered`` marks file tokens from ``read_file(with_lineno)``:
|
||||||
|
the continuation resumes the numbered view instead of raw text.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
kind: TruncateKind
|
kind: TruncateKind
|
||||||
location: str
|
location: str
|
||||||
offset: int
|
offset: int
|
||||||
limit: int
|
limit: int
|
||||||
|
numbered: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
# In-memory remainders for generic (non-resumable) tool output. Process-lifetime
|
||||||
|
# only: forgotten when the agent exits. Single-user CLI → one process per agent.
|
||||||
|
_MEMORY_REMAINDERS: dict[str, str] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def store_remainder(text: str) -> str:
|
||||||
|
"""Store truncated-away output; return its opaque store key."""
|
||||||
|
key = f"mem-{uuid.uuid4().hex[:12]}"
|
||||||
|
_MEMORY_REMAINDERS[key] = text
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def get_remainder(key: str) -> str | None:
|
||||||
|
"""Fetch a stored remainder by key; None when expired/unknown."""
|
||||||
|
return _MEMORY_REMAINDERS.get(key)
|
||||||
|
|
||||||
|
|
||||||
def encode_truncate_token(token: TruncateToken) -> str:
|
def encode_truncate_token(token: TruncateToken) -> str:
|
||||||
@@ -46,11 +83,9 @@ def decode_truncate_token(text: str) -> TruncateToken | None:
|
|||||||
return token
|
return token
|
||||||
|
|
||||||
|
|
||||||
def _truncated_marker(omitted: int, token: TruncateToken) -> str:
|
def truncation_marker(max_chars: int, omitted: int, token: TruncateToken) -> str:
|
||||||
return (
|
"""The single truncation hint: ``[Truncated (N chars max.; M omitted). truncate_token=...]``."""
|
||||||
f"\n...[truncated {omitted} chars; more via get_truncated token] "
|
return f"\n[Truncated ({max_chars} chars max.; {omitted} omitted). truncate_token={encode_truncate_token(token)}]"
|
||||||
f"[TRUNCATE_TOKEN: {encode_truncate_token(token)}]"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def truncate_with_token(
|
def truncate_with_token(
|
||||||
@@ -62,35 +97,60 @@ def truncate_with_token(
|
|||||||
offset: int,
|
offset: int,
|
||||||
limit: int,
|
limit: int,
|
||||||
total_len: int,
|
total_len: int,
|
||||||
|
emit_more_after: bool = True,
|
||||||
) -> tuple[str, TruncateToken | None]:
|
) -> tuple[str, TruncateToken | None]:
|
||||||
"""Bound ``text`` to ``max_chars``; return (bounded_text, next_token).
|
"""Bound ``text`` (a slice of the full source starting at ``offset``) to ``max_chars``.
|
||||||
|
|
||||||
``text`` is a slice of the full source starting at ``offset``; ``total_len``
|
Returns ``(bounded_text, next_token)``. A token is embedded whenever the
|
||||||
is the full source length. A token is embedded whenever more content remains
|
returned chunk is cut (overflow) or, with ``emit_more_after`` (default),
|
||||||
after the returned chunk, so ``get_truncated`` chains through truncations
|
whenever more source content remains after the chunk — so ``get_truncated``
|
||||||
and each chunk may itself carry a fresh token. The token advances by the
|
chains through truncations. The token advances by the length actually
|
||||||
length actually returned (no content is skipped), and the marker is budgeted
|
returned (no content is skipped), and the marker is budgeted so the total
|
||||||
so the total returned length stays near ``max_chars`` (the agent loop's own
|
returned length stays near ``max_chars`` (the agent loop's own cap never
|
||||||
cap never re-cuts it and never strips the token).
|
re-cuts it and never strips the token). ``omitted`` in the marker counts
|
||||||
|
source characters not yet delivered. When nothing was cut and
|
||||||
|
(``emit_more_after=False`` or the source ends), ``text`` is returned
|
||||||
|
unchanged with no marker.
|
||||||
"""
|
"""
|
||||||
if max_chars < 1:
|
if max_chars < 1:
|
||||||
return text, None
|
return text, None
|
||||||
if len(text) > max_chars:
|
if len(text) > max_chars:
|
||||||
omitted = len(text) - max_chars
|
|
||||||
# First pass sizes the marker with an offset+max_chars token; re-encode
|
# First pass sizes the marker with an offset+max_chars token; re-encode
|
||||||
# with the real cut so the cursor lands exactly where content stops.
|
# with the real cut so the cursor lands exactly where content stops.
|
||||||
first = TruncateToken(kind=kind, location=location, offset=offset + max_chars, limit=limit)
|
first = TruncateToken(kind=kind, location=location, offset=offset + max_chars, limit=limit)
|
||||||
cut = max_chars - len(_truncated_marker(omitted, first))
|
omitted = total_len - (offset + max_chars)
|
||||||
|
cut = max_chars - len(truncation_marker(max_chars, omitted, first))
|
||||||
if cut < 1:
|
if cut < 1:
|
||||||
return text[:max_chars] + _truncated_marker(omitted, first), first
|
return text[:max_chars] + truncation_marker(max_chars, omitted, first), first
|
||||||
token = TruncateToken(kind=kind, location=location, offset=offset + cut, limit=limit)
|
token = TruncateToken(kind=kind, location=location, offset=offset + cut, limit=limit)
|
||||||
return text[:cut] + _truncated_marker(omitted, token), token
|
return text[:cut] + truncation_marker(max_chars, omitted, token), token
|
||||||
|
if not emit_more_after:
|
||||||
|
return text, None
|
||||||
more_after = offset + len(text) < total_len
|
more_after = offset + len(text) < total_len
|
||||||
if not more_after:
|
if not more_after:
|
||||||
return text, None
|
return text, None
|
||||||
|
omitted = total_len - (offset + len(text))
|
||||||
token = TruncateToken(kind=kind, location=location, offset=offset + len(text), limit=limit)
|
token = TruncateToken(kind=kind, location=location, offset=offset + len(text), limit=limit)
|
||||||
marker = (
|
return text + truncation_marker(max_chars, omitted, token), token
|
||||||
f"\n...[more content available; use get_truncated with the token] "
|
|
||||||
f"[TRUNCATE_TOKEN: {encode_truncate_token(token)}]"
|
|
||||||
|
def truncate_generic(text: str, max_chars: int) -> str:
|
||||||
|
"""Cap arbitrary tool output; embed a memory truncate token (chainable).
|
||||||
|
|
||||||
|
The full remainder is kept in the in-memory store so ``get_truncated`` can
|
||||||
|
resume it. Returns ``text`` unchanged (no marker) when it fits within
|
||||||
|
``max_chars``.
|
||||||
|
"""
|
||||||
|
if max_chars < 1 or len(text) <= max_chars:
|
||||||
|
return text
|
||||||
|
key = store_remainder(text)
|
||||||
|
bounded, _ = truncate_with_token(
|
||||||
|
text,
|
||||||
|
max_chars,
|
||||||
|
kind="memory",
|
||||||
|
location=key,
|
||||||
|
offset=0,
|
||||||
|
limit=max_chars,
|
||||||
|
total_len=len(text),
|
||||||
)
|
)
|
||||||
return text + marker, token
|
return bounded
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ def _backend_or_error() -> VcsBackend | str:
|
|||||||
return backend
|
return backend
|
||||||
|
|
||||||
|
|
||||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
|
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
|
||||||
async def vcs_kind() -> str:
|
async def vcs_kind() -> str:
|
||||||
"""Return the detected VCS kind under the workspace (e.g. ``git``), or an error."""
|
"""Return the detected VCS kind under the workspace (e.g. ``git``), or an error."""
|
||||||
backend = _backend_or_error()
|
backend = _backend_or_error()
|
||||||
@@ -31,7 +31,7 @@ async def vcs_kind() -> str:
|
|||||||
return backend.kind
|
return backend.kind
|
||||||
|
|
||||||
|
|
||||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
|
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
|
||||||
async def vcs_status() -> str:
|
async def vcs_status() -> str:
|
||||||
"""Show working-tree status for the detected VCS (read-only)."""
|
"""Show working-tree status for the detected VCS (read-only)."""
|
||||||
backend = _backend_or_error()
|
backend = _backend_or_error()
|
||||||
@@ -40,7 +40,7 @@ async def vcs_status() -> str:
|
|||||||
return backend.status()
|
return backend.status()
|
||||||
|
|
||||||
|
|
||||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
|
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
|
||||||
async def vcs_diff(path: str = "", *, staged: bool = False) -> str:
|
async def vcs_diff(path: str = "", *, staged: bool = False) -> str:
|
||||||
"""Show a unified diff for the detected VCS (read-only).
|
"""Show a unified diff for the detected VCS (read-only).
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ async def vcs_diff(path: str = "", *, staged: bool = False) -> str:
|
|||||||
return backend.diff(staged=staged, path=rel)
|
return backend.diff(staged=staged, path=rel)
|
||||||
|
|
||||||
|
|
||||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
|
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
|
||||||
async def vcs_log(limit: int = 10) -> str:
|
async def vcs_log(limit: int = 10) -> str:
|
||||||
"""Show recent commits for the detected VCS (read-only)."""
|
"""Show recent commits for the detected VCS (read-only)."""
|
||||||
if limit < 1:
|
if limit < 1:
|
||||||
@@ -65,7 +65,7 @@ async def vcs_log(limit: int = 10) -> str:
|
|||||||
return backend.log(limit=limit)
|
return backend.log(limit=limit)
|
||||||
|
|
||||||
|
|
||||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
|
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
|
||||||
async def vcs_branch() -> str:
|
async def vcs_branch() -> str:
|
||||||
"""Show the current branch / named head for the detected VCS (read-only)."""
|
"""Show the current branch / named head for the detected VCS (read-only)."""
|
||||||
backend = _backend_or_error()
|
backend = _backend_or_error()
|
||||||
|
|||||||
@@ -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([])
|
||||||
|
|||||||
@@ -209,3 +209,91 @@ async def test_run_aside_tools_clones_registry_with_fresh_session() -> None:
|
|||||||
pass
|
pass
|
||||||
assert hits == ["ok"]
|
assert hits == ["ok"]
|
||||||
assert "aside" not in main_session.extras
|
assert "aside" not in main_session.extras
|
||||||
|
|
||||||
|
|
||||||
|
async def test_run_aside_read_only_clone_excludes_write_tools() -> None:
|
||||||
|
"""A read-only-cloned registry passed to run_aside exposes no write tools."""
|
||||||
|
hits: list[str] = []
|
||||||
|
|
||||||
|
@tool(tags=ToolTag.LOCAL | ToolTag.READ_ONLY, register=False)
|
||||||
|
async def peek() -> str:
|
||||||
|
hits.append("peek")
|
||||||
|
return "peeked"
|
||||||
|
|
||||||
|
@tool(tags=ToolTag.LOCAL, register=False)
|
||||||
|
async def write_thing() -> str:
|
||||||
|
hits.append("write")
|
||||||
|
return "wrote"
|
||||||
|
|
||||||
|
registry = ToolRegistry([peek, write_thing], auto_bind_state=True)
|
||||||
|
|
||||||
|
def make_client(first_tool: str | None) -> Any:
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
class _Client:
|
||||||
|
@overload
|
||||||
|
async def chat_completions(
|
||||||
|
self, param: ChatCompletionsParam, *, stream: Literal[False] = False
|
||||||
|
) -> ChatCompletionResponse: ...
|
||||||
|
|
||||||
|
@overload
|
||||||
|
async def chat_completions(
|
||||||
|
self, param: ChatCompletionsParam, *, stream: Literal[True]
|
||||||
|
) -> AsyncIterator[ChatCompletionChunk]: ...
|
||||||
|
|
||||||
|
async def chat_completions(
|
||||||
|
self, param: ChatCompletionsParam, *, stream: bool = False
|
||||||
|
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
|
||||||
|
del stream, param
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
if calls == 1 and first_tool is not None:
|
||||||
|
from plyngent.lmproto.openai_compatible.model import (
|
||||||
|
AssistantFunctionTool,
|
||||||
|
AssistantFunctionToolCall,
|
||||||
|
)
|
||||||
|
|
||||||
|
message = AssistantChatMessage(
|
||||||
|
content="",
|
||||||
|
tool_calls=[
|
||||||
|
AssistantFunctionToolCall(
|
||||||
|
id="c1",
|
||||||
|
function=AssistantFunctionTool(name=first_tool, arguments="{}"),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
finish = "tool_calls"
|
||||||
|
else:
|
||||||
|
message = AssistantChatMessage(content="done")
|
||||||
|
finish = "stop"
|
||||||
|
return ChatCompletionResponse(
|
||||||
|
id="1",
|
||||||
|
object="chat.completion",
|
||||||
|
created=0,
|
||||||
|
model="m",
|
||||||
|
choices=[
|
||||||
|
ChatCompletionChoice(
|
||||||
|
index=0,
|
||||||
|
message=message,
|
||||||
|
logprobs={},
|
||||||
|
finish_reason=finish,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
system_fingerprint="",
|
||||||
|
usage={},
|
||||||
|
)
|
||||||
|
|
||||||
|
return _Client()
|
||||||
|
|
||||||
|
# Read-only tool is available to the side turn.
|
||||||
|
agent_peek = ChatAgent(make_client("peek"), model="m", tools=registry, stream=False)
|
||||||
|
async for _ in agent_peek.run_aside("go", tools=registry.clone(read_only_only=True)):
|
||||||
|
pass
|
||||||
|
assert hits == ["peek"]
|
||||||
|
|
||||||
|
# Write tool is not in the read-only clone: the call errors, never runs.
|
||||||
|
hits.clear()
|
||||||
|
agent_write = ChatAgent(make_client("write_thing"), model="m", tools=registry, stream=False)
|
||||||
|
async for _ in agent_write.run_aside("go", tools=registry.clone(read_only_only=True)):
|
||||||
|
pass
|
||||||
|
assert hits == []
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ async def test_flush_markdown_on_source_change(
|
|||||||
|
|
||||||
call = AssistantFunctionToolCall(
|
call = AssistantFunctionToolCall(
|
||||||
id="1",
|
id="1",
|
||||||
function=AssistantFunctionTool(name="run_argv", arguments='{"argv": ["echo", "x"]}'),
|
function=AssistantFunctionTool(name="vcs_diff", arguments='{"path": ""}'),
|
||||||
)
|
)
|
||||||
await render_events(
|
await render_events(
|
||||||
_aiter(
|
_aiter(
|
||||||
@@ -103,6 +103,14 @@ async def test_pretty_read_file_done(capsys: pytest.CaptureFixture[str]) -> None
|
|||||||
assert "[tool ok]" not in out
|
assert "[tool ok]" not in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pretty_read_file_numbered_no_range(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
"""Numbered reads carry no L header, so the summary falls back to ``(done)``."""
|
||||||
|
content = " 1|one\n 2|two\n"
|
||||||
|
await render_events(_aiter([_read_call('{"path": "a.txt", "with_lineno": true}'), _result(content)]))
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "* Read 'a.txt' (done)" in out
|
||||||
|
|
||||||
|
|
||||||
async def test_pretty_read_file_statuses(capsys: pytest.CaptureFixture[str]) -> None:
|
async def test_pretty_read_file_statuses(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
cases = [
|
cases = [
|
||||||
("error: file not found: a.txt", "(file not found)"),
|
("error: file not found: a.txt", "(file not found)"),
|
||||||
@@ -116,6 +124,44 @@ async def test_pretty_read_file_statuses(capsys: pytest.CaptureFixture[str]) ->
|
|||||||
assert f"* Read 'a.txt' {status}" in out, f"status {status} not rendered for {content!r}"
|
assert f"* Read 'a.txt' {status}" in out, f"status {status} not rendered for {content!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def _tree_call(args_json: str) -> ToolCallEvent:
|
||||||
|
return ToolCallEvent(
|
||||||
|
tool_call=AssistantFunctionToolCall(
|
||||||
|
id="1",
|
||||||
|
function=AssistantFunctionTool(name="tree", arguments=args_json),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pretty_tree_markdown(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
result = "- src/\n - main.py\n - nested/\n - deep.txt\n- a.txt\n"
|
||||||
|
await render_events(_aiter([_tree_call('{"path": "."}'), _result(result)]))
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "* Tree '.' (2 dirs, 3 files, depth 3)" in out
|
||||||
|
assert "[tool]" not in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pretty_tree_flat_omits_depth(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
result = "src/\nsrc/main.py\na.txt\n… (5 more paths not shown)"
|
||||||
|
await render_events(_aiter([_tree_call('{"path": ".", "format": "flat"}'), _result(result)]))
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "* Tree '.' (1 dirs, 2 files)" in out
|
||||||
|
assert "depth" not in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pretty_tree_decorated(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
result = "src/\n├── main.py\n└── nested/\n └── deep.txt\n"
|
||||||
|
await render_events(_aiter([_tree_call('{"path": "src", "format": "decorated"}'), _result(result)]))
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "* Tree 'src' (1 dirs, 2 files, depth 2)" in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pretty_tree_error(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
await render_events(_aiter([_tree_call('{"path": "nope"}'), _result("error: not a directory: nope")]))
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "* Tree 'nope' (error)" in out
|
||||||
|
|
||||||
|
|
||||||
async def test_pretty_todo_push(capsys: pytest.CaptureFixture[str]) -> None:
|
async def test_pretty_todo_push(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
call = ToolCallEvent(
|
call = ToolCallEvent(
|
||||||
tool_call=AssistantFunctionToolCall(
|
tool_call=AssistantFunctionToolCall(
|
||||||
@@ -140,12 +186,12 @@ async def test_non_pretty_tool_keeps_old_style(capsys: pytest.CaptureFixture[str
|
|||||||
call = ToolCallEvent(
|
call = ToolCallEvent(
|
||||||
tool_call=AssistantFunctionToolCall(
|
tool_call=AssistantFunctionToolCall(
|
||||||
id="3",
|
id="3",
|
||||||
function=AssistantFunctionTool(name="run_argv", arguments='{"argv": ["ls"]}'),
|
function=AssistantFunctionTool(name="vcs_diff", arguments='{"path": ""}'),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await render_events(_aiter([call, _result("exit_code=0")]))
|
await render_events(_aiter([call, _result("diff --git a/x b/x")]))
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert "[tool] run_argv(" in out
|
assert "[tool] vcs_diff(" in out
|
||||||
assert "[tool ok]" in out
|
assert "[tool ok]" in out
|
||||||
|
|
||||||
|
|
||||||
@@ -210,3 +256,305 @@ def test_markdown_render_available_respects_plain_env(monkeypatch: pytest.Monkey
|
|||||||
monkeypatch.setenv("PLYNGENT_PLAIN", "1")
|
monkeypatch.setenv("PLYNGENT_PLAIN", "1")
|
||||||
assert markdown_render_available() is False
|
assert markdown_render_available() is False
|
||||||
monkeypatch.delenv("PLYNGENT_PLAIN", raising=False)
|
monkeypatch.delenv("PLYNGENT_PLAIN", raising=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pretty_verbose_shows_full_result(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
"""Verbose mode prints the full tool result even for pretty tools."""
|
||||||
|
await render_events(
|
||||||
|
_aiter([_read_call('{"path": "a.txt"}'), _result("L1-4\none\ntwo\n")]),
|
||||||
|
verbose=True,
|
||||||
|
)
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "[tool ok]" in out
|
||||||
|
assert "L1-4\none\ntwo" in out
|
||||||
|
assert "* Read" not in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pretty_plain_output_has_no_ansi(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
"""Pretty summaries are plain text when stdout is not a TTY."""
|
||||||
|
await render_events(_aiter([_read_call('{"path": "a.txt"}'), _result("L1-4\none\n")]))
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "\x1b[" not in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_tool_result_preview_first_line_and_count(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
"""Non-verbose preview shows the first line plus a line-count tail."""
|
||||||
|
set_markdown_enabled(False)
|
||||||
|
content = "first line\nsecond\nthird"
|
||||||
|
await render_events(_aiter([ToolResultEvent(message=ToolChatMessage(content=content, tool_call_id="1"))]))
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "[tool ok] first line (…2 more lines)" in out
|
||||||
|
|
||||||
|
await render_events(_aiter([ToolResultEvent(message=ToolChatMessage(content="only line", tool_call_id="2"))]))
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "[tool ok] only line" in out
|
||||||
|
assert "more" not in out
|
||||||
|
|
||||||
|
await render_events(_aiter([ToolResultEvent(message=ToolChatMessage(content="", tool_call_id="3"))]))
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "[tool ok] (no output)" in out
|
||||||
|
set_markdown_enabled(True)
|
||||||
|
|
||||||
|
|
||||||
|
def _pretty_call(name: str, args_json: str) -> ToolCallEvent:
|
||||||
|
return ToolCallEvent(
|
||||||
|
tool_call=AssistantFunctionToolCall(
|
||||||
|
id="1",
|
||||||
|
function=AssistantFunctionTool(name=name, arguments=args_json),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pretty_listdir(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
result = "dir\tsrc\nfile\tREADME.md\nfile\tpyproject.toml\n"
|
||||||
|
await render_events(_aiter([_pretty_call("listdir", '{"path": "src"}'), _result(result)]))
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "* List 'src' (1 dirs, 2 files)" in out
|
||||||
|
assert "[tool]" not in out
|
||||||
|
assert "[tool ok]" not in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pretty_listdir_empty(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
await render_events(_aiter([_pretty_call("listdir", '{"path": "src"}'), _result("(empty)")]))
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "* List 'src' (empty)" in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pretty_listdir_error(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
await render_events(_aiter([_pretty_call("listdir", '{"path": "nope"}'), _result("error: not a directory: nope")]))
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "* List 'nope' (error: not a directory: nope)" in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pretty_glob(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
result = "src/a.py\nsrc/b.py\n"
|
||||||
|
await render_events(_aiter([_pretty_call("glob_paths", '{"pattern": "**/*.py"}'), _result(result)]))
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "* Glob '**/*.py' in '.' (2 paths)" in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pretty_glob_no_matches(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
await render_events(_aiter([_pretty_call("glob_paths", '{"pattern": "**/*.rs"}'), _result("(no matches)")]))
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "* Glob '**/*.rs' in '.' (no matches)" in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pretty_glob_truncated(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
result = "a\nb\n...[truncated at 200 matches]"
|
||||||
|
await render_events(_aiter([_pretty_call("glob_paths", '{"pattern": "*"}'), _result(result)]))
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "* Glob '*' in '.' (2 paths)" in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pretty_grep(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
result = "src/a.py:3: x = 1\nsrc/a.py:9: x = 2\nsrc/b.py:1: y = 3\n"
|
||||||
|
await render_events(_aiter([_pretty_call("grep_files", '{"pattern": "x"}'), _result(result)]))
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "* Grep 'x' in '.' (3 matches in 2 files)" in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pretty_grep_no_matches(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
await render_events(_aiter([_pretty_call("grep_files", '{"pattern": "zzz"}'), _result("(no matches)")]))
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "* Grep 'zzz' in '.' (no matches)" in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pretty_run_argv_success(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
result = (
|
||||||
|
"exit_code=0\ntimed_out=false\ncwd=.\ncmd=git status --short\n--- stdout ---\n M src/foo.py\n--- stderr ---\n"
|
||||||
|
)
|
||||||
|
await render_events(_aiter([_pretty_call("run_argv", '{"argv": ["git", "status", "--short"]}'), _result(result)]))
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "* Run $ git status --short (exit code 0)" in out
|
||||||
|
assert "[tool]" not in out
|
||||||
|
assert "[tool ok]" not in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pretty_run_argv_failure(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
result = (
|
||||||
|
"exit_code=1\ntimed_out=false\ncwd=.\ncmd=ls /nope\n--- stdout ---\n--- stderr ---\nls: cannot access '/nope'\n"
|
||||||
|
)
|
||||||
|
await render_events(_aiter([_pretty_call("run_argv", '{"argv": ["ls", "/nope"]}'), _result(result)]))
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "* Run $ ls /nope (exit code 1)" in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pretty_run_argv_timed_out(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
result = "exit_code=\ntimed_out=true\ncwd=.\ncmd=sleep 10\n--- stdout ---\n--- stderr ---\n"
|
||||||
|
await render_events(_aiter([_pretty_call("run_argv", '{"argv": ["sleep", "10"]}'), _result(result)]))
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "* Run $ sleep 10 (timed out)" in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pretty_run_argv_error(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
await render_events(
|
||||||
|
_aiter([_pretty_call("run_argv", '{"argv": ["nope"]}'), _result("error: executable not found: 'nope'")])
|
||||||
|
)
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "* Run $ nope (error: executable not found: 'nope')" in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pretty_run_argv_batch_stopped(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
result = (
|
||||||
|
"steps=3 ran=2 stop_on_error=true stopped_early=true\n"
|
||||||
|
"--- step 1 ---\nexit_code=1\ntimed_out=false\ncwd=.\ncmd=git status\n"
|
||||||
|
"pipe_out=false\nmix_stderr=false\n--- stdout ---\n--- stderr ---\n"
|
||||||
|
"--- step 2 ---\nexit_code=0\ntimed_out=false\ncwd=.\ncmd=git diff\n"
|
||||||
|
"pipe_out=false\nmix_stderr=false\n--- stdout ---\n--- stderr ---\n"
|
||||||
|
"--- summary ---\nlast_exit=1\n"
|
||||||
|
)
|
||||||
|
await render_events(_aiter([_pretty_call("run_argv_batch", '{"steps": []}'), _result(result)]))
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "* Run batch (stopped early)" in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pretty_run_argv_batch_complete(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
result = (
|
||||||
|
"steps=2 ran=2 stop_on_error=true stopped_early=false\n"
|
||||||
|
"--- step 1 ---\nexit_code=0\n--- stdout ---\n--- stderr ---\n"
|
||||||
|
"--- step 2 ---\nexit_code=0\n--- stdout ---\n--- stderr ---\n"
|
||||||
|
"--- summary ---\nlast_exit=0\n"
|
||||||
|
)
|
||||||
|
await render_events(_aiter([_pretty_call("run_argv_batch", '{"steps": []}'), _result(result)]))
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "* Run batch (done)" in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pretty_fetch(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
result = (
|
||||||
|
"status=200\nmethod=GET\nfinal_url=https://example.com\ncontent_type=application/json\n"
|
||||||
|
"body_kind=json\nbytes=1229\ntruncated=false\nredirects=0\nsecurity=public\n--- body ---\n{...}\n"
|
||||||
|
)
|
||||||
|
await render_events(_aiter([_pretty_call("fetch", '{"url": "https://example.com"}'), _result(result)]))
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "* Fetch GET https://example.com (200)" in out
|
||||||
|
assert "[tool]" not in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pretty_fetch_error_status(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
result = (
|
||||||
|
"status=404\nmethod=GET\nfinal_url=https://example.com/nope\ncontent_type=text/html\n"
|
||||||
|
"body_kind=html\nbytes=45\ntruncated=false\nredirects=0\nsecurity=public\n--- body ---\nNot found\n"
|
||||||
|
)
|
||||||
|
await render_events(_aiter([_pretty_call("fetch", '{"url": "https://example.com/nope"}'), _result(result)]))
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "* Fetch GET https://example.com/nope (404)" in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pretty_fetch_truncated(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
result = (
|
||||||
|
"status=200\nmethod=GET\nfinal_url=https://example.com\ncontent_type=text/plain\n"
|
||||||
|
"body_kind=text\nbytes=50000\ntruncated=true\nredirects=0\nsecurity=public\n--- body ---\n..."
|
||||||
|
)
|
||||||
|
await render_events(_aiter([_pretty_call("fetch", '{"url": "https://example.com"}'), _result(result)]))
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "* Fetch GET https://example.com (200)" in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pretty_fetch_error(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
await render_events(
|
||||||
|
_aiter([_pretty_call("fetch", '{"url": "https://example.com"}'), _result("error: fetch failed: boom")])
|
||||||
|
)
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "* Fetch GET https://example.com (error: fetch failed: boom)" in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pretty_mutators(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
cases = [
|
||||||
|
("write_file", '{"path": "src/x.py"}', "wrote 120 characters to src/x.py", "* Write 'src/x.py' (120 chars)"),
|
||||||
|
(
|
||||||
|
"edit_replace",
|
||||||
|
'{"path": "src/a.py"}',
|
||||||
|
"replaced 1 occurrence in src/a.py ('x' → 'y')",
|
||||||
|
"* Edit 'src/a.py' (done)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"edit_lineno",
|
||||||
|
'{"path": "src/a.py"}',
|
||||||
|
"replaced lines 3-5 (3 lines; first: 'pass') with 2 lines in src/a.py",
|
||||||
|
"* Edit 'src/a.py' (done)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"copy_path",
|
||||||
|
'{"src": "a.txt", "dst": "b.txt"}',
|
||||||
|
"copied file a.txt -> b.txt",
|
||||||
|
"* Copy 'a.txt' → 'b.txt' (done)",
|
||||||
|
),
|
||||||
|
("move_path", '{"src": "src", "dst": "dst"}', "moved directory src -> dst", "* Move 'src' → 'dst' (done)"),
|
||||||
|
("delete_path", '{"path": "tmp/x.txt"}', "deleted file tmp/x.txt", "* Delete 'tmp/x.txt' (done)"),
|
||||||
|
]
|
||||||
|
for name, args, content, expected in cases:
|
||||||
|
await render_events(_aiter([_pretty_call(name, args), _result(content)]))
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert expected in out, f"{name}: expected {expected!r} in {out!r}"
|
||||||
|
assert "[tool ok]" not in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pretty_mutator_error(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
await render_events(
|
||||||
|
_aiter([_pretty_call("copy_path", '{"src": "x", "dst": "y"}'), _result("error: source does not exist: x")])
|
||||||
|
)
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "* Copy 'x' → 'y' (error: source does not exist: x)" in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pretty_vcs_status_done(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
await render_events(_aiter([_pretty_call("vcs_status", "{}"), _result("(clean)")]))
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "* VCS Status (done)" in out
|
||||||
|
assert "[tool]" not in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pretty_vcs_status_error(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
await render_events(
|
||||||
|
_aiter(
|
||||||
|
[
|
||||||
|
_pretty_call("vcs_status", "{}"),
|
||||||
|
_result("error: no supported VCS detected under workspace (currently: git)"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "* VCS Status (error: no supported VCS detected under workspace (currently: git))" in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pretty_vcs_log_count(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
result = (
|
||||||
|
"5436457 2026-08-15 worldmozara core/tools: allow edit_lineno append\nabc1234 2026-08-14 worldmozara cli: fix\n"
|
||||||
|
)
|
||||||
|
await render_events(_aiter([_pretty_call("vcs_log", '{"limit": 2}'), _result(result)]))
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "* VCS Log (2 commits)" in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pretty_vcs_log_single(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
content = "abc1234 2026-08-14 worldmozara x\n"
|
||||||
|
await render_events(_aiter([_pretty_call("vcs_log", '{"limit": 1}'), _result(content)]))
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "* VCS Log (1 commit)" in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pretty_vcs_log_no_commits(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
await render_events(_aiter([_pretty_call("vcs_log", "{}"), _result("(no commits)")]))
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "* VCS Log (no commits)" in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pretty_wait_waited(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
await render_events(_aiter([_pretty_call("wait", '{"duration": 5}'), _result("waited 5s")]))
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "* Wait (5s)" in out
|
||||||
|
assert "[tool]" not in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pretty_wait_disturbed(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
await render_events(_aiter([_pretty_call("wait", '{"duration": 30}'), _result("disturbed by user: ship it")]))
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "* Wait (disturbed)" in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pretty_wait_error(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
content = "error: duration must not be negative"
|
||||||
|
await render_events(_aiter([_pretty_call("wait", '{"duration": -1}'), _result(content)]))
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "* Wait (error: duration must not be negative)" in out
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
"""CLI slash ``/mcp`` command and MCP wiring in ``ReplState``."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import tomlkit
|
||||||
|
|
||||||
|
from plyngent.cli.slash import handle_slash
|
||||||
|
from plyngent.cli.state import ReplState
|
||||||
|
from plyngent.config.models import DatabaseConfig, McpConfig, McpServerConfig, OpenAIProvider
|
||||||
|
from plyngent.config.store import ConfigStore
|
||||||
|
from plyngent.memory import MemoryStore
|
||||||
|
from plyngent.runtime.mcp_client import McpManager
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
|
_FAKE_SERVER = Path(__file__).parent.parent / "test_runtime" / "mcp_fake_server.py"
|
||||||
|
|
||||||
|
|
||||||
|
def _server_config(**overrides: object) -> McpServerConfig:
|
||||||
|
base: dict[str, object] = {
|
||||||
|
"command": sys.executable,
|
||||||
|
"args": [str(_FAKE_SERVER)],
|
||||||
|
"timeout": 5.0,
|
||||||
|
}
|
||||||
|
return McpServerConfig(**{**base, **overrides}) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def state(tmp_path: Path) -> AsyncIterator[ReplState]:
|
||||||
|
memory = await MemoryStore.open(DatabaseConfig())
|
||||||
|
provider = OpenAIProvider(access_key_or_token="sk-test")
|
||||||
|
config = ConfigStore(path=tmp_path / "plyngent.toml", document=tomlkit.document())
|
||||||
|
config.providers = {"local": provider}
|
||||||
|
st = ReplState(
|
||||||
|
config=config,
|
||||||
|
memory=memory,
|
||||||
|
workspace=tmp_path,
|
||||||
|
provider_name="local",
|
||||||
|
provider=provider,
|
||||||
|
model="gpt-test",
|
||||||
|
tools_enabled=False,
|
||||||
|
)
|
||||||
|
await st.new_session("t")
|
||||||
|
yield st
|
||||||
|
await memory.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_slash_mcp_list_without_manager(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
assert await handle_slash(state, "/mcp") is True
|
||||||
|
assert "no servers configured" in capsys.readouterr().out
|
||||||
|
assert await handle_slash(state, "/mcp list") is True
|
||||||
|
assert "no servers configured" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_slash_mcp_list_statuses(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
manager = McpManager(McpConfig(servers={"fake": _server_config()}, disable=[]))
|
||||||
|
await manager.ensure_started()
|
||||||
|
state.mcp_manager = manager
|
||||||
|
try:
|
||||||
|
assert await handle_slash(state, "/mcp list") is True
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "fake" in out
|
||||||
|
assert "connected" in out
|
||||||
|
assert "tools=3" in out
|
||||||
|
finally:
|
||||||
|
await manager.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_slash_mcp_reconnect_connects_new_config(
|
||||||
|
state: ReplState,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
# The fixture's config file does not exist yet; write one with an MCP server.
|
||||||
|
payload = (
|
||||||
|
"[mcp.servers.fake]\n"
|
||||||
|
f"command = {json.dumps(sys.executable)}\n"
|
||||||
|
f"args = [{json.dumps(str(_FAKE_SERVER))}]\n"
|
||||||
|
"timeout = 5.0\n"
|
||||||
|
)
|
||||||
|
_ = (tmp_path / "plyngent.toml").write_text(payload, encoding="utf-8")
|
||||||
|
assert await handle_slash(state, "/mcp reconnect") is True
|
||||||
|
try:
|
||||||
|
assert state.mcp_manager is not None
|
||||||
|
statuses = state.mcp_manager.statuses()
|
||||||
|
assert [name for name, _, _ in statuses] == ["fake"]
|
||||||
|
assert statuses[0][1] == "connected"
|
||||||
|
assert statuses[0][2] == 3
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "tools=off" in out
|
||||||
|
assert "fake" in out
|
||||||
|
finally:
|
||||||
|
if state.mcp_manager is not None:
|
||||||
|
await state.mcp_manager.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_mcp_tools_registered_on_tools_rebuild(state: ReplState) -> None:
|
||||||
|
manager = McpManager(McpConfig(servers={"fake": _server_config()}, disable=[]))
|
||||||
|
await manager.ensure_started()
|
||||||
|
state.mcp_manager = manager
|
||||||
|
try:
|
||||||
|
from plyngent.tools.catalog import catalog_scope
|
||||||
|
|
||||||
|
state.tools_enabled = True
|
||||||
|
with catalog_scope(empty=True):
|
||||||
|
state.rebuild_client()
|
||||||
|
registry = state.agent.tools
|
||||||
|
assert registry is not None
|
||||||
|
assert registry.get("mcp__fake__echo") is not None
|
||||||
|
assert registry.get("mcp__fake__fail") is not None
|
||||||
|
# Handler proxies to the live server subprocess.
|
||||||
|
out = await registry.execute("mcp__fake__echo", '{"text": "hi"}')
|
||||||
|
assert out == "echo: hi"
|
||||||
|
finally:
|
||||||
|
await manager.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_slash_mcp_list_shows_instructions(
|
||||||
|
state: ReplState,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
guidance = "Prefer `echo` for repeating text back."
|
||||||
|
manager = McpManager(
|
||||||
|
McpConfig(
|
||||||
|
servers={
|
||||||
|
"fake": _server_config(env={"PLYNGENT_MCP_FAKE_INSTRUCTIONS": guidance}),
|
||||||
|
},
|
||||||
|
disable=[],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await manager.ensure_started()
|
||||||
|
state.mcp_manager = manager
|
||||||
|
try:
|
||||||
|
assert await handle_slash(state, "/mcp list") is True
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert f"instructions ({len(guidance)} chars)" in out
|
||||||
|
assert guidance in out
|
||||||
|
finally:
|
||||||
|
await manager.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def _make_tools_state(
|
||||||
|
tmp_path: Path,
|
||||||
|
*,
|
||||||
|
agent_toml: str,
|
||||||
|
manager: McpManager,
|
||||||
|
) -> tuple[ReplState, MemoryStore]:
|
||||||
|
"""ReplState with tools on + a started MCP manager (agent built from TOML)."""
|
||||||
|
memory = await MemoryStore.open(DatabaseConfig())
|
||||||
|
provider = OpenAIProvider(access_key_or_token="sk-test")
|
||||||
|
document = tomlkit.parse(agent_toml) if agent_toml else tomlkit.document()
|
||||||
|
config = ConfigStore(path=tmp_path / "plyngent.toml", document=document)
|
||||||
|
config.providers = {"local": provider}
|
||||||
|
st = ReplState(
|
||||||
|
config=config,
|
||||||
|
memory=memory,
|
||||||
|
workspace=tmp_path,
|
||||||
|
provider_name="local",
|
||||||
|
provider=provider,
|
||||||
|
model="gpt-test",
|
||||||
|
tools_enabled=True,
|
||||||
|
mcp_manager=manager,
|
||||||
|
)
|
||||||
|
return st, memory
|
||||||
|
|
||||||
|
|
||||||
|
async def test_mcp_instructions_folded_into_agent_system_prompt(tmp_path: Path) -> None:
|
||||||
|
from plyngent.tools.catalog import catalog_scope
|
||||||
|
|
||||||
|
guidance = "Prefer `echo` for repeating text back to the user."
|
||||||
|
manager = McpManager(
|
||||||
|
McpConfig(
|
||||||
|
servers={
|
||||||
|
"fake": _server_config(env={"PLYNGENT_MCP_FAKE_INSTRUCTIONS": guidance}),
|
||||||
|
},
|
||||||
|
disable=[],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await manager.ensure_started()
|
||||||
|
try:
|
||||||
|
with catalog_scope(empty=True):
|
||||||
|
state, memory = await _make_tools_state(tmp_path, agent_toml="", manager=manager)
|
||||||
|
try:
|
||||||
|
prompt = state.agent.system_prompt
|
||||||
|
assert prompt is not None
|
||||||
|
assert "MCP server 'fake' instructions:" in prompt
|
||||||
|
assert guidance in prompt
|
||||||
|
finally:
|
||||||
|
await memory.close()
|
||||||
|
finally:
|
||||||
|
await manager.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_mcp_instructions_flag_off_skips_server_text(tmp_path: Path) -> None:
|
||||||
|
from plyngent.tools.catalog import catalog_scope
|
||||||
|
|
||||||
|
guidance = "Prefer `echo` for repeating text back to the user."
|
||||||
|
manager = McpManager(
|
||||||
|
McpConfig(
|
||||||
|
servers={
|
||||||
|
"fake": _server_config(env={"PLYNGENT_MCP_FAKE_INSTRUCTIONS": guidance}),
|
||||||
|
},
|
||||||
|
disable=[],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await manager.ensure_started()
|
||||||
|
try:
|
||||||
|
with catalog_scope(empty=True):
|
||||||
|
state, memory = await _make_tools_state(
|
||||||
|
tmp_path,
|
||||||
|
agent_toml="[agent]\nmcp_instructions = false\n",
|
||||||
|
manager=manager,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
prompt = state.agent.system_prompt
|
||||||
|
assert prompt is not None
|
||||||
|
assert guidance not in prompt
|
||||||
|
assert "MCP server" not in prompt
|
||||||
|
assert "### Workspace" in prompt # host tool playbook still composed
|
||||||
|
finally:
|
||||||
|
await memory.close()
|
||||||
|
finally:
|
||||||
|
await manager.aclose()
|
||||||
@@ -593,3 +593,148 @@ async def test_repl_ctrl_c_from_turn_returns_to_prompt(
|
|||||||
|
|
||||||
await run_repl(state) # must return normally (EOF), never raise KeyboardInterrupt
|
await run_repl(state) # must return normally (EOF), never raise KeyboardInterrupt
|
||||||
assert calls["reads"] == 2
|
assert calls["reads"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_repl_second_ctrl_c_after_cancel_returns_to_prompt(
|
||||||
|
state: ReplState,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
"""A second Ctrl+C landing outside the turn call (e.g. while echoing the
|
||||||
|
next user input, right after a cancel) must return to the prompt, not exit."""
|
||||||
|
from plyngent.cli import repl as repl_mod
|
||||||
|
from plyngent.cli.repl import run_repl
|
||||||
|
|
||||||
|
reads = ["first", "second", "third"]
|
||||||
|
calls: dict[str, int] = {"reads": 0, "echoes": 0}
|
||||||
|
|
||||||
|
def fake_read_entry() -> str:
|
||||||
|
calls["reads"] += 1
|
||||||
|
if calls["reads"] > len(reads):
|
||||||
|
raise EOFError
|
||||||
|
return reads[calls["reads"] - 1]
|
||||||
|
|
||||||
|
async def cancel_turn(_agent: object, _text: str) -> bool:
|
||||||
|
raise KeyboardInterrupt # first Ctrl+C: cancels every turn
|
||||||
|
|
||||||
|
def echo_user(text: str) -> None:
|
||||||
|
calls["echoes"] += 1
|
||||||
|
if calls["echoes"] == 2:
|
||||||
|
# second Ctrl+C lands while echoing user text — outside the turn
|
||||||
|
# call, previously unguarded (used to exit the whole REPL).
|
||||||
|
raise KeyboardInterrupt
|
||||||
|
|
||||||
|
monkeypatch.setattr(repl_mod, "setup_readline", lambda _state: None)
|
||||||
|
monkeypatch.setattr(repl_mod, "read_repl_entry", fake_read_entry)
|
||||||
|
monkeypatch.setattr(repl_mod, "run_user_text_with_retries", cancel_turn)
|
||||||
|
monkeypatch.setattr(repl_mod, "_echo_user", echo_user)
|
||||||
|
|
||||||
|
await run_repl(state) # must return normally (EOF), never raise KeyboardInterrupt
|
||||||
|
assert calls["reads"] == 4 # three entries + EOF
|
||||||
|
assert calls["echoes"] == 3 # the interrupting echo still counted, loop survived
|
||||||
|
assert "interrupted" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_btw_default_read_falls_back_when_tools_off(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
"""Bare /btw (default --tools=read) still works under /tools off: no tools."""
|
||||||
|
assert state.tools_enabled is False
|
||||||
|
state.agent.stream = False # DummyClient only completes via non-stream path
|
||||||
|
assert await handle_slash(state, "/btw hello") is True
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "btw: hello" in out
|
||||||
|
assert "btw done" in out
|
||||||
|
assert "error" not in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_btw_explicit_tools_errors_when_off(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
"""Explicitly requesting read/full tools while they are off is an error."""
|
||||||
|
state.agent.stream = False # DummyClient only completes via non-stream path
|
||||||
|
assert await handle_slash(state, "/btw --tools read q") is True
|
||||||
|
assert "error: --tools requires tools on" in capsys.readouterr().out
|
||||||
|
assert await handle_slash(state, "/btw --tools full q") is True
|
||||||
|
assert "error: --tools requires tools on" in capsys.readouterr().out
|
||||||
|
# Explicit no is fine.
|
||||||
|
assert await handle_slash(state, "/btw --tools no q") is True
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "btw done" in out
|
||||||
|
assert "error" not in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_btw_read_mode_wires_read_only_clone(
|
||||||
|
state: ReplState,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
"""--tools=read passes a read-only clone + read-only context + main session."""
|
||||||
|
|
||||||
|
from plyngent.agent import ToolRegistry, ToolTag, tool
|
||||||
|
from plyngent.tools.context import is_read_only_context
|
||||||
|
|
||||||
|
@tool(tags=ToolTag.LOCAL | ToolTag.READ_ONLY, register=False)
|
||||||
|
async def ro_tool() -> str:
|
||||||
|
return "ro"
|
||||||
|
|
||||||
|
@tool(tags=ToolTag.LOCAL, register=False)
|
||||||
|
async def w_tool() -> str:
|
||||||
|
return "w"
|
||||||
|
|
||||||
|
state.tools_enabled = True
|
||||||
|
state.agent.tools = ToolRegistry([ro_tool, w_tool], auto_bind_state=True)
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
|
async def fake_run_aside(_text: str, **kwargs: object) -> AsyncIterator[object]:
|
||||||
|
captured["tools"] = kwargs.get("tools")
|
||||||
|
captured["session_state"] = kwargs.get("session_state")
|
||||||
|
captured["read_only"] = is_read_only_context()
|
||||||
|
if False:
|
||||||
|
yield None
|
||||||
|
|
||||||
|
monkeypatch.setattr(state.agent, "run_aside", fake_run_aside)
|
||||||
|
|
||||||
|
assert await handle_slash(state, "/btw --tools read hi") is True
|
||||||
|
assert "btw done" in capsys.readouterr().out
|
||||||
|
|
||||||
|
tools = captured["tools"]
|
||||||
|
assert isinstance(tools, ToolRegistry)
|
||||||
|
assert {t.name for t in tools.definitions()} == {"ro_tool"}
|
||||||
|
assert captured["session_state"] is state.session_state
|
||||||
|
assert captured["read_only"] is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_btw_full_mode_clones_full_registry(
|
||||||
|
state: ReplState,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
"""--tools=full keeps the whole registry with a fresh session, no context."""
|
||||||
|
|
||||||
|
from plyngent.agent import ToolRegistry, ToolTag, tool
|
||||||
|
from plyngent.tools.context import is_read_only_context
|
||||||
|
|
||||||
|
@tool(tags=ToolTag.LOCAL | ToolTag.READ_ONLY, register=False)
|
||||||
|
async def ro_tool() -> str:
|
||||||
|
return "ro"
|
||||||
|
|
||||||
|
@tool(tags=ToolTag.LOCAL, register=False)
|
||||||
|
async def w_tool() -> str:
|
||||||
|
return "w"
|
||||||
|
|
||||||
|
state.tools_enabled = True
|
||||||
|
state.agent.tools = ToolRegistry([ro_tool, w_tool], auto_bind_state=True)
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
|
async def fake_run_aside(_text: str, **kwargs: object) -> AsyncIterator[object]:
|
||||||
|
captured["tools"] = kwargs.get("tools")
|
||||||
|
captured["session_state"] = kwargs.get("session_state")
|
||||||
|
captured["read_only"] = is_read_only_context()
|
||||||
|
if False:
|
||||||
|
yield None
|
||||||
|
|
||||||
|
monkeypatch.setattr(state.agent, "run_aside", fake_run_aside)
|
||||||
|
|
||||||
|
assert await handle_slash(state, "/btw --tools full hi") is True
|
||||||
|
assert "btw done" in capsys.readouterr().out
|
||||||
|
|
||||||
|
assert captured["tools"] is True
|
||||||
|
assert captured["session_state"] is not state.session_state
|
||||||
|
assert captured["read_only"] is False
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ def test_agent_section_defaults(tmp_path: Path) -> None:
|
|||||||
store = load(path)
|
store = load(path)
|
||||||
assert store.agent_config.system_prompt == DEFAULT_SYSTEM_PROMPT
|
assert store.agent_config.system_prompt == DEFAULT_SYSTEM_PROMPT
|
||||||
assert store.agent_config.tool_directives == DEFAULT_TOOL_DIRECTIVES
|
assert store.agent_config.tool_directives == DEFAULT_TOOL_DIRECTIVES
|
||||||
|
assert store.agent_config.mcp_instructions is True
|
||||||
assert "professional coding agent" in store.agent_config.system_prompt
|
assert "professional coding agent" in store.agent_config.system_prompt
|
||||||
assert "### Workspace" in store.agent_config.tool_directives
|
assert "### Workspace" in store.agent_config.tool_directives
|
||||||
assert store.agent_config.max_tool_result_chars == 32_000
|
assert store.agent_config.max_tool_result_chars == 32_000
|
||||||
@@ -64,6 +65,9 @@ def test_compose_empty_combinations() -> None:
|
|||||||
assert compose_agent_system_content("Only persona", "") == "Only persona"
|
assert compose_agent_system_content("Only persona", "") == "Only persona"
|
||||||
assert compose_agent_system_content("", "Only tools") == "Only tools"
|
assert compose_agent_system_content("", "Only tools") == "Only tools"
|
||||||
assert compose_agent_system_content("Persona", "Tools") == "Persona\n\nTools"
|
assert compose_agent_system_content("Persona", "Tools") == "Persona\n\nTools"
|
||||||
|
assert compose_agent_system_content("", "", "") is None
|
||||||
|
assert compose_agent_system_content("Persona", "Tools", "Extra") == "Persona\n\nTools\n\nExtra"
|
||||||
|
assert compose_agent_system_content("Persona", "", "Extra") == "Persona\n\nExtra"
|
||||||
|
|
||||||
|
|
||||||
def test_agent_section_parse(tmp_path: Path) -> None:
|
def test_agent_section_parse(tmp_path: Path) -> None:
|
||||||
@@ -158,6 +162,19 @@ tool_directives = ""
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_mcp_instructions_flag(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "c.toml"
|
||||||
|
_ = path.write_text(
|
||||||
|
"""
|
||||||
|
[agent]
|
||||||
|
mcp_instructions = false
|
||||||
|
""",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
store = load(path)
|
||||||
|
assert store.agent_config.mcp_instructions is False
|
||||||
|
|
||||||
|
|
||||||
def test_plugins_section_parse(tmp_path: Path) -> None:
|
def test_plugins_section_parse(tmp_path: Path) -> None:
|
||||||
path = tmp_path / "c.toml"
|
path = tmp_path / "c.toml"
|
||||||
_ = path.write_text(
|
_ = path.write_text(
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""[mcp] section parse: server definitions, disable gate, malformed fallback."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from plyngent.config import load
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_defaults_and_parse(tmp_path) -> None:
|
||||||
|
path = tmp_path / "c.toml"
|
||||||
|
_ = path.write_text("", encoding="utf-8")
|
||||||
|
store = load(path)
|
||||||
|
assert store.mcp_config.servers == {}
|
||||||
|
assert store.mcp_config.disable == []
|
||||||
|
|
||||||
|
path.write_text(
|
||||||
|
"""
|
||||||
|
[mcp.servers.docs]
|
||||||
|
command = "npx"
|
||||||
|
args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
||||||
|
env = { RUST_LOG = "info" }
|
||||||
|
timeout = 10.0
|
||||||
|
read_only = true
|
||||||
|
|
||||||
|
[mcp.servers.search]
|
||||||
|
command = "uvx"
|
||||||
|
args = ["mcp-server-fetch"]
|
||||||
|
|
||||||
|
[mcp]
|
||||||
|
disable = ["search"]
|
||||||
|
""",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
store = load(path)
|
||||||
|
mcp = store.mcp_config
|
||||||
|
assert set(mcp.servers) == {"docs", "search"}
|
||||||
|
docs = mcp.servers["docs"]
|
||||||
|
assert docs.command == "npx"
|
||||||
|
assert docs.args == ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
||||||
|
assert docs.env == {"RUST_LOG": "info"}
|
||||||
|
assert docs.timeout == 10.0
|
||||||
|
assert docs.read_only is True
|
||||||
|
assert docs.url == ""
|
||||||
|
assert mcp.disable == ["search"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_malformed_section_falls_back_to_defaults(tmp_path) -> None:
|
||||||
|
path = tmp_path / "c.toml"
|
||||||
|
# Unknown section key.
|
||||||
|
path.write_text("[mcp]\nenabled = true\n", encoding="utf-8")
|
||||||
|
store = load(path)
|
||||||
|
assert store.mcp_config.servers == {}
|
||||||
|
|
||||||
|
# Unknown server key (typo).
|
||||||
|
path.write_text('[mcp.servers.docs]\ncmds = "npx"\n', encoding="utf-8")
|
||||||
|
store = load(path)
|
||||||
|
assert store.mcp_config.servers == {}
|
||||||
|
|
||||||
|
# Server entry is not a table.
|
||||||
|
path.write_text("[mcp]\nservers = 3\n", encoding="utf-8")
|
||||||
|
store = load(path)
|
||||||
|
assert store.mcp_config.servers == {}
|
||||||
|
|
||||||
|
# Empty server name.
|
||||||
|
path.write_text('[mcp.servers.""]\ncommand = "npx"\n', encoding="utf-8")
|
||||||
|
store = load(path)
|
||||||
|
assert store.mcp_config.servers == {}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Minimal MCP stdio server used by the client tests (echo + sleep tools).
|
||||||
|
|
||||||
|
Set ``PLYNGENT_MCP_FAKE_INSTRUCTIONS`` (env) to have the server return that
|
||||||
|
text as ``InitializeResult.instructions`` (usage guidance).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
_FAKE_INSTRUCTIONS = os.environ.get("PLYNGENT_MCP_FAKE_INSTRUCTIONS", "")
|
||||||
|
|
||||||
|
|
||||||
|
def respond(request_id: int, result: object) -> None:
|
||||||
|
sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": request_id, "result": result}) + "\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
for line in sys.stdin:
|
||||||
|
try:
|
||||||
|
payload = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
method = payload.get("method", "")
|
||||||
|
request_id = payload.get("id")
|
||||||
|
if request_id is None:
|
||||||
|
continue # notification (notifications/initialized)
|
||||||
|
if method == "initialize":
|
||||||
|
result: dict[str, object] = {
|
||||||
|
"protocolVersion": "2025-06-18",
|
||||||
|
"capabilities": {"tools": {}},
|
||||||
|
"serverInfo": {"name": "fake-mcp", "version": "0.1.0"},
|
||||||
|
}
|
||||||
|
if _FAKE_INSTRUCTIONS:
|
||||||
|
result["instructions"] = _FAKE_INSTRUCTIONS
|
||||||
|
respond(int(request_id), result)
|
||||||
|
elif method == "tools/list":
|
||||||
|
respond(
|
||||||
|
int(request_id),
|
||||||
|
{
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"name": "echo",
|
||||||
|
"description": "Echo the given text back.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"text": {"type": "string"}},
|
||||||
|
"required": ["text"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "fail",
|
||||||
|
"description": "Always reports a tool error.",
|
||||||
|
"inputSchema": {"type": "object", "properties": {}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "slow",
|
||||||
|
"description": "Sleeps past any client timeout.",
|
||||||
|
"inputSchema": {"type": "object", "properties": {}},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
elif method == "tools/call":
|
||||||
|
name = (payload.get("params") or {}).get("name")
|
||||||
|
if name == "echo":
|
||||||
|
text = str((payload.get("params") or {}).get("arguments", {}).get("text", ""))
|
||||||
|
respond(
|
||||||
|
int(request_id),
|
||||||
|
{
|
||||||
|
"content": [{"type": "text", "text": f"echo: {text}"}],
|
||||||
|
"isError": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
elif name == "fail":
|
||||||
|
respond(
|
||||||
|
int(request_id),
|
||||||
|
{"content": [{"type": "text", "text": "boom"}], "isError": True},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
import time
|
||||||
|
|
||||||
|
time.sleep(5)
|
||||||
|
respond(int(request_id), {"content": [], "isError": False})
|
||||||
|
else:
|
||||||
|
sys.stdout.write(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": request_id,
|
||||||
|
"error": {"code": -32601, "message": f"unknown method {method}"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""MCP stdio client + manager against the bundled fake server subprocess."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from plyngent.config.models import McpConfig, McpServerConfig
|
||||||
|
from plyngent.runtime.mcp_client import (
|
||||||
|
McpConnectionError,
|
||||||
|
McpManager,
|
||||||
|
McpServerConnection,
|
||||||
|
)
|
||||||
|
|
||||||
|
_FAKE_SERVER = Path(__file__).parent / "mcp_fake_server.py"
|
||||||
|
|
||||||
|
|
||||||
|
def _server_config(**overrides: object) -> McpServerConfig:
|
||||||
|
base: dict[str, object] = {
|
||||||
|
"command": sys.executable,
|
||||||
|
"args": [str(_FAKE_SERVER)],
|
||||||
|
"timeout": 5.0,
|
||||||
|
}
|
||||||
|
return McpServerConfig(**{**base, **overrides}) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_connection_handshake_lists_and_calls_tools() -> None:
|
||||||
|
connection = McpServerConnection("fake", _server_config())
|
||||||
|
await connection.start()
|
||||||
|
try:
|
||||||
|
assert connection.connected
|
||||||
|
assert connection.error is None
|
||||||
|
assert connection.instructions is None
|
||||||
|
assert [tool.name for tool in connection.tools] == ["echo", "fail", "slow"]
|
||||||
|
echo = connection.tools[0]
|
||||||
|
assert echo.description == "Echo the given text back."
|
||||||
|
assert echo.input_schema["type"] == "object"
|
||||||
|
|
||||||
|
result = await connection.call_tool("echo", {"text": "hello"})
|
||||||
|
assert result == "echo: hello"
|
||||||
|
finally:
|
||||||
|
await connection.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_initialize_captures_server_instructions() -> None:
|
||||||
|
guidance = "Use echo to repeat text back to the user."
|
||||||
|
connection = McpServerConnection(
|
||||||
|
"fake",
|
||||||
|
_server_config(env={"PLYNGENT_MCP_FAKE_INSTRUCTIONS": guidance}),
|
||||||
|
)
|
||||||
|
await connection.start()
|
||||||
|
try:
|
||||||
|
assert connection.connected
|
||||||
|
assert connection.instructions == guidance
|
||||||
|
finally:
|
||||||
|
await connection.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_tool_error_returns_error_text() -> None:
|
||||||
|
connection = McpServerConnection("fake", _server_config())
|
||||||
|
await connection.start()
|
||||||
|
try:
|
||||||
|
result = await connection.call_tool("fail", {})
|
||||||
|
assert result == "error: boom"
|
||||||
|
finally:
|
||||||
|
await connection.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_request_timeout_raises() -> None:
|
||||||
|
connection = McpServerConnection("fake", _server_config(timeout=0.2))
|
||||||
|
await connection.start()
|
||||||
|
try:
|
||||||
|
with pytest.raises(McpConnectionError, match="timed out"):
|
||||||
|
await connection.call_tool("slow", {})
|
||||||
|
finally:
|
||||||
|
await connection.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_spawn_failure_records_error() -> None:
|
||||||
|
connection = McpServerConnection("bad", _server_config(command="/nonexistent/plyngent-mcp-binary"))
|
||||||
|
await connection.start()
|
||||||
|
assert not connection.connected
|
||||||
|
assert connection.error is not None
|
||||||
|
assert "spawn" in connection.status
|
||||||
|
await connection.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_close_terminates_subprocess() -> None:
|
||||||
|
connection = McpServerConnection("fake", _server_config())
|
||||||
|
await connection.start()
|
||||||
|
proc = connection._proc
|
||||||
|
assert proc is not None
|
||||||
|
await connection.aclose()
|
||||||
|
assert connection._proc is None
|
||||||
|
assert proc.returncode is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_manager_start_status_and_call() -> None:
|
||||||
|
manager = McpManager(McpConfig(servers={"fake": _server_config()}, disable=[]))
|
||||||
|
await manager.ensure_started()
|
||||||
|
try:
|
||||||
|
assert [(name, status, count) for name, status, count in manager.statuses()] == [("fake", "connected", 3)]
|
||||||
|
assert manager.instructions() == []
|
||||||
|
assert await manager.call("fake", "echo", {"text": "hi"}) == "echo: hi"
|
||||||
|
finally:
|
||||||
|
await manager.aclose()
|
||||||
|
assert manager.statuses() == [("fake", "not started", 0)]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_manager_instructions_after_restart() -> None:
|
||||||
|
guidance = "Always answer with a haiku."
|
||||||
|
plain = McpManager(McpConfig(servers={"fake": _server_config()}, disable=[]))
|
||||||
|
await plain.ensure_started()
|
||||||
|
assert plain.instructions() == []
|
||||||
|
await plain.restart(
|
||||||
|
McpConfig(
|
||||||
|
servers={"fake": _server_config(env={"PLYNGENT_MCP_FAKE_INSTRUCTIONS": guidance})},
|
||||||
|
disable=[],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await plain.ensure_started()
|
||||||
|
try:
|
||||||
|
assert plain.instructions() == [("fake", guidance)]
|
||||||
|
finally:
|
||||||
|
await plain.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_manager_disable_and_restart() -> None:
|
||||||
|
manager = McpManager(McpConfig(servers={"fake": _server_config()}, disable=["fake"]))
|
||||||
|
await manager.ensure_started()
|
||||||
|
assert manager.connections() == []
|
||||||
|
with pytest.raises(McpConnectionError, match="not connected"):
|
||||||
|
await manager.call("fake", "echo", {})
|
||||||
|
|
||||||
|
await manager.restart(McpConfig(servers={"fake": _server_config()}, disable=[]))
|
||||||
|
await manager.ensure_started()
|
||||||
|
assert manager.statuses() == [("fake", "connected", 3)]
|
||||||
|
await manager.aclose()
|
||||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import inspect
|
import inspect
|
||||||
|
|
||||||
from plyngent.agent import ToolTag, tool
|
from plyngent.agent import ToolRegistry, ToolTag, tool
|
||||||
from plyngent.tools import default_tool_definitions, register_builtin_tools
|
from plyngent.tools import default_tool_definitions, register_builtin_tools
|
||||||
from plyngent.tools.catalog import ToolCatalog, ToolSource, catalog_scope, get_catalog, registration_source
|
from plyngent.tools.catalog import ToolCatalog, ToolSource, catalog_scope, get_catalog, registration_source
|
||||||
from plyngent.tools.chat import CHAT_TOOLS
|
from plyngent.tools.chat import CHAT_TOOLS
|
||||||
@@ -42,6 +42,45 @@ def test_public_surface_is_subset() -> None:
|
|||||||
assert "read_file" not in public
|
assert "read_file" not in public
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_only_classification() -> None:
|
||||||
|
"""READ_ONLY marks exactly the non-mutating builtins."""
|
||||||
|
register_builtin_tools()
|
||||||
|
read_only = {t.name for t in default_tool_definitions(surface="local") if t.tags & ToolTag.READ_ONLY}
|
||||||
|
assert read_only == {
|
||||||
|
"read_file",
|
||||||
|
"get_truncated",
|
||||||
|
"glob_paths",
|
||||||
|
"grep_files",
|
||||||
|
"listdir",
|
||||||
|
"tree",
|
||||||
|
"todo_list",
|
||||||
|
"wait",
|
||||||
|
"ask_user_line",
|
||||||
|
"ask_user_choice",
|
||||||
|
"ask_user_form",
|
||||||
|
"vcs_kind",
|
||||||
|
"vcs_status",
|
||||||
|
"vcs_diff",
|
||||||
|
"vcs_log",
|
||||||
|
"vcs_branch",
|
||||||
|
"fetch",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_clone_read_only_only() -> None:
|
||||||
|
"""clone(read_only_only=True) keeps only READ_ONLY tools, nothing else."""
|
||||||
|
register_builtin_tools()
|
||||||
|
full = ToolRegistry(default_tool_definitions(surface="local"))
|
||||||
|
ro = full.clone(read_only_only=True)
|
||||||
|
assert 0 < len(ro) < len(full)
|
||||||
|
assert {t.name for t in ro.definitions()} == {t.name for t in full.definitions() if t.tags & ToolTag.READ_ONLY}
|
||||||
|
assert all(t.tags & ToolTag.READ_ONLY for t in ro.definitions())
|
||||||
|
assert "run_argv" not in ro
|
||||||
|
assert "edit_replace" not in ro
|
||||||
|
# Plain clone keeps every tool.
|
||||||
|
assert len(full.clone()) == len(full)
|
||||||
|
|
||||||
|
|
||||||
def test_catalog_scope_empty_isolates_registration() -> None:
|
def test_catalog_scope_empty_isolates_registration() -> None:
|
||||||
with catalog_scope(empty=True) as catalog:
|
with catalog_scope(empty=True) as catalog:
|
||||||
|
|
||||||
|
|||||||
@@ -144,3 +144,51 @@ async def test_wait_tool_times_out(monkeypatch) -> None:
|
|||||||
registry = ToolRegistry([wait])
|
registry = ToolRegistry([wait])
|
||||||
out = await registry.execute("wait", '{"duration": 3}')
|
out = await registry.execute("wait", '{"duration": 3}')
|
||||||
assert out == "waited 3s"
|
assert out == "waited 3s"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_wait_tool_prompt_two_lines_with_reason(monkeypatch) -> None:
|
||||||
|
prompts: list[str] = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
wait_module,
|
||||||
|
"read_line_with_timeout",
|
||||||
|
lambda prompt, timeout: prompts.append(prompt) or None,
|
||||||
|
)
|
||||||
|
with temporary_backend(ScriptedBackend([])):
|
||||||
|
registry = ToolRegistry([wait])
|
||||||
|
out = await registry.execute("wait", '{"duration": 5, "reason": "tests are slow"}')
|
||||||
|
assert out == "waited 5s"
|
||||||
|
assert prompts == ["Waiting for 5s (tests are slow). Press Enter to disturb.\nReason (optional): "]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_wait_tool_prompt_two_lines_no_reason(monkeypatch) -> None:
|
||||||
|
prompts: list[str] = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
wait_module,
|
||||||
|
"read_line_with_timeout",
|
||||||
|
lambda prompt, timeout: prompts.append(prompt) or None,
|
||||||
|
)
|
||||||
|
with temporary_backend(ScriptedBackend([])):
|
||||||
|
registry = ToolRegistry([wait])
|
||||||
|
out = await registry.execute("wait", '{"duration": 5}')
|
||||||
|
assert out == "waited 5s"
|
||||||
|
assert prompts == ["Waiting for 5s. Press Enter to disturb.\nReason (optional): "]
|
||||||
|
|
||||||
|
|
||||||
|
def test_wait_prompt_plain_when_not_a_tty() -> None:
|
||||||
|
"""Prompt styling is stripped when stdout is not a TTY (pytest capture)."""
|
||||||
|
prompt = wait_module._wait_prompt(5, reason="tests")
|
||||||
|
assert "\x1b[" not in prompt
|
||||||
|
assert prompt == "Waiting for 5s (tests). Press Enter to disturb.\nReason (optional): "
|
||||||
|
|
||||||
|
|
||||||
|
def test_wait_prompt_colored_on_tty(monkeypatch) -> None:
|
||||||
|
"""On a TTY the prompt is styled: cyan status, yellow input prompt."""
|
||||||
|
|
||||||
|
class _FakeStdout:
|
||||||
|
def isatty(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(wait_module.sys, "stdout", _FakeStdout())
|
||||||
|
prompt = wait_module._wait_prompt(5, reason="tests")
|
||||||
|
assert "\x1b[36m" in prompt # cyan status
|
||||||
|
assert "\x1b[33m" in prompt # yellow input prompt
|
||||||
|
|||||||
@@ -19,9 +19,38 @@ def test_edit_lineno_replace_middle(workspace: object) -> None:
|
|||||||
_ = _read_lineno("a.txt")
|
_ = _read_lineno("a.txt")
|
||||||
out = call_sync(edit_lineno, "a.txt", 2, 3, "TWO\nTHREE\n")
|
out = call_sync(edit_lineno, "a.txt", 2, 3, "TWO\nTHREE\n")
|
||||||
assert "replaced lines 2-3" in out
|
assert "replaced lines 2-3" in out
|
||||||
|
assert "first: 'two'" in out # old-content excerpt for self-correction
|
||||||
assert call_sync(read_file, "a.txt") == "L1-4\none\nTWO\nTHREE\nfour\n"
|
assert call_sync(read_file, "a.txt") == "L1-4\none\nTWO\nTHREE\nfour\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_lineno_read_truncated_marks_only_displayed_lines(workspace: object) -> None:
|
||||||
|
"""A max_chars-truncated numbered read must not mark the unseen tail."""
|
||||||
|
del workspace
|
||||||
|
_ = call_sync(write_file, "t.txt", "".join(f"line {i}\n" for i in range(1, 101)))
|
||||||
|
# Displayed complete lines are editable...
|
||||||
|
_ = reset_lineno_tracker()
|
||||||
|
_ = call_sync(read_file, "t.txt", with_lineno=True, max_chars=80)
|
||||||
|
out = call_sync(edit_lineno, "t.txt", 5, 5, "FIVE\n")
|
||||||
|
assert "replaced lines 5-5" in out
|
||||||
|
# ...but the truncated tail (never shown) stays unread.
|
||||||
|
_ = reset_lineno_tracker()
|
||||||
|
_ = call_sync(read_file, "t.txt", with_lineno=True, max_chars=80)
|
||||||
|
out = call_sync(edit_lineno, "t.txt", 90, 90, "X\n")
|
||||||
|
assert "not read" in out and "90" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_lineno_error_shows_read_window(workspace: object) -> None:
|
||||||
|
"""The unread error names the read window so the model can re-read precisely."""
|
||||||
|
del workspace
|
||||||
|
_ = call_sync(write_file, "j.txt", "a\nb\nc\nd\n")
|
||||||
|
_ = reset_lineno_tracker()
|
||||||
|
_ = call_sync(read_file, "j.txt", offset=1, limit=2, with_lineno=True) # lines 2-3
|
||||||
|
out = call_sync(edit_lineno, "j.txt", 1, 1, "X\n")
|
||||||
|
assert "not read" in out
|
||||||
|
assert "read lines 2-3" in out
|
||||||
|
assert "margin" in out
|
||||||
|
|
||||||
|
|
||||||
def test_edit_lineno_requires_all_lines_read(workspace: object) -> None:
|
def test_edit_lineno_requires_all_lines_read(workspace: object) -> None:
|
||||||
del workspace
|
del workspace
|
||||||
_ = call_sync(write_file, "r.txt", "a\nb\nc\nd\ne\n")
|
_ = call_sync(write_file, "r.txt", "a\nb\nc\nd\ne\n")
|
||||||
@@ -37,6 +66,16 @@ def test_edit_lineno_requires_all_lines_read(workspace: object) -> None:
|
|||||||
assert "replaced lines 2-3" in out
|
assert "replaced lines 2-3" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_lineno_append_to_empty_file(workspace: object) -> None:
|
||||||
|
"""An empty file has nothing to clobber, so appending needs no read."""
|
||||||
|
del workspace
|
||||||
|
_ = call_sync(write_file, "empty.txt", "")
|
||||||
|
_ = reset_lineno_tracker()
|
||||||
|
out = call_sync(edit_lineno, "empty.txt", 1, 1, "first\n")
|
||||||
|
assert "appended" in out
|
||||||
|
assert call_sync(read_file, "empty.txt") == "L1-1\nfirst\n"
|
||||||
|
|
||||||
|
|
||||||
def test_edit_lineno_append_requires_last_line_read(workspace: object) -> None:
|
def test_edit_lineno_append_requires_last_line_read(workspace: object) -> None:
|
||||||
del workspace
|
del workspace
|
||||||
_ = call_sync(write_file, "ap.txt", "a\nb\nc\n")
|
_ = call_sync(write_file, "ap.txt", "a\nb\nc\n")
|
||||||
@@ -92,7 +131,7 @@ def test_edit_lineno_requires_rerun_after_write(workspace: object) -> None:
|
|||||||
_ = _read_lineno("f.txt")
|
_ = _read_lineno("f.txt")
|
||||||
_ = call_sync(write_file, "f.txt", "changed\n")
|
_ = call_sync(write_file, "f.txt", "changed\n")
|
||||||
out = call_sync(edit_lineno, "f.txt", 1, 1, "x\n")
|
out = call_sync(edit_lineno, "f.txt", 1, 1, "x\n")
|
||||||
assert "not read" in out
|
assert "changed" in out and "re-read" in out
|
||||||
|
|
||||||
|
|
||||||
def test_edit_lineno_requires_rerun_after_edit_replace(workspace: object) -> None:
|
def test_edit_lineno_requires_rerun_after_edit_replace(workspace: object) -> None:
|
||||||
@@ -101,7 +140,7 @@ def test_edit_lineno_requires_rerun_after_edit_replace(workspace: object) -> Non
|
|||||||
_ = _read_lineno("g.txt")
|
_ = _read_lineno("g.txt")
|
||||||
_ = call_sync(edit_replace, "g.txt", "two", "TWO")
|
_ = call_sync(edit_replace, "g.txt", "two", "TWO")
|
||||||
out = call_sync(edit_lineno, "g.txt", 2, 2, "x\n")
|
out = call_sync(edit_lineno, "g.txt", 2, 2, "x\n")
|
||||||
assert "not read" in out
|
assert "changed" in out and "re-read" in out
|
||||||
|
|
||||||
|
|
||||||
def test_edit_lineno_invalidates_after_successful_edit(workspace: object) -> None:
|
def test_edit_lineno_invalidates_after_successful_edit(workspace: object) -> None:
|
||||||
@@ -111,4 +150,19 @@ def test_edit_lineno_invalidates_after_successful_edit(workspace: object) -> Non
|
|||||||
_ = call_sync(edit_lineno, "h.txt", 2, 2, "B\n")
|
_ = call_sync(edit_lineno, "h.txt", 2, 2, "B\n")
|
||||||
# After the edit, line numbers are stale — must re-read before editing again.
|
# After the edit, line numbers are stale — must re-read before editing again.
|
||||||
out = call_sync(edit_lineno, "h.txt", 2, 2, "C\n")
|
out = call_sync(edit_lineno, "h.txt", 2, 2, "C\n")
|
||||||
assert "not read" in out
|
assert "changed" in out and "re-read" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_lineno_detects_external_change(workspace: object) -> None:
|
||||||
|
"""A mutation outside the file tools (run_argv, another process) is caught."""
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
assert isinstance(workspace, Path)
|
||||||
|
_ = call_sync(write_file, "i.txt", "one\ntwo\nthree\n")
|
||||||
|
_ = _read_lineno("i.txt")
|
||||||
|
# Simulate an external/process-tool mutation: rewrite content + new mtime.
|
||||||
|
_ = call_sync(write_file, "i.txt", "one\ntwo\nthree\nCHANGED\n")
|
||||||
|
os.utime(workspace / "i.txt", (time.time() + 1, time.time() + 1))
|
||||||
|
out = call_sync(edit_lineno, "i.txt", 1, 1, "x\n")
|
||||||
|
assert "changed since it was read" in out
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, override
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from plyngent.tools.context import read_only_context
|
||||||
from plyngent.tools.danger import classify_danger
|
from plyngent.tools.danger import classify_danger
|
||||||
from plyngent.tools.net import fetch, grant_private_host
|
from plyngent.tools.net import fetch, grant_private_host
|
||||||
from plyngent.tools.net.policy import (
|
from plyngent.tools.net.policy import (
|
||||||
@@ -199,6 +200,23 @@ async def test_fetch_get_post_put_delete(workspace: Path, http_server: str) -> N
|
|||||||
assert "status=204" in out_del
|
assert "status=204" in out_del
|
||||||
|
|
||||||
|
|
||||||
|
async def test_fetch_read_only_context_blocks_non_get(workspace: Path, http_server: str) -> None:
|
||||||
|
"""In a read-only context fetch only allows GET; outside it POST works."""
|
||||||
|
del workspace
|
||||||
|
port = int(http_server.rsplit(":", 1)[1])
|
||||||
|
grant_private_host("127.0.0.1", port)
|
||||||
|
|
||||||
|
with read_only_context():
|
||||||
|
out = await call_async(fetch, f"{http_server}/hello")
|
||||||
|
assert "status=200" in out # GET is fine in a read-only context
|
||||||
|
blocked = await call_async(fetch, f"{http_server}/echo", method="POST", body="hi")
|
||||||
|
assert blocked.startswith("error:")
|
||||||
|
assert "GET" in blocked
|
||||||
|
|
||||||
|
out_post = await call_async(fetch, f"{http_server}/echo", method="POST", body="hi")
|
||||||
|
assert "POST:hi" in out_post
|
||||||
|
|
||||||
|
|
||||||
async def test_fetch_user_agent_passthrough(workspace: Path, http_server: str) -> None:
|
async def test_fetch_user_agent_passthrough(workspace: Path, http_server: str) -> None:
|
||||||
del workspace
|
del workspace
|
||||||
port = int(http_server.rsplit(":", 1)[1])
|
port = int(http_server.rsplit(":", 1)[1])
|
||||||
@@ -246,9 +264,9 @@ async def test_fetch_char_truncation_embeds_token(workspace: Path, http_server:
|
|||||||
out = await call_async(fetch, f"{http_server}/big", max_chars=1000)
|
out = await call_async(fetch, f"{http_server}/big", max_chars=1000)
|
||||||
assert "truncated=true" in out
|
assert "truncated=true" in out
|
||||||
body = out.split("--- body ---", 1)[-1]
|
body = out.split("--- body ---", 1)[-1]
|
||||||
assert "TRUNCATE_TOKEN:" in body
|
assert "truncate_token=" in body
|
||||||
token_line = next(ln for ln in body.splitlines() if "TRUNCATE_TOKEN:" in ln)
|
token_line = next(ln for ln in body.splitlines() if "truncate_token=" in ln)
|
||||||
token_str = token_line.split("[TRUNCATE_TOKEN: ", 1)[1].rstrip("]")
|
token_str = token_line.split("truncate_token=", 1)[1].rstrip("]")
|
||||||
token = decode_truncate_token(token_str)
|
token = decode_truncate_token(token_str)
|
||||||
assert token is not None
|
assert token is not None
|
||||||
assert token.kind == "http"
|
assert token.kind == "http"
|
||||||
@@ -269,7 +287,7 @@ async def test_fetch_offset_skips_body(workspace: Path, http_server: str) -> Non
|
|||||||
out = await call_async(fetch, f"{http_server}/big", max_chars=200, offset=4900)
|
out = await call_async(fetch, f"{http_server}/big", max_chars=200, offset=4900)
|
||||||
body = out.split("--- body ---", 1)[-1]
|
body = out.split("--- body ---", 1)[-1]
|
||||||
assert body.strip() == "x" * 100
|
assert body.strip() == "x" * 100
|
||||||
assert "TRUNCATE_TOKEN:" not in out
|
assert "truncate_token=" not in out
|
||||||
|
|
||||||
|
|
||||||
async def test_fetch_private_denied_without_grant(workspace: Path, http_server: str) -> None:
|
async def test_fetch_private_denied_without_grant(workspace: Path, http_server: str) -> None:
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ get_truncated_module = importlib.import_module("plyngent.tools.file.get_truncate
|
|||||||
|
|
||||||
def _extract_token(out: str) -> str | None:
|
def _extract_token(out: str) -> str | None:
|
||||||
for line in out.splitlines():
|
for line in out.splitlines():
|
||||||
if "TRUNCATE_TOKEN:" in line:
|
if "truncate_token=" in line:
|
||||||
return line.split("[TRUNCATE_TOKEN: ", 1)[1].rstrip("]")
|
return line.split("truncate_token=", 1)[1].rstrip("]")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -33,10 +33,10 @@ def test_read_file_max_chars_embeds_token(workspace: Path) -> None:
|
|||||||
out = call_sync(read_file, "big.txt", max_chars=200)
|
out = call_sync(read_file, "big.txt", max_chars=200)
|
||||||
header, _, rest = out.partition("\n")
|
header, _, rest = out.partition("\n")
|
||||||
assert header.startswith("L1-") # 1-based range of the raw slice read
|
assert header.startswith("L1-") # 1-based range of the raw slice read
|
||||||
content, _, marker = rest.partition("\n...[")
|
content, _, marker = rest.partition("\n[Truncated")
|
||||||
assert content == "a" * len(content)
|
assert content == "a" * len(content)
|
||||||
assert len(content) < 200
|
assert len(content) < 200
|
||||||
assert "TRUNCATE_TOKEN:" in marker
|
assert "truncate_token=" in marker
|
||||||
token = _extract_token(out)
|
token = _extract_token(out)
|
||||||
assert token is not None
|
assert token is not None
|
||||||
parsed = get_truncated_module.decode_truncate_token(token)
|
parsed = get_truncated_module.decode_truncate_token(token)
|
||||||
@@ -45,11 +45,26 @@ def test_read_file_max_chars_embeds_token(workspace: Path) -> None:
|
|||||||
assert parsed.offset == len(content) # cursor lands exactly where content stops
|
assert parsed.offset == len(content) # cursor lands exactly where content stops
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_file_default_truncates_at_cap(workspace: Path) -> None:
|
||||||
|
"""Truncation is the default: a large read embeds a token without max_chars."""
|
||||||
|
(workspace / "huge.txt").write_text("\n".join(f"line-{i}" for i in range(10_000)), encoding="utf-8")
|
||||||
|
out = call_sync(read_file, "huge.txt")
|
||||||
|
header, _, rest = out.partition("\n")
|
||||||
|
assert header == "L1-10000" # the requested slice (whole file)
|
||||||
|
assert "truncate_token=" in rest
|
||||||
|
# The slice is capped near the default tool-result cap.
|
||||||
|
assert len(rest) <= 32_000
|
||||||
|
# No marker when the file is small (no truncation occurred).
|
||||||
|
small = call_sync(read_file, "huge.txt", max_chars=0)
|
||||||
|
assert "Truncated" not in small
|
||||||
|
assert small.startswith("L1-10000\n")
|
||||||
|
|
||||||
|
|
||||||
def test_read_file_max_chars_small_file_untouched(workspace: Path) -> None:
|
def test_read_file_max_chars_small_file_untouched(workspace: Path) -> None:
|
||||||
(workspace / "small.txt").write_text("tiny", encoding="utf-8")
|
(workspace / "small.txt").write_text("tiny", encoding="utf-8")
|
||||||
out = call_sync(read_file, "small.txt", max_chars=200)
|
out = call_sync(read_file, "small.txt", max_chars=200)
|
||||||
assert out == "L1-1\ntiny"
|
assert out == "L1-1\ntiny"
|
||||||
assert "TRUNCATE_TOKEN:" not in out
|
assert "truncate_token=" not in out
|
||||||
|
|
||||||
|
|
||||||
def test_edit_replace_first_only(workspace: object) -> None:
|
def test_edit_replace_first_only(workspace: object) -> None:
|
||||||
@@ -59,6 +74,7 @@ def test_edit_replace_first_only(workspace: object) -> None:
|
|||||||
assert call_sync(read_file, "t.txt") == "L1-1\nbb aa"
|
assert call_sync(read_file, "t.txt") == "L1-1\nbb aa"
|
||||||
assert "1 of 2" in result or "1 of 2 matches" in result
|
assert "1 of 2" in result or "1 of 2 matches" in result
|
||||||
assert "remain" in result
|
assert "remain" in result
|
||||||
|
assert "'aa' → 'bb'" in result # old→new excerpt for self-correction
|
||||||
|
|
||||||
|
|
||||||
def test_edit_replace_max_replaces(workspace: object) -> None:
|
def test_edit_replace_max_replaces(workspace: object) -> None:
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ get_truncated = get_truncated_module.get_truncated
|
|||||||
|
|
||||||
def _extract_token(out: str) -> str | None:
|
def _extract_token(out: str) -> str | None:
|
||||||
for line in out.splitlines():
|
for line in out.splitlines():
|
||||||
if "TRUNCATE_TOKEN:" in line:
|
if "truncate_token=" in line:
|
||||||
return line.split("[TRUNCATE_TOKEN: ", 1)[1].rstrip("]")
|
return line.split("truncate_token=", 1)[1].rstrip("]")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ async def test_get_truncated_file_chains_without_gap(workspace: Path) -> None:
|
|||||||
total_read = 0
|
total_read = 0
|
||||||
for _ in range(20):
|
for _ in range(20):
|
||||||
out = await call_async(get_truncated, token, max_chars=200)
|
out = await call_async(get_truncated, token, max_chars=200)
|
||||||
body, _, _ = out.partition("\n...[")
|
body, _, _ = out.partition("\n[Truncated")
|
||||||
header, _, content = body.partition("\n")
|
header, _, content = body.partition("\n")
|
||||||
assert header.startswith("L") and "-" in header
|
assert header.startswith("L") and "-" in header
|
||||||
assert content == "a" * len(content)
|
assert content == "a" * len(content)
|
||||||
@@ -44,6 +44,40 @@ async def test_get_truncated_file_chains_without_gap(workspace: Path) -> None:
|
|||||||
assert total_read == 1000
|
assert total_read == 1000
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_truncated_numbered_continues_and_marks(workspace: Path) -> None:
|
||||||
|
"""A numbered read's token resumes the numbered view, gap-free and editable."""
|
||||||
|
from plyngent.agent import reset_lineno_tracker
|
||||||
|
from plyngent.tools.file import edit_lineno, read_file
|
||||||
|
|
||||||
|
(workspace / "big.txt").write_text("".join(f"line {i}\n" for i in range(1, 101)), encoding="utf-8")
|
||||||
|
reset_lineno_tracker()
|
||||||
|
first = await call_async(read_file, "big.txt", with_lineno=True, max_chars=120)
|
||||||
|
token = _extract_token(first)
|
||||||
|
assert token is not None
|
||||||
|
parsed = decode_truncate_token(token)
|
||||||
|
assert parsed is not None
|
||||||
|
assert parsed.kind == "file"
|
||||||
|
assert parsed.numbered is True
|
||||||
|
# The token points at the raw start of the next line (no skip, no overlap).
|
||||||
|
body = first.split("\n[Truncated", 1)[0]
|
||||||
|
shown = body.count("\n")
|
||||||
|
expected_raw = sum(len(f"line {i}\n") for i in range(1, shown + 1))
|
||||||
|
assert parsed.offset == expected_raw
|
||||||
|
|
||||||
|
# Continuation is numbered and starts right after the displayed lines.
|
||||||
|
cont = await call_async(get_truncated, token, max_chars=200)
|
||||||
|
numbered = [ln for ln in cont.splitlines() if "|" in ln and ln.strip().split("|", 1)[0].strip().isdigit()]
|
||||||
|
assert numbered
|
||||||
|
first_num = int(numbered[0].split("|", 1)[0].strip())
|
||||||
|
assert first_num == shown + 1
|
||||||
|
|
||||||
|
# Resumed lines are editable via edit_lineno; unseen lines stay unread.
|
||||||
|
out = await call_async(edit_lineno, "big.txt", first_num, first_num, "EDITED\n")
|
||||||
|
assert "replaced lines" in out
|
||||||
|
out2 = await call_async(edit_lineno, "big.txt", 90, 90, "X\n")
|
||||||
|
assert "not read" in out2
|
||||||
|
|
||||||
|
|
||||||
async def test_get_truncated_file_missing(workspace: Path) -> None:
|
async def test_get_truncated_file_missing(workspace: Path) -> None:
|
||||||
token = encode_truncate_token(TruncateToken(kind="file", location="nope.txt", offset=0, limit=200))
|
token = encode_truncate_token(TruncateToken(kind="file", location="nope.txt", offset=0, limit=200))
|
||||||
out = await call_async(get_truncated, token, max_chars=200)
|
out = await call_async(get_truncated, token, max_chars=200)
|
||||||
@@ -62,3 +96,42 @@ async def test_get_truncated_http_passthrough(monkeypatch) -> None:
|
|||||||
out = await call_async(get_truncated, token, max_chars=200)
|
out = await call_async(get_truncated, token, max_chars=200)
|
||||||
assert out == "body-from-500"
|
assert out == "body-from-500"
|
||||||
assert calls == [("https://example.com/x", 500, 400)]
|
assert calls == [("https://example.com/x", 500, 400)]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_truncated_memory_chains() -> None:
|
||||||
|
from plyngent.tools.truncate_token import truncate_generic
|
||||||
|
|
||||||
|
bounded = truncate_generic("m" * 1000, 200)
|
||||||
|
assert "truncate_token=" in bounded
|
||||||
|
token = bounded.split("truncate_token=", 1)[1].rstrip("]")
|
||||||
|
parsed = decode_truncate_token(token)
|
||||||
|
assert parsed is not None
|
||||||
|
assert parsed.kind == "memory"
|
||||||
|
cursor = parsed.offset # chars already delivered by truncate_generic
|
||||||
|
delivered = cursor
|
||||||
|
for _ in range(20):
|
||||||
|
out = await call_async(get_truncated, token, max_chars=200)
|
||||||
|
content, _, _ = out.partition("\n[Truncated")
|
||||||
|
assert content == "m" * len(content)
|
||||||
|
delivered += len(content)
|
||||||
|
next_token = _extract_token(out)
|
||||||
|
if next_token is None:
|
||||||
|
break # source exhausted → no token, no marker
|
||||||
|
parsed = decode_truncate_token(next_token)
|
||||||
|
assert parsed is not None
|
||||||
|
assert parsed.kind == "memory"
|
||||||
|
# No gap: the next cursor is exactly where this chunk ended.
|
||||||
|
assert parsed.offset == delivered
|
||||||
|
token = next_token
|
||||||
|
assert delivered == 1000
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_truncated_memory_expired() -> None:
|
||||||
|
from plyngent.tools.truncate_token import _MEMORY_REMAINDERS, store_remainder
|
||||||
|
|
||||||
|
key = store_remainder("x" * 500)
|
||||||
|
del _MEMORY_REMAINDERS[key]
|
||||||
|
token = encode_truncate_token(TruncateToken(kind="memory", location=key, offset=0, limit=200))
|
||||||
|
out = await call_async(get_truncated, token, max_chars=200)
|
||||||
|
assert out.startswith("error:")
|
||||||
|
assert "expired" in out
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""MCP tool registration: namespacing, tags, catalog source, purge, handlers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from plyngent.agent.tools import ToolTag
|
||||||
|
from plyngent.runtime.mcp_client import McpTool
|
||||||
|
from plyngent.tools.catalog import catalog_scope
|
||||||
|
from plyngent.tools.mcp import mcp_tool_name, purge_mcp_tools, register_mcp_server_tools
|
||||||
|
|
||||||
|
|
||||||
|
class _StubManager:
|
||||||
|
"""Duck-typed McpManager stand-in (records calls, returns canned text)."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls: list[tuple[str, str, dict[str, object]]] = []
|
||||||
|
|
||||||
|
async def call(self, server: str, tool: str, arguments: dict[str, object]) -> str:
|
||||||
|
self.calls.append((server, tool, arguments))
|
||||||
|
return f"{server}:{tool}:{arguments.get('text', '')}"
|
||||||
|
|
||||||
|
|
||||||
|
def _tools() -> list[McpTool]:
|
||||||
|
return [
|
||||||
|
McpTool(
|
||||||
|
name="echo",
|
||||||
|
description="Echo text.",
|
||||||
|
input_schema={"type": "object", "properties": {"text": {"type": "string"}}},
|
||||||
|
),
|
||||||
|
McpTool(name="", description="skipped: empty name", input_schema={}),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_tool_name_sanitizes_and_namespaces() -> None:
|
||||||
|
assert mcp_tool_name("my server", "a/b c") == "mcp__my_server__a_b_c"
|
||||||
|
assert mcp_tool_name("x", "y").startswith("mcp__")
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_names_tags_source_and_purge() -> None:
|
||||||
|
manager = _StubManager()
|
||||||
|
with catalog_scope(empty=True):
|
||||||
|
names = register_mcp_server_tools("docs", _tools(), manager=manager) # type: ignore[arg-type]
|
||||||
|
assert names == ["mcp__docs__echo"]
|
||||||
|
|
||||||
|
from plyngent.tools.catalog import get_catalog
|
||||||
|
|
||||||
|
entry = get_catalog().get("mcp__docs__echo")
|
||||||
|
assert entry is not None
|
||||||
|
assert entry.source.kind == "mcp"
|
||||||
|
assert entry.source.plugin_id == "docs"
|
||||||
|
assert str(entry.source) == "mcp:docs"
|
||||||
|
definition = entry.definition
|
||||||
|
assert definition.tags == ToolTag.LOCAL
|
||||||
|
assert definition.description.startswith("[mcp:docs]")
|
||||||
|
assert definition.parameters["type"] == "object"
|
||||||
|
assert "text" in definition.parameters["properties"]
|
||||||
|
|
||||||
|
assert purge_mcp_tools() == 1
|
||||||
|
assert get_catalog().get("mcp__docs__echo") is None
|
||||||
|
assert purge_mcp_tools() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_read_only_flag() -> None:
|
||||||
|
manager = _StubManager()
|
||||||
|
with catalog_scope(empty=True):
|
||||||
|
register_mcp_server_tools("docs", _tools()[:1], manager=manager, read_only=True) # type: ignore[arg-type]
|
||||||
|
from plyngent.tools.catalog import get_catalog
|
||||||
|
|
||||||
|
entry = get_catalog().get("mcp__docs__echo")
|
||||||
|
assert entry is not None
|
||||||
|
assert entry.definition.tags == ToolTag.LOCAL | ToolTag.READ_ONLY
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_registration_collides() -> None:
|
||||||
|
manager = _StubManager()
|
||||||
|
with catalog_scope(empty=True):
|
||||||
|
register_mcp_server_tools("docs", _tools()[:1], manager=manager) # type: ignore[arg-type]
|
||||||
|
with pytest.raises(ValueError, match="collision"):
|
||||||
|
register_mcp_server_tools("docs", _tools()[:1], manager=manager) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_handler_proxies_to_manager_call() -> None:
|
||||||
|
manager = _StubManager()
|
||||||
|
with catalog_scope(empty=True):
|
||||||
|
register_mcp_server_tools("docs", _tools()[:1], manager=manager) # type: ignore[arg-type]
|
||||||
|
from plyngent.tools.catalog import get_catalog
|
||||||
|
|
||||||
|
entry = get_catalog().get("mcp__docs__echo")
|
||||||
|
assert entry is not None
|
||||||
|
result = await entry.definition.handler(text="hi") # type: ignore[union-attr]
|
||||||
|
assert result == "docs:echo:hi"
|
||||||
|
assert manager.calls == [("docs", "echo", {"text": "hi"})]
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -67,7 +67,60 @@ def test_tree_origin_subdir(workspace: object) -> None:
|
|||||||
assert isinstance(workspace, Path)
|
assert isinstance(workspace, Path)
|
||||||
_build_sample(workspace)
|
_build_sample(workspace)
|
||||||
out = call_sync(tree, "src")
|
out = call_sync(tree, "src")
|
||||||
|
assert out.startswith("- ")
|
||||||
|
assert "- nested/" in out
|
||||||
|
assert "main.py" in out
|
||||||
|
assert "deep.txt" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_tree_default_is_markdown(workspace: object) -> None:
|
||||||
|
assert isinstance(workspace, Path)
|
||||||
|
_build_sample(workspace)
|
||||||
|
out = call_sync(tree, ".")
|
||||||
|
assert out.startswith("- ")
|
||||||
|
assert "- src/" in out
|
||||||
|
assert "├──" not in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_tree_markdown_format(workspace: object) -> None:
|
||||||
|
assert isinstance(workspace, Path)
|
||||||
|
_build_sample(workspace)
|
||||||
|
out = call_sync(tree, ".", format="markdown", max_depth=3, max_entries=10)
|
||||||
|
assert "- src/" in out
|
||||||
|
# nested bullets are indented by 2 spaces per level
|
||||||
|
assert " - main.py" in out
|
||||||
|
assert " - nested/" in out
|
||||||
|
assert " - deep.txt" in out
|
||||||
|
assert "more entries not shown" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_tree_flat_format(workspace: object) -> None:
|
||||||
|
assert isinstance(workspace, Path)
|
||||||
|
_build_sample(workspace)
|
||||||
|
out = call_sync(tree, ".", format="flat", max_entries=200)
|
||||||
|
assert "src/" in out # dirs keep a trailing slash
|
||||||
|
assert "src/main.py" in out
|
||||||
|
assert "src/nested/deep.txt" in out
|
||||||
|
assert "a.txt" in out
|
||||||
|
assert "- " not in out
|
||||||
|
assert "├──" not in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_tree_flat_total_cap(workspace: object) -> None:
|
||||||
|
assert isinstance(workspace, Path)
|
||||||
|
_build_sample(workspace)
|
||||||
|
out = call_sync(tree, "pkg", format="flat", max_depth=2, max_entries=10)
|
||||||
|
paths = [line for line in out.splitlines() if not line.startswith("…")]
|
||||||
|
assert len(paths) == 10
|
||||||
|
assert "more paths not shown" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_tree_decorated_format(workspace: object) -> None:
|
||||||
|
assert isinstance(workspace, Path)
|
||||||
|
_build_sample(workspace)
|
||||||
|
out = call_sync(tree, "src", format="decorated")
|
||||||
assert out.startswith("src/")
|
assert out.startswith("src/")
|
||||||
|
assert "├──" in out
|
||||||
assert "main.py" in out
|
assert "main.py" in out
|
||||||
assert "deep.txt" in out
|
assert "deep.txt" in out
|
||||||
|
|
||||||
|
|||||||
@@ -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