mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-09 00:47:54 +08:00
Compare commits
18 Commits
d3f8132b5d
...
v0.9.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e7acbaf18 | |||
| c55c5be779 | |||
| b0afed612f | |||
| 85b0318b77 | |||
| ab6b5d590c | |||
| 1c84d63f37 | |||
| a4f852eec6 | |||
| 6232925c2c | |||
| 60dd96329d | |||
| 3bc585ad3d | |||
| 2eb99a079d | |||
| ac33e92d33 | |||
| 8729330120 | |||
| e72d2359d7 | |||
| 1f04889449 | |||
| 64a8cf7062 | |||
| 6807055b9f | |||
| 287b838db9 |
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Project overview
|
## Project overview
|
||||||
|
|
||||||
Plyngent is an LLM chat and agent toolkit (Python 3.14+, PDM-managed). Single-user CLI is usable: protocol clients, config, async memory, agent tool loop, workspace tools, skill discovery, and REPL/one-shot chat. Multi-tenant `router/` / web remain Phase H.
|
Plyngent is an LLM chat and agent toolkit (Python 3.14+, PDM-managed). Single-user CLI is usable: protocol clients, config, async memory, agent tool loop, workspace tools, skill discovery, and REPL/one-shot chat. Multi-tenant web (`web/`) remains Phase H.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ 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). `[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. `[skills]` (`SkillsConfig`): `paths` (explicit roots first), `discover` (`plyngent` user dir + `claude` dirs) with `include_project_roots`, `inject_catalog` / `max_catalog_skills`, `enabled`, and `allow_write` (standing write grant); a malformed section degrades to defaults like `[agent]` / `[mcp]`.
|
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. `[skills]` (`SkillsConfig`): `paths` (explicit roots first), `discover` (`plyngent` user dir + `claude` dirs) with `include_project_roots`, `enabled`, and `allow_write` (standing write grant); a malformed section degrades to defaults like `[agent]` / `[mcp]`.
|
||||||
|
|
||||||
`reasoning.py`: thinking strength as `reasoning_effort` (`ReasoningEffortConfig`: `""` = send nothing | `none` … `high` … `max`) and `thinking_budget_tokens`, settable on `[agent]`, a provider, or a model entry — `resolve_reasoning(agent, provider, model)` merges the layers per field (model > provider > agent) and clamps `max` (DeepSeek chat only) to `xhigh` for every other surface, returning a `ReasoningConfig` the agent loop and `create_client` consume.
|
`reasoning.py`: thinking strength as `reasoning_effort` (`ReasoningEffortConfig`: `""` = send nothing | `none` … `high` … `max`) and `thinking_budget_tokens`, settable on `[agent]`, a provider, or a model entry — `resolve_reasoning(agent, provider, model)` merges the layers per field (model > provider > agent) and clamps `max` (DeepSeek chat only) to `xhigh` for every other surface, returning a `ReasoningConfig` the agent loop and `create_client` consume.
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ MCP (`runtime/mcp_client.py`): `McpManager` owns one `McpServerConnection` per `
|
|||||||
|
|
||||||
### Skills (`skills/`)
|
### Skills (`skills/`)
|
||||||
|
|
||||||
Discovery and parsing, independent of the agent: `build_roots(config, workspace=…, user_config_dir=…, home=…)` resolves `[skills]` into ordered `SkillRoot`s (`config` explicit paths → `plyngent` user dir → `claude` user/project dirs), `SkillStore` scans each root for directories with a `SKILL.md` (a root may itself be one), parses the frontmatter subset (`frontmatter.py`), and caches the records against the directory list and its `SKILL.md` timestamps. `skills` returns the visible records sorted by name (first root wins), `shadowed()` the losing copies, `find(name)` exact-then-case-insensitive, `default_write_root()` our own directory (or the first root), `reload()` forces a rescan; `resolve_inside` / `resolve_skill_file` keep every read and write inside a skill directory (absolute paths and symlink escapes are refused). Records carry `issues` (unusable declared name, no description, unparseable frontmatter) so a repaired third-party skill stays visible instead of being silently fixed.
|
Discovery and parsing, independent of the agent: `build_roots(config, workspace=…, user_config_dir=…, home=…)` resolves `[skills]` into ordered `SkillRoot`s (`config` explicit paths → `plyngent` user dir → `claude` user/project dirs), `SkillStore` scans each root for directories with a `SKILL.md` (a root may itself be one) and parses the frontmatter subset (`frontmatter.py`) on **every** access — nothing is cached, so an edit or a new skill made outside the chat is simply there. `skills` returns the visible records sorted by name (first root wins), `shadowed()` the losing copies, `find(name)` exact-then-case-insensitive, `default_write_root()` our own directory (or the first root); `resolve_inside` / `resolve_skill_file` keep every read and write inside a skill directory (absolute paths and symlink escapes are refused). Records carry `issues` (unusable declared name, no description, unparseable frontmatter) so a repaired third-party skill stays visible instead of being silently fixed.
|
||||||
|
|
||||||
### Memory (`memory/`)
|
### Memory (`memory/`)
|
||||||
|
|
||||||
@@ -80,32 +80,32 @@ Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `
|
|||||||
- **`responses_bridge` / `responses_dispatch`**: chat ↔ OpenAI Responses; stream text/reasoning deltas (`response.reasoning_text.done` repeats what its fragments spelled out, so the loop streams each part once and still keeps the whole text in the round's message); tool calls + usage on `response.completed`; a request carrying `tools` returns every round's reasoning as a `reasoning` item of `reasoning_text` parts — "必须完整回传 `reasoning_content` … 即使该轮模型未实际进行工具调用" (a missing item and an empty part are both a 400, so a round that kept none sends a blank line) — and a round's `function_call` items stay contiguous ahead of their outputs (`call, output, call, output` was a 400: the second call reads as a round of its own without reasoning); reasoning is read back from the item's `content` parts.
|
- **`responses_bridge` / `responses_dispatch`**: chat ↔ OpenAI Responses; stream text/reasoning deltas (`response.reasoning_text.done` repeats what its fragments spelled out, so the loop streams each part once and still keeps the whole text in the round's message); tool calls + usage on `response.completed`; a request carrying `tools` returns every round's reasoning as a `reasoning` item of `reasoning_text` parts — "必须完整回传 `reasoning_content` … 即使该轮模型未实际进行工具调用" (a missing item and an empty part are both a 400, so a round that kept none sends a blank line) — and a round's `function_call` items stay contiguous ahead of their outputs (`call, output, call, output` was a 400: the second call reads as a round of its own without reasoning); reasoning is read back from the item's `content` parts.
|
||||||
- **`messages_bridge` / `messages_dispatch`**: chat ↔ Anthropic Messages; system fold; tool_use/tool_result; stream text + tool argument fragments; `thinking` from the request's `reasoning_effort` (level → token budget, floor 1024, `max_tokens` grown when the budget would leave no room) plus the client's configured `thinking_budget_tokens`; a server tool (`server_tool_use` + its `web_search_tool_result`) is the surface's own business — it streams `input_json_delta` fragments like a call but nothing local asked for it, so only a `tool_use` block becomes a tool call and nothing is executed; `thinking` and the two search blocks are kept as `AssistantChatMessage.provider_blocks` (the thinking text also lands in `reasoning_content`, so it streams as reasoning) and go back with the round, which is also why a round that kept only those blocks is not an empty completion; a chat-completions request drops the field (`compat.coerce_chat_messages`).
|
- **`messages_bridge` / `messages_dispatch`**: chat ↔ Anthropic Messages; system fold; tool_use/tool_result; stream text + tool argument fragments; `thinking` from the request's `reasoning_effort` (level → token budget, floor 1024, `max_tokens` grown when the budget would leave no room) plus the client's configured `thinking_budget_tokens`; a server tool (`server_tool_use` + its `web_search_tool_result`) is the surface's own business — it streams `input_json_delta` fragments like a call but nothing local asked for it, so only a `tool_use` block becomes a tool call and nothing is executed; `thinking` and the two search blocks are kept as `AssistantChatMessage.provider_blocks` (the thinking text also lands in `reasoning_content`, so it streams as reasoning) and go back with the round, which is also why a round that kept only those blocks is not an empty completion; a chat-completions request drops the field (`compat.coerce_chat_messages`).
|
||||||
- **Provider-side tools**: `provider_tools` on the OpenAI **and** DeepSeek presets (list of dicts; `[{type="web_search"}]` when omitted; `[]` disables) threaded via `ChatAgent`/`run_chat_loop` into Responses and Messages; never executed by `ToolRegistry`. The Messages surface declares its own server tool instead — a bare `web_search` becomes `{type="web_search_20250305", name="web_search"}` (the search then runs inside the response, and its `encrypted_content` results go back verbatim with the round — `AssistantChatMessage.provider_blocks`) — and a DeepSeek provider on a surface that ignores hosted tools (`config.routing.local_web_search`) gets the local `web_search` tool from `tools/net/search.py` instead.
|
- **Provider-side tools**: `provider_tools` on the OpenAI **and** DeepSeek presets (list of dicts; `[{type="web_search"}]` when omitted; `[]` disables) threaded via `ChatAgent`/`run_chat_loop` into Responses and Messages; never executed by `ToolRegistry`. The Messages surface declares its own server tool instead — a bare `web_search` becomes `{type="web_search_20250305", name="web_search"}` (the search then runs inside the response, and its `encrypted_content` results go back verbatim with the round — `AssistantChatMessage.provider_blocks`) — and a DeepSeek provider on a surface that ignores hosted tools (`config.routing.local_web_search`) gets the local `web_search` tool from `tools/net/search.py` instead.
|
||||||
- **`@tool` / `ToolRegistry`**: decorator infers JSON Schema from type hints; execute tools by name. Before running, `execute` answers a call that omits a required argument with the accepted names (`_missing_required_error`) — the handler's bare `TypeError` never reaches the model, and no confirm prompt fires for a call that cannot run. Shape mistakes inside a *present* argument are the tool's own job (see the `chat` bullet).
|
- **`@tool` / `ToolRegistry`**: decorator infers JSON Schema from type hints; execute tools by name. Before running, `execute` answers a call that omits a required argument with the accepted names (`_missing_required_error`) — the handler's bare `TypeError` never reaches the model, and no confirm prompt fires for a call that cannot run. Shape mistakes inside a *present* argument are the tool's own job (see the `chat` bullet). `@tool(writes=("path",))` names a handler's file arguments: one batch applies calls that name the same file one at a time, in call order — the lock is taken before the confirm prompt, so a pending prompt cannot be overtaken — while different files keep running in parallel.
|
||||||
- **`run_chat_loop`**: multi-round tool loop; default **streaming** text deltas + stream tool-call merge; parallel tools; tool-result char budget; soft context compact on request (**API-calibrated** after first usage when available); cooperative cancel points; optional `on_limit`; resolved `reasoning_effort` on every request (see Config).
|
- **`run_chat_loop`**: multi-round tool loop; default **streaming** text deltas + stream tool-call merge; parallel tools; tool-result char budget; soft context compact on request (**API-calibrated** after first usage when available); cooperative cancel points; optional `on_limit`; resolved `reasoning_effort` on every request (see Config).
|
||||||
- **`ChatAgent`**: optional `MemoryStore` (user message persisted immediately; **completed tool batches checkpointed** mid-turn; unfinished assistant suffix rolled back on failure); `stream`; system prompt; `provider_tools`; `retry()` continues incomplete turns (user-only **or** after committed tools — does not re-run those tools).
|
- **`ChatAgent`**: optional `MemoryStore` (user message persisted immediately; **completed tool batches checkpointed** mid-turn; unfinished assistant suffix rolled back on failure); `stream`; system prompt; `provider_tools`; `retry()` continues incomplete turns (user-only **or** after committed tools — does not re-run those tools).
|
||||||
- **`/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), `mcp_instructions` (fold connected MCP servers' initialize ``instructions`` into the system prompt when tools on; default true), `[skills] inject_catalog` (fold the skill catalog — name/source/description only — the same way), `allow_paths` (static out-of-workspace pre-allow `path → read|write|exec`; never prompts), `auto_continue_limits` (auto-raise tool/PTY limits without prompting; default false), `reasoning_effort` / `thinking_budget_tokens` (thinking strength; see Config), `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), `allow_paths` (static out-of-workspace pre-allow `path → read|write|exec`; never prompts), `auto_continue_limits` (auto-raise tool/PTY limits without prompting; default false), `reasoning_effort` / `thinking_budget_tokens` (thinking strength; see Config), `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 **or** a directory-access grant (mode-aware: `read` < `write` < `exec`, default required `write`; a file grant matches only that file); path substring denylist (wins over grants); command denylist via `match_command_denylist` (checks every program of the wrapper/`run` chain, resolved by `tools/command_scan.py`; an entry is a program, plus optional argument words that make it dangerous — `systemctl reboot poweroff …` matches `systemctl reboot` and `systemctl start reboot.target`, not `systemctl status`; basenames are lower-cased, so `RM` / `rm.exe` count; defaults cover privilege `sudo`/`su`/`doas`/`pkexec`/`run0`/`systemd-run`, destructive `rm`/`mkfs`/`dd`/`chown`/`mount`/…, and power state `shutdown`/`reboot`/`poweroff`/`halt`/`init`/`telinit`/`kexec`/`pm-*`/`s2*`; CLI: timed human allow override, default deny on timeout; **not** skipped by YOLO); escape error hints `request_directory_access`.
|
- **`workspace`**: path resolve under primary root **or** temporary allowlist roots **or** a directory-access grant (mode-aware: `read` < `write` < `exec`, default required `write`; a file grant matches only that file); path substring denylist (wins over grants); command denylist via `match_command_denylist` (checks every program of the wrapper/`run` chain, resolved by `tools/command_scan.py`; an entry is a program, plus optional argument words that make it dangerous — `systemctl reboot poweroff …` matches `systemctl reboot` and `systemctl start reboot.target`, not `systemctl status`; basenames are lower-cased, so `RM` / `rm.exe` count; defaults cover privilege `sudo`/`su`/`doas`/`pkexec`/`run0`/`systemd-run`, destructive `rm`/`mkfs`/`dd`/`chown`/`mount`/…, and power state `shutdown`/`reboot`/`poweroff`/`halt`/`init`/`telinit`/`kexec`/`pm-*`/`s2*`; CLI: timed human allow override, default deny on timeout; **not** skipped by YOLO); escape error names the `request_directory_access` mode the call needs (`call request_directory_access with mode='write'`).
|
||||||
- **`access`** (`tools/access.py`): `request_directory_access` (LOCAL|INSTANCE|SESSION) asks the host hook for out-of-workspace access; grants are one `path → mode` pair in one of three stores — `WorkspacePolicy.config_allow` (static `[agent].allow_paths`), `SessionState.access_grants` (human-approved, persisted on the session row), `WorkspacePolicy.yolo_allow` (`--yes`/`/yolo`, process only). `AccessDecision(mode, persist)` from the hook; denylisted paths can never be granted; cap `MAX_ACCESS_GRANTS`. Not a sandbox: `exec` grants (and any command) reach the whole FS.
|
- **`access`** (`tools/access.py`): `request_directory_access` (LOCAL|INSTANCE|SESSION) asks the host hook for out-of-workspace access; grants are one `path → mode` pair in one of three stores — `WorkspacePolicy.config_allow` (static `[agent].allow_paths`), `SessionState.access_grants` (human-approved, persisted on the session row), `WorkspacePolicy.yolo_allow` (`--yes`/`/yolo`, process only). `AccessDecision(mode, persist)` from the hook; denylisted paths can never be granted; cap `MAX_ACCESS_GRANTS`. Not a sandbox: `exec` grants (and any command) reach the whole FS.
|
||||||
- **`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`, `regex_files` (Python `re` per line, not a glob; literal file/dir `path`, no glob expansion; skips 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=...`).
|
- **`file`**: `read_file` (`with_lineno`, `max_chars` default 32k; results start with a 1-based `L{begin}-{end}` range), `write_file` (refuses to replace an existing file unless `overwrite=true`; that replacement is the one soft-confirmed), `listdir`, `tree` (model picks `format=markdown` default / `flat` paths / `decorated`; VCS + default noise dirs + optional `skip_dirs` / denylist walk), `glob_paths`, `regex_files` (Python `re` per line, not a glob; literal file/dir `path`, no glob expansion; skips 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. The prompt accepts `y`/empty (once), `n`, or `yyy` = stop asking for the rest of the turn (`cli.limits` module flag, reset by `run_turn_with_retries`, `/btw`, session switches, and chat exit). `--auto-continue` / `[agent] auto_continue_limits` set the process-wide default (covers one-shot/PTY); the flag does not imply YOLO and vice versa.
|
- CLI limit hooks: interactive confirm to raise tool-loop rounds, PTY session cap, or PTY output budget. The prompt accepts `y`/empty (once), `n`, or `yyy` = stop asking for the rest of the turn (`cli.limits` module flag, reset by `run_turn_with_retries`, `/btw`, session switches, and chat exit). `--auto-continue` / `[agent] auto_continue_limits` set the process-wide default (covers one-shot/PTY); the flag does not imply YOLO and vice versa.
|
||||||
- Destructive confirms: `classify_danger` + `ToolRegistry(on_confirm=…)`; CLI default deny; config `confirm_destructive` / `path_denylist`. Soft confirm also for interpreters — bare shells/REPLs, scripts, and `-c` one-liners alike (`-c` is code only for an interpreter, so `grep -c` / `od -c` stay unflagged; argv + code preview) —, detached runners (`nohup`), and wrapper options that carry a command line (`env -S "…"`, `su -c "…"`, whose text is printed under its own `env -S:` label), each resolved past env wrappers and `<launcher> run`; deny may include a user comment for the model. Session YOLO: `/yolo on|off|once` and `--yes` (skip soft confirms; hard denylists unchanged; `once` expires after the next user turn).
|
- Destructive confirms: `classify_danger` + `ToolRegistry(on_confirm=…)`; CLI default deny; config `confirm_destructive` / `path_denylist`. Soft confirm also for interpreters — bare shells/REPLs, scripts, and `-c` one-liners alike (`-c` is code only for an interpreter, so `grep -c` / `od -c` stay unflagged; argv + code preview) —, detached runners (`nohup`), and wrapper options that carry a command line (`env -S "…"`, `su -c "…"`, whose text is printed under its own `env -S:` label), each resolved past env wrappers and `<launcher> run`; deny may include a user comment for the model. Session YOLO: `/yolo on|off|once` and `--yes` (skip soft confirms; hard denylists unchanged; `once` expires after the next user turn).
|
||||||
- **`vcs`**: read-only VCS tools (`vcs_kind` / `vcs_status` / `vcs_diff` / `vcs_log` / `vcs_branch`) via `VcsBackend` protocol; **git** implemented; detectors are pluggable for other systems.
|
- **`vcs`**: read-only VCS tools (`vcs_kind` / `vcs_status` / `vcs_diff` / `vcs_log` / `vcs_branch`) via `VcsBackend` protocol; **git** implemented; detectors are pluggable for other systems.
|
||||||
- **`chat`**: human prompts as tools — `ask_user_line` / `ask_user_choice` / `ask_user_form` (shared `prompting` core); `wait` (line prompt with timeout; Enter disturbs). Container args are shape-checked (`tools/chat/shape.py`): a mis-typed string `options`/`fields` (or a non-string `question`) returns an explained error — `options="a, b"` used to iterate characters into a per-character menu. `wait`'s `duration` accepts numeric strings (`"5"` = 5s) and rejects booleans/text/containers.
|
- **`chat`**: human prompts as tools — `ask_user_line` / `ask_user_choice` / `ask_user_form` (shared `prompting` core); `wait` (line prompt with timeout; Enter disturbs). Container args are shape-checked (`tools/chat/shape.py`): a mis-typed string `options`/`fields` (or a non-string `question`) returns an explained error — `options="a, b"` used to iterate characters into a per-character menu. `wait`'s `duration` accepts numeric strings (`"5"` = 5s) and rejects booleans/text/containers.
|
||||||
- **`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 the number a listing shows (render order, TOP group first; the stored `id` stays internal); 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.
|
||||||
- **`net` / `web_search`** (`tools/net/search.py`, `tools/net/bing.py`): the hosted search a DeepSeek provider cannot use, as a local tool. `build_web_search_tool(backends)` closes over the sources named by `provider.search_sources` and asks them **in fallback order** (`search_in_order`): `DeepseekSearchBackend` (one `/messages` turn with the server tool, `lmproto/deepseek/anthropic/search.py`) and/or `BingSearchBackend` (Bing's RSS results, snippets included, fetched through the same client as `fetch`). The handler returns one `title — url` entry per hit (plus the snippet when the source has one); a source that raises or finds nothing is skipped and reported only if every source fails. Registered by the CLI host only when `config.routing.local_web_search` says so: a DeepSeek provider whose `provider_tools` ask for `web_search`, on a surface that ignores hosted tools (`convention` other than `anthropic`), with a non-empty `search_sources`. Tags LOCAL|READ_ONLY.
|
- **`net` / `web_search`** (`tools/net/search.py`, `tools/net/bing.py`): the hosted search a DeepSeek provider cannot use, as a local tool. `build_web_search_tool(backends)` closes over the sources named by `provider.search_sources` and asks them **in fallback order** (`search_in_order`): `DeepseekSearchBackend` (one `/messages` turn with the server tool, `lmproto/deepseek/anthropic/search.py`) and/or `BingSearchBackend` (Bing's RSS results, snippets included, fetched through the same client as `fetch`). The handler returns one `title — url` entry per hit (plus the snippet when the source has one); a source that raises or finds nothing is skipped and reported only if every source fails. Registered by the CLI host only when `config.routing.local_web_search` says so: a DeepSeek provider whose `provider_tools` ask for `web_search`, on a surface that ignores hosted tools (`convention` other than `anthropic`), with a non-empty `search_sources`. Tags LOCAL|READ_ONLY.
|
||||||
- **`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`.
|
- **`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`.
|
||||||
- **`skills`** (`tools/skills.py`): discovery, reading, and writing of skill directories (`plyngent.skills` owns roots, the `SKILL.md` frontmatter subset, the mtime-cached store, and the inside-the-skill path rule). `skill_list` (names, source root, directory, description, shadowed copies, frontmatter notes), `skill_read` (a `SKILL.md` by name, or one bundled file — reuses `read_file` for ranges/line numbers/`truncate_token`), `skill_search` (regex over every visible skill via `regex_files`, hits rebased on `<skill>/<file>`), `skill_create` (new `SKILL.md` + optional `files`), `skill_edit` (replace / `append` / rewrite one file; a rewritten `SKILL.md` is re-parsed and notes a lost description). Reads need no grant (the host publishes the roots as `WorkspacePolicy.static_read`, so `read_file`/`regex_files` see them while writes stay denied); writes go through **`SkillWritePolicy`** on the instance — denylist first, then `SessionState.skill_write_roots` (a `session` answer), then `[skills].allow_write`, then the host's confirm hook (CLI `prompt_skill_write_confirm`: `o`nce/`s`ession/`n`o, timed, default deny, **not** skipped by YOLO); a host with no hook denies. Grants are per **skill directory**; `[skills].enabled = false` drops the tools from the registry (`SKILL_TOOL_NAMES`).
|
- **`skills`** (`tools/skills.py`): discovery, reading, and writing of skill directories (`plyngent.skills` owns roots, the `SKILL.md` frontmatter subset, the uncached store, and the inside-the-skill path rule). `skill_list` (names, source root, directory, description, shadowed copies, frontmatter notes), `skill_read` (a `SKILL.md` by name, or one bundled file — reuses `read_file` for ranges/line numbers/`truncate_token`), `skill_search` (regex over every visible skill via `regex_files`, hits rebased on `<skill>/<file>`), `skill_create` (new `SKILL.md` + optional `files`), `skill_edit` (replace / `append` / rewrite one file; a rewritten `SKILL.md` is re-parsed and notes a lost description). Reads need no grant (the host publishes the roots as `WorkspacePolicy.static_read`, so `read_file`/`regex_files` see them while writes stay denied); writes go through **`SkillWritePolicy`** on the instance — denylist first, then `SessionState.skill_write_roots` (a `session` answer), then `[skills].allow_write`, then the host's confirm hook (CLI `prompt_skill_write_confirm`: `o`nce/`s`ession/`n`o, timed, default deny, **not** skipped by YOLO); a host with no hook denies. Grants are per **skill directory**; `[skills].enabled = false` drops the tools from the registry (`SKILL_TOOL_NAMES`).
|
||||||
- **`DEFAULT_TOOLS` / catalog**: access + file + process + vcs + chat + todo + skills + net (`fetch`) for local surface. `ToolTag.READ_ONLY` marks non-mutating tools (`read_file`, `get_truncated`, `listdir`, `glob_paths`, `regex_files`, `tree`, `todo_list`, `wait`, `ask_user_*`, `vcs_*`, `skill_list`/`skill_read`/`skill_search`, `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.
|
- **`DEFAULT_TOOLS` / catalog**: access + file + process + vcs + chat + todo + skills + net (`fetch`) for local surface. `ToolTag.READ_ONLY` marks non-mutating tools (`read_file`, `get_truncated`, `listdir`, `glob_paths`, `regex_files`, `tree`, `todo_list`, `wait`, `ask_user_*`, `vcs_*`, `skill_list`/`skill_read`/`skill_search`, `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`)
|
||||||
@@ -117,12 +117,12 @@ 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), `--auto-continue` (auto-raise limits), `--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`); each server spawns in its own session/process group (`start_new_session` / `CREATE_NEW_PROCESS_GROUP`), so a terminal Ctrl+C does not kill it.
|
- **`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), `--auto-continue` (auto-raise limits), `--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`); each server spawns in its own session/process group (`start_new_session` / `CREATE_NEW_PROCESS_GROUP`), so a terminal Ctrl+C does not kill it.
|
||||||
- Slash: Click group in `cli/slash.py` + `awaitlet` for async work; Tab completer from registry + ParamType `shell_complete`. `/history` is turn-oriented (`cli/transcript.py`): a turn runs from a user row to the next one, rows carry **display ordinals** (never DB row numbers; restart after `/clear`), the turn's ends (user + final answer) always print in full, `-v` expands every row (one-liners), `-vv` prints full bodies + local rows, `--preview` collapses every row, and `N`/`last [N]` address turns while `--message N` addresses one row. 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. `/skills` (`list` default) prints the discovered skills with their roots, shadowed copies, and frontmatter notes; `/skills search PATTERN` and `/skills read NAME [--file F]` run the matching tool handlers (the slash path binds the tool context itself); `/skills reload` rescans the roots and rebuilds tools. `/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. `/grants` lists directory-access grants (`revoke <index|all>`; config grants point at the TOML); `/status` includes the grant count and the visible skill count (or `off` when `[skills].enabled` is false).
|
- Slash: Click group in `cli/slash.py` + `awaitlet` for async work; Tab completer from registry + ParamType `shell_complete`. `/history` is turn-oriented (`cli/transcript.py`): a turn runs from a user row to the next one, rows carry **display ordinals** (never DB row numbers; restart with the session), the turn's ends (user + final answer) always print in full, `-v` expands every row (one-liners), `-vv` prints full bodies + local rows, `--preview` collapses every row, and `N`/`last [N]` address turns while `--message N` addresses one row. 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. `/skills` (`list` default) prints the discovered skills with their roots, shadowed copies, and frontmatter notes; `/skills search PATTERN` runs the matching tool handler (the slash path binds the tool context itself); `/skills open NAME` reveals the skill's directory in the system file manager. `/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. `/grants` lists directory-access grants (`revoke <index|all>`; config grants point at the TOML); `/status` includes the grant count and the visible skill count (or `off` when `[skills].enabled` is false).
|
||||||
- `/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. The model gets an `aside` notice as its last message before the question (unsaved exchange + tool scope), shown to the user as `[notice] aside: …`.
|
- `/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. The model gets an `aside` notice as its last message before the question (unsaved exchange + tool scope), shown to the user as `[notice] aside: …`.
|
||||||
- Explicit `/resume` or `--session` from another workspace prompts: **keep** / **update** / **abort**. A session resumed by a **freshly started process** (`--session` / default latest) also gets a `resume` notice about what a restart drops (PTY sessions, running commands, temp workspaces) — resuming inside a live process pushes nothing.
|
- Explicit `/resume` or `--session` from another workspace prompts: **keep** / **update** / **abort**. A session resumed by a **freshly started process** (`--session` / default latest) also gets a `resume` notice about what a restart drops (PTY sessions, running commands, temp workspaces) — resuming inside a live process pushes nothing.
|
||||||
- Host notices (`agent/notices.py`): system-level messages appended as trailing `developer` rows, never system-prompt edits — `Notice.to_message()` for the model, `.summary` for the user via `cli.display.echo_notice` (`--quiet` hides the user line only). Durable hosts persist them (`ChatAgent.push_notice`); a side agent without memory keeps its notice local. Trailing developer messages never end a turn, so `/retry` still sees an interrupted turn behind a notice or checkpoint.
|
- Host notices (`agent/notices.py`): system-level messages appended as trailing `developer` rows, never system-prompt edits — `Notice.to_message()` for the model, `.summary` for the user via `cli.display.echo_notice` (`--quiet` hides the user line only). Durable hosts persist them (`ChatAgent.push_notice`); a side agent without memory keeps its notice local. `has_notice()` dedupes a session-scoped notice: the `skills` one is pushed once per session (feature enabled + tools on), lists nothing, and makes the model ask with `skill_list` / `skill_search`. Trailing developer messages never end a turn, so `/retry` still sees an interrupted turn behind a notice or checkpoint.
|
||||||
- Failed/cancelled turns: user kept; **committed tool rounds kept**, and a batch cut short keeps the calls that already produced a result (`rollback_tail`; written to the store, so a resume continues from them instead of re-running them) — side effects are not re-run on `/retry`, and only unfinished assistant text is dropped; Ctrl+C cancels the in-flight turn (a session-scoped asyncio SIGINT handler routes it, `cli/interrupt.py` — a stray Ctrl+C while the loop waits never tears the REPL down); TTY confirms/ask prompts run off-loop and register their own SIGINT target, so Ctrl+C there cancels just the poll-based read (`wait` reports `cancelled by user`) or is ignored with a hint for readline prompts (`ask_user_*`, confirms) — never the turn, never the loop; auto-retry waits 5s/10s/15s/20s then +10s (10 attempts), its countdown is Ctrl+C-cancellable, and the budget **resets when an attempt completes a model round** (connection recovered); then `/retry`.
|
- Failed/cancelled turns: user kept; **committed tool rounds kept**, and a batch cut short keeps the calls that already produced a result (`rollback_tail`; written to the store, so a resume continues from them instead of re-running them) — side effects are not re-run on `/retry`, and only unfinished assistant text is dropped; Ctrl+C cancels the in-flight turn (a session-scoped asyncio SIGINT handler routes it, `cli/interrupt.py` — a stray Ctrl+C while the loop waits never tears the REPL down); TTY confirms/ask prompts run off-loop and register their own SIGINT target, so Ctrl+C there cancels just the poll-based read (`wait` reports `cancelled by user`) or is ignored with a hint for readline prompts (`ask_user_*`, confirms) — never the turn, never the loop; auto-retry waits 5s/10s/15s/20s then +10s (10 attempts), its countdown is Ctrl+C-cancellable, and the budget **resets when an attempt completes a model round** (connection recovered); then `/retry`.
|
||||||
- Pretty tool lines: every builtin tool prints its `* Verb 'target' ` prefix as the call starts and appends the outcome (`L1-4 (done)`, `(exit code 0)`, `(session 3)`) when the result lands, so a blocking tool stays visible while it runs; namespaced MCP tools share one generic line (`* MCP <server>:<tool> (done)`). Plugin tools and custom tool calls keep the `[tool] …` / `[tool ok] …` whole-line style. Interactive TTY only (`interactive_terminal()`): piped/plain output and `--verbose` keep whole lines. A second call of the same round holds its prefix back until the open row takes its own detail (results arrive in call order), so each call still prints one finished row; a prompt box (or other output) printed while the tool runs *ends* the open prefix as its own line and falls back to whole lines for the rest of the batch — the cursor is never moved, so a prefix wider than the terminal cannot be half-erased. A batch (one round's calls) that only touches todo tools prints one `* Todo x<N>: Push, Update` line plus its last result, whose text already carries the final stack, instead of one header and a full list render per call.
|
- Pretty tool lines: every builtin tool prints its `* Verb 'target' ` prefix as the call starts and appends the outcome (`L1-4 (done)`, `(exit code 0)`, `(session 3)`) when the result lands, so a blocking tool stays visible while it runs; namespaced MCP tools share one generic line (`* MCP <server>:<tool> (done)`). Plugin tools and custom tool calls keep the `[tool] …` / `[tool ok] …` whole-line style. Interactive TTY only (`interactive_terminal()`): piped/plain output and `--verbose` keep whole lines. A second call of the same round holds its prefix back until the open row takes its own detail (results arrive in call order), so each call still prints one finished row; a prompt box (or other output) printed while the tool runs *ends* the open prefix as its own line and falls back to whole lines for the rest of the batch — the cursor is never moved, so a prefix wider than the terminal cannot be half-erased. A batch (one round's calls) that only touches todo tools prints one `* Todo x<N>: Push, Update` line plus its last result, whose text already carries the final stack, instead of one header and a full list render per call. `run_argv_batch` lists its commands above its row: one `$ command` line per step, a `pipe_out` chain joined into one line with ` | `, each line elided past 80 characters.
|
||||||
- **`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.
|
||||||
- Tools default on; workspace defaults to cwd; `--max-rounds` default 32. Readline history under platformdirs (`repl_history`). PTY: `close_all` on chat exit.
|
- Tools default on; workspace defaults to cwd; `--max-rounds` default 32. Readline history under platformdirs (`repl_history`). PTY: `close_all` on chat exit.
|
||||||
|
|
||||||
@@ -171,7 +171,7 @@ A nested function is counted **twice**: its decision points are added to the enc
|
|||||||
| G5 (done) | Master FD non-inheritable; `read_pty`/`close_pty` via `to_thread`; chat exit `close_all`; fork stays on loop/main thread. |
|
| G5 (done) | Master FD non-inheritable; `read_pty`/`close_pty` via `to_thread`; chat exit `close_all`; fork stays on loop/main thread. |
|
||||||
| Phase H | Optional PTY helper process (JSON/Unix socket), or subprocess+PTY with asyncio reaping; sandboxed tools, multi-session isolation. |
|
| Phase H | Optional PTY helper process (JSON/Unix socket), or subprocess+PTY with asyncio reaping; sandboxed tools, multi-session isolation. |
|
||||||
|
|
||||||
- **Phase H**: multi-tenant platform (`router/`, auth, sandboxed tools, web). Optional **out-of-process PTY host** if isolation is required.
|
- **Phase H**: multi-tenant platform (auth, sandboxed tools, `web/`). Optional **out-of-process PTY host** if isolation is required.
|
||||||
|
|
||||||
## Commit messages
|
## Commit messages
|
||||||
|
|
||||||
|
|||||||
@@ -162,12 +162,10 @@ max_context_tokens = 200000
|
|||||||
|
|
||||||
# Optional skills (instruction directories). See doc/skills.md.
|
# Optional skills (instruction directories). See doc/skills.md.
|
||||||
# [skills]
|
# [skills]
|
||||||
# enabled = true # false = no skill tools, no prompt catalog
|
# enabled = true # false = no skill tools
|
||||||
# paths = ["~/team-skills"] # explicit roots, highest priority
|
# paths = ["~/team-skills"] # explicit roots, highest priority
|
||||||
# discover = ["plyngent", "claude"] # our user dir + other harnesses' dirs
|
# discover = ["plyngent", "claude"] # our user dir + other harnesses' dirs
|
||||||
# include_project_roots = true # also <workspace>/.claude/skills
|
# include_project_roots = true # also <workspace>/.claude/skills
|
||||||
# inject_catalog = true # fold name + description into the system prompt
|
|
||||||
# max_catalog_skills = 50
|
|
||||||
# allow_write = false # true = skip the per-write grant prompt
|
# allow_write = false # true = skip the per-write grant prompt
|
||||||
|
|
||||||
# Optional plugins (entry-point names); default load none. See doc/plugins.md.
|
# Optional plugins (entry-point names); default load none. See doc/plugins.md.
|
||||||
@@ -193,7 +191,7 @@ thinking_budget_tokens = 16000 # explicit Anthropic thinking budget
|
|||||||
|
|
||||||
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).
|
||||||
|
|
||||||
**Skills** (instruction directories with a `SKILL.md`): discovered under `[skills].paths`, our own user skills directory, and other harnesses' skill directories (`~/.claude/skills`, plus `<workspace>/.claude/skills` when `include_project_roots`). The catalog — name, source, description — is folded into the system prompt when tools are on; the model reads a `SKILL.md` with `skill_read`, searches with `skill_search`, and creates or updates skills with `skill_create` / `skill_edit` behind a timed `once`/`session`/`no` grant (`[skills].allow_write = true` for a standing one; the path denylist still wins and `--yes`/`/yolo` never skips it). Reading needs no grant: skill roots are read-only roots, so `read_file` / `regex_files` see them too while writes stay denied. Details: [doc/skills.md](doc/skills.md).
|
**Skills** (instruction directories with a `SKILL.md`): discovered under `[skills].paths`, our own user skills directory, and other harnesses' skill directories (`~/.claude/skills`, plus `<workspace>/.claude/skills` when `include_project_roots`). A session-start notice (a host notice, never a system-prompt edit) tells the model that the skill tools exist and how to use them — `skill_list` to see what exists, `skill_read` for a `SKILL.md`, `skill_search` for content — and deliberately lists nothing; `skill_create` / `skill_edit` write one behind a timed `once`/`session`/`no` grant (`[skills].allow_write = true` for a standing one; the path denylist still wins and `--yes`/`/yolo` never skips it). Reading needs no grant: skill roots are read-only roots, so `read_file` / `regex_files` see them too while writes stay denied. Details: [doc/skills.md](doc/skills.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).
|
**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).
|
||||||
|
|
||||||
@@ -288,7 +286,7 @@ Type `/help` in the REPL for the live list. Common ones:
|
|||||||
|---------|---------|
|
|---------|---------|
|
||||||
| `/status` | Provider, session, context/usage estimates |
|
| `/status` | Provider, session, context/usage estimates |
|
||||||
| `/history [N \| last [N]]` | Conversation grouped by turn: default = the last turn (your message + the model's answer); `N` = turn N, `last N` = the last N turns |
|
| `/history [N \| last [N]]` | Conversation grouped by turn: default = the last turn (your message + the model's answer); `N` = turn N, `last N` = the last N turns |
|
||||||
| `/history -v` / `-vv` | The turn's ends (your message, the answer) always print in full; `-v` adds every intermediate row (tool calls/results, notices) as a one-liner, `-vv` prints those in full too, and `--preview` collapses every row. `--message N` prints one message in full. Numbers are display ordinals shown by `/history`, never database rows (they restart after `/clear`) |
|
| `/history -v` / `-vv` | The turn's ends (your message, the answer) always print in full; `-v` adds every intermediate row (tool calls/results, notices) as a one-liner, `-vv` prints those in full too, and `--preview` collapses every row. `--message N` prints one message in full. Numbers are display ordinals shown by `/history`, never database rows (they restart with the session) |
|
||||||
| `/sessions` | Sessions for this workspace |
|
| `/sessions` | Sessions for this workspace |
|
||||||
| `/new` `/resume` `/rename` `/delete` | Session lifecycle (`/delete` confirms) |
|
| `/new` `/resume` `/rename` `/delete` | Session lifecycle (`/delete` confirms) |
|
||||||
| `/export [md\|json] [path]` | Transcript from DB (no secrets) |
|
| `/export [md\|json] [path]` | Transcript from DB (no secrets) |
|
||||||
@@ -302,13 +300,13 @@ Type `/help` in the REPL for the live list. Common ones:
|
|||||||
| `/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) |
|
||||||
| `/models --persist` | Merge remote catalog into TOML for this provider |
|
| `/models --persist` | Merge remote catalog into TOML for this provider |
|
||||||
| `/todos` | Todo/task stack: list, push, pop, done, clear |
|
| `/todos` | Todo/task stack: list (items numbered as listed), push, pop, done, clear |
|
||||||
| `/skills [list\|search\|read\|reload]` | List discovered skills, regex-search them, print a `SKILL.md` (or one bundled file), or rescan the roots |
|
| `/skills [list\|search\|open]` | List discovered skills, regex-search them, or open one's directory in the system file manager |
|
||||||
| `/grants` | Directory-access grants: list, or `revoke <index\|all>` |
|
| `/grants` | Directory-access grants: list, or `revoke <index\|all>` |
|
||||||
| `/config` | Edit `plyngent.toml` ($VISUAL/$EDITOR or system open); reload after blocking editor |
|
| `/config` | Edit `plyngent.toml` ($VISUAL/$EDITOR or system open); reload after blocking editor |
|
||||||
| `/quit` | Leave the REPL |
|
| `/quit` | Leave the REPL |
|
||||||
|
|
||||||
User messages are saved immediately. On API error or Ctrl+C, unfinished assistant text is discarded — tool calls that already produced a result stay, so `/retry` does not run them again — but the user message stays so `/retry` works after resume. Interactive auto-retry waits 5s, 10s, 15s, 20s, then +10s each step (10 attempts); an attempt that gets a full model round back **resets that budget**, so a connection that recovers and drops again starts over from 5s.
|
User messages are saved immediately. On API error or Ctrl+C, unfinished assistant text is discarded — tool calls that already produced a result stay, so `/retry` does not run them again — but the user message stays so `/retry` works after resume. Interactive auto-retry waits 5s, 10s, 15s, 20s, then +10s each step (10 attempts); an attempt that gets a full model round back **resets that budget**, so a connection that recovers and drops again starts over from 5s. Tool calls of one round run in parallel, but writes to the same file apply one at a time, in call order — a later edit sees the earlier one's result instead of stale text.
|
||||||
|
|
||||||
Ctrl+C cancels the in-flight turn, and during the auto-retry countdown it cancels the retry. Model-initiated prompts run off the event loop: a `wait` prompt is cancelled by Ctrl+C (the tool reports `cancelled by user` and the turn continues), while `ask_user_*` and confirm prompts ignore it with a one-line hint — answer them to continue. Ctrl+C never exits the REPL (use Ctrl+D or `/quit`), and MCP servers run in their own process group, so it never kills them either.
|
Ctrl+C cancels the in-flight turn, and during the auto-retry countdown it cancels the retry. Model-initiated prompts run off the event loop: a `wait` prompt is cancelled by Ctrl+C (the tool reports `cancelled by user` and the turn continues), while `ask_user_*` and confirm prompts ignore it with a one-line hint — answer them to continue. Ctrl+C never exits the REPL (use Ctrl+D or `/quit`), and MCP servers run in their own process group, so it never kills them either.
|
||||||
|
|
||||||
@@ -323,13 +321,14 @@ Ctrl+C cancels the in-flight turn, and during the auto-retry countdown it cancel
|
|||||||
|
|
||||||
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), `request_directory_access` (out-of-workspace access grants), `get_truncated` (resume any truncated result via its `truncate_token=...`), skills (`skill_list` / `skill_read` / `skill_search` / `skill_create` / `skill_edit`), 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), `request_directory_access` (out-of-workspace access grants), `get_truncated` (resume any truncated result via its `truncate_token=...`), skills (`skill_list` / `skill_read` / `skill_search` / `skill_create` / `skill_edit`), and todo stack tools (`todo_list` / `todo_push` / `todo_pop` / `todo_update` / `todo_clear`).
|
||||||
|
|
||||||
**Skills** are instruction directories shared with other harnesses: a folder with a `SKILL.md` (YAML frontmatter — `name`, `description`, … — plus a Markdown body) and optional bundled files (scripts, references). Discovery, roots, grants, and the authoring format are documented in [doc/skills.md](doc/skills.md); the short version is that the catalog (name + description) goes into the system prompt, the body is read on demand with `skill_read`, and writing a skill is a separate grant (a timed `once`/`session`/`no` prompt, or `[skills].allow_write`) that is **not** a directory-access grant and is never skipped by YOLO.
|
**Skills** are instruction directories shared with other harnesses: a folder with a `SKILL.md` (YAML frontmatter — `name`, `description`, … — plus a Markdown body) and optional bundled files (scripts, references). Discovery, roots, grants, and the authoring format are documented in [doc/skills.md](doc/skills.md); the short version is that a session-start notice points the model at them (`skill_list` / `skill_read` / `skill_search`) without listing anything, so what exists (and the bodies) is read on demand rather than injected, and writing a skill is a separate grant (a timed `once`/`session`/`no` prompt, or `[skills].allow_write`) that is **not** a directory-access grant and is never skipped by YOLO.
|
||||||
|
|
||||||
Safety defaults:
|
Safety defaults:
|
||||||
|
|
||||||
- Paths stay under the workspace unless granted (`request_directory_access` / `[agent].allow_paths`); optional `path_denylist` substrings always apply (`tree` also skips denylisted children by default).
|
- Paths stay under the workspace unless granted (`request_directory_access` / `[agent].allow_paths`); the refusal names the `mode` that would cover the call; optional `path_denylist` substrings always apply (`tree` also skips denylisted children by default).
|
||||||
- Command basename denylist (e.g. dangerous shells/utilities).
|
- Command basename denylist (e.g. dangerous shells/utilities).
|
||||||
- Destructive tools (delete/move/overwrite) can require confirm (`confirm_destructive`; default deny in non-TTY). Override for the session with `/yolo on|off|once` or startup `--yes` (path/command denylists still apply).
|
- Destructive tools (delete/move/overwrite) can require confirm (`confirm_destructive`; default deny in non-TTY). Override for the session with `/yolo on|off|once` or startup `--yes` (path/command denylists still apply).
|
||||||
|
- `write_file` refuses to replace an existing file unless `overwrite=true`; the confirm asks about that replacement.
|
||||||
- PTY sessions: caps, idle TTL, output budget; master FD is non-inheritable; sessions closed on chat exit.
|
- PTY sessions: caps, idle TTL, output budget; master FD is non-inheritable; sessions closed on chat exit.
|
||||||
Prefer file tools over full-screen editors (`vim`/`nano`) for edits. `read_pty` sanitizes CSI/controls
|
Prefer file tools over full-screen editors (`vim`/`nano`) for edits. `read_pty` sanitizes CSI/controls
|
||||||
so tool results cannot reprogram the host TTY (no host terminal reset on exit).
|
so tool results cannot reprogram the host TTY (no host terminal reset on exit).
|
||||||
@@ -361,7 +360,7 @@ See [doc/architecture.md](doc/architecture.md) and [AGENTS.md](AGENTS.md) for de
|
|||||||
- **`memory/`** — async SQLAlchemy sessions/messages
|
- **`memory/`** — async SQLAlchemy sessions/messages
|
||||||
- **`tools/`** — workspace tools
|
- **`tools/`** — workspace tools
|
||||||
- **`cli/`** — Click entry + slash registry (`awaitlet` bridges sync Click to async work)
|
- **`cli/`** — Click entry + slash registry (`awaitlet` bridges sync Click to async work)
|
||||||
- Multi-tenant / web (`router/`, real `web/`) are **not** in scope for the single-user CLI (Phase H).
|
- Multi-tenant web (`web/`) is **not** in scope for the single-user CLI (Phase H).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,6 @@
|
|||||||
- utils: Common utilities for code architecture.
|
- utils: Common utilities for code architecture.
|
||||||
- components: Utilities for class composition.
|
- components: Utilities for class composition.
|
||||||
- memory: Storage controlling for sessions and messages.
|
- memory: Storage controlling for sessions and messages.
|
||||||
- router: Multi-source capability routing (Phase H; not implemented).
|
|
||||||
- config: Plyngent configuration center (TOML), including ``[plugins]`` and
|
- config: Plyngent configuration center (TOML), including ``[plugins]`` and
|
||||||
``[networking]`` (e.g. fetch SSRF Fake-IP CIDR exemptions). Model entries
|
``[networking]`` (e.g. fetch SSRF Fake-IP CIDR exemptions). Model entries
|
||||||
may override provider ``preset`` / ``url`` for mixed API routing.
|
may override provider ``preset`` / ``url`` for mixed API routing.
|
||||||
|
|||||||
@@ -31,8 +31,8 @@
|
|||||||
# Discovery roots in priority order: explicit `paths`, our own user config
|
# Discovery roots in priority order: explicit `paths`, our own user config
|
||||||
# directory (<config dir>/skills), other harnesses' skill directories
|
# directory (<config dir>/skills), other harnesses' skill directories
|
||||||
# (~/.claude/skills; plus <workspace>/.claude/skills when include_project_roots).
|
# (~/.claude/skills; plus <workspace>/.claude/skills when include_project_roots).
|
||||||
# The catalog (name + description) is folded into the system prompt when tools
|
# A session-start notice describes the tools; what exists is found with
|
||||||
# are on; the model reads bodies with skill_read and searches with skill_search.
|
# skill_list, bodies are read with skill_read, contents searched with skill_search.
|
||||||
# Reading needs no grant (the roots are read-only roots); creating or editing a
|
# Reading needs no grant (the roots are read-only roots); creating or editing a
|
||||||
# skill asks once/session, or stands with allow_write = true. See doc/skills.md.
|
# skill asks once/session, or stands with allow_write = true. See doc/skills.md.
|
||||||
# [skills]
|
# [skills]
|
||||||
@@ -40,8 +40,6 @@
|
|||||||
# paths = ["~/team-skills", "/srv/skills"]
|
# paths = ["~/team-skills", "/srv/skills"]
|
||||||
# discover = ["plyngent", "claude"]
|
# discover = ["plyngent", "claude"]
|
||||||
# include_project_roots = true
|
# include_project_roots = true
|
||||||
# inject_catalog = true
|
|
||||||
# max_catalog_skills = 50
|
|
||||||
# allow_write = false
|
# allow_write = false
|
||||||
|
|
||||||
# MCP (Model Context Protocol) servers exposed as agent tools.
|
# MCP (Model Context Protocol) servers exposed as agent tools.
|
||||||
|
|||||||
+6
-6
@@ -49,12 +49,10 @@ lists the ones with no directory.
|
|||||||
|
|
||||||
```toml
|
```toml
|
||||||
[skills]
|
[skills]
|
||||||
enabled = true # false = no skill tools and no prompt catalog
|
enabled = true # false = no skill tools
|
||||||
paths = ["~/team-skills"] # explicit roots; `~` expands to your home
|
paths = ["~/team-skills"] # explicit roots; `~` expands to your home
|
||||||
discover = ["plyngent", "claude"]
|
discover = ["plyngent", "claude"]
|
||||||
include_project_roots = true
|
include_project_roots = true
|
||||||
inject_catalog = true # fold the catalog into the system prompt
|
|
||||||
max_catalog_skills = 50
|
|
||||||
allow_write = false # true = standing grant for skill writes
|
allow_write = false # true = standing grant for skill writes
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -70,13 +68,15 @@ a note: the skill stays listable instead of vanishing.
|
|||||||
| Field | Meaning |
|
| Field | Meaning |
|
||||||
|-------|---------|
|
|-------|---------|
|
||||||
| `name` | Skill name; must be lowercase letters, digits, and single hyphens (max 64). A name that cannot be used falls back to the directory name, with a note. |
|
| `name` | Skill name; must be lowercase letters, digits, and single hyphens (max 64). A name that cannot be used falls back to the directory name, with a note. |
|
||||||
| `description` | One line shown in the catalog and in `skill_list`. Missing → the first non-heading paragraph of the body. |
|
| `description` | One line shown by `skill_list` (and `/skills` for the human). Missing → the first non-heading paragraph of the body. |
|
||||||
| `version`, `license`, `allowed-tools`, … | Kept and shown by `skill_list`; `allowed-tools` is **recorded, not enforced** today. |
|
| `version`, `license`, `allowed-tools`, … | Kept and shown by `skill_list`; `allowed-tools` is **recorded, not enforced** today. |
|
||||||
|
|
||||||
## What the agent sees
|
## What the agent sees
|
||||||
|
|
||||||
- **Catalog** (system prompt, `inject_catalog`): names, sources, descriptions —
|
- **Notice** (a host notice, `agent/notices.py`): when skills are enabled and
|
||||||
never the bodies, so a turn only pays for the skill it actually reads.
|
tools are on, the session gets one `skills` notice describing the tools; it
|
||||||
|
lists nothing, so what *exists* is asked for and a turn only pays for the
|
||||||
|
skill it actually reads.
|
||||||
- `skill_list` — what exists, where it came from, shadowed copies, and the notes
|
- `skill_list` — what exists, where it came from, shadowed copies, and the notes
|
||||||
from repaired frontmatter.
|
from repaired frontmatter.
|
||||||
- `skill_read NAME [file]` — the `SKILL.md` body (ranges, line numbers, and a
|
- `skill_read NAME [file]` — the `SKILL.md` body (ranges, line numbers, and a
|
||||||
|
|||||||
@@ -231,13 +231,13 @@ files = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iniconfig"
|
name = "iniconfig"
|
||||||
version = "2.3.0"
|
version = "2.3.1"
|
||||||
requires_python = ">=3.10"
|
requires_python = ">=3.10"
|
||||||
summary = "brain-dead simple config-ini parsing"
|
summary = "brain-dead simple config-ini parsing"
|
||||||
groups = ["dev"]
|
groups = ["dev"]
|
||||||
files = [
|
files = [
|
||||||
{file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"},
|
{file = "iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7"},
|
||||||
{file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
|
{file = "iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -422,24 +422,24 @@ files = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "platformdirs"
|
name = "platformdirs"
|
||||||
version = "4.12.3"
|
version = "4.13.3"
|
||||||
requires_python = ">=3.10"
|
requires_python = ">=3.11"
|
||||||
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.12.3-py3-none-any.whl", hash = "sha256:080f3b39423b5abfca9a23d84c4e9795f54d395cd8459867a8ded44084fcd5f8"},
|
{file = "platformdirs-4.13.3-py3-none-any.whl", hash = "sha256:f6ad7f447f24f8a3b82cce5976387428bff894a0eca6c3488f4a17f153c130c4"},
|
||||||
{file = "platformdirs-4.12.3.tar.gz", hash = "sha256:427fc0bb321ae0c5b037fa03238ca74820437be162e78b4848c4d4055b9b766c"},
|
{file = "platformdirs-4.13.3.tar.gz", hash = "sha256:5e567f664eb087ab8521c0179cd8d1bd60857d271136567a39719e28e2d383ce"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pluggy"
|
name = "pluggy"
|
||||||
version = "1.6.0"
|
version = "1.7.0"
|
||||||
requires_python = ">=3.9"
|
requires_python = ">=3.10"
|
||||||
summary = "plugin and hook calling mechanisms for python"
|
summary = "plugin and hook calling mechanisms for python"
|
||||||
groups = ["dev"]
|
groups = ["dev"]
|
||||||
files = [
|
files = [
|
||||||
{file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
|
{file = "pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec"},
|
||||||
{file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
|
{file = "pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -491,17 +491,21 @@ files = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pywinpty"
|
name = "pywinpty"
|
||||||
version = "3.0.5"
|
version = "3.1.0"
|
||||||
requires_python = ">=3.10"
|
requires_python = ">=3.10"
|
||||||
summary = "Pseudo terminal support for Windows from Python."
|
summary = "Pseudo terminal support for Windows from Python."
|
||||||
groups = ["default"]
|
groups = ["default"]
|
||||||
marker = "sys_platform == \"win32\""
|
marker = "sys_platform == \"win32\""
|
||||||
files = [
|
files = [
|
||||||
{file = "pywinpty-3.0.5-cp314-cp314-win_amd64.whl", hash = "sha256:03bb3c16d691d9242267201830bcd0e64a9b663170e9042bc84b210da9de15ac"},
|
{file = "pywinpty-3.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:d01178e9391366bc3325b4c49068cfce95d10d416f6ade62140b274741bc11e0"},
|
||||||
{file = "pywinpty-3.0.5-cp314-cp314-win_arm64.whl", hash = "sha256:89c5c6ef08997a3b4b277b214a35fe15cab4dd6d119f0140aa71df5b1168fdbc"},
|
{file = "pywinpty-3.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:44918ef8bc408fbf52f54db9b8c33370aee0a59cee76b071a4b4f41ad5ed89c8"},
|
||||||
{file = "pywinpty-3.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7b566165e0c5fdd6abe167a5ac8b954be6a843eb55a85946576d6bc1dea03d6d"},
|
{file = "pywinpty-3.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:08afc428ec8765f2b8b9a9954727e1dafbefcb5912acc65952007779449650a1"},
|
||||||
{file = "pywinpty-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:24366280a8aa677323da87bec729cb3ea3b35367386cece0978bdc6e4695c690"},
|
{file = "pywinpty-3.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:98f00e50639e9edb740eb6eedd6c0ab82959f9633029db59363d06973d00ac0d"},
|
||||||
{file = "pywinpty-3.0.5.tar.gz", hash = "sha256:61db0db063de9865adbea66db294628f8577f608d9764a4c7d3384eeacc4e81b"},
|
{file = "pywinpty-3.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:188923081eea840c15f37c54a29ad6871b04fddf8fbf1c4acf317d0e27bd9cac"},
|
||||||
|
{file = "pywinpty-3.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:714e8aa138bc61dcfd26c139d8cb8e7b954ea5c09c98bb014ab9b1f5741e289c"},
|
||||||
|
{file = "pywinpty-3.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:c08a2e5fa7a62f31b5965f0be1b92c67394cd5f75dc0a033d45fadc597f0640d"},
|
||||||
|
{file = "pywinpty-3.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:751d0c727d0e81a246aba722cf0dcb2155fd6bc7b82574b1432f81256157ec73"},
|
||||||
|
{file = "pywinpty-3.1.0.tar.gz", hash = "sha256:14595b68054d61f2f1e90cc2b30be47be201de200ae1851c433d8cded101bce0"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -593,7 +597,7 @@ files = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlalchemy"
|
name = "sqlalchemy"
|
||||||
version = "2.1.3"
|
version = "2.1.4"
|
||||||
requires_python = ">=3.11"
|
requires_python = ">=3.11"
|
||||||
summary = "Database Abstraction Library"
|
summary = "Database Abstraction Library"
|
||||||
groups = ["default"]
|
groups = ["default"]
|
||||||
@@ -601,59 +605,59 @@ dependencies = [
|
|||||||
"typing-extensions>=4.6.0",
|
"typing-extensions>=4.6.0",
|
||||||
]
|
]
|
||||||
files = [
|
files = [
|
||||||
{file = "sqlalchemy-2.1.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:660c6e16d254568874d4b77fd593aa5426f55cf40d0acc8b5364a5e61cc1044d"},
|
{file = "sqlalchemy-2.1.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7a2f6164c0527cd8fc4cea79a5c9d8369ffee417b8ba444a42342f36b91deb75"},
|
||||||
{file = "sqlalchemy-2.1.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7277ba31ffb636666ed207d2aaf74de3cd2ee78436da7552bd4eeadf1f67740a"},
|
{file = "sqlalchemy-2.1.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6929a11ad26a91a4efd891c1252b373c2e88f056910b83ec6030ed3f2cbcb734"},
|
||||||
{file = "sqlalchemy-2.1.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b728c406b1b202e8998e8f9adacb56773bfeea67da74252ad3025bcb8f5863f1"},
|
{file = "sqlalchemy-2.1.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14528d37d7d46a92f2a483f188f7fecd86cdd789254a0412b960c9fc5e9efd6d"},
|
||||||
{file = "sqlalchemy-2.1.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:85623c57c3696dcbc4fc909bbf335a2f0c27ae36f762d83810067fb49abc040e"},
|
{file = "sqlalchemy-2.1.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d2cb669c6bd1f19caf51db6e3c4fdd4cbb76f9db3ef81c3aeb5e288d9bae101b"},
|
||||||
{file = "sqlalchemy-2.1.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0de6dc326cc99ac31206359e9fc2c13345f667edeb6ef556585fedac4ff698"},
|
{file = "sqlalchemy-2.1.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:63dc25b21fd9a41dc09b7aada4b3b0d97cf4b6414f74bced6ac45326bc799ac9"},
|
||||||
{file = "sqlalchemy-2.1.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:65e002b794efa1bacbbde9fa973e5340d6c3c29b53e592b863a281802829319d"},
|
{file = "sqlalchemy-2.1.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:308f96d24e773d64609a2a0d1161a068f9f6e9165523bc4e07aa9c45f0c4213f"},
|
||||||
{file = "sqlalchemy-2.1.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65e4813c78eaa679c9369b289e67fdba924db6be14067700c5ccbda37953af9a"},
|
{file = "sqlalchemy-2.1.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93b9416b9011a3b7689a933e04ac9f61d15686b6cb1948ebc1f41467153116c3"},
|
||||||
{file = "sqlalchemy-2.1.3-cp314-cp314-win32.whl", hash = "sha256:726987e7f8500614016be056ba5c9715b760ab465295741e2087b29753d72632"},
|
{file = "sqlalchemy-2.1.4-cp314-cp314-win32.whl", hash = "sha256:89db94855287fdac98d74595cf13ea59fbffa608d6400ff972b0fd4c036d873f"},
|
||||||
{file = "sqlalchemy-2.1.3-cp314-cp314-win_amd64.whl", hash = "sha256:0560458a775917bcd7a47eccf4f9e0b135fd9b72d7b819cb1bd60581de946f9a"},
|
{file = "sqlalchemy-2.1.4-cp314-cp314-win_amd64.whl", hash = "sha256:080f8d853aac5bb5620f0ae6f46527397cf18dce0ec2b478b478469ef3cae2c4"},
|
||||||
{file = "sqlalchemy-2.1.3-cp314-cp314-win_arm64.whl", hash = "sha256:63c9289ed81991ba4d500611ce201531711430f5d23d7ae9a61a8842639b85ea"},
|
{file = "sqlalchemy-2.1.4-cp314-cp314-win_arm64.whl", hash = "sha256:64d41be1dd88f184de1931f0173f4827122a1b49fd1150656641200c0bdf640c"},
|
||||||
{file = "sqlalchemy-2.1.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:08b2f345db8b0742e7814c49f9a210d5f5c8fb9d80d12c8a63bba762fa277477"},
|
{file = "sqlalchemy-2.1.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:84272f329c15081a1e09b4a7261118b4e8a547f43e00fca98e55bbdf19eff3be"},
|
||||||
{file = "sqlalchemy-2.1.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:493b632f426579c4b0e97a5610f76c8c3d401cede442d462bf700b537035c91e"},
|
{file = "sqlalchemy-2.1.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b3f58bd26fc010ea28976d401845e4e6ce02e1b7c0288b3ea9c9a3c396f0bcc"},
|
||||||
{file = "sqlalchemy-2.1.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:55e06933580f4faa5a7b1d2c9ad04f365fe01c40465eb982e38fda8758c1e95c"},
|
{file = "sqlalchemy-2.1.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:82d728075d42bd457d09655cf22e99d772a648c6f67e86743a4f05b7d063ca18"},
|
||||||
{file = "sqlalchemy-2.1.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fdcee9536cb3281b8810787ba651f4e5f22bd687b177de89fba2155b055b07b0"},
|
{file = "sqlalchemy-2.1.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0970394ec5d9e397aafc5bc5fa2b7f8b58cb191f2703006b19a96ef4bf00b8d9"},
|
||||||
{file = "sqlalchemy-2.1.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d2cb28779dbf54067655030d7ab3a6726c7957c56bde5ff6745ed86b5cebff41"},
|
{file = "sqlalchemy-2.1.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6005f2f5fcd67fdd721446128e6a2a1d18f77387a604fbd26b0006a086b33096"},
|
||||||
{file = "sqlalchemy-2.1.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:01a90cac3f8d93fcbda0df119ef56c4d58aad5601f6b7ee5a38bfabbd5d119b8"},
|
{file = "sqlalchemy-2.1.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:0e01a3e199ae219381c4889993c5584b1b905fffe6830f639adb6770036a8913"},
|
||||||
{file = "sqlalchemy-2.1.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:edbc28049ab8da07387ea2b85ceee747b36b25444ffa4aaba9f6e1461d5b2634"},
|
{file = "sqlalchemy-2.1.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:22129e7d00ac66b291840c4dc83a9c497456ab5bffa682dcbfdc2356f9e49e5a"},
|
||||||
{file = "sqlalchemy-2.1.3-cp314-cp314t-win32.whl", hash = "sha256:724f587ced076d89291032b35476d6bdb401cce1dd680c1e3418c01d9bd55ad4"},
|
{file = "sqlalchemy-2.1.4-cp314-cp314t-win32.whl", hash = "sha256:bc33d3e59d4e84b8866cc9ba13732585e37212dbe3542cb09f232682b36f47a5"},
|
||||||
{file = "sqlalchemy-2.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:935085a5b8849d324c22bc3cc09fd07b3669cc22ba5bdd3ec208829e535cdef9"},
|
{file = "sqlalchemy-2.1.4-cp314-cp314t-win_amd64.whl", hash = "sha256:346d144e8912ae087b10d3c2081657cb634728600693eee6dbb71d7eb4768101"},
|
||||||
{file = "sqlalchemy-2.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2ebfff82cece947fea1ed6337d4f0613b9799f6db621c9729790fdd41edfb5ea"},
|
{file = "sqlalchemy-2.1.4-cp314-cp314t-win_arm64.whl", hash = "sha256:3e5de57c71b3460e2ca6137e82cd3cb8c9f711f301f50d5c77156fdb9c822999"},
|
||||||
{file = "sqlalchemy-2.1.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:47a052dcd935a62326216bc5f0ed10ad941c77c3acc9200385e0c03c4f31d5a9"},
|
{file = "sqlalchemy-2.1.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:418786f05387ddb66ee683a1d016c5a8d9bf7be921e6ee8f285c7b6ac961a731"},
|
||||||
{file = "sqlalchemy-2.1.3-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e5916b43a9a8f346026f663583f203d0c43febad504ff2fed504dec6e078f20"},
|
{file = "sqlalchemy-2.1.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:283914efed30e4d44301e36ac90ad048570538b8a70f072fe01578d9b205d09c"},
|
||||||
{file = "sqlalchemy-2.1.3-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af78a85af63114e2650f019ddf8fffb871e659e92bffb7d0bce5753f706caf"},
|
{file = "sqlalchemy-2.1.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d2eacdbeb990b80235763860923c60a8393745b66f7149a734980c65896da72"},
|
||||||
{file = "sqlalchemy-2.1.3-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d218698c4f05728a941d6ad7c769b6491f03058e102cf811b7e4900f78fffa07"},
|
{file = "sqlalchemy-2.1.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e43fca5fdd5f34a3f8c54107a3648d3139de8bbf596a189f3f0de94bd84949bb"},
|
||||||
{file = "sqlalchemy-2.1.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:80e6ccf111bc3f57530935f5541c6c1da50ac9713c418378840683532a45dd2d"},
|
{file = "sqlalchemy-2.1.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2e1b5343d315b10a4a71da481729f66f830a561595e02b61e8a5a65d658325ac"},
|
||||||
{file = "sqlalchemy-2.1.3-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:9a3ce1ff88dfb9f9f4305da07f4fab87720b609e264d134e47ae84a52df130bd"},
|
{file = "sqlalchemy-2.1.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:42c37c06adcecf444e8c981f7e9237a41bdd445c83da0df9e08b4ad958becbbc"},
|
||||||
{file = "sqlalchemy-2.1.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:77a365f7cb81bbd738c948b64e0b683f8f1b68efe51ca2815e8ad4baf6287ed9"},
|
{file = "sqlalchemy-2.1.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:bab7f51d38766d6a64da2b41976f1b3f9cc2ff37d3f2f63bdbac876199f3a48e"},
|
||||||
{file = "sqlalchemy-2.1.3-cp315-cp315-win32.whl", hash = "sha256:83757320391b96cde715af0772675941d3e345da9ced3696c030f7f55ffa8eab"},
|
{file = "sqlalchemy-2.1.4-cp315-cp315-win32.whl", hash = "sha256:1541ba5bf0f232cd61f9ef3df78c93977c72ba6031506a0e6d057b2a3ddb76e9"},
|
||||||
{file = "sqlalchemy-2.1.3-cp315-cp315-win_amd64.whl", hash = "sha256:f87533295b84c9e19ca2a72dc26a76e0192f0a2424f8fd575a82e75e7d58f9e1"},
|
{file = "sqlalchemy-2.1.4-cp315-cp315-win_amd64.whl", hash = "sha256:596a95611c217cb19c21f02f43c637cb507cab71dcf0467c5c7d98fcdd703007"},
|
||||||
{file = "sqlalchemy-2.1.3-cp315-cp315-win_arm64.whl", hash = "sha256:3e46a27b0c74af74aa6c08c983ca51db88d22dd86c316d72a634e989b7caef58"},
|
{file = "sqlalchemy-2.1.4-cp315-cp315-win_arm64.whl", hash = "sha256:0d1ca95e42ce3c18818f170b741d30a33b292c6f6b9a202ffd717e28fc99b8c7"},
|
||||||
{file = "sqlalchemy-2.1.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:06ef75e3e0c72d774b41f659bfc4f63d5e207d6c8418abe2b80843a4a25e2fa6"},
|
{file = "sqlalchemy-2.1.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0f672ed6972164fec94a8f0b21dcf8545080d0727866335fb8adf9f4764ce6ec"},
|
||||||
{file = "sqlalchemy-2.1.3-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70e05ae07dd9be29d4723bd0c880df622387eb51efb46a7b8efd49295248ffd7"},
|
{file = "sqlalchemy-2.1.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72e3fa41d1fdab87d4e88bbdd69c9522e2795549fbe7b07bcf4ae9ec175f4b11"},
|
||||||
{file = "sqlalchemy-2.1.3-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10fdff11782842696172489bd4861ae63c212e62ee7c4fc61fdda90961abd1f3"},
|
{file = "sqlalchemy-2.1.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cb2cb98d056e63e353ed697750004e07c79b054d73059ba3184ca3bb07296bea"},
|
||||||
{file = "sqlalchemy-2.1.3-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:edd3e6690f350c89c616e78e9b58833c3d8925607ac5faf3530f38f053f58e4d"},
|
{file = "sqlalchemy-2.1.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d66fdcc5506e0f8bb8d3f4f95125220a7cd6c46e8b1762750f01e9639973dd8"},
|
||||||
{file = "sqlalchemy-2.1.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:e3c165df26eca3d2612f60ea91af02648cb1bd492eadfa64dba15c9a3a0fb09c"},
|
{file = "sqlalchemy-2.1.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:81f802c96dbf96e59c6982fa1b87da7868920fb0c27b9b81e560a62f57c2ccfb"},
|
||||||
{file = "sqlalchemy-2.1.3-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:70c073d28d043766eb1725010792f302f59b14636ffef478793045471efe92dd"},
|
{file = "sqlalchemy-2.1.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:acf8982c70471a68aa90d1aba08b48860c55b3357ec84ccb0f09368ead2ce099"},
|
||||||
{file = "sqlalchemy-2.1.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:bf5f63229512f14e7bffc9a0041386318f592e76bbeaf9b750b39056094d91f3"},
|
{file = "sqlalchemy-2.1.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:778094c83e36c430756a7e1a1ac66fc3cffb2c6a1067958fe6b920abcec7bc5a"},
|
||||||
{file = "sqlalchemy-2.1.3-cp315-cp315t-win32.whl", hash = "sha256:697942762c40e31eb0fa39bf0e58e1ac1b5b1ff9022da62b74f7cdc53096a482"},
|
{file = "sqlalchemy-2.1.4-cp315-cp315t-win32.whl", hash = "sha256:963348422b22f760e9462e56bc32bf4d95d224cc5b8c79a3c6e3b786d3d2a2b2"},
|
||||||
{file = "sqlalchemy-2.1.3-cp315-cp315t-win_amd64.whl", hash = "sha256:a9c33c0d081a9d2c94229b9ae1834cb2ef81dd81253df4763e32111287b64c31"},
|
{file = "sqlalchemy-2.1.4-cp315-cp315t-win_amd64.whl", hash = "sha256:fba3500e170d25f581e053009edeb0b158116084d91d465de218718d336b67c3"},
|
||||||
{file = "sqlalchemy-2.1.3-cp315-cp315t-win_arm64.whl", hash = "sha256:c36ff11196dacf50312a2cc51a798bb41611331c174871f065be651a838a5361"},
|
{file = "sqlalchemy-2.1.4-cp315-cp315t-win_arm64.whl", hash = "sha256:0a9a464bc360856b7ea9bf8aa26aab92ca115dd08149cb0e004063d5db13584b"},
|
||||||
{file = "sqlalchemy-2.1.3-py3-none-any.whl", hash = "sha256:6f0cf4debd86cb5623a50310784bd005c215761ca7f618d0d4f46193d033675a"},
|
{file = "sqlalchemy-2.1.4-py3-none-any.whl", hash = "sha256:0b96edcc2cd60fe1e35f67a46f4eb076e57297841b9eae949ac5f196593f00a7"},
|
||||||
{file = "sqlalchemy-2.1.3.tar.gz", hash = "sha256:ade5281df06038c6394f532590d592d3381ee5d11b9e0006a2570aef41145118"},
|
{file = "sqlalchemy-2.1.4.tar.gz", hash = "sha256:7bd7ad604487daa7eab8716471c29a7185f17b5287ce73bb7bc79fea050d8cfd"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tomlkit"
|
name = "tomlkit"
|
||||||
version = "0.15.1"
|
version = "0.15.2"
|
||||||
requires_python = ">=3.9"
|
requires_python = ">=3.9"
|
||||||
summary = "Style preserving TOML library"
|
summary = "Style preserving TOML library"
|
||||||
groups = ["default"]
|
groups = ["default"]
|
||||||
files = [
|
files = [
|
||||||
{file = "tomlkit-0.15.1-py3-none-any.whl", hash = "sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304"},
|
{file = "tomlkit-0.15.2-py3-none-any.whl", hash = "sha256:05562412163a3037b94497385fb7f9204c637bc605af7a48199e41cf2272f437"},
|
||||||
{file = "tomlkit-0.15.1.tar.gz", hash = "sha256:e25bbf38843005246210a12982776f27f99cb9be67160e14434d0c0d21ee1e97"},
|
{file = "tomlkit-0.15.2.tar.gz", hash = "sha256:83e32755ba1d20d9340bd069ee97e9f6232ba2346b63ffcba425de01f7e327a6"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "plyngent"
|
name = "plyngent"
|
||||||
version = "0.8.0"
|
version = "0.9.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" },
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ from .loop import run_chat_loop as run_chat_loop
|
|||||||
from .notices import Notice as Notice
|
from .notices import Notice as Notice
|
||||||
from .notices import aside_notice as aside_notice
|
from .notices import aside_notice as aside_notice
|
||||||
from .notices import format_moment as format_moment
|
from .notices import format_moment as format_moment
|
||||||
|
from .notices import has_notice as has_notice
|
||||||
from .notices import resume_notice as resume_notice
|
from .notices import resume_notice as resume_notice
|
||||||
|
from .notices import skill_tools_notice as skill_tools_notice
|
||||||
from .tools import DangerClassifier as DangerClassifier
|
from .tools import DangerClassifier as DangerClassifier
|
||||||
from .tools import ToolConfirmHook as ToolConfirmHook
|
from .tools import ToolConfirmHook as ToolConfirmHook
|
||||||
from .tools import ToolDefinition as ToolDefinition
|
from .tools import ToolDefinition as ToolDefinition
|
||||||
|
|||||||
@@ -14,9 +14,15 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from plyngent.lmproto.openai_compatible.model import DeveloperChatMessage
|
from plyngent.lmproto.openai_compatible.model import DeveloperChatMessage
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
|
||||||
|
|
||||||
# Marker prefix so hosts and the model can tell notices apart from other
|
# Marker prefix so hosts and the model can tell notices apart from other
|
||||||
# developer messages (durable directive checkpoints share the role).
|
# developer messages (durable directive checkpoints share the role).
|
||||||
NOTICE_MARKER = "[plyngent notice"
|
NOTICE_MARKER = "[plyngent notice"
|
||||||
@@ -35,6 +41,16 @@ class Notice:
|
|||||||
return DeveloperChatMessage(content=f"{NOTICE_MARKER}: {self.kind}]\n{self.body.strip()}")
|
return DeveloperChatMessage(content=f"{NOTICE_MARKER}: {self.kind}]\n{self.body.strip()}")
|
||||||
|
|
||||||
|
|
||||||
|
def has_notice(messages: Sequence[AnyChatMessage], kind: str) -> bool:
|
||||||
|
"""Whether *messages* already carries a notice of *kind*.
|
||||||
|
|
||||||
|
Notices are persisted with the transcript, so a host asks this before
|
||||||
|
pushing one that belongs to the session rather than to an event.
|
||||||
|
"""
|
||||||
|
marker = f"{NOTICE_MARKER}: {kind}]"
|
||||||
|
return any(isinstance(message, DeveloperChatMessage) and message.content.startswith(marker) for message in messages)
|
||||||
|
|
||||||
|
|
||||||
def format_moment(moment: datetime | None) -> str:
|
def format_moment(moment: datetime | None) -> str:
|
||||||
"""Local ``YYYY-MM-DD HH:MM`` stamp for notices; ``unknown`` when absent."""
|
"""Local ``YYYY-MM-DD HH:MM`` stamp for notices; ``unknown`` when absent."""
|
||||||
if moment is None:
|
if moment is None:
|
||||||
@@ -90,3 +106,27 @@ def aside_notice(*, tools_mode: str) -> Notice:
|
|||||||
"""Notice for a ``/btw`` side question: unsaved exchange plus tool scope."""
|
"""Notice for a ``/btw`` side question: unsaved exchange plus tool scope."""
|
||||||
body = _ASIDE_BODIES.get(tools_mode, _ASIDE_BODIES["read"])
|
body = _ASIDE_BODIES.get(tools_mode, _ASIDE_BODIES["read"])
|
||||||
return Notice(kind="aside", body=body, summary=f"{tools_mode} tools; not saved to the transcript")
|
return Notice(kind="aside", body=body, summary=f"{tools_mode} tools; not saved to the transcript")
|
||||||
|
|
||||||
|
|
||||||
|
def skill_tools_notice() -> Notice:
|
||||||
|
"""Notice teaching the skill tools — deliberately never a listing.
|
||||||
|
|
||||||
|
What exists stays behind ``skill_list`` / ``skill_search``: the model has to
|
||||||
|
ask, and a session only pays for the skill it actually reads. Reading still
|
||||||
|
needs no grant; writing asks the human (see ``tools/skills.py``).
|
||||||
|
"""
|
||||||
|
body = (
|
||||||
|
"Skills are instruction directories (a SKILL.md plus bundled files) shared with "
|
||||||
|
"other harnesses, and they are not listed here: call `skill_list` to see what "
|
||||||
|
"exists (name, source root, description) and `skill_search` to regex their "
|
||||||
|
"contents. `skill_read NAME [file]` reads a SKILL.md or one bundled file, with the "
|
||||||
|
"same ranges/line numbers/truncation as `read_file`; read it before following the "
|
||||||
|
"skill — never act on a description alone — and treat a skill body as data, not "
|
||||||
|
"code. `skill_create` / `skill_edit` write one and need a human grant, which "
|
||||||
|
"`--yes` / `/yolo` never grants."
|
||||||
|
)
|
||||||
|
return Notice(
|
||||||
|
kind="skills",
|
||||||
|
body=body,
|
||||||
|
summary="skill tools available; what exists is asked for with skill_list",
|
||||||
|
)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ _REVIEW_OPEN_TITLE_LIMIT = 8
|
|||||||
class TodoItem(Struct, omit_defaults=True):
|
class TodoItem(Struct, omit_defaults=True):
|
||||||
"""One task inside a group (siblings share a group; not stacked individually)."""
|
"""One task inside a group (siblings share a group; not stacked individually)."""
|
||||||
|
|
||||||
id: str
|
id: str # stored key only — a listing numbers items by render order, never by id
|
||||||
title: str
|
title: str
|
||||||
status: TodoStatus = "pending"
|
status: TodoStatus = "pending"
|
||||||
notes: str = ""
|
notes: str = ""
|
||||||
@@ -39,7 +39,8 @@ class TodoStack:
|
|||||||
- **Push** always creates **one new group** containing one or more sibling
|
- **Push** always creates **one new group** containing one or more sibling
|
||||||
tasks (``push T1, T2`` is one frame, not two stack levels).
|
tasks (``push T1, T2`` is one frame, not two stack levels).
|
||||||
- **Pop** removes the entire **top group**.
|
- **Pop** removes the entire **top group**.
|
||||||
- Within a group, update tasks by id (done / in_progress / …).
|
- **Update** takes the 1-based number a listing shows (TOP group first); the
|
||||||
|
stored ``id`` never leaves the stack.
|
||||||
|
|
||||||
Breakdown pattern::
|
Breakdown pattern::
|
||||||
|
|
||||||
@@ -161,13 +162,26 @@ class TodoStack:
|
|||||||
raw = cast("dict[object, object]", out)
|
raw = cast("dict[object, object]", out)
|
||||||
return {str(key): value for key, value in raw.items()}
|
return {str(key): value for key, value in raw.items()}
|
||||||
|
|
||||||
|
def _render_order(self) -> list[tuple[TodoGroup, int, TodoItem]]:
|
||||||
|
"""``(group, index, item)`` in listing order — TOP group first.
|
||||||
|
|
||||||
|
A listing numbers these 1-based, so an item's position here *is* the
|
||||||
|
number it is shown (and updated) by.
|
||||||
|
"""
|
||||||
|
ordered: list[tuple[TodoGroup, int, TodoItem]] = []
|
||||||
|
for group in reversed(self._data.groups):
|
||||||
|
ordered.extend((group, index, item) for index, item in enumerate(group.items))
|
||||||
|
return ordered
|
||||||
|
|
||||||
def render(self) -> str:
|
def render(self) -> str:
|
||||||
if not self._data.groups:
|
if not self._data.groups:
|
||||||
return "(todo stack empty — no groups)"
|
return "(todo stack empty — no groups)"
|
||||||
lines: list[str] = [
|
lines: list[str] = [
|
||||||
f"(LIFO of groups: depth={self.depth}; TOP group = current breakdown level)",
|
f"(LIFO of groups: depth={self.depth}; TOP group = current breakdown level)",
|
||||||
]
|
]
|
||||||
# Top group first for the model.
|
# Top group first for the model; the running number is the item's
|
||||||
|
# position in :meth:`_render_order`, the same list ``update`` resolves.
|
||||||
|
number = 0
|
||||||
for offset, group in enumerate(reversed(self._data.groups)):
|
for offset, group in enumerate(reversed(self._data.groups)):
|
||||||
depth = self.depth - 1 - offset
|
depth = self.depth - 1 - offset
|
||||||
tag = " TOP" if offset == 0 else ""
|
tag = " TOP" if offset == 0 else ""
|
||||||
@@ -176,6 +190,7 @@ class TodoStack:
|
|||||||
lines.append(" (empty group)")
|
lines.append(" (empty group)")
|
||||||
continue
|
continue
|
||||||
for item in group.items:
|
for item in group.items:
|
||||||
|
number += 1
|
||||||
mark = {
|
mark = {
|
||||||
"pending": "[ ]",
|
"pending": "[ ]",
|
||||||
"in_progress": "[~]",
|
"in_progress": "[~]",
|
||||||
@@ -183,7 +198,7 @@ class TodoStack:
|
|||||||
"cancelled": "[-]",
|
"cancelled": "[-]",
|
||||||
}.get(item.status, "[?]")
|
}.get(item.status, "[?]")
|
||||||
note = f" — {item.notes}" if item.notes else ""
|
note = f" — {item.notes}" if item.notes else ""
|
||||||
lines.append(f" {mark} {item.id}: {item.title}{note}")
|
lines.append(f" {mark} {number}. {item.title}{note}")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
def turn_reminder_prompt(self) -> str:
|
def turn_reminder_prompt(self) -> str:
|
||||||
@@ -220,7 +235,7 @@ class TodoStack:
|
|||||||
headline,
|
headline,
|
||||||
action,
|
action,
|
||||||
"Tools: todo_list | todo_push(titles=[...]) | todo_update | todo_pop | todo_clear",
|
"Tools: todo_list | todo_push(titles=[...]) | todo_update | todo_pop | todo_clear",
|
||||||
"Rules: TOP = current level; push = one sibling group; pop = whole TOP group.",
|
"Rules: TOP = current level; push = one sibling group; pop = whole TOP group; update by listing number.",
|
||||||
"Stack:",
|
"Stack:",
|
||||||
self.render(),
|
self.render(),
|
||||||
]
|
]
|
||||||
@@ -232,7 +247,7 @@ class TodoStack:
|
|||||||
n_open = len(open_items)
|
n_open = len(open_items)
|
||||||
n_groups = self.depth
|
n_groups = self.depth
|
||||||
if n_open:
|
if n_open:
|
||||||
open_titles = "; ".join(f"{item.id}:{item.title}" for item in open_items[:_REVIEW_OPEN_TITLE_LIMIT])
|
open_titles = "; ".join(item.title for item in open_items[:_REVIEW_OPEN_TITLE_LIMIT])
|
||||||
if n_open > _REVIEW_OPEN_TITLE_LIMIT:
|
if n_open > _REVIEW_OPEN_TITLE_LIMIT:
|
||||||
open_titles += f"; …(+{n_open - _REVIEW_OPEN_TITLE_LIMIT} more)"
|
open_titles += f"; …(+{n_open - _REVIEW_OPEN_TITLE_LIMIT} more)"
|
||||||
headline = (
|
headline = (
|
||||||
@@ -258,7 +273,7 @@ class TodoStack:
|
|||||||
headline,
|
headline,
|
||||||
action,
|
action,
|
||||||
"Tools: todo_list | todo_push(titles=[...]) | todo_update | todo_pop | todo_clear",
|
"Tools: todo_list | todo_push(titles=[...]) | todo_update | todo_pop | todo_clear",
|
||||||
"Rules: TOP group = current level; push=one sibling group; pop=remove whole TOP group.",
|
"Rules: TOP = current level; push = one sibling group; pop = whole TOP group; update by listing number.",
|
||||||
"Stack:",
|
"Stack:",
|
||||||
self.render(),
|
self.render(),
|
||||||
]
|
]
|
||||||
@@ -326,24 +341,24 @@ class TodoStack:
|
|||||||
self.mark_touched()
|
self.mark_touched()
|
||||||
return n
|
return n
|
||||||
|
|
||||||
def get(self, item_id: str) -> TodoItem | None:
|
|
||||||
for item in self.all_items():
|
|
||||||
if item.id == item_id:
|
|
||||||
return item
|
|
||||||
return None
|
|
||||||
|
|
||||||
def update(
|
def update(
|
||||||
self,
|
self,
|
||||||
item_id: str,
|
number: int,
|
||||||
*,
|
*,
|
||||||
title: str | None = None,
|
title: str | None = None,
|
||||||
status: TodoStatus | None = None,
|
status: TodoStatus | None = None,
|
||||||
notes: str | None = None,
|
notes: str | None = None,
|
||||||
) -> TodoItem:
|
) -> TodoItem:
|
||||||
for group in self._data.groups:
|
"""Update the item a listing shows as *number* (1-based, TOP group first).
|
||||||
for index, item in enumerate(group.items):
|
|
||||||
if item.id != item_id:
|
Numbers are listing positions, not stored ids: a push/pop shifts them, so
|
||||||
continue
|
they have to come from the latest listing.
|
||||||
|
"""
|
||||||
|
ordered = self._render_order()
|
||||||
|
if not 1 <= number <= len(ordered):
|
||||||
|
msg = f"no todo item {number}: the list has {len(ordered)} item(s); pick a number from the latest listing"
|
||||||
|
raise ValueError(msg)
|
||||||
|
group, index, item = ordered[number - 1]
|
||||||
new_title = title.strip() if title is not None else item.title
|
new_title = title.strip() if title is not None else item.title
|
||||||
if not new_title:
|
if not new_title:
|
||||||
msg = "title must not be empty"
|
msg = "title must not be empty"
|
||||||
@@ -354,8 +369,6 @@ class TodoStack:
|
|||||||
group.items[index] = updated
|
group.items[index] = updated
|
||||||
self.mark_touched()
|
self.mark_touched()
|
||||||
return updated
|
return updated
|
||||||
msg = f"unknown todo id {item_id!r}"
|
|
||||||
raise KeyError(msg)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_push_titles(raw: str) -> list[str]:
|
def parse_push_titles(raw: str) -> list[str]:
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import asyncio
|
|||||||
import inspect
|
import inspect
|
||||||
import types
|
import types
|
||||||
from collections.abc import Awaitable, Callable, Mapping
|
from collections.abc import Awaitable, Callable, Mapping
|
||||||
|
from contextlib import AsyncExitStack
|
||||||
from enum import Flag, auto
|
from enum import Flag, auto
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any, cast, get_args, get_origin, get_type_hints, overload
|
from typing import Any, cast, get_args, get_origin, get_type_hints, overload
|
||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
@@ -46,13 +48,19 @@ class ToolTag(Flag):
|
|||||||
|
|
||||||
|
|
||||||
class ToolDefinition:
|
class ToolDefinition:
|
||||||
"""A registered tool: schema for the model plus a callable handler."""
|
"""A registered tool: schema for the model plus a callable handler.
|
||||||
|
|
||||||
|
``writes`` names the arguments whose values name files the handler writes.
|
||||||
|
Calls of one batch that name the same file apply one at a time, in call
|
||||||
|
order, so the later one sees what the earlier one wrote.
|
||||||
|
"""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
description: str
|
description: str
|
||||||
parameters: JSONSchema
|
parameters: JSONSchema
|
||||||
handler: ToolHandler
|
handler: ToolHandler
|
||||||
tags: ToolTag
|
tags: ToolTag
|
||||||
|
writes: tuple[str, ...]
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -62,6 +70,7 @@ class ToolDefinition:
|
|||||||
handler: ToolHandler,
|
handler: ToolHandler,
|
||||||
*,
|
*,
|
||||||
tags: ToolTag = ToolTag.LOCAL,
|
tags: ToolTag = ToolTag.LOCAL,
|
||||||
|
writes: tuple[str, ...] = (),
|
||||||
) -> None:
|
) -> None:
|
||||||
if not (tags & (ToolTag.LOCAL | ToolTag.PUBLIC)):
|
if not (tags & (ToolTag.LOCAL | ToolTag.PUBLIC)):
|
||||||
msg = f"tool {name!r} tags must include LOCAL and/or PUBLIC"
|
msg = f"tool {name!r} tags must include LOCAL and/or PUBLIC"
|
||||||
@@ -71,6 +80,7 @@ class ToolDefinition:
|
|||||||
self.parameters = parameters
|
self.parameters = parameters
|
||||||
self.handler = handler
|
self.handler = handler
|
||||||
self.tags = tags
|
self.tags = tags
|
||||||
|
self.writes = writes
|
||||||
|
|
||||||
def to_tool_item(self) -> ToolFunctionItem:
|
def to_tool_item(self) -> ToolFunctionItem:
|
||||||
return ToolFunctionItem(
|
return ToolFunctionItem(
|
||||||
@@ -130,6 +140,7 @@ def _build_definition(
|
|||||||
name: str | None,
|
name: str | None,
|
||||||
description: str | None,
|
description: str | None,
|
||||||
tags: ToolTag,
|
tags: ToolTag,
|
||||||
|
writes: tuple[str, ...] = (),
|
||||||
) -> ToolDefinition:
|
) -> ToolDefinition:
|
||||||
tool_name = name or func.__name__
|
tool_name = name or func.__name__
|
||||||
tool_description = description if description is not None else (inspect.getdoc(func) or "")
|
tool_description = description if description is not None else (inspect.getdoc(func) or "")
|
||||||
@@ -139,6 +150,7 @@ def _build_definition(
|
|||||||
parameters=schema_from_callable(func),
|
parameters=schema_from_callable(func),
|
||||||
handler=func,
|
handler=func,
|
||||||
tags=tags,
|
tags=tags,
|
||||||
|
writes=writes,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -162,6 +174,7 @@ def tool[**PS, R](
|
|||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
description: str | None = None,
|
description: str | None = None,
|
||||||
tags: ToolTag = ToolTag.LOCAL,
|
tags: ToolTag = ToolTag.LOCAL,
|
||||||
|
writes: tuple[str, ...] = (),
|
||||||
register: bool = True,
|
register: bool = True,
|
||||||
) -> Callable[[Callable[PS, R]], ToolDefinition]: ...
|
) -> Callable[[Callable[PS, R]], ToolDefinition]: ...
|
||||||
|
|
||||||
@@ -173,16 +186,19 @@ def tool[**PS, R](
|
|||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
description: str | None = None,
|
description: str | None = None,
|
||||||
tags: ToolTag = ToolTag.LOCAL,
|
tags: ToolTag = ToolTag.LOCAL,
|
||||||
|
writes: tuple[str, ...] = (),
|
||||||
register: bool = True,
|
register: bool = True,
|
||||||
) -> ToolDefinition | Callable[[Callable[PS, R]], ToolDefinition]:
|
) -> ToolDefinition | Callable[[Callable[PS, R]], ToolDefinition]:
|
||||||
"""Define (and by default catalog-register) a function as an agent tool.
|
"""Define (and by default catalog-register) a function as an agent tool.
|
||||||
|
|
||||||
Schema is inferred from type hints; description defaults to the docstring.
|
Schema is inferred from type hints; description defaults to the docstring.
|
||||||
Default ``tags`` is :attr:`ToolTag.LOCAL`. When ``register`` is true, the
|
Default ``tags`` is :attr:`ToolTag.LOCAL`. ``writes`` names the arguments
|
||||||
definition is added to the process :class:`~plyngent.tools.catalog.ToolCatalog`
|
that carry the files the handler writes (:attr:`ToolDefinition.writes`).
|
||||||
with the current registration source (builtin unless a plugin context is set).
|
When ``register`` is true, the definition is added to the process
|
||||||
Catalog registration does **not** alone make the tool model-visible — hosts
|
:class:`~plyngent.tools.catalog.ToolCatalog` with the current registration
|
||||||
still **select** tools into a :class:`ToolRegistry`.
|
source (builtin unless a plugin context is set). Catalog registration does
|
||||||
|
**not** alone make the tool model-visible — hosts still **select** tools
|
||||||
|
into a :class:`ToolRegistry`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def decorator(fn: Callable[PS, R]) -> ToolDefinition:
|
def decorator(fn: Callable[PS, R]) -> ToolDefinition:
|
||||||
@@ -191,6 +207,7 @@ def tool[**PS, R](
|
|||||||
name=name,
|
name=name,
|
||||||
description=description,
|
description=description,
|
||||||
tags=tags,
|
tags=tags,
|
||||||
|
writes=writes,
|
||||||
)
|
)
|
||||||
if register:
|
if register:
|
||||||
_register_definition(definition)
|
_register_definition(definition)
|
||||||
@@ -225,6 +242,15 @@ def _missing_required_error(definition: ToolDefinition, args: Mapping[str, objec
|
|||||||
return f"error: `{definition.name}` is missing required argument(s): {', '.join(missing)}{hint}"
|
return f"error: `{definition.name}` is missing required argument(s): {', '.join(missing)}{hint}"
|
||||||
|
|
||||||
|
|
||||||
|
def _write_path_key(value: str) -> str:
|
||||||
|
"""Comparable key for one path argument of a writing tool.
|
||||||
|
|
||||||
|
Relative arguments all mean the same workspace, so the text itself separates
|
||||||
|
two spellings of one file (``a.txt`` / ``./a.txt``) from two files.
|
||||||
|
"""
|
||||||
|
return str(Path(value).expanduser())
|
||||||
|
|
||||||
|
|
||||||
class ToolRegistry:
|
class ToolRegistry:
|
||||||
"""Name → tool definition map with execution helpers."""
|
"""Name → tool definition map with execution helpers."""
|
||||||
|
|
||||||
@@ -232,6 +258,9 @@ class ToolRegistry:
|
|||||||
_danger: DangerClassifier | None
|
_danger: DangerClassifier | None
|
||||||
_on_confirm: ToolConfirmHook | None
|
_on_confirm: ToolConfirmHook | None
|
||||||
_confirm_lock: asyncio.Lock
|
_confirm_lock: asyncio.Lock
|
||||||
|
# One lock per written file, kept for the registry's life (a session's edits
|
||||||
|
# bound the map), so a batch's writes to one file apply one at a time.
|
||||||
|
_write_locks: dict[str, asyncio.Lock]
|
||||||
_yolo: bool
|
_yolo: bool
|
||||||
_auto_bind_state: bool
|
_auto_bind_state: bool
|
||||||
_instance: object | None
|
_instance: object | None
|
||||||
@@ -252,6 +281,7 @@ class ToolRegistry:
|
|||||||
self._danger = danger
|
self._danger = danger
|
||||||
self._on_confirm = on_confirm
|
self._on_confirm = on_confirm
|
||||||
self._confirm_lock = asyncio.Lock()
|
self._confirm_lock = asyncio.Lock()
|
||||||
|
self._write_locks = {}
|
||||||
self._yolo = yolo
|
self._yolo = yolo
|
||||||
self._auto_bind_state = auto_bind_state
|
self._auto_bind_state = auto_bind_state
|
||||||
self._instance = instance_state
|
self._instance = instance_state
|
||||||
@@ -381,6 +411,25 @@ class ToolRegistry:
|
|||||||
return cast("Any", self._session)
|
return cast("Any", self._session)
|
||||||
return session
|
return session
|
||||||
|
|
||||||
|
def _write_locks_for(self, definition: ToolDefinition, args: Mapping[str, object]) -> list[asyncio.Lock]:
|
||||||
|
"""Locks for the files this call writes, in a stable order (no deadlock).
|
||||||
|
|
||||||
|
Empty for a tool that declares no :attr:`ToolDefinition.writes`.
|
||||||
|
"""
|
||||||
|
keys: set[str] = set()
|
||||||
|
for name in definition.writes:
|
||||||
|
value = args.get(name)
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
keys.add(_write_path_key(value))
|
||||||
|
locks: list[asyncio.Lock] = []
|
||||||
|
for key in sorted(keys):
|
||||||
|
lock = self._write_locks.get(key)
|
||||||
|
if lock is None:
|
||||||
|
lock = asyncio.Lock()
|
||||||
|
self._write_locks[key] = lock
|
||||||
|
locks.append(lock)
|
||||||
|
return locks
|
||||||
|
|
||||||
def _grant_allows(self, name: str, *, tags: ToolTag) -> bool:
|
def _grant_allows(self, name: str, *, tags: ToolTag) -> bool:
|
||||||
if not (tags & ToolTag.TRUSTABLE):
|
if not (tags & ToolTag.TRUSTABLE):
|
||||||
return False
|
return False
|
||||||
@@ -468,6 +517,11 @@ class ToolRegistry:
|
|||||||
missing = self._check_state_tags(definition)
|
missing = self._check_state_tags(definition)
|
||||||
if missing is not None:
|
if missing is not None:
|
||||||
return missing
|
return missing
|
||||||
|
# Held before the confirm prompt, so a sibling call writing the same
|
||||||
|
# file waits for this one instead of overtaking its prompt.
|
||||||
|
async with AsyncExitStack() as held:
|
||||||
|
for lock in self._write_locks_for(definition, args):
|
||||||
|
await held.enter_async_context(lock)
|
||||||
denied = await self._maybe_confirm(name, args, tags=definition.tags)
|
denied = await self._maybe_confirm(name, args, tags=definition.tags)
|
||||||
if denied is not None:
|
if denied is not None:
|
||||||
return denied
|
return denied
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ if TYPE_CHECKING:
|
|||||||
_TOOL_RESULT_PREVIEW = 120
|
_TOOL_RESULT_PREVIEW = 120
|
||||||
_TOOL_ARGS_PREVIEW = 80
|
_TOOL_ARGS_PREVIEW = 80
|
||||||
_ASK_SUBJECT_PREVIEW = 60
|
_ASK_SUBJECT_PREVIEW = 60
|
||||||
|
# ``run_argv_batch`` lists the commands it runs above its ``* Run batch `` row — one
|
||||||
|
# line per command, a ``pipe_out`` chain joined into one — elided past this width.
|
||||||
|
_RUN_BATCH_CMD_LIMIT = 80
|
||||||
_MCP_TOOL_PREFIX = "mcp__"
|
_MCP_TOOL_PREFIX = "mcp__"
|
||||||
_SKILL_MAIN_FILE = "SKILL.md"
|
_SKILL_MAIN_FILE = "SKILL.md"
|
||||||
|
|
||||||
@@ -117,8 +120,8 @@ def _json_str_arg(args_json: str, key: str) -> str | None:
|
|||||||
return value if isinstance(value, str) else None
|
return value if isinstance(value, str) else None
|
||||||
|
|
||||||
|
|
||||||
def _json_list_arg(args_json: str, key: str) -> list[str] | None:
|
def _str_list(value: object) -> list[str] | None:
|
||||||
value = _json_arg(args_json, key)
|
"""*value* as a list of strings, or None when it is not one."""
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
raw_items = cast("list[object]", value)
|
raw_items = cast("list[object]", value)
|
||||||
items = [item for item in raw_items if isinstance(item, str)]
|
items = [item for item in raw_items if isinstance(item, str)]
|
||||||
@@ -127,6 +130,10 @@ def _json_list_arg(args_json: str, key: str) -> list[str] | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _json_list_arg(args_json: str, key: str) -> list[str] | None:
|
||||||
|
return _str_list(_json_arg(args_json, key))
|
||||||
|
|
||||||
|
|
||||||
def _json_int_arg(args_json: str, key: str) -> int | None:
|
def _json_int_arg(args_json: str, key: str) -> int | None:
|
||||||
value = _json_arg(args_json, key)
|
value = _json_arg(args_json, key)
|
||||||
if isinstance(value, bool):
|
if isinstance(value, bool):
|
||||||
@@ -387,8 +394,42 @@ def _run_argv_detail(_args_json: str, result: str) -> str:
|
|||||||
return _pretty_segments((f"(exit code {code or 'killed'})", fg))
|
return _pretty_segments((f"(exit code {code or 'killed'})", fg))
|
||||||
|
|
||||||
|
|
||||||
def _run_argv_batch_prefix(_args_json: str) -> str:
|
def _batch_steps(args_json: str) -> list[dict[str, object]]:
|
||||||
return _pretty_prefix("* Run batch ")
|
"""Steps of a batch call: a JSON array, or a JSON string holding one."""
|
||||||
|
value = _json_arg(args_json, "steps")
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
value = json.loads(value)
|
||||||
|
except ValueError:
|
||||||
|
return []
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return []
|
||||||
|
raw_steps = cast("list[object]", value)
|
||||||
|
return [cast("dict[str, object]", step) for step in raw_steps if isinstance(step, dict)]
|
||||||
|
|
||||||
|
|
||||||
|
def _batch_command_lines(args_json: str) -> list[str]:
|
||||||
|
"""One command per line, a ``pipe_out`` chain joined into one line with ``|``."""
|
||||||
|
lines: list[str] = []
|
||||||
|
piped_from_previous = False
|
||||||
|
for step in _batch_steps(args_json):
|
||||||
|
argv = _str_list(step.get("argv"))
|
||||||
|
command = shlex.join(argv) if argv else "?"
|
||||||
|
if piped_from_previous and lines:
|
||||||
|
lines[-1] = f"{lines[-1]} | {command}"
|
||||||
|
else:
|
||||||
|
lines.append(command)
|
||||||
|
# Same flag spellings the tool itself accepts (bool, or "true"/"1"/…).
|
||||||
|
piped_from_previous = str(step.get("pipe_out") or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _run_argv_batch_prefix(args_json: str) -> str:
|
||||||
|
"""``* Run batch `` with the commands it runs listed above it."""
|
||||||
|
commands = _batch_command_lines(args_json)
|
||||||
|
body = "\n".join(_pretty_prefix(f"$ {_preview(command, _RUN_BATCH_CMD_LIMIT)}") for command in commands)
|
||||||
|
header = _pretty_prefix("* Run batch ")
|
||||||
|
return f"{body}\n{header}" if body else header
|
||||||
|
|
||||||
|
|
||||||
def _run_argv_batch_detail(_args_json: str, result: str) -> str:
|
def _run_argv_batch_detail(_args_json: str, result: str) -> str:
|
||||||
|
|||||||
+25
-13
@@ -112,11 +112,17 @@ def _run_blocking_editor(editor_cmd: str, path: Path) -> None:
|
|||||||
raise click.ClickException(msg)
|
raise click.ClickException(msg)
|
||||||
|
|
||||||
|
|
||||||
def _open_with_system_default(path: Path) -> None:
|
def _open_failure(reason: str, hint: str) -> str:
|
||||||
|
"""Compose an open failure message, appending the caller's fallback hint."""
|
||||||
|
return f"{reason}; {hint}" if hint else reason
|
||||||
|
|
||||||
|
|
||||||
|
def _open_with_system_default(path: Path, *, hint: str = "") -> None:
|
||||||
"""Open *path* with the OS file association (non-blocking).
|
"""Open *path* with the OS file association (non-blocking).
|
||||||
|
|
||||||
Linux: ``xdg-open``; macOS: ``open``; Windows: ``os.startfile``.
|
Linux: ``xdg-open``; macOS: ``open``; Windows: ``os.startfile``.
|
||||||
Does not wait for the application to exit.
|
Does not wait for the application to exit. *hint* names the caller's own
|
||||||
|
fallback (an editor, a printed path) and is appended to failures.
|
||||||
"""
|
"""
|
||||||
resolved = str(path.resolve())
|
resolved = str(path.resolve())
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
@@ -141,19 +147,22 @@ def _open_with_system_default(path: Path) -> None:
|
|||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
except FileNotFoundError as exc:
|
except FileNotFoundError as exc:
|
||||||
cmd = argv[0]
|
raise click.ClickException(_open_failure(f"{argv[0]} not found", hint)) from exc
|
||||||
msg = (
|
|
||||||
f"{cmd} not found and neither VISUAL nor EDITOR is set; "
|
|
||||||
"set VISUAL or EDITOR to a blocking editor (e.g. nano, vim, codium --wait)"
|
|
||||||
)
|
|
||||||
raise click.ClickException(msg) from exc
|
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
msg = f"failed to open with system default: {exc}"
|
raise click.ClickException(_open_failure(f"failed to open with system default: {exc}", hint)) from exc
|
||||||
raise click.ClickException(msg) from exc
|
|
||||||
|
|
||||||
if completed.returncode != 0:
|
if completed.returncode != 0:
|
||||||
msg = f"system open failed (exit {completed.returncode}); set VISUAL or EDITOR to a blocking editor"
|
raise click.ClickException(_open_failure(f"system open failed (exit {completed.returncode})", hint))
|
||||||
raise click.ClickException(msg)
|
|
||||||
|
|
||||||
|
def open_directory(path: Path) -> None:
|
||||||
|
"""Reveal *path* (a directory) in the OS file manager (non-blocking).
|
||||||
|
|
||||||
|
A folder is not an editor target, so ``$VISUAL`` / ``$EDITOR`` are never
|
||||||
|
consulted: the platform opener decides. The caller prints *path* too, which
|
||||||
|
is the way in when the host has no opener at all.
|
||||||
|
"""
|
||||||
|
_open_with_system_default(path, hint="open the printed path manually")
|
||||||
|
|
||||||
|
|
||||||
def open_in_editor(
|
def open_in_editor(
|
||||||
@@ -189,7 +198,10 @@ def open_in_editor(
|
|||||||
msg = "neither VISUAL nor EDITOR is set"
|
msg = "neither VISUAL nor EDITOR is set"
|
||||||
raise click.ClickException(msg)
|
raise click.ClickException(msg)
|
||||||
|
|
||||||
_open_with_system_default(path)
|
_open_with_system_default(
|
||||||
|
path,
|
||||||
|
hint="set VISUAL or EDITOR to a blocking editor (e.g. nano, vim, codium --wait)",
|
||||||
|
)
|
||||||
return "system"
|
return "system"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+50
-38
@@ -18,6 +18,7 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
from plyngent.agent import ToolRegistry
|
from plyngent.agent import ToolRegistry
|
||||||
from plyngent.cli.state import ReplState, YoloMode
|
from plyngent.cli.state import ReplState, YoloMode
|
||||||
|
from plyngent.skills import SkillStore
|
||||||
|
|
||||||
_COMPACT_PREVIEW = 400
|
_COMPACT_PREVIEW = 400
|
||||||
_ON_OFF_CHOICES = ("on", "off")
|
_ON_OFF_CHOICES = ("on", "off")
|
||||||
@@ -419,12 +420,12 @@ def complete_slash_args(
|
|||||||
if flags:
|
if flags:
|
||||||
return sorted(set(flags))
|
return sorted(set(flags))
|
||||||
|
|
||||||
# Contextual /todos second token: todo ids after done|cancel|pending|in_progress.
|
# Contextual /todos second token: listing numbers after done|cancel|…
|
||||||
if name == "todos" and prior:
|
if name == "todos" and prior:
|
||||||
action = prior[0].lower()
|
action = prior[0].lower()
|
||||||
if action in {"done", "cancel", "pending", "in_progress"} and len(prior) == 1:
|
if action in {"done", "cancel", "pending", "in_progress"} and len(prior) == 1:
|
||||||
ids = [item.id for item in state.todo_stack.all_items()]
|
numbers = [str(n) for n in range(1, len(state.todo_stack.all_items()) + 1)]
|
||||||
return [i for i in ids if i.startswith(incomplete)]
|
return [n for n in numbers if n.startswith(incomplete)]
|
||||||
|
|
||||||
# Positional arguments: complete the next unset argument.
|
# Positional arguments: complete the next unset argument.
|
||||||
arg_params = [p for p in cmd.params if isinstance(p, click.Argument)]
|
arg_params = [p for p in cmd.params if isinstance(p, click.Argument)]
|
||||||
@@ -478,14 +479,6 @@ slash.add_command(quit_cmd, name="exit")
|
|||||||
slash.add_command(quit_cmd, name="q")
|
slash.add_command(quit_cmd, name="q")
|
||||||
|
|
||||||
|
|
||||||
@slash.command("clear")
|
|
||||||
@click.pass_obj
|
|
||||||
def clear_cmd(state: ReplState) -> None:
|
|
||||||
"""Clear in-memory conversation (keeps session id)."""
|
|
||||||
state.agent.messages.clear()
|
|
||||||
click.echo("conversation cleared (in-memory only; DB history kept)")
|
|
||||||
|
|
||||||
|
|
||||||
@slash.command("edit")
|
@slash.command("edit")
|
||||||
@click.pass_obj
|
@click.pass_obj
|
||||||
def edit_cmd(state: ReplState) -> None:
|
def edit_cmd(state: ReplState) -> None:
|
||||||
@@ -1252,30 +1245,50 @@ def mcp_slash_cmd(state: ReplState, action: str | None) -> None:
|
|||||||
_print_mcp_status(state)
|
_print_mcp_status(state)
|
||||||
|
|
||||||
|
|
||||||
|
def _open_skill_directory(store: SkillStore, name: str) -> None:
|
||||||
|
"""Reveal a skill's directory in the host file manager (``/skills open``).
|
||||||
|
|
||||||
|
Reading a skill is a file-manager job, not a render of the model-facing
|
||||||
|
``skill_read`` output; the printed path is the fallback on a headless host.
|
||||||
|
"""
|
||||||
|
from plyngent.cli.editor import open_directory
|
||||||
|
|
||||||
|
skill = store.find(name)
|
||||||
|
if skill is None:
|
||||||
|
click.echo(f"error: unknown skill {name!r}; /skills lists the discovered ones")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
open_directory(skill.path)
|
||||||
|
except click.ClickException as exc:
|
||||||
|
click.echo(f"error: {exc}")
|
||||||
|
click.echo(f"skill directory: {skill.path}")
|
||||||
|
return
|
||||||
|
click.echo(f"opened {skill.path}")
|
||||||
|
|
||||||
|
|
||||||
@slash.command("skills")
|
@slash.command("skills")
|
||||||
@click.argument("action", required=False, type=click.Choice(["list", "search", "read", "reload"]))
|
@click.argument("action", required=False, type=click.Choice(["list", "search", "open"]))
|
||||||
@click.argument("target", required=False)
|
@click.argument("target", required=False)
|
||||||
@click.option("--file", "file", default="", help="File inside the skill (with ``read``).")
|
|
||||||
@click.pass_obj
|
@click.pass_obj
|
||||||
def skills_slash_cmd(state: ReplState, action: str | None, target: str | None, file: str) -> None:
|
def skills_slash_cmd(state: ReplState, action: str | None, target: str | None) -> None:
|
||||||
"""List skills, search or read them, or rescan the roots.
|
"""List skills, search them, or open one's directory.
|
||||||
|
|
||||||
``/skills`` or ``/skills list`` — discovered skills with their source root.
|
``/skills`` or ``/skills list`` — discovered skills with their source root.
|
||||||
``/skills search PATTERN`` — regex over every skill's contents.
|
``/skills search PATTERN`` — regex over every skill's contents.
|
||||||
``/skills read NAME [--file F]`` — print a skill's SKILL.md (or one file).
|
``/skills open NAME`` — reveal the skill's directory in the file manager.
|
||||||
``/skills reload`` — rescan the roots after an external edit.
|
|
||||||
"""
|
"""
|
||||||
from plyngent.tools.context import bind_tool_context
|
from plyngent.tools.context import bind_tool_context
|
||||||
from plyngent.tools.skills import SKILL_TOOL_NAMES, format_skill_table, skill_read, skill_search
|
from plyngent.tools.skills import SKILL_TOOL_NAMES, format_skill_table, skill_search
|
||||||
|
|
||||||
if state.skills is None:
|
if state.skills is None:
|
||||||
click.echo("skills: disabled (set [skills].enabled = true)")
|
click.echo("skills: disabled (set [skills].enabled = true)")
|
||||||
return
|
return
|
||||||
act = (action or "list").lower()
|
act = (action or "list").lower()
|
||||||
if act == "reload":
|
if act == "open":
|
||||||
state.reload_skills()
|
if not target:
|
||||||
_slash_rebuild_tools_if_on(state)
|
msg = "/skills open requires a skill name"
|
||||||
click.echo(f"skills reloaded: {len(state.skills.skills)} visible; tools rebuilt")
|
raise click.UsageError(msg)
|
||||||
|
_open_skill_directory(state.skills, target)
|
||||||
return
|
return
|
||||||
# The tool handlers read the bound instance (the registry binds it for a
|
# The tool handlers read the bound instance (the registry binds it for a
|
||||||
# model call; a slash command has to do it itself).
|
# model call; a slash command has to do it itself).
|
||||||
@@ -1286,12 +1299,6 @@ def skills_slash_cmd(state: ReplState, action: str | None, target: str | None, f
|
|||||||
raise click.UsageError(msg)
|
raise click.UsageError(msg)
|
||||||
click.echo(_await(skill_search.handler(target)))
|
click.echo(_await(skill_search.handler(target)))
|
||||||
return
|
return
|
||||||
if act == "read":
|
|
||||||
if not target:
|
|
||||||
msg = "/skills read requires a skill name"
|
|
||||||
raise click.UsageError(msg)
|
|
||||||
click.echo(_await(skill_read.handler(target, file or "SKILL.md")))
|
|
||||||
return
|
|
||||||
click.echo(format_skill_table(state.skills))
|
click.echo(format_skill_table(state.skills))
|
||||||
if not state.tools_enabled:
|
if not state.tools_enabled:
|
||||||
click.secho(
|
click.secho(
|
||||||
@@ -1517,10 +1524,10 @@ def todos_cmd( # noqa: C901, PLR0911, PLR0915 — one branch per /todos action
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Show or edit the LIFO stack of **task groups**.
|
"""Show or edit the LIFO stack of **task groups**.
|
||||||
|
|
||||||
``/todos`` — list (top group first)
|
``/todos`` — list (top group first; items numbered as shown)
|
||||||
``/todos push T1; T2`` — one new group of siblings
|
``/todos push T1; T2`` — one new group of siblings
|
||||||
``/todos pop`` — pop entire top group
|
``/todos pop`` — pop entire top group
|
||||||
``/todos done <id>`` / ``/todos cancel <id>`` — set status
|
``/todos done <number>`` / ``/todos cancel <number>`` — set status
|
||||||
``/todos clear`` — wipe stack
|
``/todos clear`` — wipe stack
|
||||||
"""
|
"""
|
||||||
from plyngent.agent.todo_stack import parse_push_titles
|
from plyngent.agent.todo_stack import parse_push_titles
|
||||||
@@ -1545,8 +1552,8 @@ def todos_cmd( # noqa: C901, PLR0911, PLR0915 — one branch per /todos action
|
|||||||
click.echo(f"error: {exc}")
|
click.echo(f"error: {exc}")
|
||||||
return
|
return
|
||||||
_await(state.persist_todo_stack())
|
_await(state.persist_todo_stack())
|
||||||
ids = ", ".join(i.id for i in group.items)
|
pushed = ", ".join(i.title for i in group.items)
|
||||||
click.echo(f"pushed group depth={stack.depth} items=[{ids}]")
|
click.echo(f"pushed group depth={stack.depth} items=[{pushed}]")
|
||||||
click.echo(stack.render())
|
click.echo(stack.render())
|
||||||
return
|
return
|
||||||
if act == "pop":
|
if act == "pop":
|
||||||
@@ -1555,13 +1562,13 @@ def todos_cmd( # noqa: C901, PLR0911, PLR0915 — one branch per /todos action
|
|||||||
click.echo("todo stack empty")
|
click.echo("todo stack empty")
|
||||||
return
|
return
|
||||||
_await(state.persist_todo_stack())
|
_await(state.persist_todo_stack())
|
||||||
titles = ", ".join(f"{i.id}:{i.title}" for i in group.items) or "(empty)"
|
popped = ", ".join(i.title for i in group.items) or "(empty)"
|
||||||
click.echo(f"popped TOP group ({titles})")
|
click.echo(f"popped TOP group ({popped})")
|
||||||
click.echo(stack.render())
|
click.echo(stack.render())
|
||||||
return
|
return
|
||||||
if act in {"done", "cancel", "pending", "in_progress"}:
|
if act in {"done", "cancel", "pending", "in_progress"}:
|
||||||
if not rest:
|
if not rest:
|
||||||
click.echo(f"error: usage: /todos {act} <id>")
|
click.echo(f"error: usage: /todos {act} <number>")
|
||||||
return
|
return
|
||||||
if act == "done":
|
if act == "done":
|
||||||
new_status = "done"
|
new_status = "done"
|
||||||
@@ -1572,12 +1579,17 @@ def todos_cmd( # noqa: C901, PLR0911, PLR0915 — one branch per /todos action
|
|||||||
else:
|
else:
|
||||||
new_status = "in_progress"
|
new_status = "in_progress"
|
||||||
try:
|
try:
|
||||||
item = stack.update(rest[0], status=new_status)
|
number = int(rest[0].strip())
|
||||||
except (KeyError, ValueError) as exc:
|
except ValueError:
|
||||||
|
click.echo(f"error: /todos {act} takes an item number from the list (e.g. /todos {act} 2)")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
item = stack.update(number, status=new_status)
|
||||||
|
except ValueError as exc:
|
||||||
click.echo(f"error: {exc}")
|
click.echo(f"error: {exc}")
|
||||||
return
|
return
|
||||||
_await(state.persist_todo_stack())
|
_await(state.persist_todo_stack())
|
||||||
click.echo(f"updated {item.id} → {item.status}")
|
click.echo(f"updated {number} → {item.status}")
|
||||||
click.echo(stack.render())
|
click.echo(stack.render())
|
||||||
return
|
return
|
||||||
if act == "clear":
|
if act == "clear":
|
||||||
|
|||||||
+23
-31
@@ -343,36 +343,6 @@ class ReplState:
|
|||||||
self.instance_state.skills = self.skills
|
self.instance_state.skills = self.skills
|
||||||
_ = set_static_read_roots([root.path for root in roots], instance=self.instance_state)
|
_ = set_static_read_roots([root.path for root in roots], instance=self.instance_state)
|
||||||
|
|
||||||
def reload_skills(self) -> None:
|
|
||||||
"""Rescan the roots (after an external edit or a config change)."""
|
|
||||||
if self.skills is not None:
|
|
||||||
self.skills.reload()
|
|
||||||
|
|
||||||
def _skills_text(self) -> str:
|
|
||||||
"""Fold the discovered skills into the system prompt (like MCP instructions).
|
|
||||||
|
|
||||||
Only names, sources, and descriptions: the bodies stay on disk so a turn
|
|
||||||
pays for the skill it actually reads. Notes and shadowed copies are left
|
|
||||||
to ``skill_list``, which the model can call when something looks off.
|
|
||||||
"""
|
|
||||||
cfg = self.config.skills_config
|
|
||||||
if self.skills is None or not self.tools_enabled or not cfg.inject_catalog:
|
|
||||||
return ""
|
|
||||||
skills = self.skills.skills
|
|
||||||
if not skills:
|
|
||||||
return ""
|
|
||||||
limit = max(0, cfg.max_catalog_skills)
|
|
||||||
shown = skills[:limit]
|
|
||||||
lines = [
|
|
||||||
"### Skills",
|
|
||||||
"Available skills (read a skill's SKILL.md with `skill_read` before following it):",
|
|
||||||
]
|
|
||||||
lines.extend(f"- {skill.name} ({skill.root.label}): {skill.description}" for skill in shown)
|
|
||||||
if len(skills) > len(shown):
|
|
||||||
lines.append(f"- … and {len(skills) - len(shown)} more; use `skill_search` or `skill_list`")
|
|
||||||
lines.append("Search all skills with `skill_search`; create or update one with `skill_create` / `skill_edit`.")
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
def _tool_registry(self) -> ToolRegistry | None:
|
def _tool_registry(self) -> ToolRegistry | None:
|
||||||
if not self.tools_enabled:
|
if not self.tools_enabled:
|
||||||
return None
|
return None
|
||||||
@@ -476,7 +446,6 @@ class ReplState:
|
|||||||
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,
|
||||||
self._skills_text(),
|
|
||||||
mcp_text,
|
mcp_text,
|
||||||
)
|
)
|
||||||
on_limit = prompt_continue_limit_async if (self.interactive_limits or auto_continue_enabled()) else None
|
on_limit = prompt_continue_limit_async if (self.interactive_limits or auto_continue_enabled()) else None
|
||||||
@@ -820,6 +789,7 @@ class ReplState:
|
|||||||
self.session_state = self._session_data_for_todo()
|
self.session_state = self._session_data_for_todo()
|
||||||
self.agent = self._make_agent()
|
self.agent = self._make_agent()
|
||||||
self._bind_todo_tools()
|
self._bind_todo_tools()
|
||||||
|
await self._notice_skill_tools()
|
||||||
|
|
||||||
async def rename_current_session(self, name: str) -> SessionRow:
|
async def rename_current_session(self, name: str) -> SessionRow:
|
||||||
if self.session_id is None:
|
if self.session_id is None:
|
||||||
@@ -884,6 +854,7 @@ class ReplState:
|
|||||||
await self.agent.load_history()
|
await self.agent.load_history()
|
||||||
await self.load_todo_stack()
|
await self.load_todo_stack()
|
||||||
await self.load_access_grants()
|
await self.load_access_grants()
|
||||||
|
await self._notice_skill_tools()
|
||||||
if restarted:
|
if restarted:
|
||||||
await self._notice_restart(last_active=row.updated_at)
|
await self._notice_restart(last_active=row.updated_at)
|
||||||
|
|
||||||
@@ -907,12 +878,33 @@ class ReplState:
|
|||||||
await self.agent.load_history()
|
await self.agent.load_history()
|
||||||
await self.load_todo_stack()
|
await self.load_todo_stack()
|
||||||
await self.load_access_grants()
|
await self.load_access_grants()
|
||||||
|
await self._notice_skill_tools()
|
||||||
if restarted:
|
if restarted:
|
||||||
# Before touch_session: the stamp should be the previous run's activity.
|
# Before touch_session: the stamp should be the previous run's activity.
|
||||||
await self._notice_restart(last_active=latest.updated_at)
|
await self._notice_restart(last_active=latest.updated_at)
|
||||||
_ = await self.memory.touch_session(latest.sid)
|
_ = await self.memory.touch_session(latest.sid)
|
||||||
return "resume"
|
return "resume"
|
||||||
|
|
||||||
|
async def _notice_skill_tools(self) -> None:
|
||||||
|
"""Teach the skill tools once per session — never what skills exist.
|
||||||
|
|
||||||
|
Instructions only: the listing stays behind ``skill_list`` /
|
||||||
|
``skill_search``, and the store reads the filesystem live, so a skill that
|
||||||
|
appears later in the session is found the same way. A session created
|
||||||
|
before this host pushed the note gets it on resume instead of a second copy.
|
||||||
|
"""
|
||||||
|
from plyngent.agent import has_notice, skill_tools_notice
|
||||||
|
from plyngent.cli.display import echo_notice
|
||||||
|
|
||||||
|
if self.skills is None or not self.tools_enabled:
|
||||||
|
return
|
||||||
|
notice = skill_tools_notice()
|
||||||
|
if has_notice(self.agent.messages, notice.kind):
|
||||||
|
return
|
||||||
|
_ = await self.agent.push_notice(notice)
|
||||||
|
if not self.quiet:
|
||||||
|
echo_notice(notice, err=True)
|
||||||
|
|
||||||
async def _notice_restart(self, *, last_active: datetime | None) -> None:
|
async def _notice_restart(self, *, last_active: datetime | None) -> None:
|
||||||
"""Tell the model — and the user — that this session outlived a restart."""
|
"""Tell the model — and the user — that this session outlived a restart."""
|
||||||
from plyngent.agent import resume_notice
|
from plyngent.agent import resume_notice
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ for every printed row (``-vv``).
|
|||||||
Numbering is a **display ordinal** over the conversation rows (1-based, in the
|
Numbering is a **display ordinal** over the conversation rows (1-based, in the
|
||||||
order the session holds them): it is what ``/history`` prints and what
|
order the session holds them): it is what ``/history`` prints and what
|
||||||
``/history --message N`` addresses. Database row numbers are deliberately never
|
``/history --message N`` addresses. Database row numbers are deliberately never
|
||||||
shown or used — they are an implementation detail that shifts with ``/clear``.
|
shown or used — they are an implementation detail of the store.
|
||||||
Local-only rows (the injected system prompt) have no number.
|
Local-only rows (the injected system prompt) have no number.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
@@ -40,12 +40,15 @@ DEFAULT_TOOL_DIRECTIVES = """\
|
|||||||
### Workspace
|
### Workspace
|
||||||
- Explore with `tree`/`listdir`/`glob_paths`/`regex_files`/`read_file` when unsure.
|
- Explore with `tree`/`listdir`/`glob_paths`/`regex_files`/`read_file` when unsure.
|
||||||
- Stay under the workspace (or a path from `new_temporary_workspace`). Respect path denylists.
|
- Stay under the workspace (or a path from `new_temporary_workspace`). Respect path denylists.
|
||||||
- Need a path outside the workspace? Call `request_directory_access` \
|
- Need a path outside the workspace? Call `request_directory_access` with the \
|
||||||
(human approves; denylists apply) instead of retrying an escaped path.
|
`mode` the escaped-path error names (human approves; denylists apply) instead of \
|
||||||
|
retrying that path.
|
||||||
|
|
||||||
### Files
|
### Files
|
||||||
- Prefer file tools over shell. `edit_replace` fails usually means a bad match — \
|
- Prefer file tools over shell. `edit_replace` fails usually means a bad match — \
|
||||||
fix `old_string` or `max_replaces`; shell workarounds rarely help.
|
fix `old_string` or `max_replaces`; shell workarounds rarely help.
|
||||||
|
- `write_file` refuses to replace an existing file (read it, or change it with \
|
||||||
|
`edit_replace` / `edit_lineno`); a full replacement needs `overwrite=true`, which asks the human.
|
||||||
- `edit_replace` defaults to the first match; if remaining matches are reported, \
|
- `edit_replace` defaults to the first match; if remaining matches are reported, \
|
||||||
raise `max_replaces` or narrow `old_string`.
|
raise `max_replaces` or narrow `old_string`.
|
||||||
- `edit_lineno` edits only lines you read via `read_file(path, with_lineno=true)`; \
|
- `edit_lineno` edits only lines you read via `read_file(path, with_lineno=true)`; \
|
||||||
@@ -95,6 +98,9 @@ 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.
|
||||||
|
- A listing numbers items in the order it shows them (TOP group first); those numbers are \
|
||||||
|
positions, not ids, and shift when the stack changes — `todo_update` takes the number from \
|
||||||
|
the latest listing.
|
||||||
- The stack does **not** auto-clear. When every task is done (all items done/cancelled), \
|
- 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.
|
call `todo_clear` (or pop finished TOP groups) so no hygiene noise is left behind.
|
||||||
"""
|
"""
|
||||||
@@ -253,17 +259,15 @@ class SkillsConfig(Struct, omit_defaults=True):
|
|||||||
``include_project_roots``)
|
``include_project_roots``)
|
||||||
|
|
||||||
A skill found in several roots shadows the lower-priority copies: they stay
|
A skill found in several roots shadows the lower-priority copies: they stay
|
||||||
listed, never hidden. ``enabled = false`` registers no skill tools at all and
|
listed, never hidden. ``enabled = false`` registers no skill tools at all.
|
||||||
injects no catalog. Writing needs an explicit grant (a confirm per call, or
|
Writing needs an explicit grant (a confirm per call, or ``allow_write`` for a
|
||||||
``allow_write`` for a standing one); reads need none.
|
standing one); reads need none.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
paths: list[str] = field(default_factory=list)
|
paths: list[str] = field(default_factory=list)
|
||||||
discover: list[SkillDiscoverySource] = field(default_factory=lambda: ["plyngent", "claude"])
|
discover: list[SkillDiscoverySource] = field(default_factory=lambda: ["plyngent", "claude"])
|
||||||
include_project_roots: bool = True
|
include_project_roots: bool = True
|
||||||
inject_catalog: bool = True
|
|
||||||
max_catalog_skills: int = 50
|
|
||||||
allow_write: bool = False
|
allow_write: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,8 @@
|
|||||||
A skill is a directory holding a ``SKILL.md``. Roots are ordered by priority
|
A skill is a directory holding a ``SKILL.md``. Roots are ordered by priority
|
||||||
(explicit ``[skills].paths`` first, then the built-in sources), so the first
|
(explicit ``[skills].paths`` first, then the built-in sources), so the first
|
||||||
skill with a given name wins and the copies it shadows stay visible for
|
skill with a given name wins and the copies it shadows stay visible for
|
||||||
reporting rather than disappearing. Parsed metadata is cached against the
|
reporting rather than disappearing. Nothing is cached: every lookup reads the
|
||||||
directory list and its ``SKILL.md`` timestamps, which makes a listing cheap
|
filesystem, so an edit or a new skill made outside the chat is simply there.
|
||||||
while still noticing an edit made outside the chat.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -40,7 +39,7 @@ class SkillRoot:
|
|||||||
``origin`` names where the root came from (``config`` for an explicit path,
|
``origin`` names where the root came from (``config`` for an explicit path,
|
||||||
``plyngent`` for our own user directory, ``claude`` for another harness);
|
``plyngent`` for our own user directory, ``claude`` for another harness);
|
||||||
``scope`` is ``user`` or ``project``. Together they label a skill's
|
``scope`` is ``user`` or ``project``. Together they label a skill's
|
||||||
provenance in listings and in the injected catalog.
|
provenance in listings.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
path: Path
|
path: Path
|
||||||
@@ -119,11 +118,10 @@ def _expand(token: str, *, home: Path | None) -> Path:
|
|||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class _Entry:
|
class _Entry:
|
||||||
"""One candidate skill directory plus the timestamp that invalidates the cache."""
|
"""One candidate skill directory under its root."""
|
||||||
|
|
||||||
root: SkillRoot
|
root: SkillRoot
|
||||||
directory: Path
|
directory: Path
|
||||||
mtime_ns: int
|
|
||||||
|
|
||||||
|
|
||||||
def validate_skill_name(name: str) -> str | None:
|
def validate_skill_name(name: str) -> str | None:
|
||||||
@@ -166,14 +164,16 @@ def resolve_skill_file(skill: Skill, relative: str) -> Path | None:
|
|||||||
|
|
||||||
|
|
||||||
class SkillStore:
|
class SkillStore:
|
||||||
"""Skills discovered under *roots*, highest-priority root first."""
|
"""Skills discovered under *roots*, highest-priority root first.
|
||||||
|
|
||||||
|
Each access re-reads the filesystem: the roots are a fixed list, the skills
|
||||||
|
under them are not.
|
||||||
|
"""
|
||||||
|
|
||||||
_roots: tuple[SkillRoot, ...]
|
_roots: tuple[SkillRoot, ...]
|
||||||
_cache: tuple[tuple[tuple[str, int], ...], list[Skill]] | None
|
|
||||||
|
|
||||||
def __init__(self, roots: Sequence[SkillRoot]) -> None:
|
def __init__(self, roots: Sequence[SkillRoot]) -> None:
|
||||||
self._roots = tuple(roots)
|
self._roots = tuple(roots)
|
||||||
self._cache = None
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def roots(self) -> tuple[SkillRoot, ...]:
|
def roots(self) -> tuple[SkillRoot, ...]:
|
||||||
@@ -182,20 +182,20 @@ class SkillStore:
|
|||||||
@property
|
@property
|
||||||
def all_skills(self) -> list[Skill]:
|
def all_skills(self) -> list[Skill]:
|
||||||
"""Every discovered skill, shadowed copies included (priority order)."""
|
"""Every discovered skill, shadowed copies included (priority order)."""
|
||||||
return list(self._load())
|
return list(self._discover())
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def skills(self) -> list[Skill]:
|
def skills(self) -> list[Skill]:
|
||||||
"""Visible skills by name, sorted for display (shadows removed)."""
|
"""Visible skills by name, sorted for display (shadows removed)."""
|
||||||
by_name: dict[str, Skill] = {}
|
by_name: dict[str, Skill] = {}
|
||||||
for skill in self._load():
|
for skill in self._discover():
|
||||||
_ = by_name.setdefault(skill.name, skill)
|
_ = by_name.setdefault(skill.name, skill)
|
||||||
return [by_name[name] for name in sorted(by_name)]
|
return [by_name[name] for name in sorted(by_name)]
|
||||||
|
|
||||||
def shadowed(self) -> dict[str, list[Skill]]:
|
def shadowed(self) -> dict[str, list[Skill]]:
|
||||||
"""Skill names found in more than one root → the losing copies in order."""
|
"""Skill names found in more than one root → the losing copies in order."""
|
||||||
found: dict[str, list[Skill]] = {}
|
found: dict[str, list[Skill]] = {}
|
||||||
for skill in self._load():
|
for skill in self._discover():
|
||||||
found.setdefault(skill.name, []).append(skill)
|
found.setdefault(skill.name, []).append(skill)
|
||||||
return {name: skills for name, skills in sorted(found.items()) if len(skills) > 1}
|
return {name: skills for name, skills in sorted(found.items()) if len(skills) > 1}
|
||||||
|
|
||||||
@@ -204,7 +204,7 @@ class SkillStore:
|
|||||||
token = name.strip()
|
token = name.strip()
|
||||||
wanted = token.casefold()
|
wanted = token.casefold()
|
||||||
fallback: Skill | None = None
|
fallback: Skill | None = None
|
||||||
for skill in self._load():
|
for skill in self._discover():
|
||||||
if skill.name == token:
|
if skill.name == token:
|
||||||
return skill
|
return skill
|
||||||
if fallback is None and skill.name.casefold() == wanted:
|
if fallback is None and skill.name.casefold() == wanted:
|
||||||
@@ -218,25 +218,12 @@ class SkillStore:
|
|||||||
return root
|
return root
|
||||||
return self._roots[0] if self._roots else None
|
return self._roots[0] if self._roots else None
|
||||||
|
|
||||||
def reload(self) -> None:
|
def _discover(self) -> list[Skill]:
|
||||||
"""Drop the cache so the next access rescans the filesystem."""
|
"""Read every candidate ``SKILL.md`` now — nothing is cached."""
|
||||||
self._cache = None
|
return [skill for entry in self._scan() if (skill := _read_skill(entry)) is not None]
|
||||||
|
|
||||||
def _load(self) -> list[Skill]:
|
|
||||||
entries = self._scan()
|
|
||||||
signature = tuple((str(entry.directory), entry.mtime_ns) for entry in entries)
|
|
||||||
if self._cache is not None and self._cache[0] == signature:
|
|
||||||
return self._cache[1]
|
|
||||||
skills = [skill for entry in entries if (skill := _read_skill(entry)) is not None]
|
|
||||||
self._cache = (signature, skills)
|
|
||||||
return skills
|
|
||||||
|
|
||||||
def _scan(self) -> list[_Entry]:
|
def _scan(self) -> list[_Entry]:
|
||||||
return [
|
return [_Entry(root=root, directory=directory) for root in self._roots for directory in _skill_dirs(root.path)]
|
||||||
_Entry(root=root, directory=directory, mtime_ns=_skill_mtime(directory))
|
|
||||||
for root in self._roots
|
|
||||||
for directory in _skill_dirs(root.path)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _skill_dirs(root: Path) -> list[Path]:
|
def _skill_dirs(root: Path) -> list[Path]:
|
||||||
@@ -252,13 +239,6 @@ def _skill_dirs(root: Path) -> list[Path]:
|
|||||||
return found
|
return found
|
||||||
|
|
||||||
|
|
||||||
def _skill_mtime(directory: Path) -> int:
|
|
||||||
try:
|
|
||||||
return (directory / SKILL_FILE).stat().st_mtime_ns
|
|
||||||
except OSError:
|
|
||||||
return -1
|
|
||||||
|
|
||||||
|
|
||||||
def _read_skill(entry: _Entry) -> Skill | None:
|
def _read_skill(entry: _Entry) -> Skill | None:
|
||||||
"""Read one ``SKILL.md`` head and derive the record; ``None`` when unreadable."""
|
"""Read one ``SKILL.md`` head and derive the record; ``None`` when unreadable."""
|
||||||
issues: list[str] = []
|
issues: list[str] = []
|
||||||
|
|||||||
@@ -254,11 +254,11 @@ async def request_directory_access(path: str, reason: str = "", mode: str = "rea
|
|||||||
"""Request human approval to use a directory (or exact file) outside the workspace.
|
"""Request human approval to use a directory (or exact file) outside the workspace.
|
||||||
|
|
||||||
Call this instead of retrying a path that failed with ``path escapes
|
Call this instead of retrying a path that failed with ``path escapes
|
||||||
workspace root``. ``mode`` is ``read`` (inspect files), ``write`` (also
|
workspace root``, passing the ``mode`` that error names. ``mode`` is ``read``
|
||||||
modify files) or ``exec`` (also use it as ``run_argv`` / PTY cwd); the human
|
(inspect files), ``write`` (also modify files) or ``exec`` (also use it as
|
||||||
may approve a different level. Approval lasts for the session (or for this
|
``run_argv`` / PTY cwd); the human may approve a different level. Approval
|
||||||
process when ``--yes`` auto-approves). Paths matching the configured
|
lasts for the session (or for this process when ``--yes`` auto-approves).
|
||||||
denylist can never be granted.
|
Paths matching the configured denylist can never be granted.
|
||||||
"""
|
"""
|
||||||
requested = parse_access_mode(mode)
|
requested = parse_access_mode(mode)
|
||||||
if requested is None:
|
if requested is None:
|
||||||
|
|||||||
@@ -87,7 +87,13 @@ def _command_reason(argv: Sequence[str], *, via: str) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
def _write_file_reason(args: Mapping[str, object]) -> str | None:
|
def _write_file_reason(args: Mapping[str, object]) -> str | None:
|
||||||
"""Confirm only when write_file would replace an existing file."""
|
"""Confirm only when write_file was asked to replace an existing file.
|
||||||
|
|
||||||
|
An unset/``false`` ``overwrite`` never reaches this prompt: the tool itself
|
||||||
|
refuses an existing file, so there is nothing to ask about.
|
||||||
|
"""
|
||||||
|
if not bool(args.get("overwrite", False)):
|
||||||
|
return None
|
||||||
path = args.get("path")
|
path = args.get("path")
|
||||||
if not isinstance(path, str) or not path:
|
if not isinstance(path, str) or not path:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ def _replace_range(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
|
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO, writes=("path",))
|
||||||
async def edit_lineno(path: str, start_line: int, end_line: int, new_content: str) -> str:
|
async def edit_lineno(path: str, start_line: int, end_line: int, new_content: str) -> str:
|
||||||
"""Replace lines ``start_line``..``end_line`` (1-based, inclusive) in a file.
|
"""Replace lines ``start_line``..``end_line`` (1-based, inclusive) in a file.
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ def _success_message(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
|
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO, writes=("path",))
|
||||||
async def edit_replace(path: str, old_string: str, new_string: str, max_replaces: int = 1) -> str:
|
async def edit_replace(path: str, old_string: str, new_string: str, max_replaces: int = 1) -> str:
|
||||||
"""Replace occurrences of ``old_string`` with ``new_string`` in a file.
|
"""Replace occurrences of ``old_string`` with ``new_string`` in a file.
|
||||||
|
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ def _copy_or_move_validated(
|
|||||||
return f"error: {action} failed: {exc}"
|
return f"error: {action} failed: {exc}"
|
||||||
|
|
||||||
|
|
||||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
|
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO, writes=("dst",))
|
||||||
async def copy_path(src: str, dst: str, *, overwrite: bool = False) -> str:
|
async def copy_path(src: str, dst: str, *, overwrite: bool = False) -> str:
|
||||||
"""Copy a file or directory under the workspace (``shutil.copy2`` / ``copytree``).
|
"""Copy a file or directory under the workspace (``shutil.copy2`` / ``copytree``).
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ async def copy_path(src: str, dst: str, *, overwrite: bool = False) -> str:
|
|||||||
return _copy_or_move_validated(source, dest, root, src=src, dst=dst, overwrite=overwrite, action="copy")
|
return _copy_or_move_validated(source, dest, root, src=src, dst=dst, overwrite=overwrite, action="copy")
|
||||||
|
|
||||||
|
|
||||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
|
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO, writes=("src", "dst"))
|
||||||
async def move_path(src: str, dst: str, *, overwrite: bool = False) -> str:
|
async def move_path(src: str, dst: str, *, overwrite: bool = False) -> str:
|
||||||
"""Move/rename a file or directory under the workspace (``shutil.move``)."""
|
"""Move/rename a file or directory under the workspace (``shutil.move``)."""
|
||||||
pair = _resolve_pair(src, dst)
|
pair = _resolve_pair(src, dst)
|
||||||
@@ -149,7 +149,7 @@ def _delete_target(target: Path, path: str, *, recursive: bool) -> str:
|
|||||||
return f"error: unsupported path type: {path}"
|
return f"error: unsupported path type: {path}"
|
||||||
|
|
||||||
|
|
||||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
|
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO, writes=("path",))
|
||||||
async def delete_path(path: str, *, recursive: bool = False) -> str:
|
async def delete_path(path: str, *, recursive: bool = False) -> str:
|
||||||
"""Delete a file or directory under the workspace.
|
"""Delete a file or directory under the workspace.
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,16 @@ from plyngent.agent import ToolTag, tool
|
|||||||
from plyngent.tools.workspace import AccessMode, resolve_path
|
from plyngent.tools.workspace import AccessMode, resolve_path
|
||||||
|
|
||||||
|
|
||||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
|
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO, writes=("path",))
|
||||||
async def write_file(path: str, content: str) -> str:
|
async def write_file(path: str, content: str, *, overwrite: bool = False) -> str:
|
||||||
"""Write text content to a file under the workspace (creates parents)."""
|
"""Write text content to a file under the workspace (creates parents).
|
||||||
|
|
||||||
|
Refuses to replace an existing file: pass ``overwrite=true`` to replace it
|
||||||
|
(the replacement is then confirmed by the human).
|
||||||
|
"""
|
||||||
target = resolve_path(path, required=AccessMode.WRITE)
|
target = resolve_path(path, required=AccessMode.WRITE)
|
||||||
|
if target.is_file() and not overwrite:
|
||||||
|
return f"error: file exists: {path} (set overwrite=true to replace it)"
|
||||||
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")
|
||||||
# The write changes the file's mtime, so edit_lineno's freshness check
|
# The write changes the file's mtime, so edit_lineno's freshness check
|
||||||
|
|||||||
@@ -465,7 +465,6 @@ async def skill_create(
|
|||||||
_write_file(path, content)
|
_write_file(path, content)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
return f"error: failed to create skill {token!r}: {exc}"
|
return f"error: failed to create skill {token!r}: {exc}"
|
||||||
store.reload()
|
|
||||||
return f"created skill {token!r} at {target}{_skill_notes(target / SKILL_FILE)}"
|
return f"created skill {token!r} at {target}{_skill_notes(target / SKILL_FILE)}"
|
||||||
|
|
||||||
|
|
||||||
@@ -571,7 +570,6 @@ async def skill_edit(
|
|||||||
_write_file(target, updated)
|
_write_file(target, updated)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
return f"error: failed to write {relative} of skill {skill.name!r}: {exc}"
|
return f"error: failed to write {relative} of skill {skill.name!r}: {exc}"
|
||||||
store.reload()
|
|
||||||
notes = _skill_notes(target) if target.name == SKILL_FILE else ""
|
notes = _skill_notes(target) if target.name == SKILL_FILE else ""
|
||||||
return f"updated skill {skill.name!r} ({detail}){notes}"
|
return f"updated skill {skill.name!r} ({detail}){notes}"
|
||||||
|
|
||||||
|
|||||||
+27
-13
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
from typing import TYPE_CHECKING, cast
|
from typing import TYPE_CHECKING, cast
|
||||||
|
|
||||||
from plyngent.agent import ToolTag, tool
|
from plyngent.agent import ToolTag, tool
|
||||||
|
from plyngent.tools.chat.shape import number_arg
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
@@ -52,6 +53,10 @@ async def _with_todo_stack(mutator: Callable[[TodoStack], str]) -> str:
|
|||||||
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).
|
||||||
|
|
||||||
|
Items are numbered in the order they print (1-based, TOP group first). Those
|
||||||
|
numbers are listing positions, not stored ids, so they shift whenever the
|
||||||
|
stack changes — take them from the latest listing.
|
||||||
|
|
||||||
Non-empty stack with open (pending/in_progress) items = unfinished work.
|
Non-empty stack with open (pending/in_progress) items = unfinished work.
|
||||||
All-terminal but non-empty = hygiene only (pop/clear). Push creates one
|
All-terminal but non-empty = hygiene only (pop/clear). Push creates one
|
||||||
group of siblings; pop removes the whole top group.
|
group of siblings; pop removes the whole top group.
|
||||||
@@ -82,8 +87,8 @@ async def todo_push(titles: list[str], notes: str = "") -> str:
|
|||||||
group = stack.push_group(parsed, notes=notes)
|
group = stack.push_group(parsed, notes=notes)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return f"error: {exc}"
|
return f"error: {exc}"
|
||||||
ids = ", ".join(i.id for i in group.items)
|
pushed = ", ".join(i.title for i in group.items)
|
||||||
return f"pushed group (depth={stack.depth}) items=[{ids}]\n{stack.render()}"
|
return f"pushed group (depth={stack.depth}) items=[{pushed}]\n{stack.render()}"
|
||||||
|
|
||||||
return await _with_todo_stack(_run)
|
return await _with_todo_stack(_run)
|
||||||
|
|
||||||
@@ -100,27 +105,36 @@ async def todo_pop() -> str:
|
|||||||
group = stack.pop()
|
group = stack.pop()
|
||||||
if group is None:
|
if group is None:
|
||||||
return "todo stack empty"
|
return "todo stack empty"
|
||||||
titles = ", ".join(f"{i.id}:{i.title}" for i in group.items) or "(empty)"
|
popped_titles = ", ".join(i.title for i in group.items) or "(empty)"
|
||||||
top = stack.top_group
|
top = stack.top_group
|
||||||
top_s = "(empty)" if top is None else ", ".join(i.id for i in top.items)
|
top_titles = "(empty)" if top is None else ", ".join(i.title for i in top.items)
|
||||||
return f"popped TOP group ({titles}); new top group=[{top_s}]\n{stack.render()}"
|
return f"popped TOP group ({popped_titles}); new top group=[{top_titles}]\n{stack.render()}"
|
||||||
|
|
||||||
return await _with_todo_stack(_run)
|
return await _with_todo_stack(_run)
|
||||||
|
|
||||||
|
|
||||||
@tool(name="todo_update", tags=ToolTag.LOCAL | ToolTag.PUBLIC | ToolTag.SESSION_STATE)
|
@tool(name="todo_update", tags=ToolTag.LOCAL | ToolTag.PUBLIC | ToolTag.SESSION_STATE)
|
||||||
async def todo_update(
|
async def todo_update(
|
||||||
item_id: str,
|
number: int,
|
||||||
status: str = "",
|
status: str = "",
|
||||||
title: str = "",
|
title: str = "",
|
||||||
notes: str = "",
|
notes: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Update a task by id inside any group.
|
"""Update a task by the number the listing shows (1-based, TOP group first).
|
||||||
|
|
||||||
Open items are unfinished work: mark done/cancelled when the work is truly
|
Numbers come from the latest listing and shift when the stack changes, so
|
||||||
finished (not just deferred). Pop the TOP group when that breakdown level
|
re-list rather than reusing an older number. Open items are unfinished work:
|
||||||
is complete so the stack does not linger as false open work.
|
mark done/cancelled when the work is truly finished (not just deferred). Pop
|
||||||
|
the TOP group when that breakdown level is complete so the stack does not
|
||||||
|
linger as false open work.
|
||||||
"""
|
"""
|
||||||
|
item_number, number_error = number_arg("todo_update", "number", number, example="2")
|
||||||
|
if number_error is not None:
|
||||||
|
return f"error: {number_error}"
|
||||||
|
assert item_number is not None # number_arg returns a value whenever it accepts
|
||||||
|
if not item_number.is_integer():
|
||||||
|
return "error: `todo_update` expects `number` as a whole item number; pass e.g. 2"
|
||||||
|
target = int(item_number)
|
||||||
|
|
||||||
def _run(stack: TodoStack) -> str:
|
def _run(stack: TodoStack) -> str:
|
||||||
status_arg: TodoStatus | None = None
|
status_arg: TodoStatus | None = None
|
||||||
@@ -131,14 +145,14 @@ async def todo_update(
|
|||||||
status_arg = cast("TodoStatus", token)
|
status_arg = cast("TodoStatus", token)
|
||||||
try:
|
try:
|
||||||
item = stack.update(
|
item = stack.update(
|
||||||
item_id.strip(),
|
target,
|
||||||
title=title if title.strip() else None,
|
title=title if title.strip() else None,
|
||||||
status=status_arg,
|
status=status_arg,
|
||||||
notes=notes if notes != "" else None,
|
notes=notes if notes != "" else None,
|
||||||
)
|
)
|
||||||
except (KeyError, ValueError) as exc:
|
except ValueError as exc:
|
||||||
return f"error: {exc}"
|
return f"error: {exc}"
|
||||||
return f"updated {item.id} → {item.status}: {item.title}\n{stack.render()}"
|
return f"updated {target} → {item.status}: {item.title}\n{stack.render()}"
|
||||||
|
|
||||||
return await _with_todo_stack(_run)
|
return await _with_todo_stack(_run)
|
||||||
|
|
||||||
|
|||||||
@@ -393,7 +393,7 @@ def resolve_path(path: str | Path, *, required: AccessMode = AccessMode.WRITE) -
|
|||||||
if not _under_any_root(resolved, instance, policy) and not _path_grant_covers(resolved, required):
|
if not _under_any_root(resolved, instance, policy) and not _path_grant_covers(resolved, required):
|
||||||
msg = (
|
msg = (
|
||||||
f"path escapes workspace root ({root}): {path}; "
|
f"path escapes workspace root ({root}): {path}; "
|
||||||
"call request_directory_access to request access to a directory outside the workspace"
|
f"call request_directory_access with mode={required.name.lower()!r} to request access outside the workspace"
|
||||||
)
|
)
|
||||||
raise WorkspaceError(msg)
|
raise WorkspaceError(msg)
|
||||||
# Normalize separators so denylist entries like ``/secrets/`` match on Windows.
|
# Normalize separators so denylist entries like ``/secrets/`` match on Windows.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import UTC, datetime, timedelta, timezone
|
from datetime import UTC, datetime, timedelta, timezone
|
||||||
|
|
||||||
from plyngent.agent import ChatAgent, Notice, aside_notice, format_moment, resume_notice
|
from plyngent.agent import ChatAgent, Notice, aside_notice, format_moment, has_notice, resume_notice, skill_tools_notice
|
||||||
from plyngent.agent.chat import incomplete_turn_user_text
|
from plyngent.agent.chat import incomplete_turn_user_text
|
||||||
from plyngent.config.models import DatabaseConfig
|
from plyngent.config.models import DatabaseConfig
|
||||||
from plyngent.lmproto.openai_compatible.model import (
|
from plyngent.lmproto.openai_compatible.model import (
|
||||||
@@ -57,6 +57,31 @@ def test_aside_notice_matches_tool_scope() -> None:
|
|||||||
assert aside_notice(tools_mode="weird").body == read.body
|
assert aside_notice(tools_mode="weird").body == read.body
|
||||||
|
|
||||||
|
|
||||||
|
def test_skill_tools_notice_points_at_the_tools_without_listing() -> None:
|
||||||
|
notice = skill_tools_notice()
|
||||||
|
assert notice.kind == "skills"
|
||||||
|
# Instructions only — what exists is asked for, never injected.
|
||||||
|
assert "not listed here" in notice.body
|
||||||
|
assert "`skill_list`" in notice.body
|
||||||
|
assert "`skill_search`" in notice.body
|
||||||
|
assert "`skill_read NAME [file]`" in notice.body
|
||||||
|
assert "data, not code" in notice.body
|
||||||
|
assert "skill_list" in notice.summary
|
||||||
|
|
||||||
|
|
||||||
|
def test_has_notice_finds_a_persisted_row_by_kind() -> None:
|
||||||
|
messages = [
|
||||||
|
SystemChatMessage(content="sys"),
|
||||||
|
skill_tools_notice().to_message(),
|
||||||
|
resume_notice(last_active=None).to_message(),
|
||||||
|
]
|
||||||
|
assert has_notice(messages, "skills") is True
|
||||||
|
assert has_notice(messages, "resume") is True
|
||||||
|
assert has_notice(messages, "aside") is False
|
||||||
|
# A plain developer row (a directive checkpoint) is not a notice.
|
||||||
|
assert has_notice([DeveloperChatMessage(content="checkpoint")], "skills") is False
|
||||||
|
|
||||||
|
|
||||||
async def test_push_notice_appends_and_persists() -> None:
|
async def test_push_notice_appends_and_persists() -> None:
|
||||||
store = await MemoryStore.open(DatabaseConfig())
|
store = await MemoryStore.open(DatabaseConfig())
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from plyngent.lmproto.openai_compatible.model import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator, Mapping
|
||||||
|
|
||||||
|
|
||||||
def _response(message: AssistantChatMessage) -> ChatCompletionResponse:
|
def _response(message: AssistantChatMessage) -> ChatCompletionResponse:
|
||||||
@@ -156,3 +156,102 @@ async def test_parallel_tool_calls_run_concurrently() -> None:
|
|||||||
tool_msgs = [e for e in agent.messages if isinstance(e, ToolChatMessage)]
|
tool_msgs = [e for e in agent.messages if isinstance(e, ToolChatMessage)]
|
||||||
assert {m.content for m in tool_msgs} == {"a", "b"}
|
assert {m.content for m in tool_msgs} == {"a", "b"}
|
||||||
assert any(getattr(e, "content", None) == "done" for e in events)
|
assert any(getattr(e, "content", None) == "done" for e in events)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_same_file_writes_apply_in_call_order() -> None:
|
||||||
|
"""A batch's writes to one file run one at a time, in call order."""
|
||||||
|
order: list[str] = []
|
||||||
|
|
||||||
|
@tool(register=False, writes=("path",))
|
||||||
|
async def write_a(path: str) -> str:
|
||||||
|
order.append(f"a start {path}")
|
||||||
|
await asyncio.sleep(0) # an unserialized sibling would interleave here
|
||||||
|
order.append(f"a end {path}")
|
||||||
|
return "a"
|
||||||
|
|
||||||
|
@tool(register=False, writes=("path",))
|
||||||
|
async def write_b(path: str) -> str:
|
||||||
|
order.append(f"b start {path}")
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
order.append(f"b end {path}")
|
||||||
|
return "b"
|
||||||
|
|
||||||
|
registry = ToolRegistry([write_a, write_b])
|
||||||
|
results = await asyncio.gather(
|
||||||
|
registry.execute("write_a", '{"path": "a.txt"}'),
|
||||||
|
registry.execute("write_b", '{"path": "./a.txt"}'),
|
||||||
|
)
|
||||||
|
assert results == ["a", "b"]
|
||||||
|
assert order == ["a start a.txt", "a end a.txt", "b start ./a.txt", "b end ./a.txt"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_different_files_still_write_concurrently() -> None:
|
||||||
|
"""Serialization is per file: two files of one batch still overlap."""
|
||||||
|
overlaps = 0
|
||||||
|
gate = asyncio.Event()
|
||||||
|
|
||||||
|
async def _rendezvous() -> None:
|
||||||
|
nonlocal overlaps
|
||||||
|
overlaps += 1
|
||||||
|
if overlaps == 2:
|
||||||
|
_ = gate.set()
|
||||||
|
_ = await asyncio.wait_for(gate.wait(), timeout=2.0)
|
||||||
|
|
||||||
|
@tool(register=False, writes=("path",))
|
||||||
|
async def write_a(path: str) -> str:
|
||||||
|
del path
|
||||||
|
await _rendezvous()
|
||||||
|
return "a"
|
||||||
|
|
||||||
|
@tool(register=False, writes=("path",))
|
||||||
|
async def write_b(path: str) -> str:
|
||||||
|
del path
|
||||||
|
await _rendezvous()
|
||||||
|
return "b"
|
||||||
|
|
||||||
|
registry = ToolRegistry([write_a, write_b])
|
||||||
|
results = await asyncio.gather(
|
||||||
|
registry.execute("write_a", '{"path": "a.txt"}'),
|
||||||
|
registry.execute("write_b", '{"path": "b.txt"}'),
|
||||||
|
)
|
||||||
|
assert results == ["a", "b"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_write_holds_its_file_across_the_confirm() -> None:
|
||||||
|
"""The file is locked before the prompt, so a sibling write cannot overtake it."""
|
||||||
|
order: list[str] = []
|
||||||
|
prompt_shown = asyncio.Event()
|
||||||
|
answered = asyncio.Event()
|
||||||
|
|
||||||
|
@tool(register=False, writes=("path",))
|
||||||
|
async def write_a(path: str) -> str:
|
||||||
|
del path
|
||||||
|
order.append("a")
|
||||||
|
return "a"
|
||||||
|
|
||||||
|
@tool(register=False, writes=("path",))
|
||||||
|
async def write_b(path: str) -> str:
|
||||||
|
del path
|
||||||
|
order.append("b")
|
||||||
|
return "b"
|
||||||
|
|
||||||
|
async def on_confirm(name: str, args: Mapping[str, object], reason: str) -> bool:
|
||||||
|
del args, reason
|
||||||
|
order.append(f"confirm {name}")
|
||||||
|
prompt_shown.set()
|
||||||
|
await answered.wait()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def danger(name: str, args: Mapping[str, object]) -> str | None:
|
||||||
|
del args
|
||||||
|
return "overwrite" if name == "write_a" else None
|
||||||
|
|
||||||
|
registry = ToolRegistry([write_a, write_b], danger=danger, on_confirm=on_confirm)
|
||||||
|
first = asyncio.create_task(registry.execute("write_a", '{"path": "a.txt"}'))
|
||||||
|
await prompt_shown.wait()
|
||||||
|
second = asyncio.create_task(registry.execute("write_b", '{"path": "a.txt"}'))
|
||||||
|
await asyncio.sleep(0) # the second write waits for the file, not for the human
|
||||||
|
assert order == ["confirm write_a"]
|
||||||
|
answered.set()
|
||||||
|
assert await asyncio.gather(first, second) == ["a", "b"]
|
||||||
|
assert order == ["confirm write_a", "a", "b"]
|
||||||
|
|||||||
@@ -66,6 +66,29 @@ def test_ids_reuse_after_pop() -> None:
|
|||||||
assert g3.items[0].id == "t3"
|
assert g3.items[0].id == "t3"
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_numbers_follow_listing_order() -> None:
|
||||||
|
"""Numbers are listing positions (TOP group first), never the stored ids."""
|
||||||
|
stack = TodoStack()
|
||||||
|
root = stack.push_group(["root", "sibling"]) # stored ids t1, t2
|
||||||
|
_ = stack.push_group(["child"]) # stored id t3
|
||||||
|
listing = stack.render()
|
||||||
|
assert " [ ] 1. child" in listing
|
||||||
|
assert " [ ] 2. root" in listing
|
||||||
|
assert " [ ] 3. sibling" in listing
|
||||||
|
assert "t1" not in listing
|
||||||
|
assert "t3" not in listing
|
||||||
|
|
||||||
|
# update(1) is the first item the listing shows — the one stored as t3.
|
||||||
|
updated = stack.update(1, status="done")
|
||||||
|
assert updated.id == "t3"
|
||||||
|
assert updated.title == "child"
|
||||||
|
assert updated.status == "done"
|
||||||
|
assert root.items[0].status == "pending"
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="no todo item 4"):
|
||||||
|
stack.update(4, status="done")
|
||||||
|
|
||||||
|
|
||||||
def test_push_is_group_not_per_task_stack() -> None:
|
def test_push_is_group_not_per_task_stack() -> None:
|
||||||
"""Multi-title push is one group; pop removes the whole group."""
|
"""Multi-title push is one group; pop removes the whole group."""
|
||||||
stack = TodoStack()
|
stack = TodoStack()
|
||||||
@@ -94,11 +117,16 @@ def test_dfs_breakdown_with_groups() -> None:
|
|||||||
root = stack.push_group(["T1", "T2"])
|
root = stack.push_group(["T1", "T2"])
|
||||||
children = stack.push_group(["T1.1", "T1.2"])
|
children = stack.push_group(["T1.1", "T1.2"])
|
||||||
assert stack.depth == 2
|
assert stack.depth == 2
|
||||||
stack.update(children.items[0].id, status="done")
|
# Numbers come from the listing: the pushed children are the TOP group → 1, 2.
|
||||||
stack.update(children.items[1].id, status="done")
|
stack.update(1, status="done")
|
||||||
|
stack.update(2, status="done")
|
||||||
|
assert [i.status for i in children.items] == ["done", "done"]
|
||||||
_ = stack.pop()
|
_ = stack.pop()
|
||||||
assert stack.depth == 1
|
assert stack.depth == 1
|
||||||
stack.update(root.items[0].id, status="done")
|
# Children gone: the root group is the listing again → 1 is T1, 2 is T2.
|
||||||
|
stack.update(1, status="done")
|
||||||
|
assert root.items[0].status == "done"
|
||||||
|
assert root.items[1].status == "pending"
|
||||||
_ = stack.push_group(["T2.1"])
|
_ = stack.push_group(["T2.1"])
|
||||||
assert stack.depth == 2
|
assert stack.depth == 2
|
||||||
assert stack.top_group is not None
|
assert stack.top_group is not None
|
||||||
@@ -119,14 +147,14 @@ def test_single_title_still_one_group() -> None:
|
|||||||
def test_todo_stack_needs_review() -> None:
|
def test_todo_stack_needs_review() -> None:
|
||||||
stack = TodoStack()
|
stack = TodoStack()
|
||||||
assert not stack.needs_review()
|
assert not stack.needs_review()
|
||||||
item = stack.push("work")
|
_ = stack.push("work")
|
||||||
stack.begin_turn()
|
stack.begin_turn()
|
||||||
# Non-empty + untouched → nag (open or hygiene).
|
# Non-empty + untouched → nag (open or hygiene).
|
||||||
assert stack.needs_review()
|
assert stack.needs_review()
|
||||||
# Any todo_* access this turn suppresses end-of-turn nag, even with open items.
|
# Any todo_* access this turn suppresses end-of-turn nag, even with open items.
|
||||||
stack.mark_touched()
|
stack.mark_touched()
|
||||||
assert not stack.needs_review()
|
assert not stack.needs_review()
|
||||||
stack.update(item.id, status="done")
|
stack.update(1, status="done")
|
||||||
stack.begin_turn()
|
stack.begin_turn()
|
||||||
# Terminal-only stack: same gate (untouched only).
|
# Terminal-only stack: same gate (untouched only).
|
||||||
assert stack.needs_review()
|
assert stack.needs_review()
|
||||||
@@ -212,7 +240,7 @@ def test_inject_todo_nag_strategies() -> None:
|
|||||||
|
|
||||||
def test_todo_prompts_signal_undone_work() -> None:
|
def test_todo_prompts_signal_undone_work() -> None:
|
||||||
stack = TodoStack()
|
stack = TodoStack()
|
||||||
item = stack.push("open work")
|
_ = stack.push("open work")
|
||||||
reminder = stack.turn_reminder_prompt()
|
reminder = stack.turn_reminder_prompt()
|
||||||
assert "[TODO OPEN WORK]" in reminder
|
assert "[TODO OPEN WORK]" in reminder
|
||||||
assert "Stack not empty" in reminder
|
assert "Stack not empty" in reminder
|
||||||
@@ -223,9 +251,9 @@ def test_todo_prompts_signal_undone_work() -> None:
|
|||||||
assert "[TODO OPEN WORK]" in review
|
assert "[TODO OPEN WORK]" in review
|
||||||
assert "undone work" in review.lower() or "open item" in review.lower()
|
assert "undone work" in review.lower() or "open item" in review.lower()
|
||||||
assert "open work" in review
|
assert "open work" in review
|
||||||
assert "t1:open work" in review or "open work" in review
|
assert "1. open work" in review # the review embeds the numbered listing
|
||||||
|
|
||||||
stack.update(item.id, status="done")
|
stack.update(1, status="done")
|
||||||
terminal_review = stack.review_prompt()
|
terminal_review = stack.review_prompt()
|
||||||
assert "[TODO HYGIENE]" in terminal_review
|
assert "[TODO HYGIENE]" in terminal_review
|
||||||
assert "done/cancelled" in terminal_review
|
assert "done/cancelled" in terminal_review
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from typing import TYPE_CHECKING
|
|||||||
|
|
||||||
from plyngent.agent import ErrorEvent, ReasoningDeltaEvent, TextDeltaEvent, ToolCallEvent, ToolResultEvent
|
from plyngent.agent import ErrorEvent, ReasoningDeltaEvent, TextDeltaEvent, ToolCallEvent, ToolResultEvent
|
||||||
from plyngent.cli.display import (
|
from plyngent.cli.display import (
|
||||||
|
_RUN_BATCH_CMD_LIMIT,
|
||||||
_clear_streamed_lines,
|
_clear_streamed_lines,
|
||||||
_line_count_for_clear,
|
_line_count_for_clear,
|
||||||
_pretty_line_for,
|
_pretty_line_for,
|
||||||
@@ -174,28 +175,28 @@ async def test_pretty_todo_push(capsys: pytest.CaptureFixture[str]) -> None:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
result = (
|
result = (
|
||||||
"pushed group (depth=1) items=[a1]\n"
|
"pushed group (depth=1) items=[T1]\n"
|
||||||
"(LIFO of groups: depth=1; TOP group = current breakdown level)\n"
|
"(LIFO of groups: depth=1; TOP group = current breakdown level)\n"
|
||||||
"group d=0 TOP:\n"
|
"group d=0 TOP:\n"
|
||||||
" [ ] a1: T1"
|
" [ ] 1. T1"
|
||||||
)
|
)
|
||||||
await render_events(_aiter([call, _result(result)]))
|
await render_events(_aiter([call, _result(result)]))
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert "* Todo Push:" in out
|
assert "* Todo Push:" in out
|
||||||
assert " [ ] a1: T1" in out
|
assert " [ ] 1. T1" in out
|
||||||
assert "[tool]" not in out
|
assert "[tool]" not in out
|
||||||
|
|
||||||
|
|
||||||
async def test_pretty_todo_batch_merges_to_one_stack(capsys: pytest.CaptureFixture[str]) -> None:
|
async def test_pretty_todo_batch_merges_to_one_stack(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
"""A batch of todo calls: one summary line, then the last result's stack."""
|
"""A batch of todo calls: one summary line, then the last result's stack."""
|
||||||
stack = "(LIFO of groups: depth=1; TOP group = current breakdown level)\ngroup d=0 TOP:\n [ ] a1: T1"
|
stack = "(LIFO of groups: depth=1; TOP group = current breakdown level)\ngroup d=0 TOP:\n [ ] 1. T1"
|
||||||
await render_events(
|
await render_events(
|
||||||
_aiter(
|
_aiter(
|
||||||
[
|
[
|
||||||
_pretty_call("todo_push", '{"titles": ["T1"]}'),
|
_pretty_call("todo_push", '{"titles": ["T1"]}'),
|
||||||
_pretty_call("todo_update", '{"item_id": "a1", "status": "done"}'),
|
_pretty_call("todo_update", '{"number": 1, "status": "done"}'),
|
||||||
_result(f"pushed group (depth=1) items=[a1]\n{stack}"),
|
_result(f"pushed group (depth=1) items=[T1]\n{stack}"),
|
||||||
_result(f"updated a1 → done: T1\n{stack}"),
|
_result(f"updated 1 → done: T1\n{stack}"),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -206,14 +207,14 @@ async def test_pretty_todo_batch_merges_to_one_stack(capsys: pytest.CaptureFixtu
|
|||||||
# Only the last result is printed, so the stack renders once.
|
# Only the last result is printed, so the stack renders once.
|
||||||
assert out.count("group d=0 TOP:") == 1
|
assert out.count("group d=0 TOP:") == 1
|
||||||
assert "pushed group" not in out
|
assert "pushed group" not in out
|
||||||
assert "updated a1 → done: T1" in out
|
assert "updated 1 → done: T1" in out
|
||||||
|
|
||||||
|
|
||||||
async def test_pretty_todo_batch_mixed_with_other_tool_stays_per_call(
|
async def test_pretty_todo_batch_mixed_with_other_tool_stays_per_call(
|
||||||
capsys: pytest.CaptureFixture[str],
|
capsys: pytest.CaptureFixture[str],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""A batch holding a non-todo call keeps one line (and stack) per todo call."""
|
"""A batch holding a non-todo call keeps one line (and stack) per todo call."""
|
||||||
stack = "group d=0 TOP:\n [ ] a1: T1"
|
stack = "group d=0 TOP:\n [ ] 1. T1"
|
||||||
await render_events(
|
await render_events(
|
||||||
_aiter(
|
_aiter(
|
||||||
[
|
[
|
||||||
@@ -470,6 +471,67 @@ async def test_pretty_run_argv_batch_complete(capsys: pytest.CaptureFixture[str]
|
|||||||
assert "* Run batch (done)" in out
|
assert "* Run batch (done)" in out
|
||||||
|
|
||||||
|
|
||||||
|
_BATCH_DONE_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"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pretty_run_argv_batch_lists_commands(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
"""The batch row leads with one ``$ command`` line per step."""
|
||||||
|
args = '{"steps": [{"argv": ["git", "status"]}, {"argv": ["git", "diff", "--stat"]}]}'
|
||||||
|
await render_events(_aiter([_pretty_call("run_argv_batch", args), _result(_BATCH_DONE_RESULT)]))
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "$ git status\n$ git diff --stat\n* Run batch (done)" in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pretty_run_argv_batch_joins_pipe_chain(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
"""``pipe_out`` steps share one line; a step they do not feed keeps its own."""
|
||||||
|
args = (
|
||||||
|
'{"steps": [{"argv": ["grep", "-n", "foo", "a.py"], "pipe_out": true},'
|
||||||
|
' {"argv": ["wc", "-l"]}, {"argv": ["git", "status"]}]}'
|
||||||
|
)
|
||||||
|
await render_events(_aiter([_pretty_call("run_argv_batch", args), _result(_BATCH_DONE_RESULT)]))
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "$ grep -n foo a.py | wc -l\n$ git status\n* Run batch (done)" in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pretty_run_argv_batch_elides_a_long_command(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
long_arg = "x" * 200
|
||||||
|
args = f'{{"steps": [{{"argv": ["echo", "{long_arg}"]}}]}}'
|
||||||
|
await render_events(_aiter([_pretty_call("run_argv_batch", args), _result(_BATCH_DONE_RESULT)]))
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
command = f"echo {long_arg}"
|
||||||
|
line = next(text for text in out.splitlines() if text.startswith("$ "))
|
||||||
|
assert line == f"$ {command[:_RUN_BATCH_CMD_LIMIT]}…"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pretty_run_argv_batch_steps_as_json_string(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
"""``steps`` may arrive as a JSON string holding the array."""
|
||||||
|
args = '{"steps": "[{\\"argv\\": [\\"git\\", \\"status\\"]}]"}'
|
||||||
|
await render_events(_aiter([_pretty_call("run_argv_batch", args), _result(_BATCH_DONE_RESULT)]))
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "$ git status\n* Run batch (done)" in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pretty_run_argv_batch_unreadable_steps_hide_commands(
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
await render_events(
|
||||||
|
_aiter(
|
||||||
|
[
|
||||||
|
_pretty_call("run_argv_batch", '{"steps": "nope"}'),
|
||||||
|
_result("error: steps must be a JSON array: Expecting value"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "$ " not in out
|
||||||
|
assert "* Run batch (error: steps must be a JSON array" in out
|
||||||
|
|
||||||
|
|
||||||
async def test_pretty_fetch(capsys: pytest.CaptureFixture[str]) -> None:
|
async def test_pretty_fetch(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
result = (
|
result = (
|
||||||
"status=200\nmethod=GET\nfinal_url=https://example.com\ncontent_type=application/json\n"
|
"status=200\nmethod=GET\nfinal_url=https://example.com\ncontent_type=application/json\n"
|
||||||
@@ -867,13 +929,13 @@ async def test_pretty_vcs_kind_branch_and_diff(capsys: pytest.CaptureFixture[str
|
|||||||
|
|
||||||
|
|
||||||
async def test_pretty_todo_list_pop_and_clear(capsys: pytest.CaptureFixture[str]) -> None:
|
async def test_pretty_todo_list_pop_and_clear(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
stack = " [ ] a1: T1\n [x] a2: T2"
|
stack = " [ ] 1. T1\n [x] 2. T2"
|
||||||
await render_events(_aiter([_pretty_call("todo_list", "{}"), _result(stack)]))
|
await render_events(_aiter([_pretty_call("todo_list", "{}"), _result(stack)]))
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert "* Todo List:" in out
|
assert "* Todo List:" in out
|
||||||
assert " [ ] a1: T1" in out
|
assert " [ ] 1. T1" in out
|
||||||
|
|
||||||
await render_events(_aiter([_pretty_call("todo_pop", "{}"), _result(f"popped TOP group (a1:T1)\n{stack}")]))
|
await render_events(_aiter([_pretty_call("todo_pop", "{}"), _result(f"popped TOP group (T1)\n{stack}")]))
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert "* Todo Pop:" in out
|
assert "* Todo Pop:" in out
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import click
|
||||||
import pytest
|
import pytest
|
||||||
from click.testing import CliRunner
|
from click.testing import CliRunner
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ from plyngent.cli.app import main
|
|||||||
from plyngent.cli.editor import (
|
from plyngent.cli.editor import (
|
||||||
ensure_config_file,
|
ensure_config_file,
|
||||||
get_editor,
|
get_editor,
|
||||||
|
open_directory,
|
||||||
open_in_editor,
|
open_in_editor,
|
||||||
prompt_edit_config,
|
prompt_edit_config,
|
||||||
resolve_config_path,
|
resolve_config_path,
|
||||||
@@ -70,7 +72,8 @@ def test_open_in_editor_system_fallback(
|
|||||||
path = tmp_path / "x.toml"
|
path = tmp_path / "x.toml"
|
||||||
opened: list[str] = []
|
opened: list[str] = []
|
||||||
|
|
||||||
def fake_system(p: Path) -> None:
|
def fake_system(p: Path, *, hint: str = "") -> None:
|
||||||
|
assert hint
|
||||||
opened.append(str(p))
|
opened.append(str(p))
|
||||||
|
|
||||||
monkeypatch.setattr("plyngent.cli.editor._open_with_system_default", fake_system)
|
monkeypatch.setattr("plyngent.cli.editor._open_with_system_default", fake_system)
|
||||||
@@ -80,6 +83,43 @@ def test_open_in_editor_system_fallback(
|
|||||||
assert path.is_file()
|
assert path.is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_directory_uses_the_system_opener_even_with_an_editor(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
opened: list[Path] = []
|
||||||
|
|
||||||
|
def fake_system(p: Path, *, hint: str = "") -> None:
|
||||||
|
assert hint
|
||||||
|
opened.append(p)
|
||||||
|
|
||||||
|
# A folder is not an editor target: $EDITOR must not be consulted.
|
||||||
|
monkeypatch.setenv("EDITOR", "codium --wait")
|
||||||
|
monkeypatch.setattr("plyngent.cli.editor._open_with_system_default", fake_system)
|
||||||
|
directory = tmp_path / "acme-deploy"
|
||||||
|
directory.mkdir()
|
||||||
|
|
||||||
|
open_directory(directory)
|
||||||
|
|
||||||
|
assert opened == [directory]
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_directory_names_the_printed_path_when_the_opener_is_missing(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
def missing(*args: object, **kwargs: object) -> object:
|
||||||
|
del args, kwargs
|
||||||
|
msg = "xdg-open"
|
||||||
|
raise FileNotFoundError(msg)
|
||||||
|
|
||||||
|
monkeypatch.setattr("sys.platform", "linux")
|
||||||
|
monkeypatch.setattr("plyngent.cli.editor.subprocess.run", missing)
|
||||||
|
|
||||||
|
with pytest.raises(click.ClickException, match=r"xdg-open not found; open the printed path"):
|
||||||
|
open_directory(tmp_path)
|
||||||
|
|
||||||
|
|
||||||
def test_open_in_editor_no_fallback_requires_editor(
|
def test_open_in_editor_no_fallback_requires_editor(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
@@ -227,7 +267,7 @@ def test_config_edit_system_fallback_message(
|
|||||||
monkeypatch.delenv("VISUAL", raising=False)
|
monkeypatch.delenv("VISUAL", raising=False)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"plyngent.cli.editor._open_with_system_default",
|
"plyngent.cli.editor._open_with_system_default",
|
||||||
lambda p: None,
|
lambda p, **kwargs: None,
|
||||||
)
|
)
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(main, ["config", "edit", "--config", str(path)])
|
result = runner.invoke(main, ["config", "edit", "--config", str(path)])
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ def test_completer_help_commands(tmp_path: object, monkeypatch: object) -> None:
|
|||||||
found.append(item)
|
found.append(item)
|
||||||
index += 1
|
index += 1
|
||||||
assert "compact" in found
|
assert "compact" in found
|
||||||
assert "clear" in found
|
assert "config" in found
|
||||||
|
|
||||||
|
|
||||||
def test_completer_stream_on_off(tmp_path: object, monkeypatch: object) -> None:
|
def test_completer_stream_on_off(tmp_path: object, monkeypatch: object) -> None:
|
||||||
@@ -194,9 +194,9 @@ def test_complete_slash_args_from_registry(tmp_path: object) -> None:
|
|||||||
|
|
||||||
state.todo_stack = TodoStack()
|
state.todo_stack = TodoStack()
|
||||||
_ = state.todo_stack.push_group(["alpha", "beta"])
|
_ = state.todo_stack.push_group(["alpha", "beta"])
|
||||||
ids = complete_slash_args(state, "/todos", "t", prior_args=["done"])
|
numbers = complete_slash_args(state, "/todos", "", prior_args=["done"])
|
||||||
assert "t1" in ids
|
assert numbers == ["1", "2"]
|
||||||
assert "t2" in ids
|
assert complete_slash_args(state, "/todos", "2", prior_args=["done"]) == ["2"]
|
||||||
|
|
||||||
state.remember_session_ids([3, 12, 40])
|
state.remember_session_ids([3, 12, 40])
|
||||||
assert complete_slash_args(state, "/resume", "1") == ["12"]
|
assert complete_slash_args(state, "/resume", "1") == ["12"]
|
||||||
|
|||||||
@@ -87,11 +87,8 @@ async def state(tmp_path: Path) -> AsyncIterator[ReplState]:
|
|||||||
await memory.close()
|
await memory.close()
|
||||||
|
|
||||||
|
|
||||||
async def test_help_and_clear(state: ReplState) -> None:
|
async def test_help(state: ReplState) -> None:
|
||||||
assert await handle_slash(state, "/help") is True
|
assert await handle_slash(state, "/help") is True
|
||||||
state.agent.messages.append(AssistantChatMessage(content="x"))
|
|
||||||
assert await handle_slash(state, "/clear") is True
|
|
||||||
assert state.agent.messages == []
|
|
||||||
|
|
||||||
|
|
||||||
async def test_auto_continue_limits_installs_on_limit(tmp_path: Path) -> None:
|
async def test_auto_continue_limits_installs_on_limit(tmp_path: Path) -> None:
|
||||||
@@ -760,3 +757,26 @@ async def test_btw_read_notice_states_read_only_scope(
|
|||||||
assert "read-only" in notice.body
|
assert "read-only" in notice.body
|
||||||
assert "not saved" in notice.body
|
assert "not saved" in notice.body
|
||||||
assert "[notice] aside: read tools" in capsys.readouterr().out
|
assert "[notice] aside: read tools" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_todos_update_takes_a_listing_number(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
assert await handle_slash(state, "/todos push first; second") is True
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "pushed group depth=1 items=[first, second]" in out
|
||||||
|
assert " [ ] 1. first" in out
|
||||||
|
assert " [ ] 2. second" in out
|
||||||
|
assert "t1" not in out
|
||||||
|
|
||||||
|
assert await handle_slash(state, "/todos done 2") is True
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "updated 2 → done" in out
|
||||||
|
assert " [x] 2. second" in out
|
||||||
|
|
||||||
|
# The stored id is not an address: only listing numbers are.
|
||||||
|
assert await handle_slash(state, "/todos done t1") is True
|
||||||
|
assert "takes an item number from the list" in capsys.readouterr().out
|
||||||
|
assert await handle_slash(state, "/todos done 7") is True
|
||||||
|
assert "no todo item 7" in capsys.readouterr().out
|
||||||
|
|
||||||
|
assert await handle_slash(state, "/todos pop") is True
|
||||||
|
assert "popped TOP group (first, second)" in capsys.readouterr().out
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from plyngent.cli.slash import handle_slash
|
|||||||
from plyngent.cli.state import ReplState
|
from plyngent.cli.state import ReplState
|
||||||
from plyngent.config.models import DatabaseConfig, OpenAIProvider
|
from plyngent.config.models import DatabaseConfig, OpenAIProvider
|
||||||
from plyngent.config.store import ConfigStore
|
from plyngent.config.store import ConfigStore
|
||||||
|
from plyngent.lmproto.openai_compatible.model import DeveloperChatMessage
|
||||||
from plyngent.memory import MemoryStore
|
from plyngent.memory import MemoryStore
|
||||||
from plyngent.prompting import NonInteractiveBackend, temporary_backend
|
from plyngent.prompting import NonInteractiveBackend, temporary_backend
|
||||||
from plyngent.skills import SKILL_FILE
|
from plyngent.skills import SKILL_FILE
|
||||||
@@ -31,6 +32,15 @@ def _write_skill(root: Path, name: str, *, description: str = "A test skill", bo
|
|||||||
return directory
|
return directory
|
||||||
|
|
||||||
|
|
||||||
|
def _skill_notice_rows(state: ReplState) -> list[str]:
|
||||||
|
"""Body of every ``skills`` host notice in the session transcript."""
|
||||||
|
return [
|
||||||
|
message.content
|
||||||
|
for message in state.agent.messages
|
||||||
|
if isinstance(message, DeveloperChatMessage) and message.content.startswith("[plyngent notice: skills]")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _config_doc(tmp_path: Path, *, paths: list[Path] | None = None, extra: str = "") -> tomlkit.TOMLDocument:
|
def _config_doc(tmp_path: Path, *, paths: list[Path] | None = None, extra: str = "") -> tomlkit.TOMLDocument:
|
||||||
lines = ["[skills]", "discover = []"]
|
lines = ["[skills]", "discover = []"]
|
||||||
if paths:
|
if paths:
|
||||||
@@ -99,7 +109,7 @@ async def test_skills_are_discovered_from_a_configured_root(tmp_path: Path) -> N
|
|||||||
await state.memory.close()
|
await state.memory.close()
|
||||||
|
|
||||||
|
|
||||||
async def test_slash_skills_lists_search_and_reads(
|
async def test_slash_skills_lists_and_searches(
|
||||||
skills_state: ReplState,
|
skills_state: ReplState,
|
||||||
capsys: pytest.CaptureFixture[str],
|
capsys: pytest.CaptureFixture[str],
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -112,14 +122,56 @@ async def test_slash_skills_lists_search_and_reads(
|
|||||||
assert await handle_slash(skills_state, "/skills search pdftotext") is True
|
assert await handle_slash(skills_state, "/skills search pdftotext") is True
|
||||||
assert "acme-deploy/SKILL.md:" in capsys.readouterr().out
|
assert "acme-deploy/SKILL.md:" in capsys.readouterr().out
|
||||||
|
|
||||||
assert await handle_slash(skills_state, "/skills read acme-deploy") is True
|
|
||||||
assert "description: Deploy the acme stack" in capsys.readouterr().out
|
|
||||||
|
|
||||||
assert await handle_slash(skills_state, "/skills read acme-deploy --file SKILL.md") is True
|
async def test_slash_skills_open_reveals_the_skill_directory(
|
||||||
assert "acme-deploy" in capsys.readouterr().out
|
skills_state: ReplState,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
opened: list[Path] = []
|
||||||
|
monkeypatch.setattr("plyngent.cli.editor._open_with_system_default", lambda path, **kw: opened.append(path))
|
||||||
|
|
||||||
|
assert await handle_slash(skills_state, "/skills open acme-deploy") is True
|
||||||
|
|
||||||
|
assert skills_state.skills is not None
|
||||||
|
skill = skills_state.skills.find("acme-deploy")
|
||||||
|
assert skill is not None
|
||||||
|
assert opened == [skill.path]
|
||||||
|
assert f"opened {skill.path}" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
async def test_slash_skills_reload_picks_up_a_new_skill(
|
async def test_slash_skills_open_falls_back_to_the_printed_path(
|
||||||
|
skills_state: ReplState,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
import click
|
||||||
|
|
||||||
|
def no_opener(path: Path, **kwargs: object) -> None:
|
||||||
|
del path, kwargs
|
||||||
|
msg = "xdg-open not found"
|
||||||
|
raise click.ClickException(msg)
|
||||||
|
|
||||||
|
monkeypatch.setattr("plyngent.cli.editor._open_with_system_default", no_opener)
|
||||||
|
assert await handle_slash(skills_state, "/skills open acme-deploy") is True
|
||||||
|
|
||||||
|
assert skills_state.skills is not None
|
||||||
|
skill = skills_state.skills.find("acme-deploy")
|
||||||
|
assert skill is not None
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "error: xdg-open not found" in out
|
||||||
|
assert f"skill directory: {skill.path}" in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_slash_skills_open_reports_an_unknown_skill(
|
||||||
|
skills_state: ReplState,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
assert await handle_slash(skills_state, "/skills open nope") is True
|
||||||
|
assert "unknown skill 'nope'" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_slash_skills_sees_a_skill_added_while_chatting(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
capsys: pytest.CaptureFixture[str],
|
capsys: pytest.CaptureFixture[str],
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -130,8 +182,7 @@ async def test_slash_skills_reload_picks_up_a_new_skill(
|
|||||||
assert await handle_slash(state, "/skills") is True
|
assert await handle_slash(state, "/skills") is True
|
||||||
assert "skills: 0" in capsys.readouterr().out
|
assert "skills: 0" in capsys.readouterr().out
|
||||||
_ = _write_skill(root, "added-later")
|
_ = _write_skill(root, "added-later")
|
||||||
assert await handle_slash(state, "/skills reload") is True
|
# No reload: the next listing reads the filesystem.
|
||||||
assert "skills reloaded: 1 visible" in capsys.readouterr().out
|
|
||||||
assert await handle_slash(state, "/skills") is True
|
assert await handle_slash(state, "/skills") is True
|
||||||
assert "added-later" in capsys.readouterr().out
|
assert "added-later" in capsys.readouterr().out
|
||||||
finally:
|
finally:
|
||||||
@@ -150,7 +201,7 @@ async def test_slash_skills_reports_a_disabled_feature(tmp_path: Path, capsys: p
|
|||||||
|
|
||||||
|
|
||||||
async def test_slash_skills_requires_a_target(skills_state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
async def test_slash_skills_requires_a_target(skills_state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
for command in ("/skills search", "/skills read"):
|
for command in ("/skills search", "/skills open"):
|
||||||
assert await handle_slash(skills_state, command) is True
|
assert await handle_slash(skills_state, command) is True
|
||||||
assert "requires" in capsys.readouterr().err
|
assert "requires" in capsys.readouterr().err
|
||||||
|
|
||||||
@@ -189,61 +240,44 @@ async def test_skill_tools_are_absent_when_disabled(tmp_path: Path) -> None:
|
|||||||
await state.memory.close()
|
await state.memory.close()
|
||||||
|
|
||||||
|
|
||||||
async def test_skills_catalog_is_folded_into_the_system_prompt(tmp_path: Path) -> None:
|
async def test_skill_tools_note_teaches_the_tools_without_listing_skills(tmp_path: Path) -> None:
|
||||||
root = tmp_path / "ours"
|
root = tmp_path / "ours"
|
||||||
root.mkdir()
|
root.mkdir()
|
||||||
_ = _write_skill(root, "acme-deploy", description="Deploy the acme stack")
|
_ = _write_skill(root, "acme-deploy", description="Deploy the acme stack")
|
||||||
state = await _state(tmp_path, skills_dir=root, tools_enabled=True)
|
state = await _state(tmp_path, skills_dir=root, tools_enabled=True)
|
||||||
try:
|
try:
|
||||||
prompt = state.agent.system_prompt or ""
|
prompt = state.agent.system_prompt or ""
|
||||||
assert "### Skills" in prompt
|
# Nothing about the skill set is injected: not the names, not the bodies.
|
||||||
assert "- acme-deploy (config-user): Deploy the acme stack" in prompt
|
assert "acme-deploy" not in prompt
|
||||||
assert "`skill_read`" in prompt
|
assert "Deploy the acme stack" not in prompt
|
||||||
# The body stays on disk: only the catalog goes into the prompt.
|
|
||||||
assert "Body." not in prompt
|
assert "Body." not in prompt
|
||||||
|
|
||||||
|
notes = _skill_notice_rows(state)
|
||||||
|
assert len(notes) == 1
|
||||||
|
assert "`skill_list`" in notes[0]
|
||||||
|
assert "`skill_search`" in notes[0]
|
||||||
|
|
||||||
|
# The note belongs to the session, not the event: a second push is a no-op.
|
||||||
|
await state._notice_skill_tools()
|
||||||
|
assert len(_skill_notice_rows(state)) == 1
|
||||||
finally:
|
finally:
|
||||||
await state.memory.close()
|
await state.memory.close()
|
||||||
|
|
||||||
|
|
||||||
async def test_skills_catalog_respects_the_cap(tmp_path: Path) -> None:
|
async def test_skill_tools_note_needs_the_feature_and_tools(tmp_path: Path) -> None:
|
||||||
root = tmp_path / "ours"
|
|
||||||
root.mkdir()
|
|
||||||
for name in ("alpha", "beta", "gamma"):
|
|
||||||
_ = _write_skill(root, name)
|
|
||||||
state = await _state(tmp_path, skills_dir=root, tools_enabled=True, extra="max_catalog_skills = 1")
|
|
||||||
try:
|
|
||||||
prompt = state.agent.system_prompt or ""
|
|
||||||
assert "alpha" in prompt
|
|
||||||
assert "beta" not in prompt
|
|
||||||
assert "… and 2 more" in prompt
|
|
||||||
finally:
|
|
||||||
await state.memory.close()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_skills_catalog_can_be_switched_off(tmp_path: Path) -> None:
|
|
||||||
root = tmp_path / "ours"
|
root = tmp_path / "ours"
|
||||||
root.mkdir()
|
root.mkdir()
|
||||||
_ = _write_skill(root, "acme")
|
_ = _write_skill(root, "acme")
|
||||||
state = await _state(tmp_path, skills_dir=root, tools_enabled=True, extra="inject_catalog = false")
|
disabled = await _state(tmp_path, skills_dir=root, tools_enabled=True, extra="enabled = false")
|
||||||
try:
|
try:
|
||||||
assert "### Skills" not in (state.agent.system_prompt or "")
|
assert _skill_notice_rows(disabled) == []
|
||||||
# The tools are still offered; only the prompt block is gone.
|
|
||||||
assert state.agent.tools is not None
|
|
||||||
assert state.agent.tools.get("skill_list") is not None
|
|
||||||
finally:
|
finally:
|
||||||
await state.memory.close()
|
await disabled.memory.close()
|
||||||
|
no_tools = await _state(tmp_path, skills_dir=root, tools_enabled=False)
|
||||||
|
|
||||||
async def test_skills_catalog_needs_tools(tmp_path: Path) -> None:
|
|
||||||
root = tmp_path / "ours"
|
|
||||||
root.mkdir()
|
|
||||||
_ = _write_skill(root, "acme")
|
|
||||||
state = await _state(tmp_path, skills_dir=root, tools_enabled=False)
|
|
||||||
try:
|
try:
|
||||||
assert state.agent.tools is None
|
assert _skill_notice_rows(no_tools) == []
|
||||||
assert "### Skills" not in (state.agent.system_prompt or "")
|
|
||||||
finally:
|
finally:
|
||||||
await state.memory.close()
|
await no_tools.memory.close()
|
||||||
|
|
||||||
|
|
||||||
async def test_slash_skills_notes_when_tools_are_off(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
async def test_slash_skills_notes_when_tools_are_off(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ def test_skills_section_defaults(tmp_path: Path) -> None:
|
|||||||
assert store.skills_config.paths == []
|
assert store.skills_config.paths == []
|
||||||
assert store.skills_config.discover == ["plyngent", "claude"]
|
assert store.skills_config.discover == ["plyngent", "claude"]
|
||||||
assert store.skills_config.include_project_roots is True
|
assert store.skills_config.include_project_roots is True
|
||||||
assert store.skills_config.inject_catalog is True
|
|
||||||
assert store.skills_config.max_catalog_skills == 50
|
|
||||||
assert store.skills_config.allow_write is False
|
assert store.skills_config.allow_write is False
|
||||||
|
|
||||||
|
|
||||||
@@ -30,8 +28,6 @@ enabled = false
|
|||||||
paths = ["~/team-skills", "/srv/skills"]
|
paths = ["~/team-skills", "/srv/skills"]
|
||||||
discover = ["plyngent"]
|
discover = ["plyngent"]
|
||||||
include_project_roots = false
|
include_project_roots = false
|
||||||
inject_catalog = false
|
|
||||||
max_catalog_skills = 5
|
|
||||||
allow_write = true
|
allow_write = true
|
||||||
""",
|
""",
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
@@ -41,8 +37,6 @@ allow_write = true
|
|||||||
assert cfg.paths == ["~/team-skills", "/srv/skills"]
|
assert cfg.paths == ["~/team-skills", "/srv/skills"]
|
||||||
assert cfg.discover == ["plyngent"]
|
assert cfg.discover == ["plyngent"]
|
||||||
assert cfg.include_project_roots is False
|
assert cfg.include_project_roots is False
|
||||||
assert cfg.inject_catalog is False
|
|
||||||
assert cfg.max_catalog_skills == 5
|
|
||||||
assert cfg.allow_write is True
|
assert cfg.allow_write is True
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -144,21 +144,21 @@ def test_broken_frontmatter_still_yields_a_listable_skill(tmp_path: Path) -> Non
|
|||||||
assert any("frontmatter not parsed" in issue for issue in skill.issues)
|
assert any("frontmatter not parsed" in issue for issue in skill.issues)
|
||||||
|
|
||||||
|
|
||||||
def test_cache_picks_up_an_edit_and_reload_forces_a_rescan(tmp_path: Path) -> None:
|
def test_every_lookup_reads_the_filesystem(tmp_path: Path) -> None:
|
||||||
|
"""No cache: an edit or a skill added outside the chat is simply there."""
|
||||||
root = tmp_path / "skills"
|
root = tmp_path / "skills"
|
||||||
directory = _write_skill(root, "cached", description="Before")
|
directory = _write_skill(root, "live", description="Before")
|
||||||
store = SkillStore(build_roots(SkillsConfig(discover=["plyngent"]), workspace=tmp_path, user_config_dir=tmp_path))
|
store = SkillStore(build_roots(SkillsConfig(discover=["plyngent"]), workspace=tmp_path, user_config_dir=tmp_path))
|
||||||
assert store.skills[0].description == "Before"
|
assert store.skills[0].description == "Before"
|
||||||
|
|
||||||
_ = (directory / SKILL_FILE).write_text(
|
_ = (directory / SKILL_FILE).write_text(
|
||||||
"---\nname: cached\ndescription: After\n---\n\nBody.\n",
|
"---\nname: live\ndescription: After\n---\n\nBody.\n",
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
assert store.skills[0].description == "After"
|
assert store.skills[0].description == "After"
|
||||||
|
|
||||||
_ = _write_skill(root, "added")
|
_ = _write_skill(root, "added")
|
||||||
store.reload()
|
assert [s.name for s in store.skills] == ["added", "live"]
|
||||||
assert [s.name for s in store.skills] == ["added", "cached"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_default_write_root_prefers_our_own_directory(tmp_path: Path) -> None:
|
def test_default_write_root_prefers_our_own_directory(tmp_path: Path) -> None:
|
||||||
|
|||||||
@@ -24,14 +24,18 @@ def test_classify_write_overwrite_only(tmp_path: Path) -> None:
|
|||||||
instance = InstanceState(workspace_root=tmp_path.resolve())
|
instance = InstanceState(workspace_root=tmp_path.resolve())
|
||||||
with bind_instance(instance):
|
with bind_instance(instance):
|
||||||
_ = set_workspace_root(tmp_path)
|
_ = set_workspace_root(tmp_path)
|
||||||
# New file: no soft-confirm
|
# New file: no soft-confirm, with or without the overwrite flag
|
||||||
assert classify_danger("write_file", {"path": "new.txt"}) is None
|
assert classify_danger("write_file", {"path": "new.txt"}) is None
|
||||||
|
assert classify_danger("write_file", {"path": "new.txt", "overwrite": True}) is None
|
||||||
# Partial edits: never soft-confirm
|
# Partial edits: never soft-confirm
|
||||||
assert classify_danger("edit_replace", {"path": "x.txt"}) is None
|
assert classify_danger("edit_replace", {"path": "x.txt"}) is None
|
||||||
assert classify_danger("edit_lineno", {"path": "x.txt", "start_line": 1, "end_line": 2}) is None
|
assert classify_danger("edit_lineno", {"path": "x.txt", "start_line": 1, "end_line": 2}) is None
|
||||||
# Existing file write: confirm total overwrite
|
# Existing file: only an explicit overwrite asks; an unset/false flag is
|
||||||
|
# refused inside the tool, so no prompt may fire for it.
|
||||||
(tmp_path / "x.txt").write_text("old", encoding="utf-8")
|
(tmp_path / "x.txt").write_text("old", encoding="utf-8")
|
||||||
reason = classify_danger("write_file", {"path": "x.txt"})
|
assert classify_danger("write_file", {"path": "x.txt"}) is None
|
||||||
|
assert classify_danger("write_file", {"path": "x.txt", "overwrite": False}) is None
|
||||||
|
reason = classify_danger("write_file", {"path": "x.txt", "overwrite": True})
|
||||||
assert reason is not None and "overwrite" in reason
|
assert reason is not None and "overwrite" in reason
|
||||||
(tmp_path / "dst.txt").write_text("d", encoding="utf-8")
|
(tmp_path / "dst.txt").write_text("d", encoding="utf-8")
|
||||||
(tmp_path / "src.txt").write_text("s", encoding="utf-8")
|
(tmp_path / "src.txt").write_text("s", encoding="utf-8")
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ def test_edit_lineno_error_suggests_the_read_file_call(workspace: object) -> Non
|
|||||||
out = call_sync(edit_lineno, "s.txt", 15, 15, "X\n")
|
out = call_sync(edit_lineno, "s.txt", 15, 15, "X\n")
|
||||||
assert "read_file(path='s.txt', offset=11, limit=7, with_lineno=true)" in out
|
assert "read_file(path='s.txt', offset=11, limit=7, with_lineno=true)" in out
|
||||||
# A stale read gets a fresh call for the same range after an external write.
|
# A stale read gets a fresh call for the same range after an external write.
|
||||||
_ = call_sync(write_file, "s.txt", "changed\n")
|
_ = call_sync(write_file, "s.txt", "changed\n", overwrite=True)
|
||||||
out = call_sync(edit_lineno, "s.txt", 1, 1, "X\n")
|
out = call_sync(edit_lineno, "s.txt", 1, 1, "X\n")
|
||||||
assert "changed since it was read" in out
|
assert "changed since it was read" in out
|
||||||
assert "read_file(path='s.txt', offset=0, limit=1, with_lineno=true)" in out
|
assert "read_file(path='s.txt', offset=0, limit=1, with_lineno=true)" in out
|
||||||
@@ -149,7 +149,7 @@ def test_edit_lineno_requires_rerun_after_write(workspace: object) -> None:
|
|||||||
del workspace
|
del workspace
|
||||||
_ = call_sync(write_file, "f.txt", "one\ntwo\nthree\n")
|
_ = call_sync(write_file, "f.txt", "one\ntwo\nthree\n")
|
||||||
_ = _read_lineno("f.txt")
|
_ = _read_lineno("f.txt")
|
||||||
_ = call_sync(write_file, "f.txt", "changed\n")
|
_ = call_sync(write_file, "f.txt", "changed\n", overwrite=True)
|
||||||
out = call_sync(edit_lineno, "f.txt", 1, 1, "x\n")
|
out = call_sync(edit_lineno, "f.txt", 1, 1, "x\n")
|
||||||
assert "changed" in out and "re-read" in out
|
assert "changed" in out and "re-read" in out
|
||||||
|
|
||||||
@@ -182,7 +182,7 @@ def test_edit_lineno_detects_external_change(workspace: object) -> None:
|
|||||||
_ = call_sync(write_file, "i.txt", "one\ntwo\nthree\n")
|
_ = call_sync(write_file, "i.txt", "one\ntwo\nthree\n")
|
||||||
_ = _read_lineno("i.txt")
|
_ = _read_lineno("i.txt")
|
||||||
# Simulate an external/process-tool mutation: rewrite content + new mtime.
|
# Simulate an external/process-tool mutation: rewrite content + new mtime.
|
||||||
_ = call_sync(write_file, "i.txt", "one\ntwo\nthree\nCHANGED\n")
|
_ = call_sync(write_file, "i.txt", "one\ntwo\nthree\nCHANGED\n", overwrite=True)
|
||||||
os.utime(workspace / "i.txt", (time.time() + 1, time.time() + 1))
|
os.utime(workspace / "i.txt", (time.time() + 1, time.time() + 1))
|
||||||
out = call_sync(edit_lineno, "i.txt", 1, 1, "x\n")
|
out = call_sync(edit_lineno, "i.txt", 1, 1, "x\n")
|
||||||
assert "changed since it was read" in out
|
assert "changed since it was read" in out
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import importlib
|
import importlib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from plyngent.agent import ToolRegistry
|
||||||
|
from plyngent.tools.danger import classify_danger
|
||||||
from plyngent.tools.file import edit_replace, listdir, read_file, write_file
|
from plyngent.tools.file import edit_replace, listdir, read_file, write_file
|
||||||
from tests.test_tools.helpers import call_sync
|
from tests.test_tools.helpers import call_sync
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Mapping
|
||||||
|
|
||||||
get_truncated_module = importlib.import_module("plyngent.tools.file.get_truncated")
|
get_truncated_module = importlib.import_module("plyngent.tools.file.get_truncated")
|
||||||
|
|
||||||
|
|
||||||
@@ -28,6 +35,46 @@ def test_write_read_listdir_edit(workspace: object) -> None:
|
|||||||
assert call_sync(read_file, "notes/a.txt") == "L1-1\nhello there"
|
assert call_sync(read_file, "notes/a.txt") == "L1-1\nhello there"
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_file_refuses_existing_file_without_overwrite(workspace: Path) -> None:
|
||||||
|
assert "wrote" in call_sync(write_file, "keep.txt", "first")
|
||||||
|
refused = call_sync(write_file, "keep.txt", "second")
|
||||||
|
assert refused == "error: file exists: keep.txt (set overwrite=true to replace it)"
|
||||||
|
assert (workspace / "keep.txt").read_text(encoding="utf-8") == "first"
|
||||||
|
assert "wrote" in call_sync(write_file, "keep.txt", "second", overwrite=True)
|
||||||
|
assert (workspace / "keep.txt").read_text(encoding="utf-8") == "second"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_same_file_writes_in_one_batch_keep_call_order(workspace: Path) -> None:
|
||||||
|
"""The second call writes onto the first one's text, not onto the stale text."""
|
||||||
|
target = workspace / "notes.txt"
|
||||||
|
_ = target.write_text("one", encoding="utf-8")
|
||||||
|
confirmed = asyncio.Event()
|
||||||
|
|
||||||
|
async def on_confirm(name: str, args: Mapping[str, object], reason: str) -> bool:
|
||||||
|
del name, args, reason
|
||||||
|
await confirmed.wait()
|
||||||
|
return True
|
||||||
|
|
||||||
|
registry = ToolRegistry([write_file, edit_replace], danger=classify_danger, on_confirm=on_confirm)
|
||||||
|
rewrite = asyncio.create_task(
|
||||||
|
registry.execute("write_file", '{"path": "notes.txt", "content": "two", "overwrite": true}')
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0) # the rewrite is waiting on its overwrite confirm
|
||||||
|
edit = asyncio.create_task(
|
||||||
|
registry.execute(
|
||||||
|
"edit_replace",
|
||||||
|
'{"path": "notes.txt", "old_string": "two", "new_string": "three"}',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0) # the edit waits for the file instead of editing stale text
|
||||||
|
assert target.read_text(encoding="utf-8") == "one"
|
||||||
|
confirmed.set()
|
||||||
|
rewrite_out, edit_out = await asyncio.gather(rewrite, edit)
|
||||||
|
assert "wrote" in rewrite_out
|
||||||
|
assert "replaced" in edit_out
|
||||||
|
assert target.read_text(encoding="utf-8") == "three"
|
||||||
|
|
||||||
|
|
||||||
def test_read_file_max_chars_embeds_token(workspace: Path) -> None:
|
def test_read_file_max_chars_embeds_token(workspace: Path) -> None:
|
||||||
(workspace / "big.txt").write_text("a" * 1000, encoding="utf-8")
|
(workspace / "big.txt").write_text("a" * 1000, encoding="utf-8")
|
||||||
out = call_sync(read_file, "big.txt", max_chars=200)
|
out = call_sync(read_file, "big.txt", max_chars=200)
|
||||||
|
|||||||
@@ -101,6 +101,33 @@ async def test_session_on_todo_change_fires() -> None:
|
|||||||
assert hits == ["session"]
|
assert hits == ["session"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_todo_update_uses_listing_numbers() -> None:
|
||||||
|
store = MemoryViewStore({})
|
||||||
|
session = SessionState(session_id="numbers", data=session_data_view(store=store))
|
||||||
|
registry = ToolRegistry(list(TODO_TOOLS), session_state=session)
|
||||||
|
|
||||||
|
out = await registry.execute("todo_push", '{"titles": ["root"]}')
|
||||||
|
assert "pushed group (depth=1) items=[root]" in out
|
||||||
|
out = await registry.execute("todo_push", '{"titles": ["child"]}')
|
||||||
|
assert "pushed group (depth=2) items=[child]" in out
|
||||||
|
# TOP group first; the stored ids stay out of every result.
|
||||||
|
assert " [ ] 1. child" in out
|
||||||
|
assert " [ ] 2. root" in out
|
||||||
|
assert "t1" not in out and "t2" not in out
|
||||||
|
|
||||||
|
numbered = await registry.execute("todo_update", '{"number": 2, "status": "done"}')
|
||||||
|
assert "updated 2 → done: root" in numbered
|
||||||
|
assert " [x] 2. root" in numbered
|
||||||
|
|
||||||
|
stale = await registry.execute("todo_update", '{"number": 9, "status": "done"}')
|
||||||
|
assert stale.startswith("error: no todo item 9")
|
||||||
|
shaped = await registry.execute("todo_update", '{"number": "first"}')
|
||||||
|
assert shaped.startswith("error: `todo_update` expects `number`")
|
||||||
|
|
||||||
|
popped = await registry.execute("todo_pop", "{}")
|
||||||
|
assert "popped TOP group (child); new top group=[root]" in popped
|
||||||
|
|
||||||
|
|
||||||
async def test_todo_without_session_errors() -> None:
|
async def test_todo_without_session_errors() -> None:
|
||||||
registry = ToolRegistry(list(TODO_TOOLS))
|
registry = ToolRegistry(list(TODO_TOOLS))
|
||||||
out = await registry.execute("todo_list", "{}")
|
out = await registry.execute("todo_list", "{}")
|
||||||
|
|||||||
@@ -38,6 +38,14 @@ def test_escape_error_hints_request_tool(workspace: object) -> None:
|
|||||||
_ = resolve_path("../outside")
|
_ = resolve_path("../outside")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("required", [AccessMode.READ, AccessMode.WRITE, AccessMode.EXEC])
|
||||||
|
def test_escape_error_names_the_mode_to_request(workspace: object, required: AccessMode) -> None:
|
||||||
|
"""The refusal carries the lowest access mode that would cover the call."""
|
||||||
|
del workspace
|
||||||
|
with pytest.raises(WorkspaceError, match=f"mode='{required.name.lower()}'"):
|
||||||
|
_ = resolve_path("../outside", required=required)
|
||||||
|
|
||||||
|
|
||||||
def test_access_mode_parse_and_covers() -> None:
|
def test_access_mode_parse_and_covers() -> None:
|
||||||
assert parse_access_mode(" Read ") is AccessMode.READ
|
assert parse_access_mode(" Read ") is AccessMode.READ
|
||||||
assert parse_access_mode("EXEC") is AccessMode.EXEC
|
assert parse_access_mode("EXEC") is AccessMode.EXEC
|
||||||
|
|||||||
Reference in New Issue
Block a user