34 Commits

Author SHA1 Message Date
NCBM acdc62f2a9 release: plyngent 0.2.0 2026-07-24 17:51:43 +08:00
NCBM 908e68665b core/agent: run_aside side turns; CLI /btw with --tools 2026-07-24 17:42:34 +08:00
NCBM 559a3410bc core/config: write LF strings as TOML multi-line literals 2026-07-24 17:21:50 +08:00
NCBM da4d3d097a core/config: persist provider models as TOML inline tables 2026-07-24 17:11:39 +08:00
NCBM 03e04f9a38 core/cli: plugins list/enable/disable commands and /plugins slash 2026-07-24 16:44:44 +08:00
NCBM 1d718582ac core/tools: drop process workspace/todo globals; require bound state 2026-07-24 16:32:33 +08:00
NCBM 4215a8cea0 core/config: top-level [plugins] enable/disable allowlist 2026-07-24 16:19:50 +08:00
NCBM 278c930fae docs: tool plugins guide and config cross-links 2026-07-24 16:05:10 +08:00
NCBM ac3230647a test/agent: bind todo tools via SessionState instead of process global 2026-07-24 16:00:17 +08:00
NCBM 8baaa3f2e1 core/tools: hang workspace policy on InstanceState 2026-07-24 15:58:55 +08:00
NCBM 6c6cec05c4 core/cli: bind todo persist via session state, not process global 2026-07-24 15:49:57 +08:00
NCBM 3c0eb8ffad core/tools: session on_todo_change and MemoryViewStore seed helpers 2026-07-24 15:47:57 +08:00
NCBM 7a3704edc1 core/tools: resolve_path accepts instance workspace root 2026-07-24 15:41:59 +08:00
NCBM 90c07061f3 core/tools: persist soft-confirm grants under session.data 2026-07-24 15:41:39 +08:00
NCBM 3d5380a3d4 core/tools: todo tools commit via session.data transactions 2026-07-24 15:31:26 +08:00
NCBM e82a226f91 core/tools: serialize domain objects on PersistentDataView commit 2026-07-24 15:31:06 +08:00
NCBM 96518974be core/tools: allowlisted entry-point plugin loader 2026-07-24 15:09:11 +08:00
NCBM 07e3c5caa5 core/tools: PTY tools use instance-aware manager facade 2026-07-24 15:06:18 +08:00
NCBM 662e95f693 core/cli: /tools --list shows tags and catalog source 2026-07-24 15:04:33 +08:00
NCBM 7253ca84dd core/tools: prefer instance workspace and session todo views 2026-07-24 15:02:52 +08:00
NCBM 12c8adda3a test/tools: catalog, grants, and async tool helpers 2026-07-24 14:57:03 +08:00
NCBM 2f6133f685 core/cli: catalog select, session state, tag-aware yolo 2026-07-24 14:56:48 +08:00
NCBM 15dde7cdaf core/tools: async builtins with policy tags 2026-07-24 14:56:33 +08:00
NCBM 4feb0e0d26 core/tools: catalog, tags, state context, and PersistentDataView 2026-07-24 14:56:19 +08:00
NCBM 30beec6600 core/config: split agent system_prompt and tool_directives 2026-07-21 22:55:51 +08:00
NCBM db7c097466 test/cli: isolate oneshot chat from user-data database 2026-07-21 22:44:26 +08:00
NCBM b06bb1d409 core/agent: shift persist_from when prepending system prompt 2026-07-21 22:44:09 +08:00
NCBM b51d4780ab core/agent: refresh synthetic_tool todo nags to live stack 2026-07-21 22:42:27 +08:00
NCBM 6d74d31cea core/config: default system prompt for coding agent tools 2026-07-20 18:21:27 +08:00
NCBM 767578723e core/runtime: configurable provider HTTP request timeouts 2026-07-20 18:08:27 +08:00
NCBM 71b9f91e14 core/agent: keep more recent tool results in soft-compact
Raise DEFAULT_RECENT_TOOL_RESULTS from 4 to 12 so near-term tool
payloads stay full-size longer when trimming older dumps for context.
2026-07-20 17:34:01 +08:00
NCBM 879e28ea4c core/agent: synthetic_tool review always when stack non-empty
needs_review is true for any non-empty stack so end-of-turn inject
keeps firing under synthetic_tool. Message history keeps only the last
todo-nag tool pair (dedupe by todo-nag- tool_call_id).
2026-07-20 16:20:01 +08:00
NCBM 614ffaaf8f ci: publish only after CI check succeeds
Merge release into ci.yml: tag pushes run check then publish (needs:
check). Remove standalone python-publish so PyPI never ships on red CI.
Retag (delete+push v*) still retriggers the pipeline.
2026-07-20 04:44:57 +08:00
NCBM 7bdd9ff8b7 chore: pin ruff and basedpyright for prek and lockfile
Match prek additional_dependencies and pyproject dev pins to pdm.lock
(ruff==0.15.22, basedpyright==1.39.9). Format fix for ruff 0.15.22.
2026-07-20 04:35:26 +08:00
88 changed files with 4498 additions and 599 deletions
+48 -6
View File
@@ -1,11 +1,20 @@
# Single pipeline: CI on main/PRs; on v* tags, publish only after CI succeeds.
# Retagging the same commit (delete + push tag) re-triggers this workflow.
name: CI
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
branches: [main]
# Cancel superseded runs on the same ref (e.g. force-push / retag races).
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
check:
runs-on: ubuntu-latest
@@ -18,15 +27,14 @@ jobs:
python-version: "3.14"
- name: Install PDM
uses: pdm-project/setup-pdm@v4
with:
python-version: "3.14"
run: pip install pdm
- name: Install dependencies
run: pdm sync --dev
# Locked install so CI matches prek pins / pdm.lock.
run: pdm install --check -G :all
- name: Ruff check
run: pdm run ruff check --output-format=github .
run: pdm run ruff check .
- name: Ruff format
run: pdm run ruff format --check .
@@ -34,5 +42,39 @@ jobs:
- name: basedpyright
run: pdm run basedpyright .
- name: pytest
- name: Pytest
run: pdm run pytest
# Only on version tags, and only after check is green (no publish on red CI).
publish:
needs: check
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.14"
- name: Install PDM
run: pip install pdm
- name: Build package
run: pdm build
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: dist/*
generate_release_notes: true
fail_on_unmatched_files: true
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
-39
View File
@@ -1,39 +0,0 @@
name: Release and Publish Python Package
on:
push:
tags:
- "v*"
permissions:
contents: write
id-token: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.14"
- name: Install build
run: python -m pip install --upgrade pip build
- name: Build package
run: python -m build --sdist --wheel --outdir dist/ .
- name: Publish package
uses: pypa/gh-action-pypi-publish@release/v1
- name: Create GitHub release
uses: softprops/action-gh-release@v2
with:
files: |
dist/*
generate_release_notes: true
+4 -4
View File
@@ -51,11 +51,11 @@ lmproto/deepseek/openai_compat/ ← Extends base via inheritance + extra fields
### Config (`config/`)
TOML load/store (`ConfigStore`): `[providers]` tagged union presets, `[database]` section. Default path via platformdirs.
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).
### Runtime (`runtime/`)
`create_client(provider)` maps config `Provider` → protocol client. OpenAI → `lmproto.openai.OpenAIClient` (Responses-capable); openai-compatible / deepseek(openai convention) → chat-completions clients; anthropic and deepseek anthropic convention raise `ProviderNotSupportedError`.
`create_client(provider)` maps config `Provider` → protocol client. `provider_to_openai_config` normalizes `timeout` via `normalize_http_timeout` into `OpenAIConfig.timeout` for `niquests.AsyncSession`. OpenAI → `lmproto.openai.OpenAIClient` (Responses-capable); openai-compatible / deepseek(openai convention) → chat-completions clients; anthropic and deepseek anthropic convention raise `ProviderNotSupportedError`.
### Memory (`memory/`)
@@ -69,10 +69,10 @@ Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `
- **`@tool` / `ToolRegistry`**: decorator infers JSON Schema from type hints; execute tools by name.
- **`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`.
- **`ChatAgent`**: optional `MemoryStore` (user message persisted immediately; **completed tool batches checkpointed** mid-turn; unfinished assistant suffix rolled back on failure); `stream`; system prompt; `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) → **new** session seeded with summary message.
- **`/compact`**: soft-compact tool dumps → model summary (no tools) → **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`).
- 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`, `max_tool_result_chars`, `parallel_tools`, `confirm_destructive`, `path_denylist`, `max_context_tokens` (default 200k est. tokens).
- Config ``[agent]``: `system_prompt` (persona; default `DEFAULT_SYSTEM_PROMPT`; `""` omits persona), `tool_directives` (tool playbook; default `DEFAULT_TOOL_DIRECTIVES`; `""` omits playbook; both empty → no system), `max_tool_result_chars`, `parallel_tools`, `confirm_destructive`, `path_denylist`, `max_context_tokens` (default 200k est. tokens).
### Tools (`tools/`)
+20 -1
View File
@@ -101,6 +101,10 @@ plyngent chat -p "Summarize this repo" --provider oai --model gpt-5.4-mini --no-
# 3) List providers from config
plyngent providers
# 4) Plugins (entry-point allowlist under [plugins])
plyngent plugins list
plyngent plugins enable acme
```
In the REPL: type normally, use `/help` for slash commands, `"""``"""` for multiline, `/markdown` for Rich rendering, `/quit` to leave.
@@ -128,16 +132,31 @@ Minimal shape:
preset = "openai-compatible"
url = "https://api.openai.com/v1"
access_key_or_token = "sk-..."
# Optional HTTP timeouts (seconds). Default: connect=10, read=600.
# timeout = 120
# timeout = { connect = 10, read = 600 }
[providers.local.models]
"gpt-4o-mini" = { text = true }
[agent]
system_prompt = "You are a careful coding assistant."
# system_prompt = persona (omit → built-in). tool_directives = tool playbook.
# system_prompt = "" / tool_directives = "" disable each part; both "" → no system.
# Or multi-line override (prefer '''...'''):
# system_prompt = '''Your custom persona...'''
# tool_directives = '''### Workspace ...'''
confirm_destructive = true
max_context_tokens = 200000
# Optional plugins (entry-point names); default load none. See doc/plugins.md.
# [plugins]
# enable = ["acme"]
```
Per-provider **`timeout`** is passed to the HTTP session for chat/completions, Responses, and `GET /models`. A single number sets one timeout; `{ connect, read }` splits TCP/TLS setup vs idle wait between response bytes (SSE can run longer than `read` while chunks keep arriving). Tool/process timeouts (`run_command`, PTY, policy confirm) are separate.
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).
Supported provider presets today: `openai` (default if `preset` is omitted; default models `gpt-5.4` / `gpt-5.4-mini` / `gpt-5.4-nano` when `models` is omitted), `openai-compatible`, `deepseek` (OpenAI convention; default models `deepseek-v4-flash` and `deepseek-v4-pro` if `models` is omitted). Anthropic presets are modeled in config but not wired in the runtime client yet.
If `[database]` is omitted (or SQLite `url` is unset/empty), chat uses a durable file under the user data dir (e.g. `~/.local/share/plyngent/chat.db` on Linux). Set `url = ":memory:"` for a true in-memory SQLite (CLI warns; no file; useful for tests).
+6 -3
View File
@@ -19,10 +19,13 @@
- components: Utilities for class composition.
- memory: Storage controlling for sessions and messages.
- router: Multi-source capability routing (Phase H; not implemented).
- config: Plyngent configuration center (TOML).
- agent: Tool loop, streaming, usage, compact.
- tools: Workspace file/process/VCS/chat tools.
- config: Plyngent configuration center (TOML), including ``[plugins]``.
- agent: Tool loop, streaming, usage, compact; `@tool` / tags / registry.
- tools: Workspace file/process/VCS/chat/todo tools; catalog; plugins;
instance/session context and views.
- prompting: Shared ask/choose/form for CLI and tools.
- cli: Click entry, slash registry, REPL, one-shot chat.
- web: Web service (Phase H; not implemented).
Plugins (third-party entry points, allowlisted under ``[plugins]``): [plugins.md](./plugins.md).
+197
View File
@@ -0,0 +1,197 @@
# Tool plugins
Third-party tools install as Python packages and register through the
**`plyngent.tools`** entry-point group. The host **allowlists** which plugins
load; default is **load none**.
Importing a plugin only **registers** tools into the process catalog. The CLI
still **selects** which names become model-visible (`surface=local` today).
## Layers (short)
```text
DEFINE+REGISTER @tool → ToolDefinition + catalog entry (source=plugin:…)
SELECT catalog.select(surface=…, …) → list[ToolDefinition]
EXECUTE ToolRegistry (confirm, instance/session bind, invoke)
```
| Layer | Question |
|--------|----------|
| Register | What tools exist in this process? |
| Select | Which may the model see this session? |
| Execute | How are they confirmed and run? |
Builtins use the same path with `source=builtin`. Plugins must not reuse a
builtin (or another plugin) **tool name** — registration fails with a
collision error.
## Author a plugin package
### 1. Define tools with `@tool`
```python
# acme_plyngent/tools.py
from plyngent.agent import ToolTag, tool
@tool(name="acme_ping", tags=ToolTag.LOCAL)
async def acme_ping() -> str:
"""Return a fixed pong (example plugin tool)."""
return "pong"
```
Notes:
- Prefer **`async def`** handlers (builtins are async-first).
- Default **`tags=ToolTag.LOCAL`**. Set at least one of `LOCAL` or `PUBLIC`.
- Workspace/exec tools should stay **`LOCAL`** unless reviewed for a shared host.
- Session-only helpers may add **`PUBLIC`** and **`SESSION_STATE`** when they
only touch session data and host-approved APIs (see plans under `doc/plans/`).
- Optional soft-confirm bits: **`YOLO`** (eligible for YOLO auto-approve),
**`TRUSTABLE`** (grant once per session after approve). Hard denylists are
never YOLO-skipped.
- Docstring becomes the model-facing description; parameter schemas come from
type hints.
- Use `register=False` only for unit tests that build a private registry.
### 2. Entry point
Call a zero-arg loader (or import a module) so `@tool` runs under the plugin
registration source:
```python
# acme_plyngent/__init__.py
def load() -> None:
"""Import tool modules so @tool registers into the process catalog."""
from acme_plyngent import tools as _tools
_ = _tools # registration side effects
```
Declare the entry point (PEP 621 / setuptools / hatch style):
```toml
# pyproject.toml of the plugin package
[project]
name = "acme-plyngent"
version = "0.1.0"
dependencies = [
"plyngent", # or pin a compatible range
]
[project.entry-points."plyngent.tools"]
acme = "acme_plyngent:load"
```
- Entry-point **name** (`acme`) is the **plugin id** used in config allowlists
and in catalog metadata (`plugin:acme`).
- Value is `module:attr`. If `attr` is callable, plyngent calls it after load;
if it is a module, import alone is enough when tools registered at import time.
Install the plugin into the **same environment** as `plyngent` (editable fine):
```bash
pdm add acme-plyngent
# or: pip install -e ./path/to/acme-plyngent
```
## Enable plugins in config
Plugins are **not** agent-only: allowlisting lives under a top-level **`[plugins]`**
section so future non-tool extensions can reuse the same gate.
In the user config (`plyngent config path`):
```toml
[plugins]
# Allowlist of entry-point names. Default empty = load no plugins.
enable = ["acme"]
# enable = ["*"] # every discovered plyngent.tools entry point
# Never load these names even if listed or matched by *:
# disable = ["legacy"]
```
| Setting | Meaning |
|---------|---------|
| `enable = []` / omitted | Load **no** plugins (safe default). |
| `enable = ["acme", "other"]` | Load only those entry-point names. |
| `enable = ["*"]` | Load all discovered plugins for the group. |
| `disable = ["x"]` | Skip plugin id `x` even when allowlisted or `*`. |
`enable` / `disable` are **plugin entry-point names** (e.g. `acme`), not individual
tool function names.
### CLI management
```bash
# Installed entry points + enable/disable status
plyngent plugins list
plyngent plugins list --config /path/to/plyngent.toml
# Allowlist / block (writes [plugins] and saves the file)
plyngent plugins enable acme
plyngent plugins enable '*' # load all discovered
plyngent plugins disable legacy
plyngent plugins undeny legacy # drop disable only
plyngent plugins clear --yes
```
In the chat REPL (writes config and rebuilds the tool registry when tools are on):
```text
/plugins # same as /plugins list
/plugins enable acme
/plugins disable legacy
/plugins undeny legacy
/plugins reload # re-read config + rebuild tools
/plugins clear
/tools --list # model-visible tools + catalog source
```
CLI load order (simplified):
1. `register_builtin_tools()`
2. `load_plugin_tools(plugins.enable, disable=plugins.disable)`
3. `catalog.select(surface="local")``ToolRegistry`
Listed tools show **tags** and **catalog source** (`builtin` vs `plugin:…`).
## Policy checklist for authors
1. Prefer **LOCAL** for host FS, shell, PTY, or arbitrary network side effects.
2. Do not shadow builtin names (`read_file`, `run_command`, todo tools, …).
3. Do not rely on process-global workspace/todo alone when a host binds
instance/session context — prefer the same patterns as builtins
(`get_workspace_root` already prefers instance policy; session state via
context when you need it).
4. Soft danger: annotate with `YOLO` / `TRUSTABLE` only when the product
confirm story matches; hard denylists stay in workspace/command policy.
5. Keep tools **async**; raise ordinary exceptions or return `error: …` strings
consistently with builtins.
## Surfaces (local vs public)
| Catalog select | Included tools |
|----------------|----------------|
| `surface="local"` (CLI) | `LOCAL` and/or `PUBLIC` |
| `surface="public"` | `PUBLIC` only |
There is **no** multi-tenant public host in-tree yet; `surface=public` is
available for hosts that want a PUBLIC-only select (builtins: mainly the
todo series). Plugins should not set `PUBLIC` on workspace/exec tools by
default.
## API reference (code)
| Piece | Module |
|-------|--------|
| `@tool`, `ToolTag`, `ToolRegistry` | `plyngent.agent` / `plyngent.agent.tools` |
| Catalog, `ToolSource`, `select` | `plyngent.tools.catalog` |
| `load_plugin_tools` | `plyngent.tools.plugins` |
| Config fields | `PluginsConfig.enable`, `PluginsConfig.disable` (`[plugins]`) |
## Related docs
- [architecture.md](./architecture.md) — package layout
- [plyngent.example.toml](./plyngent.example.toml) — config sample
- Design notes (may lag code): [plans/](./plans/) — registration, tags, PUBLIC surface
+23 -1
View File
@@ -10,8 +10,27 @@
# url = "/path/to/chat.db"
# # url = ":memory:"
# Third-party plugins (entry-point names). Default: load none.
# Today the CLI loads allowlisted ``plyngent.tools`` entry points; the section is
# not tool-specific so other extension points can share the same allowlist later.
# See doc/plugins.md.
# [plugins]
# enable = ["acme"]
# enable = ["*"]
# disable = ["legacy"]
[agent]
system_prompt = "You are a careful coding assistant. Prefer small, verified edits."
# Persona (omit → built-in coding-agent line). tool_directives is the tool playbook.
# Override with multi-line literals (prefer ''' so nested " is fine):
# system_prompt = '''
# Your custom persona...
# '''
# tool_directives = '''
# ### Workspace
# ...
# '''
# system_prompt = "" # persona off; keep default tool_directives unless also cleared
# tool_directives = "" # playbook off (set both "" to disable system entirely)
max_tool_result_chars = 32000
parallel_tools = true
confirm_destructive = true
@@ -33,6 +52,9 @@ max_context_tokens = 200000
preset = "openai-compatible"
url = "https://api.openai.com/v1"
access_key_or_token = "sk-replace-me"
# HTTP timeouts for LLM API calls (seconds). Default if omitted: connect=10, read=600.
# timeout = 120
# timeout = { connect = 10, read = 600 }
[providers.openai_compat.models]
"gpt-4o-mini" = { text = true, cost_factor = 1.0 }
Generated
+51 -51
View File
@@ -5,7 +5,7 @@
groups = ["default", "dev"]
strategy = ["inherit_metadata"]
lock_version = "4.5.0"
content_hash = "sha256:b849e1647ce0ec47b61889ae0e66863acce020cd1f2e3deaeb9e2e92becf9da5"
content_hash = "sha256:94dc16bf6ed12e2fcd7a109b500e9b2d7e39f224b60472d64ecb1d3436ddbb82"
[[metadata.targets]]
requires_python = "~=3.14"
@@ -115,51 +115,51 @@ files = [
[[package]]
name = "greenlet"
version = "3.5.3"
version = "3.5.4"
requires_python = ">=3.10"
summary = "Lightweight in-process concurrent programming"
groups = ["default"]
files = [
{file = "greenlet-3.5.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:fd2e02fa07485778536a036222d616ab957b1d533f36b3ed98ce725d9c9d3117"},
{file = "greenlet-3.5.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df0a0628d1597eb0897b62f55d1343f772405fd25f3b2a796c76874b0c2e22e8"},
{file = "greenlet-3.5.3-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ebd933a6adabc298bab47731a130fe6bfb888bd934eee37810f151159544540d"},
{file = "greenlet-3.5.3-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8d19fe6c39ebff9259f07bcc685d3290f8fa4ea2278e51dd0008e4d6b0f2d814"},
{file = "greenlet-3.5.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b9d501b40e80b70e32323c799dd9b420a5577a9601469d362ae1ffb690f3a7c"},
{file = "greenlet-3.5.3-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:962c5df2db8cb446da51edf1ca5296c389d93b99c9d8aa2ee4c7d0d8f1218260"},
{file = "greenlet-3.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a1fad1d11e7d6aab184107baa8e4ece11ccba3ec9599cd7efa5ff4d70d43256a"},
{file = "greenlet-3.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fad5aec764399f1b5cc347ad250a59660f20c8f8888ea6bae1f93b769cce1154"},
{file = "greenlet-3.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:7669aa24cf2a1041d6f7899575b494a3ab4cf68bfcc8609b1dc0be7272db835e"},
{file = "greenlet-3.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:5b4807c4082c9d1b6d9eed56fcd041863e37f2228106eef24c30ca096e238605"},
{file = "greenlet-3.5.3-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:271a8ea7c1024e8a0d7dd2be66dd66dda8a07193f41a17b9e924f7600f5b62be"},
{file = "greenlet-3.5.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19131729ae0ddc3c2e1ef85e650169b5e37ee32e400f215f78b94d7b0d567310"},
{file = "greenlet-3.5.3-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1540dd8e5fc2a5aec40fbb98ef8e149fa47c89a4b4a1cf2575a14d3d1869d7a8"},
{file = "greenlet-3.5.3-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b897d97759425953f69a9c0fac67f8fe333ec0ce7377ef186fb2b0c3ad5e354d"},
{file = "greenlet-3.5.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e81fa194a1d20967877bdf9c7794db2bc99063e5be36aee710c08f04c5bb087f"},
{file = "greenlet-3.5.3-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:3236754d423955ea08e9bb5f6c04a7895f9e22c290b66aa7653fcb922d839eb0"},
{file = "greenlet-3.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55cf4d777485d43110e47133cbba6d74a8885a87ec1227ef0267f9ee80c5aa21"},
{file = "greenlet-3.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:12a248ba75f6a9a236375f52296c498c89ff1d8badf32deb9eca7abd5853f7da"},
{file = "greenlet-3.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:efc6bd60ea02e085862c74a3ef64b147ffc6f1a5ea7d9f26e7a939943f68c1e3"},
{file = "greenlet-3.5.3-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:ea03f2f04367845d6b58eeed276e1e56e51f0b97d8ad5a88a7d20a91dc9056cc"},
{file = "greenlet-3.5.3-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78dbef602fda6d97d957eb7937f70c9ce9e9527330347f8f6b6f9e554a9e7a47"},
{file = "greenlet-3.5.3-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f73857adb8fee13fa56c172bd11262f888c0c648f9fea113e777bb2c7904a81"},
{file = "greenlet-3.5.3-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cefa9cef4b371f9844c6053db71f1138bc6807bab1578b0dae5149c1f1141357"},
{file = "greenlet-3.5.3-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:232fec92e823addaf02d9472cf7381e24a1d046a6ced1103c5caa4c21b9dfc1d"},
{file = "greenlet-3.5.3-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:6219b6d04dbf6ba6084d77dc609e8473060dc55f759cbf626d512122781fa128"},
{file = "greenlet-3.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2421c3564da9429d5586d46ca31ebb26516b5498a802cf65c041a8e8a8980d34"},
{file = "greenlet-3.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e0f0d160f0b2e558e6c75f7930967183255dc9735e5f5b8cae58ee09c9576d8b"},
{file = "greenlet-3.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:dd99329bbc15ca78dcc583dba05d0b1b0bae01ab6c2174989f5aaee3e41ac930"},
{file = "greenlet-3.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:499fef2acede88c1864a57bb586b4bf533c81e1b82df7ab93451cdb47dfec227"},
{file = "greenlet-3.5.3-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:176bc16a721fa5fc294d70b87b4dfa5fbdd251b3da5d5372735ecef9bd7d6d0c"},
{file = "greenlet-3.5.3-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:629b614d2b786e89c50440e246f33eea78f58a962d0bdbbcc809e6d13605903f"},
{file = "greenlet-3.5.3-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b2e857ae16f5f72142edf75f9f176fe7526ba19a2841df1420516f83831c9f2"},
{file = "greenlet-3.5.3-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d192579ed281051396dddd7f7754dac6259e6b1fb26378c87b66622f8e3f91"},
{file = "greenlet-3.5.3-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e515757e2e36bcbf1fad09a46e1557e8b1ae1797d4b44d09da7deed88ad28608"},
{file = "greenlet-3.5.3-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:4399eb8d041f20b68d943918bc55502a93d6fdc0a37c14da7881c04139acee9d"},
{file = "greenlet-3.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b363d46ed1ea431825fdb01471bb024fc08399bad1572a616e853c7684415adb"},
{file = "greenlet-3.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e44da2f5bbdaabaf7d80b73dbb430c7035771e9f244e3c8b769715c9d8fa0a16"},
{file = "greenlet-3.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:8ff8bed3e3baa20a3ea261ce00526f1898ad4801d4886fd2220580ee0ad8fadf"},
{file = "greenlet-3.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:b7068bd09f761f3f5b4d214c2bed063186b2a86148c740b3873e3f56d79bac31"},
{file = "greenlet-3.5.3.tar.gz", hash = "sha256:a61efc018fd3eb317eeca31aba90ee9e7f26f22884a79b6c6ec715bf71bb62f1"},
{file = "greenlet-3.5.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7e8afa5eac028f8140ceafe5ceec66e6aa127ddcb21452d2a564dcd2900b5f22"},
{file = "greenlet-3.5.4-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73b37afe369021423ea53dd3123e04bffa7e93ac64429b9f50835b2e4fcae7cf"},
{file = "greenlet-3.5.4-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ef964f56dfcb6f9bbef2a190d9126795eac408716aeae47b5e7c73c32aafca9"},
{file = "greenlet-3.5.4-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cef589bc65fae02d10bca2ac341191c5b33acc2967892ebf4fcbd10eabb7a74c"},
{file = "greenlet-3.5.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c53ff01a5c53a40f2c16820ebc56d7c61a77f5fbe009dadd96292d5682f80f8"},
{file = "greenlet-3.5.4-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:dfc41ae893d9ceaf22c824f2153a88b30651b20e8758c2cd9ac143f23640563c"},
{file = "greenlet-3.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecca4d80d55a01ad6b23b33262662956149fbb7b2c6be2910f1705921958cbf3"},
{file = "greenlet-3.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffbc533e0eaf8e80d8471411646ab88fe58f641d508c0b02b24494479f4d9ec"},
{file = "greenlet-3.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:305f69e6c4523d7f6979ed001cff4e5853c063e5da04880296603aa0227e544c"},
{file = "greenlet-3.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:f260930bbbbcf9caee661211235a5111c86dfe5832fdf6ae4570da1e0995320f"},
{file = "greenlet-3.5.4-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:41ddab54e4b238f4a6c323f39b4e59e176affd5a94d461a9fb7583dac74240a3"},
{file = "greenlet-3.5.4-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3dabe3e2809013052c68bdf0b7fa5f5f2859c43a80803131ad61af9cabd7867"},
{file = "greenlet-3.5.4-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:27d3f00718634d4520a3a150154ac5da36f257869d41321953375b90bfbbc72c"},
{file = "greenlet-3.5.4-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39169a11d87a6a263afda3e9a27d1df16d0f919d40a4837cc73986c9884c0dd8"},
{file = "greenlet-3.5.4-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbd60b5763c6543c1827e48faaf14ea9bfbad245f52b1a4d76a2a2d8884c6c66"},
{file = "greenlet-3.5.4-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:bd3d1145f603b2db19feb9078c2e6855eb7c67e15580c010ed815cee519b86fd"},
{file = "greenlet-3.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f00f910f0e7b35416c63b23ad78b769aeccfc1775f712b43c4ee525624a2eef7"},
{file = "greenlet-3.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:91c26423753b92caf41ab3f98fd547d7374d4d9fc2d85be041886c1579d9255e"},
{file = "greenlet-3.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:ee032b91fd8ec29ec6c4cea2b8c561b178435134bd0752c7334b94e9c736c132"},
{file = "greenlet-3.5.4-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:178111881dd7a6c946471fda85485ec796e1043c2b939f694b096e2ecf986809"},
{file = "greenlet-3.5.4-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d92df08dd65fede97fc37aad36c2e9dcda3b31c467f8e0c2c096456cb818e927"},
{file = "greenlet-3.5.4-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:99e8f8c4ebc4fd80aa26c1280ae9ad43a0976e786349703a181cf0bae60413e5"},
{file = "greenlet-3.5.4-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1f17e362d78e37559e0506c5a7d066bdd45073c36a0127a543e8a0df27242ff3"},
{file = "greenlet-3.5.4-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:394de08dad5ffcb1f50c2159d93e398d9d2da3ed437645eaa54771fa720db9f0"},
{file = "greenlet-3.5.4-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:cd320d998cbaa032932830448e39abf3c6a12901295e386e8114db926e10cffb"},
{file = "greenlet-3.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:c883d61f2282d72c767a14936641b3efcbde9d82f1080712aaea0b1d3126cb88"},
{file = "greenlet-3.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2a924f15d17957e252a810acefcb5942f5ca712298e8b6fcaed9a307d357522c"},
{file = "greenlet-3.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:ed17e5f3420360d5b459de8462efb52060399a5326a613d4cde31cef63ef95da"},
{file = "greenlet-3.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:f908898d6fa484ce4b6f447ce70ea99b52c503fee419e53cf74d60a16bc9e667"},
{file = "greenlet-3.5.4-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:1833637f17d5e7472548a48575c394fe39f1b1890d676d162d86593610f44d8c"},
{file = "greenlet-3.5.4-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12cda9122e03341f1cb6b8207a19d7a9d375e52f1b4e9243918375f40fd7b4b9"},
{file = "greenlet-3.5.4-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d83ae0e32d14957ab7170785a20f582635c8474deab1bfbb552b17e769a6ce25"},
{file = "greenlet-3.5.4-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:123aa379c962ed5fe90a880327e0c3066124ac64ec99e12a238be9fd8eb3db3d"},
{file = "greenlet-3.5.4-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f1467de1bb767f75db0aa34c195e3a496d8d1278c796e70c24ce205d3e99cde"},
{file = "greenlet-3.5.4-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:adf2244d7f69409925a8f22ed22cc5f93cdfe5c9dc87ff3476be2c2aaae61a05"},
{file = "greenlet-3.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0fa53040b78b578120eecdc0265e3f1051487cc425d11a2b7c761daadf4feaa8"},
{file = "greenlet-3.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:60e0bc961d367df506660e9ac0177a76bc6d81305300704b0977d1634f76efe2"},
{file = "greenlet-3.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:f680e549edb3eaf21eea4e7fe101e15ec180c74b7879ab46adc080f22d4015d2"},
{file = "greenlet-3.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:08fc36de8442d5c3e95b044550dbea9bf144d31ec0cc58e36fb241cb6ef6a994"},
{file = "greenlet-3.5.4.tar.gz", hash = "sha256:0232ae1de90a8e07867bb127d7a6ba2301e859145489f25cda8a6096dabe1d20"},
]
[[package]]
@@ -326,13 +326,13 @@ files = [
[[package]]
name = "platformdirs"
version = "4.10.1"
version = "4.11.0"
requires_python = ">=3.10"
summary = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
groups = ["default"]
files = [
{file = "platformdirs-4.10.1-py3-none-any.whl", hash = "sha256:0e4eff26be2d75293977f7cddc153fd9b8eaa7fb0c7b64ffe4076cb443117443"},
{file = "platformdirs-4.10.1.tar.gz", hash = "sha256:ceab4084426fe6319ce18e86deada8ab1b7487c7aee7040c55e277c9ae793695"},
{file = "platformdirs-4.11.0-py3-none-any.whl", hash = "sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74"},
{file = "platformdirs-4.11.0.tar.gz", hash = "sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0"},
]
[[package]]
@@ -527,13 +527,13 @@ files = [
[[package]]
name = "tomlkit"
version = "0.15.0"
version = "0.15.1"
requires_python = ">=3.9"
summary = "Style preserving TOML library"
groups = ["default"]
files = [
{file = "tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738"},
{file = "tomlkit-0.15.0.tar.gz", hash = "sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3"},
{file = "tomlkit-0.15.1-py3-none-any.whl", hash = "sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304"},
{file = "tomlkit-0.15.1.tar.gz", hash = "sha256:e25bbf38843005246210a12982776f27f99cb9be67160e14434d0c0d21ee1e97"},
]
[[package]]
@@ -549,7 +549,7 @@ files = [
[[package]]
name = "urllib3-future"
version = "2.22.901"
version = "2.23.900"
requires_python = ">=3.7"
summary = "urllib3.future is a powerful HTTP 1.1, 2, and 3 client with both sync and async interfaces"
groups = ["default"]
@@ -559,8 +559,8 @@ dependencies = [
"qh3<2.0.0,>=1.5.4; (platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and platform_python_implementation == \"PyPy\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and python_version < \"3.12\" or (platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and python_full_version > \"3.7.10\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and platform_python_implementation == \"CPython\"",
]
files = [
{file = "urllib3_future-2.22.901-py3-none-any.whl", hash = "sha256:dc2d4572ba612be69e3f6db87d1f469b7d08f107e80fd8749081d7e6a5a2fe10"},
{file = "urllib3_future-2.22.901.tar.gz", hash = "sha256:fbece0ff51299a213c926897ffbed61d820ab5ef2ba81cece361c049fe2f51a9"},
{file = "urllib3_future-2.23.900-py3-none-any.whl", hash = "sha256:4303dc9a5d8c4c810f3fcd3b13bd62d69c3a4f63bb7e62089410907c34aeaf6b"},
{file = "urllib3_future-2.23.900.tar.gz", hash = "sha256:f488f22983f96cd8c733640b47e6a32def462d33c59bcc4bea6d97a8568b70c8"},
]
[[package]]
+38 -17
View File
@@ -2,26 +2,47 @@
# Install: uv tool install prek (or: pipx install prek)
# Wire git: prek install
# Manual: prek run --all-files
#
# Pin additional_dependencies to the same versions as pdm.lock (dev group).
# Bump these when you pdm update ruff / basedpyright.
default_install_hook_types = ["pre-commit"]
default_stages = ["pre-commit"]
default_language_version = { python = "python3.14" }
# Ruff: lint (with autofix) then format — order matters (fix before format).
# https://docs.astral.sh/ruff/integrations/#pre-commit
[[repos]]
repo = "https://github.com/astral-sh/ruff-pre-commit"
rev = "v0.15.22"
hooks = [
{ id = "ruff-check", args = ["--fix"], types_or = ["python", "pyi"] },
{ id = "ruff-format", types_or = ["python", "pyi"] },
]
# basedpyright: full-project typecheck (same as CI `pdm run basedpyright .`).
# Mirror hook: https://docs.basedpyright.com/latest/installation/prek-hook/
# Uses system+PDM so imports resolve from the project venv (isolated hook envs lack deps).
[[repos]]
repo = "local"
hooks = [
{ id = "basedpyright", name = "basedpyright", language = "system", entry = "pdm run basedpyright .", pass_filenames = false, files = "\\.pyi?$" },
# Match CI / local pdm env (see pdm.lock).
repos = [
{
repo = "local",
hooks = [
{
id = "ruff-check",
name = "ruff check",
entry = "ruff check",
language = "python",
types_or = ["python", "pyi"],
args = [],
require_serial = true,
additional_dependencies = ["ruff==0.15.22"],
},
{
id = "ruff-format",
name = "ruff format",
entry = "ruff format --check --force-exclude",
language = "python",
types_or = ["python", "pyi"],
require_serial = true,
additional_dependencies = ["ruff==0.15.22"],
},
{
id = "basedpyright",
name = "basedpyright",
entry = "basedpyright",
language = "python",
pass_filenames = false,
require_serial = true,
additional_dependencies = ["basedpyright==1.39.9"],
},
],
},
]
+3 -3
View File
@@ -1,6 +1,6 @@
[project]
name = "plyngent"
version = "0.1.3"
version = "0.2.0"
description = "LLM chat and agent toolkit"
authors = [
{ name = "HivertMoZara", email = "worldmozara@163.com" },
@@ -27,8 +27,8 @@ plyngent = "plyngent.cli:main"
[dependency-groups]
dev = [
"basedpyright>=1.39.7",
"ruff>=0.15.17",
"basedpyright==1.39.9",
"ruff==0.15.22",
"pytest>=9.1.1",
"pytest-asyncio>=1.4.0",
]
+1
View File
@@ -20,6 +20,7 @@ from .tools import DangerClassifier as DangerClassifier
from .tools import ToolConfirmHook as ToolConfirmHook
from .tools import ToolDefinition as ToolDefinition
from .tools import ToolRegistry as ToolRegistry
from .tools import ToolTag as ToolTag
from .tools import schema_from_callable as schema_from_callable
from .tools import tool as tool
from .usage import TokenUsage as TokenUsage
+2 -1
View File
@@ -21,7 +21,8 @@ DEFAULT_TOOL_RESULT_MAX_CHARS = 32_000
# Soft context budget in tokens (API-calibrated when possible; else ~4 chars/token).
DEFAULT_CONTEXT_MAX_TOKENS = 200_000
DEFAULT_OLD_TOOL_RESULT_CHARS = 800
DEFAULT_RECENT_TOOL_RESULTS = 4
# Soft-compact: leave this many most-recent tool results at full size.
DEFAULT_RECENT_TOOL_RESULTS = 12
# Backward-compat alias (older code/docs may still import this name).
DEFAULT_CONTEXT_MAX_CHARS = DEFAULT_CONTEXT_MAX_TOKENS * 4
+100 -4
View File
@@ -23,7 +23,9 @@ from .todo_nag import (
DEFAULT_TODO_NAG_STRATEGY,
inject_todo_nag_for_stack_with_events,
parse_todo_nag_strategy,
refresh_synthetic_todo_nags,
)
from .tools import ToolRegistry
from .usage import TokenUsage
if TYPE_CHECKING:
@@ -36,7 +38,6 @@ if TYPE_CHECKING:
from .events import AgentEvent
from .todo_nag import TodoNagStrategy
from .todo_stack import TodoStack
from .tools import ToolRegistry
type LimitContinueHook = Callable[[str], bool | Awaitable[bool]]
@@ -209,9 +210,9 @@ class ChatAgent:
if self.messages and isinstance(self.messages[0], SystemChatMessage):
return
self.messages.insert(0, SystemChatMessage(content=self.system_prompt))
# System inject is not a DB message unless already stored.
if self._persist_from == 0:
self._persist_from = 1
# Prepended system is local-only; shift the checkpoint so indices that
# already pointed past stored messages stay correct after insert.
self._persist_from = min(len(self.messages), self._persist_from + 1)
def replace_messages(
self,
@@ -304,6 +305,9 @@ class ChatAgent:
user_index = self._user_index(user_msg)
if self.todo_stack is not None:
self.todo_stack.begin_turn()
# Keep forged synthetic_tool nags aligned with the live stack so a
# previously dirty stack does not re-surface after it was cleaned.
_ = refresh_synthetic_todo_nags(self.messages, self.todo_stack)
completed = False
turn_usage = TokenUsage()
@@ -370,6 +374,98 @@ class ChatAgent:
async for event in self._run_from_user_message(user_msg):
yield event
def _resolve_aside_tools(
self,
*,
tools: ToolRegistry | bool | None,
instance_state: object | None,
session_state: object | None,
) -> ToolRegistry | None:
"""Resolve the tools argument for :meth:`run_aside`."""
if tools is False or tools is None:
return None
if tools is True:
if self.tools is None:
return None
return self.tools.clone(
instance_state=instance_state,
session_state=session_state,
)
reg = tools
if instance_state is not None:
reg.set_instance_state(instance_state)
if session_state is not None:
reg.set_session_state(session_state)
return reg
async def run_aside(
self,
user_text: str,
*,
include_history: bool = True,
tools: ToolRegistry | bool | None = False,
max_rounds: int | None = None,
system_prompt: str | None = None,
instance_state: object | None = None,
session_state: object | None = None,
) -> AsyncIterator[AgentEvent]:
"""Run a side turn that does not mutate this agent or its memory.
- Message list is forked (optional history copy); never written back.
- ``memory`` / ``session_id`` are unset on the side agent (no DB).
- ``todo_stack`` is unset (no nags / main stack thrash).
- Tools default **off**. ``tools=True`` clones this agent's registry with
a **fresh session** bag (unless *session_state* is passed) and the
given *instance_state* (CLI typically shares the host instance for
workspace identity).
"""
text = user_text.strip()
if not text:
msg = "aside question must not be empty"
raise ValueError(msg)
# Prefer explicit session fork; default empty SessionState when tools on.
aside_session = session_state
aside_instance = instance_state
if tools is True or isinstance(tools, ToolRegistry):
if aside_session is None:
from plyngent.tools.context import SessionState
aside_session = SessionState()
if aside_instance is None and self.tools is not None:
# Inherit host instance when the main registry holds one.
aside_instance = getattr(self.tools, "_instance", None)
aside_tools = self._resolve_aside_tools(
tools=tools,
instance_state=aside_instance,
session_state=aside_session,
)
history = list(self.messages) if include_history else []
rounds = self.max_rounds if max_rounds is None else max_rounds
if aside_tools is None and max_rounds is None:
rounds = 1
aside = ChatAgent(
self.client,
model=self.model,
tools=aside_tools,
memory=None,
session_id=None,
max_rounds=rounds,
temperature=self.temperature,
on_limit=self.on_limit,
stream=self.stream,
system_prompt=self.system_prompt if system_prompt is None else system_prompt,
max_tool_result_chars=self.max_tool_result_chars,
parallel_tools=self.parallel_tools,
max_context_tokens=self.max_context_tokens,
todo_stack=None,
todo_nag_strategy="none",
messages=history,
)
async for event in aside.run(text):
yield event
async def retry(self) -> AsyncIterator[AgentEvent]:
"""Continue an incomplete turn without re-appending the user message.
+9 -1
View File
@@ -37,7 +37,11 @@ from .events import (
ToolResultEvent,
UsageEvent,
)
from .todo_nag import DEFAULT_TODO_NAG_STRATEGY, inject_todo_nag_for_stack_with_events
from .todo_nag import (
DEFAULT_TODO_NAG_STRATEGY,
inject_todo_nag_for_stack_with_events,
refresh_synthetic_todo_nags,
)
from .usage import resolve_round_usage, token_usage_from_api
if TYPE_CHECKING:
@@ -349,12 +353,16 @@ async def run_chat_loop( # noqa: C901 — multi-phase tool loop
while True:
while rounds_used < allowance:
rounds_used += 1
# Request copy: shrink old tool dumps, then rewrite forged todo nags
# so cleaned stacks do not re-appear with stale OPEN WORK text.
request_messages = compact_messages_for_request(
messages,
max_tokens=max_context_tokens,
prompt_tokens_hint=prompt_tokens_hint,
sent_estimate_tokens=sent_estimate_tokens,
)
if todo_stack is not None:
_ = refresh_synthetic_todo_nags(request_messages, todo_stack)
sent_est = estimate_messages_tokens(request_messages)
param = ChatCompletionsParam(
messages=request_messages,
+52 -1
View File
@@ -29,6 +29,8 @@ type TodoNagKind = Literal["turn_start", "end_of_turn"]
TODO_NAG_STRATEGIES: frozenset[str] = frozenset({"developer", "user", "synthetic_tool", "none"})
DEFAULT_TODO_NAG_STRATEGY: TodoNagStrategy = "developer"
_SYNTHETIC_TOOL_NAME = "todo_list"
# Forged call ids from :func:`_append_synthetic_todo_list` (not model-authored).
_SYNTHETIC_CALL_ID_PREFIX = "todo-nag-"
def parse_todo_nag_strategy(raw: str | None) -> TodoNagStrategy:
@@ -63,7 +65,7 @@ def synthetic_todo_list_result(stack: TodoStack) -> str:
def _append_synthetic_todo_list(messages: list[AnyChatMessage], body: str) -> str:
"""Append forged todo_list call + result. Returns the synthetic tool_call id."""
call_id = f"todo-nag-{uuid.uuid4().hex[:12]}"
call_id = f"{_SYNTHETIC_CALL_ID_PREFIX}{uuid.uuid4().hex[:12]}"
messages.append(
AssistantChatMessage(
content=UNSET,
@@ -82,6 +84,55 @@ def _append_synthetic_todo_list(messages: list[AnyChatMessage], body: str) -> st
return call_id
def is_synthetic_todo_nag_call_id(call_id: str) -> bool:
"""True for forged ``todo_list`` nag tool_call ids (not model-authored)."""
return call_id.startswith(_SYNTHETIC_CALL_ID_PREFIX)
def refresh_synthetic_todo_nags(
messages: list[AnyChatMessage],
stack: TodoStack,
) -> int:
"""Rewrite forged ``todo_list`` nag results to the live stack render.
Synthetic nags are append-only snapshots. After the stack is cleaned (or
otherwise mutated), older nag results still sit in history and re-surface
on later model requests with stale OPEN WORK. Call this on a **request
copy** (not necessarily durable history) before each completion so the
model always sees the current stack for forged nags.
Real model-authored ``todo_list`` results are left unchanged.
Returns the number of tool messages updated.
"""
body = stack.render()
synth_ids: set[str] = set()
for msg in messages:
if not isinstance(msg, AssistantChatMessage):
continue
tool_calls = msg.tool_calls
if tool_calls is UNSET or not tool_calls:
continue
for call in tool_calls:
if (
isinstance(call, AssistantFunctionToolCall)
and is_synthetic_todo_nag_call_id(call.id)
and call.function.name == _SYNTHETIC_TOOL_NAME
):
synth_ids.add(call.id)
updated = 0
for index, msg in enumerate(messages):
if not isinstance(msg, ToolChatMessage):
continue
if msg.tool_call_id not in synth_ids and not is_synthetic_todo_nag_call_id(msg.tool_call_id):
continue
if msg.content == body:
continue
messages[index] = ToolChatMessage(tool_call_id=msg.tool_call_id, content=body)
updated += 1
return updated
def inject_todo_nag(
messages: list[AnyChatMessage],
body: str,
+8 -6
View File
@@ -89,16 +89,18 @@ class TodoStack:
return not self._data.groups
def needs_review(self) -> bool:
"""True when the stack still signals unfinished or unreconciled work.
"""True when end-of-turn should inject a todo nag.
Open (pending/in_progress) items always need attention. A non-empty stack
with only terminal items still needs a pop/clear if the agent ignored
todos this turn.
Only when the stack is non-empty **and** no ``todo_*`` tool ran this
turn (list/push/pop/update/clear all call :meth:`mark_touched`).
Open items alone must not re-nag after the model already accessed the
stack — that caused improper ``synthetic_tool`` injects mid-flow.
Hygiene (all-terminal non-empty) and open work share the same gate:
ignore todos for the whole turn → nag once; touch once → no end nag.
"""
if self.is_empty():
return False
if self.open_items():
return True
return not self._touched_this_turn
def to_data(self) -> TodoStackData:
+217 -20
View File
@@ -4,6 +4,7 @@ import asyncio
import inspect
import types
from collections.abc import Awaitable, Callable, Mapping
from enum import Flag, auto
from typing import Any, cast, get_args, get_origin, get_type_hints, overload
import msgspec
@@ -28,6 +29,21 @@ _PRIMITIVE_SCHEMA: dict[type, JSONSchema] = {
}
class ToolTag(Flag):
"""Host policy / affinity bits for a tool (not I/O taxonomy).
Default when omitted on ``@tool`` is :attr:`LOCAL`. A tool should set at
least one of :attr:`LOCAL` or :attr:`PUBLIC` (register rejects neither).
"""
LOCAL = auto() # local agent frontend (CLI)
PUBLIC = auto() # may be exposed on shared / multi-tenant frontends
TRUSTABLE = auto() # soft-confirm: grant once, then reuse
YOLO = auto() # soft-confirm: eligible for YOLO auto-approve
INSTANCE_STATE = auto() # needs instance-scoped state
SESSION_STATE = auto() # needs session-scoped state
class ToolDefinition:
"""A registered tool: schema for the model plus a callable handler."""
@@ -35,6 +51,7 @@ class ToolDefinition:
description: str
parameters: JSONSchema
handler: ToolHandler
tags: ToolTag
def __init__(
self,
@@ -42,11 +59,17 @@ class ToolDefinition:
description: str,
parameters: JSONSchema,
handler: ToolHandler,
*,
tags: ToolTag = ToolTag.LOCAL,
) -> None:
if not (tags & (ToolTag.LOCAL | ToolTag.PUBLIC)):
msg = f"tool {name!r} tags must include LOCAL and/or PUBLIC"
raise ValueError(msg)
self.name = name
self.description = description
self.parameters = parameters
self.handler = handler
self.tags = tags
def to_tool_item(self) -> ToolFunctionItem:
return ToolFunctionItem(
@@ -105,6 +128,7 @@ def _build_definition(
*,
name: str | None,
description: str | None,
tags: ToolTag,
) -> ToolDefinition:
tool_name = name or func.__name__
tool_description = description if description is not None else (inspect.getdoc(func) or "")
@@ -113,9 +137,18 @@ def _build_definition(
description=tool_description,
parameters=schema_from_callable(func),
handler=func,
tags=tags,
)
def _register_definition(definition: ToolDefinition) -> None:
"""Push *definition* into the process tool catalog (lazy import)."""
# Imported lazily so agent.tools does not hard-depend on plyngent.tools.
from plyngent.tools.catalog import register_tool
register_tool(definition)
@overload
def tool[**PS, R](func: Callable[PS, R], /) -> ToolDefinition: ...
@@ -127,6 +160,8 @@ def tool[**PS, R](
*,
name: str | None = None,
description: str | None = None,
tags: ToolTag = ToolTag.LOCAL,
register: bool = True,
) -> Callable[[Callable[PS, R]], ToolDefinition]: ...
@@ -136,14 +171,29 @@ def tool[**PS, R](
*,
name: str | None = None,
description: str | None = None,
tags: ToolTag = ToolTag.LOCAL,
register: bool = True,
) -> ToolDefinition | Callable[[Callable[PS, R]], ToolDefinition]:
"""Register a function as an agent tool (decorator).
"""Define (and by default catalog-register) a function as an agent tool.
Schema is inferred from type hints; description defaults to the docstring.
Default ``tags`` is :attr:`ToolTag.LOCAL`. When ``register`` is true, the
definition is added to the process :class:`~plyngent.tools.catalog.ToolCatalog`
with the current registration 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:
return _build_definition(fn, name=name, description=description)
definition = _build_definition(
fn,
name=name,
description=description,
tags=tags,
)
if register:
_register_definition(definition)
return definition
if func is not None:
return decorator(func)
@@ -157,6 +207,10 @@ class ToolRegistry:
_danger: DangerClassifier | None
_on_confirm: ToolConfirmHook | None
_confirm_lock: asyncio.Lock
_yolo: bool
_auto_bind_state: bool
_instance: object | None
_session: object | None
def __init__(
self,
@@ -164,11 +218,19 @@ class ToolRegistry:
*,
danger: DangerClassifier | None = None,
on_confirm: ToolConfirmHook | None = None,
yolo: bool = False,
auto_bind_state: bool = False,
instance_state: object | None = None,
session_state: object | None = None,
) -> None:
self._tools = {}
self._danger = danger
self._on_confirm = on_confirm
self._confirm_lock = asyncio.Lock()
self._yolo = yolo
self._auto_bind_state = auto_bind_state
self._instance = instance_state
self._session = session_state
if tools is None:
return
if isinstance(tools, list):
@@ -193,11 +255,76 @@ class ToolRegistry:
def __len__(self) -> int:
return len(self._tools)
def definitions(self) -> list[ToolDefinition]:
"""Return tool definitions in registration order (for cloning registries)."""
return list(self._tools.values())
def clone(
self,
*,
instance_state: object | None = None,
session_state: object | None = None,
yolo: bool | None = None,
auto_bind_state: bool | None = None,
) -> ToolRegistry:
"""New registry with the same tools/hooks and rebound host state.
*instance_state* / *session_state* are applied as given (use the parent
registry's handles only when the caller passes them through).
"""
return ToolRegistry(
self.definitions(),
danger=self._danger,
on_confirm=self._on_confirm,
yolo=self._yolo if yolo is None else yolo,
auto_bind_state=self._auto_bind_state if auto_bind_state is None else auto_bind_state,
instance_state=instance_state,
session_state=session_state,
)
def set_yolo(self, *, enabled: bool) -> None:
self._yolo = enabled
def set_session_state(self, session_state: object | None) -> None:
self._session = session_state
def set_instance_state(self, instance_state: object | None) -> None:
self._instance = instance_state
@property
def yolo(self) -> bool:
"""Whether YOLO mode may auto-approve YOLO-tagged soft confirms."""
return self._yolo
@property
def soft_confirm(self) -> bool:
"""True when dangerous tools are gated by ``on_confirm``."""
"""True when a soft-confirm path is configured (danger + on_confirm).
YOLO mode does not clear this: non-YOLO-tagged tools still prompt.
"""
return self._danger is not None and self._on_confirm is not None
def _check_state_tags(self, definition: ToolDefinition) -> str | None:
"""Return an error string if required state context is missing.
Only enforced when the host opted into ``auto_bind_state`` (CLI). Hand
registries in tests may still rely on process globals during migration.
"""
if not self._auto_bind_state:
return None
tags = definition.tags
if tags & ToolTag.INSTANCE_STATE:
from plyngent.tools.context import get_instance
if get_instance() is None and self._instance is None:
return f"error: tool {definition.name!r} requires instance state (INSTANCE_STATE) but none is bound"
if tags & ToolTag.SESSION_STATE:
from plyngent.tools.context import get_session
if get_session() is None and self._session is None:
return f"error: tool {definition.name!r} requires session state (SESSION_STATE) but none is bound"
return None
async def _invoke(self, definition: ToolDefinition, args: dict[str, object]) -> str:
try:
result = definition.handler(**args)
@@ -213,30 +340,82 @@ class ToolRegistry:
return result
return msgspec.json.encode(result).decode()
async def _maybe_confirm(self, name: str, args: dict[str, object]) -> str | None:
"""If confirm is required and denied, return an error string for the model.
def _session_for_grants(self) -> Any | None:
from plyngent.tools.context import get_session
The confirm hook may return ``True`` (allow), ``False`` (deny), or a
non-empty string (deny with user comment for the model).
session = get_session()
if session is None and self._session is not None:
return cast("Any", self._session)
return session
def _grant_allows(self, name: str, *, tags: ToolTag) -> bool:
if not (tags & ToolTag.TRUSTABLE):
return False
from plyngent.tools.grants import has_grant
session = self._session_for_grants()
return session is not None and has_grant(session, name)
async def _record_grant(self, name: str, *, tags: ToolTag) -> None:
if not (tags & ToolTag.TRUSTABLE):
return
from plyngent.tools.grants import add_grant
session = self._session_for_grants()
if session is not None:
await add_grant(session, name)
async def _prompt_soft_confirm(
self,
name: str,
args: dict[str, object],
reason: str,
*,
tags: ToolTag,
) -> str | None:
if self._on_confirm is None:
return f"error: tool {name!r} denied by policy ({reason}; no confirm hook)"
decision = self._on_confirm(name, args, reason)
if inspect.isawaitable(decision):
decision = await decision
if decision is True:
await self._record_grant(name, tags=tags)
return None
if isinstance(decision, str) and decision.strip():
return f"error: tool {name!r} denied by user confirm ({reason}); user comment: {decision.strip()}"
return f"error: tool {name!r} denied by user confirm ({reason})"
async def _maybe_confirm(
self,
name: str,
args: dict[str, object],
*,
tags: ToolTag,
) -> str | None:
"""Soft-confirm gate driven by danger reason + tags + grants + YOLO.
Pipeline (soft gate only; hard denylists stay in tool handlers)::
no soft reason → run
YOLO mode and (tags & YOLO) → allow
(tags & TRUSTABLE) and grant exists → allow
else on_confirm; on approve + TRUSTABLE → store grant
"""
if self._danger is None or self._on_confirm is None:
if self._danger is None:
return None
reason = self._danger(name, args)
if reason is None:
return None
async with self._confirm_lock:
# Re-check under the lock so parallel tools do not race the prompt.
reason = self._danger(name, args)
if reason is None:
return None
decision = self._on_confirm(name, args, reason)
if inspect.isawaitable(decision):
decision = await decision
if decision is True:
if self._yolo and (tags & ToolTag.YOLO):
return None
if isinstance(decision, str) and decision.strip():
return f"error: tool {name!r} denied by user confirm ({reason}); user comment: {decision.strip()}"
return f"error: tool {name!r} denied by user confirm ({reason})"
if self._grant_allows(name, tags=tags):
return None
return await self._prompt_soft_confirm(name, args, reason, tags=tags)
async def execute(self, name: str, arguments_json: str) -> str:
"""Run a tool by name; returns a string result (errors become error text)."""
@@ -250,7 +429,25 @@ class ToolRegistry:
if not isinstance(raw_args, dict):
return "error: tool arguments must be a JSON object"
args = {str(key): value for key, value in cast("dict[object, object]", raw_args).items()}
denied = await self._maybe_confirm(name, args)
if denied is not None:
return denied
return await self._invoke(definition, args)
async def _run() -> str:
missing = self._check_state_tags(definition)
if missing is not None:
return missing
denied = await self._maybe_confirm(name, args, tags=definition.tags)
if denied is not None:
return denied
return await self._invoke(definition, args)
# Bind host-provided state for tools that read contextvars (todo, workspace).
# Tag enforcement remains gated by ``auto_bind_state``; binding is always useful
# when the registry holds instance/session handles.
if self._instance is not None or self._session is not None:
from plyngent.tools.context import bind_tool_context
with bind_tool_context(
instance=cast("Any", self._instance),
session=cast("Any", self._session),
):
return await _run()
return await _run()
+179 -23
View File
@@ -26,7 +26,6 @@ from plyngent.config.models import DatabaseConfig
from plyngent.memory import MemoryStore
from plyngent.prompting import NonInteractiveBackend, configure_prompting
from plyngent.runtime import ProviderNotSupportedError, create_client
from plyngent.tools import set_workspace_root
if TYPE_CHECKING:
from collections.abc import Mapping
@@ -36,9 +35,18 @@ if TYPE_CHECKING:
_DEFAULT_DB_FILENAME = "chat.db"
def _load_config(config_path: Path | None) -> ConfigStore:
def _load_config(config_path: Path | None, *, require_providers: bool = True) -> ConfigStore:
"""Load config; optionally skip the interactive “no providers” recovery path.
Plugin management only needs a valid TOML file (and will create one if
missing via :func:`load_config_with_optional_edit` when providers are required
for chat). For plugins, use ``require_providers=False``.
"""
try:
return load_config_with_optional_edit(config_path)
if require_providers:
return load_config_with_optional_edit(config_path)
path = resolve_config_path(config_path)
return config_mod.load(path)
except config_mod.ConfigFormatError as exc:
path = resolve_config_path(config_path)
msg = f"invalid config TOML ({path}): {exc}"
@@ -115,16 +123,8 @@ def _read_prompt_text(prompt: str | None, *, stdin_isatty: bool) -> str | None:
return text or None
def _setup_workspace_and_hooks(
store: ConfigStore,
workspace: Path,
*,
interactive: bool,
) -> None:
_ = set_workspace_root(workspace)
from plyngent.tools import set_path_denylist
set_path_denylist(store.agent_config.path_denylist or None)
def _setup_hooks(*, interactive: bool) -> None:
"""Install interactive limit hooks (workspace policy is set on ReplState)."""
if interactive:
install_cli_limit_hooks()
else:
@@ -217,7 +217,7 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
if store.recoverable_providers:
_warn_recoverable_providers(store.recoverable_providers)
_setup_workspace_and_hooks(store, workspace, interactive=interactive)
_setup_hooks(interactive=interactive)
# --yes forces sticky YOLO; else derive from config.confirm_destructive.
from plyngent.cli.state import YoloMode
@@ -297,6 +297,12 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
interactive_limits=interactive,
yolo=yolo,
)
# Path denylist and policy confirm live on instance.workspace (no process bag).
state.instance_state.workspace.path_denylist = tuple(store.agent_config.path_denylist or ())
if interactive:
from plyngent.cli.limits import prompt_policy_command_confirm
state.instance_state.workspace.policy_confirm_hook = prompt_policy_command_confirm
# Seed cache if we already fetched; else warm in background for Tab.
if remote_ids is not None:
state.seed_remote_models(remote_ids)
@@ -323,15 +329,17 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
return EXIT_OK
finally:
await memory.close()
from plyngent.tools.process.pty_session import PtyManager
from plyngent.tools.temp_workspace import cleanup_temporary_workspaces
# PTY + temp workspace cleanup via instance shutdown when state exists.
state_obj = locals().get("state")
if isinstance(state_obj, ReplState):
state_obj.instance_state.workspace.policy_confirm_hook = None
state_obj.instance_state.workspace.policy_allowed_commands.clear()
await state_obj.instance_state.shutdown()
else:
# No ReplState: only PTY class cleanup (temps require instance allowlist).
from plyngent.tools.process.pty_session import PtyManager
PtyManager.close_all()
from plyngent.tools.workspace import clear_policy_allowed_commands, set_policy_confirm_hook
set_policy_confirm_hook(None)
clear_policy_allowed_commands()
_ = cleanup_temporary_workspaces()
PtyManager.close_all()
def _configure_logging(level: str) -> None:
@@ -379,7 +387,7 @@ def main(ctx: click.Context, log_level: str) -> None:
)
@click.option("--provider", "provider_name", default=None, help="Provider name from config.")
@click.option("--model", default=None, help="Model id.")
@click.option("--tools/--no-tools", default=True, show_default=True, help="Enable DEFAULT_TOOLS.")
@click.option("--tools/--no-tools", default=True, show_default=True, help="Enable catalog tools (local surface).")
@click.option(
"--workspace",
type=click.Path(path_type=Path, file_okay=False, exists=True),
@@ -502,6 +510,154 @@ def providers_cmd(config_path: Path | None) -> None:
_warn_bad_providers(store.bad_providers)
def print_plugins_table(store: ConfigStore) -> None:
"""Print discovered plugins with allowlist status (CLI + slash)."""
from plyngent.tools.plugins import list_plugin_statuses
cfg = store.plugins_config
click.echo(f"config={store.path}")
enable_s = ", ".join(cfg.enable) if cfg.enable else "(none)"
disable_s = ", ".join(cfg.disable) if cfg.disable else "(none)"
click.echo(f"enable=[{enable_s}] disable=[{disable_s}]")
rows = list_plugin_statuses(enable=cfg.enable, disable=cfg.disable)
if not rows:
click.echo("(no plyngent.tools entry points installed)")
return
click.echo("id\tstatus\tpackage\tvalue")
for row in rows:
if row.disabled:
status = "disabled"
elif row.enabled:
status = "enabled"
else:
status = "off"
pkg = row.plugin.package or "-"
if row.plugin.version:
pkg = f"{pkg}@{row.plugin.version}"
click.echo(f"{row.plugin.id}\t{status}\t{pkg}\t{row.plugin.value}")
@main.group("plugins")
def plugins_group() -> None:
"""Manage third-party plugins (``[plugins]`` allowlist in config)."""
@plugins_group.command("list")
@click.option(
"--config",
"config_path",
type=click.Path(path_type=Path, dir_okay=False),
default=None,
help="Path to plyngent.toml.",
)
def plugins_list_cmd(config_path: Path | None) -> None:
"""List installed ``plyngent.tools`` entry points and enable/disable status."""
store = _load_config(config_path, require_providers=False)
print_plugins_table(store)
@plugins_group.command("enable")
@click.argument("name")
@click.option(
"--config",
"config_path",
type=click.Path(path_type=Path, dir_okay=False),
default=None,
help="Path to plyngent.toml.",
)
def plugins_enable_cmd(name: str, config_path: Path | None) -> None:
"""Allowlist a plugin entry-point name (or ``*`` for all) and write config.
Removes the name from ``[plugins].disable`` if present. Takes effect for
new chat sessions (registry is built at chat start).
"""
store = _load_config(config_path, require_providers=False)
try:
_ = store.enable_plugin(name)
except ValueError as exc:
raise click.ClickException(str(exc)) from exc
store.write()
click.echo(f"enabled {name.strip()!r} in {store.path}")
enable_s = ", ".join(store.plugins_config.enable) or "(none)"
disable_s = ", ".join(store.plugins_config.disable) or "(none)"
click.echo(f"enable=[{enable_s}] disable=[{disable_s}]")
@plugins_group.command("disable")
@click.argument("name")
@click.option(
"--config",
"config_path",
type=click.Path(path_type=Path, dir_okay=False),
default=None,
help="Path to plyngent.toml.",
)
def plugins_disable_cmd(name: str, config_path: Path | None) -> None:
"""Block a plugin via ``[plugins].disable`` and write config.
Disable always wins over enable / ``*``. Use ``plugins undeny`` to drop the
block without adding the plugin to enable.
"""
store = _load_config(config_path, require_providers=False)
try:
_ = store.disable_plugin(name)
except ValueError as exc:
raise click.ClickException(str(exc)) from exc
store.write()
click.echo(f"disabled {name.strip()!r} in {store.path}")
enable_s = ", ".join(store.plugins_config.enable) or "(none)"
disable_s = ", ".join(store.plugins_config.disable) or "(none)"
click.echo(f"enable=[{enable_s}] disable=[{disable_s}]")
@plugins_group.command("undeny")
@click.argument("name")
@click.option(
"--config",
"config_path",
type=click.Path(path_type=Path, dir_okay=False),
default=None,
help="Path to plyngent.toml.",
)
def plugins_undeny_cmd(name: str, config_path: Path | None) -> None:
"""Remove *name* from ``[plugins].disable`` only (does not enable it)."""
store = _load_config(config_path, require_providers=False)
try:
_ = store.undeny_plugin(name)
except ValueError as exc:
raise click.ClickException(str(exc)) from exc
store.write()
click.echo(f"undenied {name.strip()!r} in {store.path}")
disable_s = ", ".join(store.plugins_config.disable) or "(none)"
click.echo(f"disable=[{disable_s}]")
@plugins_group.command("clear")
@click.option(
"--config",
"config_path",
type=click.Path(path_type=Path, dir_okay=False),
default=None,
help="Path to plyngent.toml.",
)
@click.option(
"--yes",
"confirm_yes",
is_flag=True,
default=False,
help="Skip confirmation prompt.",
)
def plugins_clear_cmd(config_path: Path | None, *, confirm_yes: bool) -> None:
"""Clear ``[plugins].enable`` and ``disable`` (load no plugins)."""
store = _load_config(config_path, require_providers=False)
needs_confirm = not confirm_yes and (store.plugins_config.enable or store.plugins_config.disable)
if needs_confirm and not click.confirm("Clear all plugin enable/disable entries?", default=False):
raise click.Abort
_ = store.clear_plugins()
store.write()
click.echo(f"cleared plugins lists in {store.path}")
@main.group("config")
def config_group() -> None:
"""Manage plyngent configuration."""
+3 -1
View File
@@ -29,7 +29,9 @@ _MINIMAL_CONFIG = """\
# # url = ":memory:"
# [agent]
# system_prompt = "You are a careful coding assistant."
# # system_prompt = persona (omit → built-in). tool_directives = tool playbook.
# # system_prompt = "" / tool_directives = "" disable each part; both "" → no system.
# # Or multi-line '''...''' to override.
# max_tool_result_chars = 32000
# parallel_tools = true
# confirm_destructive = true
+5 -4
View File
@@ -350,9 +350,10 @@ async def prompt_workspace_mismatch_async(
def install_cli_limit_hooks() -> None:
"""Register interactive continue hooks, policy confirm, and prompt cancel-pause."""
"""Register interactive continue hooks and prompt cancel-pause.
Command denylist policy confirm is set on :class:`~plyngent.tools.context.InstanceState`
when the REPL starts (no process-global hook).
"""
configure_prompting(pause_factory=pause_task_cancel_for_prompt)
PtyManager.set_limit_continue_hook(prompt_continue_limit)
from plyngent.tools.workspace import set_policy_confirm_hook
set_policy_confirm_hook(prompt_policy_command_confirm)
+194 -4
View File
@@ -82,9 +82,9 @@ HELP_FOOTER = (
"assistant/tool output is discarded but the user message stays (so /retry\n"
"works after resume, not only via readline history). Auto-retry: 10s/20s/30s.\n"
"\n"
"Tab completes slash commands and arguments (provider, model, session ids,\n"
"todos actions, on/off, yolo, export, flags). Use --session ID or /resume to\n"
"continue a prior chat after restart.\n"
"Side questions: /btw … answers without changing the main session\n"
"(optional --tools / --fresh). Tab completes slash commands and arguments.\n"
"Use --session ID or /resume to continue a prior chat after restart.\n"
"\n"
'Multiline: start a message with """ then end a later line with """.\n'
"Long prompts: /edit opens $VISUAL/$EDITOR (blocking).\n"
@@ -908,14 +908,133 @@ def model_cmd(state: ReplState, model_id: str | None, *, persist: bool) -> None:
click.secho(f"error: could not persist model: {exc}", fg="red")
def _format_tool_tags(tags: object) -> str:
"""Compact Flag names for operator display (e.g. LOCAL|INSTANCE_STATE)."""
from plyngent.agent.tools import ToolTag
if not isinstance(tags, ToolTag):
return str(tags)
if tags.value == 0:
return "-"
names = [member.name for member in ToolTag if member.name and (tags & member) == member]
return "|".join(names) if names else str(tags)
@slash.command("btw")
@click.argument("question", nargs=-1, required=False)
@click.option(
"--tools/--no-tools",
"with_tools",
default=False,
show_default=True,
help="Allow tools on the side turn (shared workspace; forked session grants/todo).",
)
@click.option(
"--fresh",
is_flag=True,
default=False,
help="Ignore main history; system prompt only.",
)
@click.pass_obj
def btw_cmd(
state: ReplState,
question: tuple[str, ...],
*,
with_tools: bool,
fresh: bool,
) -> None:
"""Ask a side question without changing the main session transcript or DB.
Uses the current model and (unless ``--fresh``) a copy of main history.
Tools are **off** by default; ``--tools`` clones the main tool registry with
a fresh session bag and the shared instance workspace.
"""
text = " ".join(question).strip()
if not text:
click.echo("usage: /btw [--tools] [--fresh] <question>")
return
if state.agent.pending_retry_text is not None:
click.echo("error: finish or /retry the main turn before /btw")
return
if with_tools and (not state.tools_enabled or state.agent.tools is None):
click.echo("error: --tools requires tools on (/tools on)")
return
from plyngent.cli.display import render_events
from plyngent.cli.retry import run_cancellable
from plyngent.tools.context import SessionState
click.secho("btw: ", fg="magenta", nl=False)
click.echo(text)
tools_arg: bool = with_tools
aside_session = SessionState() if with_tools else None
aside_instance = state.instance_state if with_tools else None
async def _run() -> None:
await run_cancellable(
render_events(
state.agent.run_aside(
text,
include_history=not fresh,
tools=tools_arg,
instance_state=aside_instance,
session_state=aside_session,
)
)
)
try:
_await(_run())
except Exception as exc: # noqa: BLE001 — surface side-turn failures
click.secho(f"error: btw failed: {exc}", fg="red")
return
click.secho("(btw done — main session unchanged)", fg="bright_black")
@slash.command("tools")
@click.argument("enabled", required=False, type=ON_OFF, metavar="[on|off]")
@click.option(
"--list",
"list_tools",
is_flag=True,
default=False,
help="List registry tools with tags and catalog source.",
)
@click.pass_obj
def tools_cmd(state: ReplState, enabled: bool | None) -> None: # noqa: FBT001
def tools_cmd(
state: ReplState,
enabled: bool | None, # noqa: FBT001
*,
list_tools: bool,
) -> None:
"""Show or set whether agent tools are enabled.
Omit the argument to print the current value; pass ``on`` or ``off`` to change it.
Use ``--list`` to print name, tags, and catalog source for each registry tool.
"""
if list_tools:
if not state.tools_enabled or state.agent.tools is None:
click.echo("tools=off (no registry)")
return
from plyngent.tools.catalog import get_catalog
catalog = get_catalog()
registry = state.agent.tools
# ToolRegistry has no name iterator; use schema items then re-resolve defs.
items = sorted(registry.tool_items(), key=lambda item: item.function.name)
click.echo(f"tools=on count={len(items)}")
for item in items:
name = item.function.name
definition = registry.get(name)
tags_s = _format_tool_tags(definition.tags) if definition is not None else "-"
entry = catalog.get(name)
source_s = str(entry.source) if entry is not None else "registry"
click.echo(f" {name}\t{tags_s}\t{source_s}")
if enabled is not None:
state.tools_enabled = enabled
state.rebuild_client()
click.echo(f"tools={'on' if enabled else 'off'}")
return
if enabled is None:
click.echo(f"tools={'on' if state.tools_enabled else 'off'}")
return
@@ -924,6 +1043,77 @@ def tools_cmd(state: ReplState, enabled: bool | None) -> None: # noqa: FBT001
click.echo(f"tools={'on' if enabled else 'off'}")
def _slash_rebuild_tools_if_on(state: ReplState) -> None:
if state.tools_enabled:
state.rebuild_client()
def _slash_apply_plugin_action(state: ReplState, act: str, token: str) -> None:
if act == "enable":
_ = state.config.enable_plugin(token)
elif act == "disable":
_ = state.config.disable_plugin(token)
elif act == "undeny":
_ = state.config.undeny_plugin(token)
else:
msg = f"unknown plugins action {act!r}"
raise click.UsageError(msg)
state.config.write()
_slash_rebuild_tools_if_on(state)
cfg = state.config.plugins_config
click.echo(f"plugins {act} {token!r} enable={list(cfg.enable)!r} disable={list(cfg.disable)!r}")
@slash.command("plugins")
@click.argument(
"action",
required=False,
type=click.Choice(["list", "enable", "disable", "undeny", "reload", "clear"]),
)
@click.argument("name", required=False)
@click.pass_obj
def plugins_slash_cmd(state: ReplState, action: str | None, name: str | None) -> None:
"""List or change plugin allowlist; reload tools into this session.
``/plugins`` or ``/plugins list`` — installed entry points + config status.
``/plugins enable NAME`` / ``disable NAME`` / ``undeny NAME`` — update
config on disk, then rebuild the tool registry for this session.
``/plugins reload`` — re-read config and rebuild tools without list changes.
``/plugins clear`` — empty enable/disable lists and rebuild.
"""
act = (action or "list").lower()
if act == "list":
from plyngent.cli.app import print_plugins_table
print_plugins_table(state.config)
return
if act == "reload":
state.config.reload()
_slash_rebuild_tools_if_on(state)
cfg = state.config.plugins_config
click.echo(
f"reloaded plugins enable={list(cfg.enable)!r} disable={list(cfg.disable)!r} "
f"tools={'on' if state.tools_enabled else 'off'}"
)
return
if act == "clear":
_ = state.config.clear_plugins()
state.config.write()
_slash_rebuild_tools_if_on(state)
if state.tools_enabled:
click.echo("cleared plugins enable/disable; tools registry rebuilt")
else:
click.echo("cleared plugins enable/disable")
return
if name is None or not name.strip():
msg = f"/plugins {act} requires a plugin entry-point name"
raise click.UsageError(msg)
try:
_slash_apply_plugin_action(state, act, name.strip())
except ValueError as exc:
raise click.UsageError(str(exc)) from exc
@slash.command("yolo")
@click.argument("mode", required=False, type=YOLO_MODE, metavar="[on|off|once]")
@click.pass_obj
+96 -30
View File
@@ -18,7 +18,8 @@ from plyngent.cli.models_source import (
)
from plyngent.memory.database.store import normalize_workspace
from plyngent.runtime import create_client
from plyngent.tools import DEFAULT_TOOLS, set_todo_stack, set_workspace_root
from plyngent.tools import InstanceState, SessionState
from plyngent.tools.view import MemoryViewStore, session_data_view
if TYPE_CHECKING:
from collections.abc import Sequence
@@ -58,6 +59,8 @@ class ReplState:
agent: ChatAgent = field(init=False)
session_id: int | None = None
todo_stack: TodoStack = field(default_factory=TodoStack)
instance_state: InstanceState = field(default_factory=InstanceState)
session_state: SessionState = field(default_factory=SessionState)
_todo_persist_tasks: set[object] = field(default_factory=set, init=False, repr=False)
# Session ids for Tab complete (updated when listing/creating/resuming).
_session_id_cache: list[int] = field(default_factory=list, init=False, repr=False)
@@ -71,6 +74,9 @@ class ReplState:
# DeepSeek client uses a compatible but distinct param type; treat as ChatClient.
self.client = cast("ChatClient", cast("object", create_client(self.provider)))
self.workspace = Path(self.workspace).expanduser().resolve()
self.instance_state.workspace_root = self.workspace
self.instance_state.workspace.root = self.workspace
self.session_state = self._session_data_for_todo()
self.agent = self._make_agent()
self.sync_display_flags()
self._bind_todo_tools()
@@ -99,11 +105,17 @@ class ReplState:
return self.effective_yolo() == "off"
def set_yolo(self, mode: YoloMode) -> None:
"""Set YOLO mode; rebuild tool registry when soft-confirm hooks change."""
prev = self.soft_confirm_enabled()
"""Set YOLO mode; update registry YOLO bit (and rebuild if needed)."""
prev = self.effective_yolo()
self.yolo = mode
if prev != self.soft_confirm_enabled():
self.rebuild_client()
if prev != mode:
# Tag-aware confirm: YOLO only auto-approves YOLO-tagged tools.
if hasattr(self, "agent") and self.agent.tools is not None:
self.agent.tools.set_yolo(enabled=mode != "off")
else:
self.rebuild_client()
if mode == "off":
self.session_state.clear_grants()
def expire_yolo_once(self, *, quiet: bool = False) -> None:
"""If mode is ``once``, drop back to ``off`` after a user turn."""
@@ -115,23 +127,55 @@ class ReplState:
click.secho("yolo=off (once expired)", fg="bright_black", err=True)
def _bind_todo_tools(self) -> None:
"""Point module-level todo tools at this session stack + persist hook."""
def _todo_on_change(self) -> None:
"""Schedule memory persist for the live todo stack (session-scoped hook)."""
import asyncio
def on_change() -> None:
if self.session_id is None:
return
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
task = loop.create_task(self.memory.update_session_todo_stack(self.session_id, self.todo_stack.to_raw()))
# Keep a strong ref until done so the task is not GC'd mid-flight.
self._todo_persist_tasks.add(task)
task.add_done_callback(self._todo_persist_tasks.discard)
if self.session_id is None:
return
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
task = loop.create_task(self.memory.update_session_todo_stack(self.session_id, self.todo_stack.to_raw()))
# Keep a strong ref until done so the task is not GC'd mid-flight.
self._todo_persist_tasks.add(task)
task.add_done_callback(self._todo_persist_tasks.discard)
set_todo_stack(self.todo_stack, on_change=on_change)
def _session_data_for_todo(self, *, grants: dict[str, bool] | None = None) -> SessionState:
"""Build a SessionState with live todo + MemoryViewStore seed (durable tree).
*grants* defaults to empty (new session / startup). Pass the previous map
only when intentionally preserving soft-confirm trust across rebinds.
"""
grant_map = dict(grants or {})
store = MemoryViewStore({"todo": self.todo_stack.to_raw(), "grants": dict(grant_map)})
return SessionState(
session_id=self.session_id,
data=session_data_view(store=store),
todo=self.todo_stack,
on_todo_change=self._todo_on_change,
grants=grant_map,
)
def _bind_todo_tools(self) -> None:
"""Bind session/instance state for tools.
Seeds ``session.data["todo"]`` from the live stack and attaches
:meth:`_todo_on_change` for memory persist.
"""
self.session_state.session_id = self.session_id
self.session_state.todo = self.todo_stack
self.session_state.on_todo_change = self._todo_on_change
# Refresh durable seed so tools that rehydrate from the view see current data.
store = getattr(self.session_state.data, "_store", None)
if isinstance(store, MemoryViewStore):
store.merge_key("todo", self.todo_stack.to_raw())
self.instance_state.workspace_root = self.workspace
self.instance_state.workspace.root = self.workspace
if hasattr(self, "agent") and self.agent.tools is not None:
self.agent.tools.set_session_state(self.session_state)
self.agent.tools.set_instance_state(self.instance_state)
async def persist_todo_stack(self) -> None:
"""Write the in-memory todo stack to the active session row."""
@@ -157,21 +201,40 @@ class ReplState:
if not self.tools_enabled:
return None
from plyngent.cli.limits import prompt_confirm_tool_async
from plyngent.tools.catalog import register_builtin_tools
from plyngent.tools.danger import classify_danger
from plyngent.tools.plugins import load_plugin_tools
if self.soft_confirm_enabled():
return ToolRegistry(
list(DEFAULT_TOOLS),
danger=classify_danger,
on_confirm=prompt_confirm_tool_async,
)
return ToolRegistry(list(DEFAULT_TOOLS))
plugins_cfg = self.config.plugins_config
catalog = register_builtin_tools()
# Allowlisted entry points (plugin ids); disable is plugin-id only, not tool names.
_ = load_plugin_tools(
plugins_cfg.enable,
disable=plugins_cfg.disable,
)
# Local surface: builtins + allowlisted plugins (import registers into catalog).
tools = catalog.select(surface="local")
yolo = self.effective_yolo() != "off"
# Always attach soft-confirm path so non-YOLO tools still prompt under YOLO mode.
return ToolRegistry(
tools,
danger=classify_danger,
on_confirm=prompt_confirm_tool_async,
yolo=yolo,
auto_bind_state=True,
instance_state=self.instance_state,
session_state=self.session_state,
)
def _make_agent(self) -> ChatAgent:
from plyngent.cli.limits import prompt_continue_limit_async
from plyngent.config import compose_agent_system_content
agent_cfg = self.config.agent_config
system_prompt = agent_cfg.system_prompt or None
system_prompt = compose_agent_system_content(
agent_cfg.system_prompt,
agent_cfg.tool_directives,
)
on_limit = prompt_continue_limit_async if self.interactive_limits else None
return ChatAgent(
self.client,
@@ -327,10 +390,9 @@ class ReplState:
from plyngent.cli.selection import select_model, select_provider
from plyngent.runtime import ProviderNotSupportedError
from plyngent.tools import set_path_denylist
self.config.reload()
set_path_denylist(self.config.agent_config.path_denylist or None)
self.instance_state.workspace.path_denylist = tuple(self.config.agent_config.path_denylist or ())
selectable = self.config.selectable_providers()
preferred_provider = self.provider_name if self.provider_name in selectable else None
@@ -366,7 +428,10 @@ class ReplState:
msg = f"workspace is not a directory: {resolved}"
raise ValueError(msg)
self.workspace = resolved
_ = set_workspace_root(resolved)
self.instance_state.workspace_root = resolved
self.instance_state.workspace.root = resolved
if hasattr(self, "agent") and self.agent.tools is not None:
self.agent.tools.set_instance_state(self.instance_state)
def _apply_session_workspace(self, row: SessionRow) -> None:
"""Bind tools/REPL workspace to the session's directory when set."""
@@ -472,6 +537,7 @@ class ReplState:
if session.sid not in self._session_id_cache:
self._session_id_cache.insert(0, session.sid)
self.todo_stack = TodoStack()
self.session_state = self._session_data_for_todo()
self.agent = self._make_agent()
self._bind_todo_tools()
+5
View File
@@ -5,14 +5,19 @@ from typing import TYPE_CHECKING
import tomlkit
from tomlkit.exceptions import TOMLKitError
from .models import DEFAULT_SYSTEM_PROMPT as DEFAULT_SYSTEM_PROMPT
from .models import DEFAULT_TOOL_DIRECTIVES as DEFAULT_TOOL_DIRECTIVES
from .models import AgentConfig as AgentConfig
from .models import AnthropicProvider as AnthropicProvider
from .models import DatabaseConfig as DatabaseConfig
from .models import DeepseekProvider as DeepseekProvider
from .models import HttpTimeoutConfig as HttpTimeoutConfig
from .models import ModelConfig as ModelConfig
from .models import OpenAICompatibleProvider as OpenAICompatibleProvider
from .models import OpenAIProvider as OpenAIProvider
from .models import PluginsConfig as PluginsConfig
from .models import ProviderConfig as ProviderConfig
from .models import compose_agent_system_content as compose_agent_system_content
from .path import get_default_path as _get_default_path
from .store import ConfigFormatError as ConfigFormatError
from .store import ConfigStore as ConfigStore
+95 -1
View File
@@ -2,6 +2,63 @@ from typing import Any
from msgspec import Struct, field
# Built-in persona when ``[agent].system_prompt`` is omitted.
# Set ``system_prompt = ""`` to omit the persona block only.
# Override with a multi-line TOML literal (prefer ''' so nested " is fine).
DEFAULT_SYSTEM_PROMPT = """\
You are a professional coding agent in a workspace-bound tool environment.
"""
# Built-in tool playbook when ``[agent].tool_directives`` is omitted.
# Set ``tool_directives = ""`` to omit this block only.
DEFAULT_TOOL_DIRECTIVES = """\
### Workspace
- Explore with `tree`/`listdir`/`glob_paths`/`grep_files`/`read_file` when unsure.
- Stay under the workspace (or a path from `new_temporary_workspace`). Respect path denylists.
### Files
- Prefer file tools over shell. `edit_replace` fails usually means a bad match — \
fix `old_string` or `max_replaces`; shell workarounds rarely help.
- `edit_replace` defaults to the first match; if remaining matches are reported, \
raise `max_replaces` or narrow `old_string`.
- Before `edit_lineno`, `read_file` with `with_lineno=true`.
### Commands
- Prefer `run_command` / `run_command_batch` (argv, no shell) over `bash -c` or similar.
- Several `run_command` calls in one step may run in parallel; use `run_command_batch` \
for ordered pipelines (`pipe_out` / `mix_stderr` as needed).
- Prefer `vcs_*` for status/diff/log/branch when enough.
### Humans & safety
- Prefer `ask_user_line` / `ask_user_choice` / `ask_user_form` over waiting for the next free-form turn.
- Overwrites, deletes, shells, and risky ops may require human confirm; hard denylists are not skipped by YOLO.
- Temporary scratch: `new_temporary_workspace`.
### PTY
- Use PTY for interactive/TUI, sudo, ssh, or live servers.
- Passwords and other secret input: only `ask_into_pty` (never echo secrets into `write_pty`).
- Control keys: `write_pty_keys`, not literal `write_pty` data.
### Todos
- Use the todo stack for multi-step work (LIFO groups: push related items, finish/update, \
pop the group). Open items mean unfinished work.
"""
def compose_agent_system_content(
system_prompt: str,
tool_directives: str,
) -> str | None:
"""Join persona + tool playbook into one system body, or ``None`` if both empty.
Non-empty parts are stripped and joined with a blank line. Either field may
be ``""`` to disable that part while keeping the other.
"""
parts = [part.strip() for part in (system_prompt, tool_directives) if part and part.strip()]
if not parts:
return None
return "\n\n".join(parts)
class DatabaseConfig(Struct, omit_defaults=True):
"""Database connection configuration.
@@ -20,7 +77,10 @@ class DatabaseConfig(Struct, omit_defaults=True):
class AgentConfig(Struct, omit_defaults=True):
"""Single-user agent profile defaults."""
system_prompt: str = ""
# Persona / role (omit → DEFAULT_SYSTEM_PROMPT; "" disables persona only).
system_prompt: str = DEFAULT_SYSTEM_PROMPT
# Tool how-to (omit → DEFAULT_TOOL_DIRECTIVES; "" disables playbook only).
tool_directives: str = DEFAULT_TOOL_DIRECTIVES
max_tool_result_chars: int = 32_000
parallel_tools: bool = True
confirm_destructive: bool = True
@@ -37,6 +97,21 @@ class AgentConfig(Struct, omit_defaults=True):
compact_seed_text: str = ""
class PluginsConfig(Struct, omit_defaults=True):
"""Third-party plugins (not tool-specific config).
Hosts allowlist plugin **entry-point names** (today: group ``plyngent.tools``;
other extension points may reuse the same allowlist later).
- ``enable`` empty / omitted → load **no** plugins (safe default).
- ``enable = ["*"]`` → load every discovered entry point for that group.
- ``disable`` always wins over ``enable`` / ``*``.
"""
enable: list[str] = field(default_factory=list)
disable: list[str] = field(default_factory=list)
class ModelConfig(Struct, omit_defaults=True):
"""Capability flags for a model within a provider."""
@@ -50,6 +125,23 @@ class ModelConfig(Struct, omit_defaults=True):
cost_factor: float = 1.0
class HttpTimeoutConfig(Struct, omit_defaults=True):
"""Per-provider HTTP timeouts (seconds) for the LLM API client.
TOML examples::
timeout = 120
timeout = { connect = 10, read = 600 }
``connect`` bounds TCP/TLS setup; ``read`` bounds idle wait between response
bytes (SSE streams can run longer than *read* as long as chunks keep arriving).
Omitted fields use product defaults (10s connect / 600s read).
"""
connect: float | None = None
read: float | None = None
class ProviderConfig(Struct, tag_field="preset", omit_defaults=True):
"""Base config for all LLM providers.
@@ -60,6 +152,8 @@ class ProviderConfig(Struct, tag_field="preset", omit_defaults=True):
access_key_or_token: str
url: str = ""
models: dict[str, ModelConfig] = field(default_factory=dict)
# float = single timeout for the session; table = connect/read split; omit = defaults.
timeout: float | HttpTimeoutConfig | None = None
def _default_openai_models() -> dict[str, ModelConfig]:
+160 -12
View File
@@ -1,12 +1,12 @@
from __future__ import annotations
from types import MappingProxyType
from typing import TYPE_CHECKING, cast
from typing import TYPE_CHECKING, Any, cast
import msgspec
import tomlkit
from .models import AgentConfig, DatabaseConfig, ModelConfig, Provider
from .models import AgentConfig, DatabaseConfig, ModelConfig, PluginsConfig, Provider
if TYPE_CHECKING:
from collections.abc import Mapping, MutableMapping, Sequence
@@ -33,6 +33,14 @@ def _parse_agent(raw: dict[str, object]) -> AgentConfig:
return AgentConfig()
def _parse_plugins(raw: dict[str, object]) -> PluginsConfig:
"""Parse the ``[plugins]`` section, falling back to defaults."""
try:
return msgspec.convert(raw, PluginsConfig)
except msgspec.ValidationError:
return PluginsConfig()
def _parse_providers(
document: tomlkit.TOMLDocument,
) -> tuple[dict[str, Provider], dict[str, object], dict[str, Provider]]:
@@ -100,6 +108,7 @@ class ConfigStore:
_document: tomlkit.TOMLDocument
_database: DatabaseConfig
_agent: AgentConfig
_plugins: PluginsConfig
_providers: dict[str, Provider]
_bad_providers: dict[str, object]
_recoverable_providers: dict[str, Provider]
@@ -110,6 +119,7 @@ class ConfigStore:
raw: dict[str, object] = document.unwrap()
self._database = _parse_database(cast("dict[str, object]", raw.get("database", {})))
self._agent = _parse_agent(cast("dict[str, object]", raw.get("agent", {})))
self._plugins = _parse_plugins(cast("dict[str, object]", raw.get("plugins", {})))
self._providers, self._bad_providers, self._recoverable_providers = _parse_providers(document)
@property
@@ -136,6 +146,86 @@ class ConfigStore:
"""Typed agent profile (system prompt, tool budgets, etc.)."""
return self._agent
# -- plugins (read-only) --
@property
def plugins(self) -> MappingProxyType[str, object]:
"""Read-only mapping view of plugin allowlist configuration."""
return MappingProxyType(msgspec.structs.asdict(self._plugins))
@property
def plugins_config(self) -> PluginsConfig:
"""Typed plugin allowlist (enable / disable entry-point names)."""
return self._plugins
def set_plugins_enable(self, names: Sequence[str]) -> PluginsConfig:
"""Replace the plugin enable list (in-memory; call :meth:`write` to persist)."""
cleaned = [name.strip() for name in names if name.strip()]
self._plugins = msgspec.structs.replace(self._plugins, enable=cleaned)
return self._plugins
def set_plugins_disable(self, names: Sequence[str]) -> PluginsConfig:
"""Replace the plugin disable list (in-memory; call :meth:`write` to persist)."""
cleaned = [name.strip() for name in names if name.strip()]
self._plugins = msgspec.structs.replace(self._plugins, disable=cleaned)
return self._plugins
def enable_plugin(self, name: str) -> PluginsConfig:
"""Allowlist *name* and remove it from the disable list.
Special case: ``name == "*"`` sets enable to ``["*"]`` (load all).
"""
token = name.strip()
if not token:
msg = "plugin name must not be empty"
raise ValueError(msg)
disable = [n for n in self._plugins.disable if n.strip() and n.strip() != token]
if token == "*":
self._plugins = msgspec.structs.replace(self._plugins, enable=["*"], disable=disable)
return self._plugins
enable = list(self._plugins.enable)
if any(item.strip() == "*" for item in enable):
# Already load-all; only ensure not disabled.
self._plugins = msgspec.structs.replace(self._plugins, disable=disable)
return self._plugins
if token not in enable:
enable.append(token)
self._plugins = msgspec.structs.replace(self._plugins, enable=enable, disable=disable)
return self._plugins
def disable_plugin(self, name: str) -> PluginsConfig:
"""Block *name* via the disable list (wins over enable / ``*``).
Also removes *name* from a non-``*`` enable list so config stays tidy.
"""
token = name.strip()
if not token or token == "*":
msg = "disable requires a concrete plugin entry-point name"
raise ValueError(msg)
enable = list(self._plugins.enable)
if not any(item.strip() == "*" for item in enable) and token in enable:
enable = [n for n in enable if n.strip() != token]
disable = list(self._plugins.disable)
if token not in disable:
disable.append(token)
self._plugins = msgspec.structs.replace(self._plugins, enable=enable, disable=disable)
return self._plugins
def undeny_plugin(self, name: str) -> PluginsConfig:
"""Remove *name* from the disable list only (does not add to enable)."""
token = name.strip()
if not token:
msg = "plugin name must not be empty"
raise ValueError(msg)
disable = [n for n in self._plugins.disable if n.strip() != token]
self._plugins = msgspec.structs.replace(self._plugins, disable=disable)
return self._plugins
def clear_plugins(self) -> PluginsConfig:
"""Clear enable and disable lists (load no plugins)."""
self._plugins = PluginsConfig()
return self._plugins
# -- providers (read/write) --
@property
@@ -269,6 +359,7 @@ class ConfigStore:
raw: dict[str, object] = self._document.unwrap()
self._database = _parse_database(cast("dict[str, object]", raw.get("database", {})))
self._agent = _parse_agent(cast("dict[str, object]", raw.get("agent", {})))
self._plugins = _parse_plugins(cast("dict[str, object]", raw.get("plugins", {})))
self._providers, self._bad_providers, self._recoverable_providers = _parse_providers(self._document)
# -- internal sync helpers --
@@ -279,6 +370,24 @@ class ConfigStore:
self._document[key] = tomlkit.table()
return cast("MutableMapping[str, object]", self._document[key])
@staticmethod
def _encode_toml_value(value: object) -> object:
"""Encode builtins for tomlkit; strings with LF become multi-line literals."""
if isinstance(value, str):
if "\n" in value:
return tomlkit.string(value, multiline=True)
return value
if isinstance(value, list):
arr: object = tomlkit.array()
append = cast("Any", arr).append
for item in cast("list[object]", value):
append(ConfigStore._encode_toml_value(item))
return arr
if isinstance(value, dict):
# Nested dicts at section level are rare; providers use dedicated helpers.
return ConfigStore._to_inline_table(cast("Mapping[str, object]", value))
return value
def _sync_section(self, key: str, data: object) -> None:
raw: dict[str, object] = msgspec.to_builtins(data)
if not raw:
@@ -290,7 +399,7 @@ class ConfigStore:
if k not in raw:
del section[k]
for k, v in raw.items():
section[k] = v
section[k] = self._encode_toml_value(v)
def _sync_database_section(self) -> None:
"""Sync ``[database]`` to the document."""
@@ -300,8 +409,52 @@ class ConfigStore:
"""Sync ``[agent]`` to the document."""
self._sync_section("agent", self._agent)
def _sync_plugins_section(self) -> None:
"""Sync ``[plugins]`` to the document."""
self._sync_section("plugins", self._plugins)
@staticmethod
def _to_inline_table(mapping: Mapping[str, object]) -> object:
"""Encode a flat (or nested-dict) mapping as a TOML inline table."""
table = tomlkit.inline_table()
for key, value in mapping.items():
if isinstance(value, dict):
table[key] = ConfigStore._to_inline_table(cast("Mapping[str, object]", value))
else:
table[key] = ConfigStore._encode_toml_value(value)
return table
@staticmethod
def _models_to_toml_table(models: Mapping[str, object]) -> object:
"""Write each model as ``name = { … }`` under ``[providers.*.models]``."""
table = tomlkit.table()
for model_id, cfg in models.items():
if isinstance(cfg, dict):
table[model_id] = ConfigStore._to_inline_table(cast("Mapping[str, object]", cfg))
else:
table[model_id] = ConfigStore._to_inline_table({})
return table
@staticmethod
def _provider_to_toml_table(raw: Mapping[str, object]) -> object:
"""Build a provider table; nested maps become inline tables except ``models``."""
entry = tomlkit.table()
for key, value in raw.items():
if key == "models" and isinstance(value, dict):
entry[key] = ConfigStore._models_to_toml_table(cast("Mapping[str, object]", value))
elif isinstance(value, dict):
entry[key] = ConfigStore._to_inline_table(cast("Mapping[str, object]", value))
else:
entry[key] = ConfigStore._encode_toml_value(value)
return entry
def _sync_providers_section(self) -> None:
"""Sync ``[providers]`` to the document (ready + recoverable)."""
"""Sync ``[providers]`` to the document (ready + recoverable).
Model configs are written as **inline tables** under
``[providers.<name>.models]`` (e.g. ``\"gpt-test\" = { text = true }``),
not as one dotted section per model id.
"""
section = self._toml_table("providers")
keep = set(self._providers) | set(self._recoverable_providers)
@@ -310,17 +463,12 @@ class ConfigStore:
del section[name]
for name, provider in {**self._recoverable_providers, **self._providers}.items():
raw: dict[str, object] = msgspec.to_builtins(provider)
if name in section:
entry = cast("MutableMapping[str, object]", section[name])
entry.clear()
for k, v in raw.items():
entry[k] = v
else:
section[name] = raw
raw = cast("dict[str, object]", msgspec.to_builtins(provider))
section[name] = self._provider_to_toml_table(raw)
def _sync_to_document(self) -> None:
"""Incrementally sync all sections into the document."""
self._sync_database_section()
self._sync_agent_section()
self._sync_plugins_section()
self._sync_providers_section()
@@ -113,6 +113,7 @@ class BaseOpenAIClient:
self.session = niquests.AsyncSession(
base_url=config.base_url,
auth=BearerTokenAuth(config.access_key_or_token),
timeout=config.timeout,
)
self.encoder = msgspec.json.Encoder()
self.decoder = msgspec.json.Decoder(ChatCompletionResponse)
@@ -1,7 +1,18 @@
from __future__ import annotations
from dataclasses import dataclass
# Product defaults when provider omits ``timeout`` (connect vs read idle).
DEFAULT_HTTP_CONNECT_TIMEOUT = 10.0
DEFAULT_HTTP_READ_TIMEOUT = 600.0
type HttpTimeout = float | tuple[float, float]
@dataclass
class OpenAIConfig:
access_key_or_token: str
base_url: str = "https://api.openai.com/v1"
# Passed to ``niquests.AsyncSession(timeout=...)``.
# ``float`` = single timeout; ``(connect, read)`` = split; factory always sets a concrete value.
timeout: HttpTimeout = (DEFAULT_HTTP_CONNECT_TIMEOUT, DEFAULT_HTTP_READ_TIMEOUT)
+2
View File
@@ -1,3 +1,5 @@
from .client_factory import InvalidHttpTimeoutError as InvalidHttpTimeoutError
from .client_factory import ProviderNotSupportedError as ProviderNotSupportedError
from .client_factory import create_client as create_client
from .client_factory import normalize_http_timeout as normalize_http_timeout
from .client_factory import provider_to_openai_config as provider_to_openai_config
+50
View File
@@ -1,9 +1,11 @@
from __future__ import annotations
import math
from typing import TYPE_CHECKING
from plyngent.config.models import (
DeepseekProvider,
HttpTimeoutConfig,
OpenAICompatibleProvider,
OpenAIProvider,
Provider,
@@ -11,6 +13,11 @@ from plyngent.config.models import (
from plyngent.lmproto.deepseek import DeepseekOpenAIClient
from plyngent.lmproto.openai import OpenAIClient
from plyngent.lmproto.openai_compatible import OpenAICompatibleClient, OpenAIConfig
from plyngent.lmproto.openai_compatible.config import (
DEFAULT_HTTP_CONNECT_TIMEOUT,
DEFAULT_HTTP_READ_TIMEOUT,
HttpTimeout,
)
if TYPE_CHECKING:
from collections.abc import Mapping
@@ -29,6 +36,44 @@ class ProviderNotSupportedError(NotImplementedError):
"""Raised when a provider preset cannot be turned into a runtime client."""
class InvalidHttpTimeoutError(ValueError):
"""Raised when a provider ``timeout`` value is not usable for HTTP clients."""
def normalize_http_timeout(timeout: float | HttpTimeoutConfig | None) -> HttpTimeout:
"""Normalize TOML/provider timeout into a niquests session timeout.
* ``None`` → product defaults ``(connect=10, read=600)``
* ``float`` / ``int`` → single timeout (niquests applies to the request)
* :class:`HttpTimeoutConfig` → ``(connect, read)`` with defaults for omitted fields
All finite values must be ``> 0``.
"""
if timeout is None:
return (DEFAULT_HTTP_CONNECT_TIMEOUT, DEFAULT_HTTP_READ_TIMEOUT)
if isinstance(timeout, bool):
# ``bool`` is an ``int`` subclass; reject before the numeric branch.
msg = "timeout must be a positive number or { connect, read }, not a boolean"
raise InvalidHttpTimeoutError(msg)
if isinstance(timeout, int | float):
value = float(timeout)
if not math.isfinite(value) or value <= 0:
msg = f"timeout must be a finite number > 0, got {timeout!r}"
raise InvalidHttpTimeoutError(msg)
return value
# Remaining union member: HttpTimeoutConfig
connect = DEFAULT_HTTP_CONNECT_TIMEOUT if timeout.connect is None else float(timeout.connect)
read = DEFAULT_HTTP_READ_TIMEOUT if timeout.read is None else float(timeout.read)
if not math.isfinite(connect) or connect <= 0:
msg = f"timeout.connect must be a finite number > 0, got {timeout.connect!r}"
raise InvalidHttpTimeoutError(msg)
if not math.isfinite(read) or read <= 0:
msg = f"timeout.read must be a finite number > 0, got {timeout.read!r}"
raise InvalidHttpTimeoutError(msg)
return (connect, read)
def provider_to_openai_config(provider: OpenAIProvider | OpenAICompatibleProvider | DeepseekProvider) -> OpenAIConfig:
"""Map a provider config entry to :class:`OpenAIConfig`."""
if isinstance(provider, OpenAIProvider):
@@ -40,9 +85,14 @@ def provider_to_openai_config(provider: OpenAIProvider | OpenAICompatibleProvide
if not base_url:
msg = "openai-compatible provider requires a non-empty url"
raise ProviderNotSupportedError(msg)
try:
http_timeout = normalize_http_timeout(provider.timeout)
except InvalidHttpTimeoutError as exc:
raise ProviderNotSupportedError(str(exc)) from exc
return OpenAIConfig(
access_key_or_token=provider.access_key_or_token,
base_url=base_url,
timeout=http_timeout,
)
+18 -4
View File
@@ -1,7 +1,20 @@
from .catalog import ToolCatalog as ToolCatalog
from .catalog import ToolSource as ToolSource
from .catalog import catalog_scope as catalog_scope
from .catalog import default_tool_definitions as default_tool_definitions
from .catalog import get_catalog as get_catalog
from .catalog import register_builtin_tools as register_builtin_tools
from .chat import CHAT_TOOLS as CHAT_TOOLS
from .chat import ask_user as ask_user
from .chat import choose_user as choose_user
from .chat import form_user as form_user
from .context import InstanceState as InstanceState
from .context import SessionState as SessionState
from .context import bind_instance as bind_instance
from .context import bind_session as bind_session
from .context import bind_tool_context as bind_tool_context
from .context import require_instance as require_instance
from .context import require_session as require_session
from .danger import classify_danger as classify_danger
from .file import FILE_TOOLS as FILE_TOOLS
from .file import copy_path as copy_path
@@ -15,6 +28,7 @@ from .file import move_path as move_path
from .file import read_file as read_file
from .file import tree as tree
from .file import write_file as write_file
from .plugins import load_plugin_tools as load_plugin_tools
from .process import PROCESS_TOOLS as PROCESS_TOOLS
from .process import ask_into_pty as ask_into_pty
from .process import close_pty as close_pty
@@ -27,8 +41,6 @@ from .process import write_pty_keys as write_pty_keys
from .temp_workspace import cleanup_temporary_workspaces as cleanup_temporary_workspaces
from .temp_workspace import new_temporary_workspace as new_temporary_workspace
from .todo import TODO_TOOLS as TODO_TOOLS
from .todo import get_todo_stack as get_todo_stack
from .todo import set_todo_stack as set_todo_stack
from .todo import todo_clear as todo_clear
from .todo import todo_list as todo_list
from .todo import todo_pop as todo_pop
@@ -40,11 +52,15 @@ from .vcs import vcs_diff as vcs_diff
from .vcs import vcs_kind as vcs_kind
from .vcs import vcs_log as vcs_log
from .vcs import vcs_status as vcs_status
from .view import MemoryViewStore as MemoryViewStore
from .view import PersistentDataView as PersistentDataView
from .workspace import (
DEFAULT_COMMAND_DENYLIST as DEFAULT_COMMAND_DENYLIST,
)
from .workspace import DEFAULT_POLICY_CONFIRM_TIMEOUT_SECONDS as DEFAULT_POLICY_CONFIRM_TIMEOUT_SECONDS
from .workspace import WorkspaceError as WorkspaceError
from .workspace import WorkspacePolicy as WorkspacePolicy
from .workspace import active_workspace_policy as active_workspace_policy
from .workspace import add_workspace_allowlist as add_workspace_allowlist
from .workspace import check_command_allowed as check_command_allowed
from .workspace import clear_policy_allowed_commands as clear_policy_allowed_commands
@@ -64,5 +80,3 @@ from .workspace import set_path_denylist as set_path_denylist
from .workspace import set_policy_confirm_hook as set_policy_confirm_hook
from .workspace import set_policy_confirm_timeout as set_policy_confirm_timeout
from .workspace import set_workspace_root as set_workspace_root
DEFAULT_TOOLS = [*FILE_TOOLS, *PROCESS_TOOLS, *VCS_TOOLS, *CHAT_TOOLS, *TODO_TOOLS]
+268
View File
@@ -0,0 +1,268 @@
"""Process-wide tool catalog: define+register vs select for model visibility."""
from __future__ import annotations
from contextlib import contextmanager
from contextvars import ContextVar, Token
from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal, override
from plyngent.agent.tools import ToolDefinition, ToolTag
if TYPE_CHECKING:
from collections.abc import Container, Generator
type ToolSourceKind = Literal["builtin", "plugin"]
type ToolSurface = Literal["local", "public"]
@dataclass(frozen=True, slots=True)
class ToolSource:
"""Where a catalog entry came from (not a :class:`~plyngent.agent.tools.ToolTag`)."""
kind: ToolSourceKind
plugin_id: str | None = None
package: str | None = None
module: str | None = None
@override
def __str__(self) -> str:
if self.kind == "builtin":
return "builtin"
plugin = self.plugin_id or "?"
return f"plugin:{plugin}"
@dataclass(frozen=True, slots=True)
class RegisteredTool:
definition: ToolDefinition
source: ToolSource
_BUILTIN_SOURCE = ToolSource(kind="builtin")
_registration_source: ContextVar[ToolSource] = ContextVar(
"plyngent_tool_registration_source",
default=_BUILTIN_SOURCE,
)
def get_registration_source() -> ToolSource:
return _registration_source.get()
@contextmanager
def registration_source(source: ToolSource) -> Generator[None]:
"""Set :func:`get_registration_source` for the duration of a load block."""
token = _registration_source.set(source)
try:
yield
finally:
_registration_source.reset(token)
class ToolCatalog:
"""Name → registered tool map for one process (or test-scoped snapshot)."""
_by_name: dict[str, RegisteredTool]
def __init__(self) -> None:
self._by_name = {}
def register(self, definition: ToolDefinition, *, source: ToolSource | None = None) -> None:
"""Add *definition*; never shadow an existing name (builtin or plugin)."""
resolved = source if source is not None else get_registration_source()
existing = self._by_name.get(definition.name)
if existing is not None:
msg = (
f"tool name collision: {definition.name!r} already registered "
f"from {existing.source} (refusing {resolved})"
)
raise ValueError(msg)
self._by_name[definition.name] = RegisteredTool(definition=definition, source=resolved)
def get(self, name: str) -> RegisteredTool | None:
return self._by_name.get(name)
def names(self) -> list[str]:
return sorted(self._by_name)
@staticmethod
def _matches( # noqa: PLR0911 — filter predicates are clearer as early exits
entry: RegisteredTool,
*,
surface: ToolSurface,
sources: Container[ToolSourceKind] | None,
plugin_ids: Container[str] | None,
require_tags: ToolTag | None,
exclude_tags: ToolTag | None,
include_names: set[str] | None,
exclude_names: set[str] | None,
) -> bool:
name = entry.definition.name
if include_names is not None and name not in include_names:
return False
if exclude_names is not None and name in exclude_names:
return False
if sources is not None and entry.source.kind not in sources:
return False
if plugin_ids is not None:
plugin_id = entry.source.plugin_id
if entry.source.kind != "plugin" or plugin_id is None or plugin_id not in plugin_ids:
return False
tags = entry.definition.tags
if surface == "public" and not (tags & ToolTag.PUBLIC):
return False
if surface == "local" and not (tags & (ToolTag.LOCAL | ToolTag.PUBLIC)):
return False
if require_tags is not None and (tags & require_tags) != require_tags:
return False
return not (exclude_tags is not None and tags & exclude_tags)
def select(
self,
*,
surface: ToolSurface = "local",
sources: Container[ToolSourceKind] | None = None,
plugin_ids: Container[str] | None = None,
require_tags: ToolTag | None = None,
exclude_tags: ToolTag | None = None,
include_names: set[str] | None = None,
exclude_names: set[str] | None = None,
) -> list[ToolDefinition]:
"""Return definitions allowed for this host surface / filters.
*surface* ``local`` keeps tools with LOCAL and/or PUBLIC.
*surface* ``public`` keeps only tools with PUBLIC.
"""
return [
self._by_name[name].definition
for name in sorted(self._by_name)
if self._matches(
self._by_name[name],
surface=surface,
sources=sources,
plugin_ids=plugin_ids,
require_tags=require_tags,
exclude_tags=exclude_tags,
include_names=include_names,
exclude_names=exclude_names,
)
]
def clear(self) -> None:
self._by_name.clear()
def snapshot(self) -> dict[str, RegisteredTool]:
return dict(self._by_name)
def restore(self, snapshot: dict[str, RegisteredTool]) -> None:
self._by_name = dict(snapshot)
_catalog = ToolCatalog()
_catalog_override: ContextVar[ToolCatalog | None] = ContextVar(
"plyngent_tool_catalog_override",
default=None,
)
def get_catalog() -> ToolCatalog:
override = _catalog_override.get()
if override is not None:
return override
return _catalog
def register_tool(definition: ToolDefinition, *, source: ToolSource | None = None) -> None:
get_catalog().register(definition, source=source)
@contextmanager
def catalog_scope(*, empty: bool = True) -> Generator[ToolCatalog]:
"""Snapshot the process catalog; optionally start empty for unit tests.
Restores the previous catalog contents on exit. Nested scopes stack via
contextvars when *empty* installs a fresh catalog override.
"""
if empty:
scoped = ToolCatalog()
token: Token[ToolCatalog | None] = _catalog_override.set(scoped)
try:
yield scoped
finally:
_catalog_override.reset(token)
return
catalog = get_catalog()
snap = catalog.snapshot()
try:
yield catalog
finally:
catalog.restore(snap)
_builtins_loaded = False
def _ensure_builtin_definitions_registered(catalog: ToolCatalog) -> None:
"""Register builtin ToolDefinitions if the active catalog is missing them.
Importing tool modules only registers into the catalog that was active at
import time (usually the process catalog). Test ``catalog_scope(empty=True)``
overrides must still see builtins for ``default_tool_definitions``.
"""
from plyngent.tools.chat import CHAT_TOOLS
from plyngent.tools.file import FILE_TOOLS
from plyngent.tools.process import PROCESS_TOOLS
from plyngent.tools.todo import TODO_TOOLS
from plyngent.tools.vcs import VCS_TOOLS
for definition in (*FILE_TOOLS, *PROCESS_TOOLS, *VCS_TOOLS, *CHAT_TOOLS, *TODO_TOOLS):
if catalog.get(definition.name) is None:
catalog.register(definition, source=_BUILTIN_SOURCE)
def register_builtin_tools(*, force: bool = False) -> ToolCatalog:
"""Import builtin tool modules so ``@tool`` registrations run.
Idempotent unless *force* is true (re-import path still no-ops if names
already exist — callers should use :func:`catalog_scope` for isolation).
Always ensures the **active** catalog (including overrides) has builtins.
"""
global _builtins_loaded # noqa: PLW0603 — process load flag
if not _builtins_loaded or force:
# Import side effects: each module's @tool(...) registers into the catalog.
# Group packages pull their leaf modules (FILE_TOOLS etc. still exported).
import plyngent.tools.chat as _chat_tools
import plyngent.tools.file as _file_tools
import plyngent.tools.process as _process_tools
import plyngent.tools.temp_workspace as _temp_workspace_tools
import plyngent.tools.todo as _todo_tools
import plyngent.tools.vcs as _vcs_tools
_ = (
_chat_tools,
_file_tools,
_process_tools,
_temp_workspace_tools,
_todo_tools,
_vcs_tools,
)
_builtins_loaded = True
catalog = get_catalog()
_ensure_builtin_definitions_registered(catalog)
return catalog
def default_tool_definitions(
*,
surface: ToolSurface = "local",
sources: Container[ToolSourceKind] | None = None,
) -> list[ToolDefinition]:
"""Load builtins (if needed) and select for the given surface."""
catalog = register_builtin_tools()
kind_filter: Container[ToolSourceKind] | None = sources
if kind_filter is None:
kind_filter = ("builtin",)
return catalog.select(surface=surface, sources=kind_filter)
+2 -2
View File
@@ -1,10 +1,10 @@
from __future__ import annotations
from plyngent.agent import tool
from plyngent.agent import ToolTag, tool
from plyngent.prompting import NonInteractiveError, ask_async
@tool(name="ask_user_line")
@tool(name="ask_user_line", tags=ToolTag.LOCAL)
async def ask_user(question: str, default: str = "") -> str:
"""Ask the human a free-form one-line question and return their answer.
+2 -2
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import json
from typing import cast
from plyngent.agent import tool
from plyngent.agent import ToolTag, tool
from plyngent.prompting import ChoiceOption, NonInteractiveError, choose_async
@@ -46,7 +46,7 @@ def parse_options(raw: str) -> list[ChoiceOption]:
return out
@tool(name="ask_user_choice")
@tool(name="ask_user_choice", tags=ToolTag.LOCAL)
async def choose_user(
question: str,
options: str,
+2 -2
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import json
from typing import cast
from plyngent.agent import tool
from plyngent.agent import ToolTag, tool
from plyngent.prompting import FormField, NonInteractiveError, form_async
from plyngent.tools.chat.choose import parse_options
@@ -54,7 +54,7 @@ def parse_fields(raw: str) -> list[FormField]:
return out
@tool(name="ask_user_form")
@tool(name="ask_user_form", tags=ToolTag.LOCAL)
async def form_user(title: str, fields: str, *, confirm_submit: bool = True) -> str:
"""Run a multi-step form with the human; returns JSON object of answers.
+148
View File
@@ -0,0 +1,148 @@
"""Instance / session tool context (contextvars) for state affinity tags."""
from __future__ import annotations
from contextlib import contextmanager
from contextvars import ContextVar, Token
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
from plyngent.tools.view import PersistentDataView, session_data_view
if TYPE_CHECKING:
from collections.abc import Callable, Generator
from pathlib import Path
from plyngent.agent.todo_stack import TodoStack
from plyngent.tools.workspace import WorkspacePolicy
def _default_instance_data() -> PersistentDataView[Any]:
return session_data_view()
def _default_session_data() -> PersistentDataView[Any]:
return session_data_view()
def _default_workspace_policy() -> WorkspacePolicy:
from plyngent.tools.workspace import WorkspacePolicy
return WorkspacePolicy()
@dataclass
class InstanceState:
"""Process / agent-host scoped state for INSTANCE_STATE tools."""
# Primary workspace root facet (mirrors workspace.policy.root when set).
workspace_root: Path | None = None
# Path/command policy bag for this host (preferred over process globals).
workspace: WorkspacePolicy = field(default_factory=_default_workspace_policy)
data: PersistentDataView[Any] = field(default_factory=_default_instance_data)
# Ephemeral process maps (PTY etc.) may hang here later.
extras: dict[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
if self.workspace_root is not None and self.workspace.root is None:
self.workspace.root = self.workspace_root
elif self.workspace.root is not None and self.workspace_root is None:
self.workspace_root = self.workspace.root
@property
def pty(self) -> Any:
"""PTY manager facade for this process (class-level sessions today)."""
from plyngent.tools.process.pty_session import PtyManager
return PtyManager
async def shutdown(self) -> None:
"""Best-effort cleanup hooks (PTY close, temp workspaces)."""
from plyngent.tools.temp_workspace import cleanup_temporary_workspaces
self.pty.close_all()
_ = cleanup_temporary_workspaces()
@dataclass
class SessionState:
"""One chat session for SESSION_STATE tools."""
session_id: int | str | None = None
data: PersistentDataView[Any] = field(default_factory=_default_session_data)
# Live domain object (also under data["todo"] when views bind).
todo: TodoStack | None = None
# Host hook after todo tools mutate (e.g. CLI memory persist).
on_todo_change: Callable[[], None] | None = None
# Soft-confirm grants live map: tool_name → True (Phase 1 key is tool name).
# Durable copy lives under data["grants"] (see plyngent.tools.grants).
grants: dict[str, bool] = field(default_factory=dict)
extras: dict[str, Any] = field(default_factory=dict)
def has_grant(self, tool_name: str) -> bool:
return bool(self.grants.get(tool_name))
def add_grant(self, tool_name: str) -> None:
"""Update the live map only; prefer :func:`plyngent.tools.grants.add_grant` to persist."""
self.grants[tool_name] = True
def clear_grants(self) -> None:
"""Clear the live map; durable view is updated separately when needed."""
self.grants.clear()
_instance: ContextVar[InstanceState | None] = ContextVar("plyngent_instance_state", default=None)
_session: ContextVar[SessionState | None] = ContextVar("plyngent_session_state", default=None)
def get_instance() -> InstanceState | None:
return _instance.get()
def get_session() -> SessionState | None:
return _session.get()
def require_instance() -> InstanceState:
state = _instance.get()
if state is None:
msg = "instance state is not bound; host must set InstanceState around tool execution"
raise RuntimeError(msg)
return state
def require_session() -> SessionState:
state = _session.get()
if state is None:
msg = "session state is not bound; host must set SessionState around tool execution"
raise RuntimeError(msg)
return state
@contextmanager
def bind_instance(state: InstanceState | None) -> Generator[InstanceState | None]:
token: Token[InstanceState | None] = _instance.set(state)
try:
yield state
finally:
_instance.reset(token)
@contextmanager
def bind_session(state: SessionState | None) -> Generator[SessionState | None]:
token: Token[SessionState | None] = _session.set(state)
try:
yield state
finally:
_session.reset(token)
@contextmanager
def bind_tool_context(
*,
instance: InstanceState | None = None,
session: SessionState | None = None,
) -> Generator[tuple[InstanceState | None, SessionState | None]]:
"""Bind instance and session contextvars for a tool batch / test."""
with bind_instance(instance), bind_session(session):
yield instance, session
+3 -3
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from plyngent.agent import tool
from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import resolve_path
if TYPE_CHECKING:
@@ -76,8 +76,8 @@ def _replace_range(
return f"replaced lines {start_line}-{end} ({removed} lines) with {len(replacement)} lines in {path}"
@tool
def edit_lineno(path: str, start_line: int, end_line: int, new_content: str) -> str:
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
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.
``new_content`` may be multi-line. Use an empty string to delete the range.
+3 -3
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from plyngent.agent import tool
from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import resolve_path
@@ -32,8 +32,8 @@ def _success_message(path: str, *, replaced: int, found: int) -> str:
)
@tool
def edit_replace(path: str, old_string: str, new_string: str, max_replaces: int = 1) -> str:
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
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.
Replaces left-to-right, non-overlapping. Default ``max_replaces=1`` (first match
+7 -7
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import shutil
from pathlib import Path
from plyngent.agent import tool
from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import WorkspaceError, get_workspace_root, resolve_path
@@ -77,8 +77,8 @@ def _copy_or_move_validated(
return f"error: {action} failed: {exc}"
@tool
def copy_path(src: str, dst: str, *, overwrite: bool = False) -> str:
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
async def copy_path(src: str, dst: str, *, overwrite: bool = False) -> str:
"""Copy a file or directory under the workspace (``shutil.copy2`` / ``copytree``).
``dst`` parent directories are created as needed. If ``dst`` exists, set
@@ -93,8 +93,8 @@ 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")
@tool
def move_path(src: str, dst: str, *, overwrite: bool = False) -> str:
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
async def move_path(src: str, dst: str, *, overwrite: bool = False) -> str:
"""Move/rename a file or directory under the workspace (``shutil.move``)."""
pair = _resolve_pair(src, dst)
if isinstance(pair, str):
@@ -129,8 +129,8 @@ def _delete_target(target: Path, path: str, *, recursive: bool) -> str:
return f"error: unsupported path type: {path}"
@tool
def delete_path(path: str, *, recursive: bool = False) -> str:
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
async def delete_path(path: str, *, recursive: bool = False) -> str:
"""Delete a file or directory under the workspace.
Files and empty directories are removed always. Non-empty directories require
+3 -3
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from plyngent.agent import tool
from plyngent.agent import ToolTag, tool
from plyngent.tools.file.tree import VCS_DIR_NAMES
from plyngent.tools.workspace import WorkspaceError, get_workspace_root, resolve_path
@@ -65,8 +65,8 @@ def _resolve_glob_base(path: str) -> tuple[Path, Path] | str:
return root, base
@tool
def glob_paths(
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
async def glob_paths(
pattern: str,
path: str = ".",
*,
+3 -3
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import re
from typing import TYPE_CHECKING
from plyngent.agent import tool
from plyngent.agent import ToolTag, tool
from plyngent.tools.file.tree import VCS_DIR_NAMES
from plyngent.tools.workspace import WorkspaceError, get_workspace_root, resolve_path
@@ -127,8 +127,8 @@ def _compile_pattern(pattern: str, *, case_insensitive: bool) -> re.Pattern[str]
return f"error: invalid regex: {exc}"
@tool
def grep_files(
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
async def grep_files(
pattern: str,
path: str = ".",
*,
+3 -3
View File
@@ -1,11 +1,11 @@
from __future__ import annotations
from plyngent.agent import tool
from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import resolve_path
@tool
def listdir(path: str = ".") -> str:
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
async def listdir(path: str = ".") -> str:
"""List entries in a directory under the workspace (name and type)."""
target = resolve_path(path)
if not target.is_dir():
+3 -3
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from plyngent.agent import tool
from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import resolve_path
_LINENO_WIDTH = 6
@@ -17,8 +17,8 @@ def _format_with_lineno(lines: list[str], *, start_lineno: int) -> str:
return "".join(out)
@tool
def read_file(
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
async def read_file(
path: str,
*,
offset: int = 0,
+3 -3
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from plyngent.agent import tool
from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import WorkspaceError, get_path_denylist, resolve_path
if TYPE_CHECKING:
@@ -151,8 +151,8 @@ def _resolve_skip_basenames(skip_dirs: Sequence[str] | None) -> frozenset[str]:
return frozenset(name for name in skip_dirs if name)
@tool
def tree(
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
async def tree(
path: str = ".",
*,
max_depth: int = DEFAULT_MAX_DEPTH,
+3 -3
View File
@@ -1,11 +1,11 @@
from __future__ import annotations
from plyngent.agent import tool
from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import resolve_path
@tool
def write_file(path: str, content: str) -> str:
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
async def write_file(path: str, content: str) -> str:
"""Write text content to a file under the workspace (creates parents)."""
target = resolve_path(path)
target.parent.mkdir(parents=True, exist_ok=True)
+74
View File
@@ -0,0 +1,74 @@
"""Soft-confirm trust grants (session-scoped).
Live map: ``SessionState.grants`` (sync reads for the confirm gate).
Durable tree: ``session.data["grants"]`` (tool name → bool) via
:class:`~plyngent.tools.view.PersistentDataView`.
Hosts that resume a session document should call :func:`hydrate_grants`
after binding :class:`~plyngent.tools.context.SessionState`. Soft-confirm
approval calls :func:`add_grant`, which updates the live map and commits
the view.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from plyngent.tools.context import SessionState
def grant_key(tool_name: str) -> str:
"""Phase 1 grant key is the tool name (session isolation via SessionState)."""
return tool_name
def has_grant(session: SessionState, tool_name: str) -> bool:
"""Return whether *tool_name* is granted in the live session map."""
return session.has_grant(grant_key(tool_name))
async def add_grant(session: SessionState, tool_name: str) -> None:
"""Grant *tool_name* and commit the grants map under ``session.data``."""
session.add_grant(grant_key(tool_name))
await persist_grants(session)
def clear_grants(session: SessionState) -> None:
"""Clear the live grant map (sync; e.g. CLI YOLO off / ``/new``).
Does not open a view transaction (callers may be outside an event loop).
Use :func:`persist_grants` when the durable tree should also clear.
"""
session.clear_grants()
async def clear_grants_and_persist(session: SessionState) -> None:
"""Clear live grants and write an empty map to ``session.data``."""
session.clear_grants()
await persist_grants(session)
async def persist_grants(session: SessionState) -> None:
"""Write the live grants map to ``session.data["grants"]``."""
async with session.data as data:
data["grants"].store({key: bool(value) for key, value in session.grants.items()})
async def hydrate_grants(session: SessionState) -> None:
"""Load durable grants into the live map (replace)."""
from typing import cast
raw: object | None = None
async with session.data as data:
try:
raw = data["grants"].load()
except RuntimeError:
raw = None
session.grants.clear()
if not isinstance(raw, dict):
return
blob = cast("dict[object, object]", raw)
for key_obj, value in blob.items():
if value:
session.grants[str(key_obj)] = True
+171
View File
@@ -0,0 +1,171 @@
"""Load third-party tools via package entry points (allowlisted)."""
from __future__ import annotations
import importlib.metadata
from dataclasses import dataclass
from typing import TYPE_CHECKING
from plyngent.tools.catalog import ToolSource, get_catalog, registration_source
if TYPE_CHECKING:
from collections.abc import Iterable, Sequence
ENTRY_POINT_GROUP = "plyngent.tools"
def _iter_entry_points() -> list[importlib.metadata.EntryPoint]:
try:
selected = importlib.metadata.entry_points(group=ENTRY_POINT_GROUP)
except TypeError:
# Older importlib.metadata API (unlikely on 3.14, kept for clarity).
selected = importlib.metadata.entry_points().select(group=ENTRY_POINT_GROUP)
return list(selected)
@dataclass(frozen=True, slots=True)
class DiscoveredPlugin:
"""One installed ``plyngent.tools`` entry point (not necessarily enabled)."""
id: str
value: str
package: str | None = None
version: str | None = None
@property
def module(self) -> str:
return self.value.split(":", 1)[0] if ":" in self.value else self.value
@dataclass(frozen=True, slots=True)
class PluginStatus:
"""Discovery + allowlist status for CLI listing."""
plugin: DiscoveredPlugin
enabled: bool
disabled: bool
@property
def will_load(self) -> bool:
"""True if current config would load this plugin."""
return self.enabled and not self.disabled
def list_discovered_plugins() -> list[DiscoveredPlugin]:
"""Return installed entry points for :data:`ENTRY_POINT_GROUP`, sorted by id."""
found: list[DiscoveredPlugin] = []
for entry in _iter_entry_points():
dist = entry.dist
package = dist.name if dist is not None else None
version = dist.version if dist is not None else None
found.append(
DiscoveredPlugin(
id=entry.name,
value=entry.value,
package=package,
version=version,
)
)
return sorted(found, key=lambda p: p.id)
def plugin_would_load(
plugin_id: str,
*,
enable: Sequence[str] | None,
disable: Iterable[str] | None = None,
) -> bool:
"""Whether *plugin_id* would be loaded under the given allowlist."""
disabled = {name.strip() for name in (disable or ()) if name.strip()}
if plugin_id in disabled:
return False
allow = resolve_plugin_allowlist(enable)
if allow is None:
return True
return plugin_id in allow
def list_plugin_statuses(
*,
enable: Sequence[str] | None,
disable: Iterable[str] | None = None,
) -> list[PluginStatus]:
"""Discovered plugins with enable/disable/will-load flags from config lists."""
disabled = {name.strip() for name in (disable or ()) if name.strip()}
allow = resolve_plugin_allowlist(enable)
rows: list[PluginStatus] = []
for plugin in list_discovered_plugins():
is_disabled = plugin.id in disabled
is_enabled = True if allow is None else plugin.id in allow
rows.append(
PluginStatus(
plugin=plugin,
enabled=is_enabled and not is_disabled,
disabled=is_disabled,
)
)
return rows
def resolve_plugin_allowlist(plugins: Sequence[str] | None) -> set[str] | None:
"""Return the set of plugin ids to load, or ``None`` meaning all.
- ``None`` / empty → load **no** plugins (default safe).
- ``["*"]`` → load every discovered entry point.
- otherwise → only listed entry-point names.
"""
if not plugins:
return set()
if any(item.strip() == "*" for item in plugins):
return None
return {item.strip() for item in plugins if item.strip()}
def load_plugin_tools(
plugins: Sequence[str] | None = None,
*,
disable: Iterable[str] | None = None,
) -> list[str]:
"""Import allowlisted ``plyngent.tools`` entry points under plugin sources.
Each entry point's ``load()`` may call ``@tool``; registrations use
``ToolSource(kind="plugin", plugin_id=ep.name)``. Builtin name collisions
still fail at catalog.register.
Returns the list of plugin ids that were loaded successfully.
"""
allow = resolve_plugin_allowlist(plugins)
disabled = {name.strip() for name in (disable or ()) if name.strip()}
loaded: list[str] = []
catalog = get_catalog()
for entry in _iter_entry_points():
name = entry.name
if name in disabled:
continue
if allow is not None and name not in allow:
continue
dist = entry.dist
package = dist.name if dist is not None else None
module = entry.value.split(":", 1)[0] if ":" in entry.value else entry.value
source = ToolSource(
kind="plugin",
plugin_id=name,
package=package,
module=module,
)
with registration_source(source):
# load() may return a callable or module; either is fine as long as
# @tool side effects ran. Call if callable.
obj = entry.load()
if callable(obj):
_ = obj()
# Presence of any tool from this plugin is enough for "loaded".
if any(
entry_tool.source.kind == "plugin" and entry_tool.source.plugin_id == name
for entry_tool in catalog.snapshot().values()
):
loaded.append(name)
else:
# Entry point ran without registering tools — still count as loaded host-side.
loaded.append(name)
return loaded
+4 -4
View File
@@ -2,11 +2,11 @@ from __future__ import annotations
from typing import Literal
from plyngent.agent import tool
from plyngent.agent import ToolTag, tool
from plyngent.prompting import NonInteractiveError, ask_async, ask_secret_async
from plyngent.tools.workspace import WorkspaceError
from .pty_session import PtyManager
from .pty_session import active_pty_manager
from .write_pty import write_pty_payload
type _PromptResult = tuple[Literal["ok"], str] | tuple[Literal["err"], str]
@@ -38,7 +38,7 @@ async def _prompt_answer(label: str, *, secret: bool) -> _PromptResult:
return ("ok", answer)
@tool
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
async def ask_into_pty(
session_id: int,
message: str,
@@ -59,7 +59,7 @@ async def ask_into_pty(
label = message.strip() or ("Secret" if secret else "Input")
try:
# Validate session before blocking the human.
_ = PtyManager.refresh(session_id)
_ = active_pty_manager().refresh(session_id)
except WorkspaceError as exc:
return f"error: {exc}"
+4 -4
View File
@@ -2,16 +2,16 @@ from __future__ import annotations
import asyncio
from plyngent.agent import tool
from plyngent.agent import ToolTag, tool
from .pty_session import PtyManager, format_close_result
from .pty_session import active_pty_manager, format_close_result
@tool
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
async def close_pty(session_id: int) -> str:
"""Close a PTY session (SIGTERM, then SIGKILL after a short grace period).
Runs off the event loop so grace sleeps do not freeze the chat UI.
"""
result = await asyncio.to_thread(PtyManager.close, session_id)
result = await asyncio.to_thread(active_pty_manager().close, session_id)
return format_close_result(result)
+5 -5
View File
@@ -2,14 +2,14 @@ from __future__ import annotations
import shlex
from plyngent.agent import tool
from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import WorkspaceError
from .pty_session import PtyManager
from .pty_session import active_pty_manager
@tool
def open_pty(command: list[str], *, cwd: str = ".") -> str:
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
async def open_pty(command: list[str], *, cwd: str = ".") -> str:
"""Open a PTY session running ``command`` (argv) under the workspace.
POSIX uses openpty/fork; Windows uses ConPTY (pywinpty). Returns structured
@@ -17,7 +17,7 @@ def open_pty(command: list[str], *, cwd: str = ".") -> str:
data (marker) and a non-zero exit_code.
"""
try:
session = PtyManager.open(command, cwd=cwd)
session = active_pty_manager().open(command, cwd=cwd)
except WorkspaceError as exc:
return f"error: {exc}"
except OSError as exc:
+15 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import time
from dataclasses import dataclass, field
from threading import Lock
from typing import TYPE_CHECKING, ClassVar
from typing import TYPE_CHECKING, ClassVar, cast
from plyngent.tools.process.pty_backend import PtyHandle, pty_available, spawn_pty
from plyngent.tools.process.pty_terminal import sanitize_pty_output_for_tool
@@ -393,6 +393,20 @@ class PtyManager:
_ = cls.close(session_id)
def active_pty_manager() -> type[PtyManager]:
"""Return the PTY manager for the bound instance, else the process default.
Today :class:`PtyManager` is process-global; the instance facet is a stable
host API so tools do not hard-code the class forever.
"""
from plyngent.tools.context import get_instance
instance = get_instance()
if instance is not None:
return cast("type[PtyManager]", instance.pty)
return PtyManager
def format_read_result(result: PtyReadResult) -> str:
exit_disp = "" if result.exit_code is None else str(result.exit_code)
lines = [
+4 -4
View File
@@ -2,16 +2,16 @@ from __future__ import annotations
import asyncio
from plyngent.agent import tool
from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import WorkspaceError
from .pty_session import DEFAULT_PTY_READ_BYTES, PtyManager, format_read_result
from .pty_session import DEFAULT_PTY_READ_BYTES, active_pty_manager, format_read_result
# Cap per-call wait so a misbehaving tool arg cannot stall forever even off-loop.
_MAX_READ_TIMEOUT = 120.0
@tool
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
async def read_pty(
session_id: int,
*,
@@ -34,7 +34,7 @@ async def read_pty(
wait = min(max(0.0, timeout), _MAX_READ_TIMEOUT)
try:
result = await asyncio.to_thread(
PtyManager.read,
active_pty_manager().read,
session_id,
max_bytes=max_bytes,
timeout=wait,
+2 -2
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from plyngent.agent import tool
from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import WorkspaceError
from .command_exec import (
@@ -10,7 +10,7 @@ from .command_exec import (
)
@tool
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
async def run_command(
command: list[str],
*,
@@ -4,7 +4,7 @@ import json
import shlex
from typing import Any, cast
from plyngent.agent import tool
from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import WorkspaceError
from .command_exec import (
@@ -154,7 +154,7 @@ async def _run_batch_steps(
return results, stopped_early
@tool
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
async def run_command_batch(
commands: list[dict[str, object]] | str,
*,
+7 -6
View File
@@ -1,15 +1,16 @@
from __future__ import annotations
from plyngent.agent import tool
from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import WorkspaceError
from .pty_session import PtyManager
from .pty_session import active_pty_manager
def write_pty_payload(session_id: int, raw: str) -> str:
"""Write raw bytes (as str) to the PTY and format the tool status string."""
PtyManager.write(session_id, raw)
session = PtyManager.refresh(session_id)
manager = active_pty_manager()
manager.write(session_id, raw)
session = manager.refresh(session_id)
exit_disp = "" if session.exit_code is None else str(session.exit_code)
return "\n".join(
[
@@ -21,8 +22,8 @@ def write_pty_payload(session_id: int, raw: str) -> str:
)
@tool
def write_pty(session_id: int, data: str) -> str:
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
async def write_pty(session_id: int, data: str) -> str:
"""Write **literal** text to a PTY session. Does not append a newline.
``data`` is sent unchanged (no ``ctrl+x`` / ``\\\\xHH`` expansion). For
+3 -3
View File
@@ -1,14 +1,14 @@
from __future__ import annotations
from plyngent.agent import tool
from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import WorkspaceError
from .pty_terminal import decode_write_data
from .write_pty import write_pty_payload
@tool
def write_pty_keys(session_id: int, data: str) -> str:
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
async def write_pty_keys(session_id: int, data: str) -> str:
"""Write to a PTY after expanding key escapes (never for normal typing).
Use this only for control sequences. Prefer :func:`write_pty` for plain text
+23 -13
View File
@@ -5,7 +5,7 @@ import shutil
import tempfile
from pathlib import Path
from plyngent.agent import tool
from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import (
MAX_TEMPORARY_WORKSPACES,
WorkspaceError,
@@ -47,8 +47,12 @@ def cleanup_temporary_workspaces() -> int:
"""Remove directories created by :func:`new_temporary_workspace` (chat exit).
Only deletes paths still under the system temp dir. Returns the number of
directories removed.
directories removed. No-op when no instance is bound.
"""
from plyngent.tools.context import get_instance
if get_instance() is None:
return 0
removed = 0
for path in pop_owned_temporary_workspaces():
if not _is_under_system_temp(path):
@@ -61,17 +65,7 @@ def cleanup_temporary_workspaces() -> int:
return removed
@tool
def new_temporary_workspace(prefix: str = "ws") -> str:
"""Create a scratch directory under the system temp dir and allow tool paths in it.
The project workspace is unchanged: relative paths still resolve there.
Use the returned **absolute** path for file/process tools. Temporary
workspaces are deleted when this chat process exits (not on each turn).
Cross-platform (uses ``tempfile``; typically ``/tmp`` on POSIX, ``%TEMP%``
on Windows). At most 16 concurrent temporary workspaces per process.
"""
def _create_temporary_workspace(prefix: str) -> str:
if len(list_workspace_allowlist()) >= MAX_TEMPORARY_WORKSPACES:
return f"error: too many temporary workspaces (max {MAX_TEMPORARY_WORKSPACES})"
@@ -94,3 +88,19 @@ def new_temporary_workspace(prefix: str = "ws") -> str:
"note: project workspace unchanged; use this absolute path for tools; "
"removed when chat exits"
)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
async def new_temporary_workspace(prefix: str = "ws") -> str:
"""Create a scratch directory under the system temp dir and allow tool paths in it.
The project workspace is unchanged: relative paths still resolve there.
Use the returned **absolute** path for file/process tools. Temporary
workspaces are deleted when this chat process exits (not on each turn).
Cross-platform (uses ``tempfile``; typically ``/tmp`` on POSIX, ``%TEMP%``
on Windows). At most 16 concurrent temporary workspaces per process.
"""
import asyncio
return await asyncio.to_thread(_create_temporary_workspace, prefix)
+100 -80
View File
@@ -2,44 +2,54 @@ from __future__ import annotations
from typing import TYPE_CHECKING, cast
from plyngent.agent import tool
from plyngent.agent import ToolTag, tool
if TYPE_CHECKING:
from collections.abc import Callable
from plyngent.agent.todo_stack import TodoStack, TodoStatus
_stack: TodoStack | None = None
_on_change: Callable[[], None] | None = None
_VALID_STATUS = frozenset({"pending", "in_progress", "done", "cancelled"})
def set_todo_stack(stack: TodoStack | None, *, on_change: Callable[[], None] | None = None) -> None:
"""Bind the session todo stack for tool handlers (and optional persist hook)."""
global _stack, _on_change # noqa: PLW0603 — intentional process bind
_stack = stack
_on_change = on_change
def get_todo_stack() -> TodoStack | None:
return _stack
def _require_stack() -> TodoStack:
if _stack is None:
msg = "todo stack is not available in this session"
raise RuntimeError(msg)
return _stack
def _notify() -> None:
if _on_change is not None:
_on_change()
"""Fire host persist hook on the bound session (if any)."""
from plyngent.tools.context import get_session
session = get_session()
if session is not None and session.on_todo_change is not None:
session.on_todo_change()
@tool(name="todo_list")
def todo_list() -> str:
async def _with_todo_stack(mutator: Callable[[TodoStack], str]) -> str:
"""Run *mutator* against the session todo stack and publish changes.
Requires a bound :class:`~plyngent.tools.context.SessionState`::
async with session.data:
stack = session.data["todo"].typed(TodoStack)
...
"""
from plyngent.agent.todo_stack import TodoStack
from plyngent.tools.context import require_session
session = require_session()
result = ""
async with session.data as data:
if session.todo is not None:
stack = session.todo
else:
stack = data["todo"].typed(TodoStack)
session.todo = stack
# Keep view domain + buffer in sync for durable commit.
data["todo"].store(stack)
result = mutator(stack)
_notify()
return result
@tool(name="todo_list", tags=ToolTag.LOCAL | ToolTag.PUBLIC | ToolTag.SESSION_STATE)
async def todo_list() -> str:
"""Show the LIFO stack of **task groups** (TOP group = current breakdown level).
Non-empty stack with open (pending/in_progress) items = unfinished work.
@@ -47,53 +57,59 @@ def todo_list() -> str:
group of siblings; pop removes the whole top group.
Pattern: push [T1,T2] → push [T1.1,T1.2] → finish children → pop → push [T2.1]…
"""
stack = _require_stack()
stack.mark_touched()
_notify()
return stack.render()
def _run(stack: TodoStack) -> str:
stack.mark_touched()
return stack.render()
return await _with_todo_stack(_run)
@tool(name="todo_push")
def todo_push(titles: list[str], notes: str = "") -> str:
@tool(name="todo_push", tags=ToolTag.LOCAL | ToolTag.PUBLIC | ToolTag.SESSION_STATE)
async def todo_push(titles: list[str], notes: str = "") -> str:
"""Push **one task group** (siblings) onto the stack — not one level per title.
``titles``: JSON array of strings (tool schema type ``array``). All entries
become members of a single new TOP group. Example: ``[\"T1\", \"T2\"]`` pushes
one group {T1, T2}; a later ``[\"T1.1\", \"T1.2\"]`` pushes a child group above it.
"""
stack = _require_stack()
parsed = [t.strip() for t in titles if t and t.strip()]
if not parsed:
return "error: titles must be a non-empty array of strings"
try:
group = stack.push_group(parsed, notes=notes)
except ValueError as exc:
return f"error: {exc}"
_notify()
ids = ", ".join(i.id for i in group.items)
return f"pushed group (depth={stack.depth}) items=[{ids}]\n{stack.render()}"
def _run(stack: TodoStack) -> str:
parsed = [t.strip() for t in titles if t and t.strip()]
if not parsed:
return "error: titles must be a non-empty array of strings"
try:
group = stack.push_group(parsed, notes=notes)
except ValueError as exc:
return f"error: {exc}"
ids = ", ".join(i.id for i in group.items)
return f"pushed group (depth={stack.depth}) items=[{ids}]\n{stack.render()}"
return await _with_todo_stack(_run)
@tool(name="todo_pop")
def todo_pop() -> str:
@tool(name="todo_pop", tags=ToolTag.LOCAL | ToolTag.PUBLIC | ToolTag.SESSION_STATE)
async def todo_pop() -> str:
"""Pop the entire **top group** (all siblings from that push).
Prefer after TOP items are done/cancelled so the stack does not stay
non-empty with only finished work.
"""
stack = _require_stack()
group = stack.pop()
if group is None:
return "todo stack empty"
_notify()
titles = ", ".join(f"{i.id}:{i.title}" for i in group.items) or "(empty)"
top = stack.top_group
top_s = "(empty)" if top is None else ", ".join(i.id for i in top.items)
return f"popped TOP group ({titles}); new top group=[{top_s}]\n{stack.render()}"
def _run(stack: TodoStack) -> str:
group = stack.pop()
if group is None:
return "todo stack empty"
titles = ", ".join(f"{i.id}:{i.title}" for i in group.items) or "(empty)"
top = stack.top_group
top_s = "(empty)" if top is None else ", ".join(i.id for i in top.items)
return f"popped TOP group ({titles}); new top group=[{top_s}]\n{stack.render()}"
return await _with_todo_stack(_run)
@tool(name="todo_update")
def todo_update(
@tool(name="todo_update", tags=ToolTag.LOCAL | ToolTag.PUBLIC | ToolTag.SESSION_STATE)
async def todo_update(
item_id: str,
status: str = "",
title: str = "",
@@ -105,33 +121,37 @@ def todo_update(
finished (not just deferred). Pop the TOP group when that breakdown level
is complete so the stack does not linger as false open work.
"""
stack = _require_stack()
status_arg: TodoStatus | None = None
if status.strip():
token = status.strip().lower()
if token not in _VALID_STATUS:
return "error: status must be pending, in_progress, done, or cancelled"
status_arg = cast("TodoStatus", token)
try:
item = stack.update(
item_id.strip(),
title=title if title.strip() else None,
status=status_arg,
notes=notes if notes != "" else None,
)
except (KeyError, ValueError) as exc:
return f"error: {exc}"
_notify()
return f"updated {item.id}{item.status}: {item.title}\n{stack.render()}"
def _run(stack: TodoStack) -> str:
status_arg: TodoStatus | None = None
if status.strip():
token = status.strip().lower()
if token not in _VALID_STATUS:
return "error: status must be pending, in_progress, done, or cancelled"
status_arg = cast("TodoStatus", token)
try:
item = stack.update(
item_id.strip(),
title=title if title.strip() else None,
status=status_arg,
notes=notes if notes != "" else None,
)
except (KeyError, ValueError) as exc:
return f"error: {exc}"
return f"updated {item.id}{item.status}: {item.title}\n{stack.render()}"
return await _with_todo_stack(_run)
@tool(name="todo_clear")
def todo_clear() -> str:
@tool(name="todo_clear", tags=ToolTag.LOCAL | ToolTag.PUBLIC | ToolTag.SESSION_STATE)
async def todo_clear() -> str:
"""Clear all groups on the stack."""
stack = _require_stack()
n = stack.clear()
_notify()
return f"cleared {n} item(s)"
def _run(stack: TodoStack) -> str:
n = stack.clear()
return f"cleared {n} item(s)"
return await _with_todo_stack(_run)
TODO_TOOLS = [todo_list, todo_push, todo_pop, todo_update, todo_clear]
+11 -11
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from plyngent.agent import tool
from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import WorkspaceError, get_workspace_root
from .detect import detect_vcs
@@ -22,8 +22,8 @@ def _backend_or_error() -> VcsBackend | str:
return backend
@tool
def vcs_kind() -> str:
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
async def vcs_kind() -> str:
"""Return the detected VCS kind under the workspace (e.g. ``git``), or an error."""
backend = _backend_or_error()
if isinstance(backend, str):
@@ -31,8 +31,8 @@ def vcs_kind() -> str:
return backend.kind
@tool
def vcs_status() -> str:
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
async def vcs_status() -> str:
"""Show working-tree status for the detected VCS (read-only)."""
backend = _backend_or_error()
if isinstance(backend, str):
@@ -40,8 +40,8 @@ def vcs_status() -> str:
return backend.status()
@tool
def vcs_diff(path: str = "", *, staged: bool = False) -> str:
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
async def vcs_diff(path: str = "", *, staged: bool = False) -> str:
"""Show a unified diff for the detected VCS (read-only).
``path`` is optional and relative to the workspace. ``staged=true`` is
@@ -54,8 +54,8 @@ def vcs_diff(path: str = "", *, staged: bool = False) -> str:
return backend.diff(staged=staged, path=rel)
@tool
def vcs_log(limit: int = 10) -> str:
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
async def vcs_log(limit: int = 10) -> str:
"""Show recent commits for the detected VCS (read-only)."""
if limit < 1:
return "error: limit must be >= 1"
@@ -65,8 +65,8 @@ def vcs_log(limit: int = 10) -> str:
return backend.log(limit=limit)
@tool
def vcs_branch() -> str:
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
async def vcs_branch() -> str:
"""Show the current branch / named head for the detected VCS (read-only)."""
backend = _backend_or_error()
if isinstance(backend, str):
+317
View File
@@ -0,0 +1,317 @@
"""Transactional path-scoped views over durable / publishable trees."""
from __future__ import annotations
from contextlib import AbstractAsyncContextManager
from dataclasses import dataclass
from typing import Any, Protocol, Self, TypeVar, cast, overload, override
U = TypeVar("U")
class ViewStore(Protocol):
"""Backend for a root PersistentDataView tree."""
async def load(self) -> object: ...
async def store(self, root: object) -> None: ...
class MemoryViewStore:
"""In-memory root store (tests / process-only state)."""
def __init__(self, initial: object | None = None) -> None:
self._root: object = {} if initial is None else initial
async def load(self) -> object:
return self._root
async def store(self, root: object) -> None:
self._root = root
def peek(self) -> object:
"""Return the current root without a transaction (sync hosts / tests)."""
return self._root
def replace(self, root: object) -> None:
"""Replace the root without a transaction (sync hosts / tests)."""
self._root = root
def merge_key(self, key: str, value: object) -> None:
"""Set *key* on a dict root (creates a dict root if needed)."""
root = self._root
if not isinstance(root, dict):
self._root = {key: value}
return
updated: dict[object, object] = dict(cast("dict[object, object]", root))
updated[key] = value
self._root = updated
@dataclass
class _TxnState:
"""Private buffer for an open root transaction."""
root: object
dirty: bool = False
depth: int = 0
class PersistentDataView[T](AbstractAsyncContextManager["PersistentDataView[T]"]):
"""Path-scoped view over a durable/publishable tree.
``T`` is the type of data referenced at this path (for load/store/typed()).
The view **is** the transaction context manager: ``async with view``.
Mutations outside an open txn raise. Nested ``async with`` joins the same
root txn (savepoint-lite: depth counter; full rollback on any exception).
"""
_store: ViewStore
_path: tuple[str | int, ...]
_bound_type: type[T] | None
_txn: _TxnState | None
_domain: dict[tuple[str | int, ...], object]
_child_cache: dict[tuple[str | int, ...], PersistentDataView[Any]]
def __init__(
self,
store: ViewStore,
*,
path: tuple[str | int, ...] = (),
bound_type: type[T] | None = None,
_txn: _TxnState | None = None,
_domain: dict[tuple[str | int, ...], object] | None = None,
_child_cache: dict[tuple[str | int, ...], PersistentDataView[Any]] | None = None,
) -> None:
self._store = store
self._path = path
self._bound_type = bound_type
self._txn = _txn
self._domain = _domain if _domain is not None else {}
self._child_cache = _child_cache if _child_cache is not None else {}
@property
def path(self) -> tuple[str | int, ...]:
return self._path
def _require_txn(self) -> _TxnState:
if self._txn is None or self._txn.depth < 1:
msg = "PersistentDataView mutation/read of live buffer requires an open transaction (async with view)"
raise RuntimeError(msg)
return self._txn
def _navigate(self, root: object) -> object:
current: object = root
for key in self._path:
if isinstance(current, dict):
current = cast("dict[object, object]", current).get(key)
elif isinstance(current, list) and isinstance(key, int):
lst = cast("list[object]", current)
current = lst[key] if 0 <= key < len(lst) else None
else:
return None
return current
def __getitem__(self, key: str | int) -> PersistentDataView[Any]:
child_path = (*self._path, key)
cached = self._child_cache.get(child_path)
if cached is not None:
return cached
child: PersistentDataView[Any] = PersistentDataView(
self._store,
path=child_path,
bound_type=None,
_txn=self._txn,
_domain=self._domain,
_child_cache=self._child_cache,
)
self._child_cache[child_path] = child
return child
def load(self) -> T:
"""Materialize the value at this path (from txn buffer or by reading store root snapshot)."""
if self._txn is not None and self._txn.depth >= 1:
if self._path in self._domain:
return cast("T", self._domain[self._path])
value = self._navigate(self._txn.root)
return cast("T", value)
# Outside txn: only allowed as a frozen read via store is async — sync load
# outside txn is not supported for remote stores; require txn.
msg = "load() requires an open transaction"
raise RuntimeError(msg)
def store(self, value: T) -> None:
txn = self._require_txn()
self._domain[self._path] = value
self._write_at_path(txn, self._path, value)
txn.dirty = True
def _write_at_path(self, txn: _TxnState, path: tuple[str | int, ...], value: object) -> None:
"""Write *value* into the txn buffer at *path* (may be a live domain object)."""
if not path:
txn.root = value
return
parent = self._ensure_parent_for(txn.root, path)
last = path[-1]
if isinstance(parent, dict):
cast("dict[object, object]", parent)[last] = value
elif isinstance(parent, list) and isinstance(last, int):
lst = cast("list[object]", parent)
while len(lst) <= last:
lst.append(None)
lst[last] = value
else:
msg = f"cannot store at path {path!r}"
raise TypeError(msg)
def _ensure_parent_for(self, root: object, path: tuple[str | int, ...]) -> object:
"""Ensure dict parents along *path* exist; return parent container for last key."""
if not path:
return root
current: object = root
for key in path[:-1]:
if not isinstance(current, dict):
msg = f"cannot navigate non-dict parent at {key!r}"
raise TypeError(msg)
mapping = cast("dict[object, object]", current)
child: object | None = mapping.get(key)
if child is None:
child = cast("object", {})
mapping[key] = child
current = child
return current
@staticmethod
def _serialize_value(value: object) -> object:
"""Prefer domain ``to_raw()`` so stores receive JSON-ish trees, not live objects."""
to_raw = getattr(value, "to_raw", None)
if callable(to_raw):
return to_raw()
return value
def _flush_domain(self, txn: _TxnState) -> None:
"""Rewrite domain objects into the buffer in serializable form before store."""
for path, value in self._domain.items():
self._write_at_path(txn, path, self._serialize_value(value))
def _materialize_domain(self, typ: type[Any], current: object) -> object:
if current is not None and isinstance(current, typ):
self._domain[self._path] = current
return current
# Hybrid domain objects (e.g. TodoStack): reconstruct from durable raw.
from_raw = getattr(typ, "from_raw", None)
if current is not None and callable(from_raw):
converted: object = from_raw(current)
self._domain[self._path] = converted
self.store(cast("T", converted))
return converted
ctor = cast("Any", typ)
if current is None:
try:
created: object = ctor()
except TypeError:
created = None
self._domain[self._path] = created
if created is not None:
self.store(cast("T", created))
return created
try:
converted = ctor(current)
except TypeError:
converted = current
self._domain[self._path] = converted
self.store(cast("T", converted))
return converted
@overload
def typed(self, typ: None = None) -> T: ...
@overload
def typed(self, typ: type[U]) -> U: ...
def typed(self, typ: type[U] | None = None) -> T | U:
"""Return the live bound data at this path (not a view).
- ``typed()`` → ``T`` when bound
- ``typed(U)`` → rebind / convert as ``U``
"""
txn = self._require_txn()
if self._path in self._domain:
current = self._domain[self._path]
else:
current = self._navigate(txn.root)
if current is not None:
self._domain[self._path] = current
if typ is not None:
return cast("U", self._materialize_domain(typ, current))
if self._bound_type is not None and current is None:
return cast("T", self._materialize_domain(self._bound_type, current))
return cast("T", current)
async def save(self) -> None:
"""Flush the root buffer to the store without closing the txn."""
txn = self._require_txn()
if txn.dirty:
self._flush_domain(txn)
await self._store.store(txn.root)
txn.dirty = False
@override
async def __aenter__(self) -> Self:
if self._txn is None:
# Root view owns the txn state.
root = await self._store.load()
self._txn = _TxnState(root=root, dirty=False, depth=1)
# Propagate txn to cached children created before enter (rare).
for child in self._child_cache.values():
child._txn = self._txn
return self
self._txn.depth += 1
return self
@override
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: object,
) -> bool:
txn = self._txn
if txn is None:
return False
txn.depth -= 1
if txn.depth > 0:
# Nested exit: on exception, mark for full rollback at root.
if exc_type is not None:
txn.dirty = False
# Reload root discarded on root exit via exception path.
txn.root = await self._store.load()
self._domain.clear()
return False
# Root exit
try:
if exc_type is None and txn.dirty:
self._flush_domain(txn)
await self._store.store(txn.root)
finally:
self._txn = None
self._domain.clear()
# Drop child txn links
for child in self._child_cache.values():
child._txn = None
self._child_cache.clear()
return False
def session_data_view(
initial: dict[str, object] | None = None,
*,
store: ViewStore | None = None,
) -> PersistentDataView[dict[str, object]]:
"""Convenience root view for session documents (todo, grants, …)."""
backend: ViewStore = store if store is not None else MemoryViewStore(initial if initial is not None else {})
return PersistentDataView(backend, path=(), bound_type=dict)
+100 -62
View File
@@ -1,11 +1,14 @@
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Callable, Sequence
from plyngent.tools.context import InstanceState
DEFAULT_COMMAND_DENYLIST: frozenset[str] = frozenset(
{
"sudo",
@@ -44,81 +47,102 @@ class WorkspaceError(ValueError):
"""Raised when a path or command violates workspace policy."""
class _WorkspaceState:
@dataclass
class WorkspacePolicy:
"""Workspace path/command policy for one agent host / instance.
Lives on :class:`~plyngent.tools.context.InstanceState.workspace`. There is
no process-global policy bag — hosts must bind instance state around tool use.
"""
root: Path | None = None
path_denylist: tuple[str, ...] = ()
command_denylist: frozenset[str] = DEFAULT_COMMAND_DENYLIST
# Extra roots allowed for resolve_path (e.g. temporary workspaces under system temp).
allowlist: list[Path]
# Paths created by new_temporary_workspace (subset of allowlist); cleaned on chat exit.
temporary_owned: list[Path]
# Basenames the human allowed this process (denylist override, not permanent).
policy_allowed_commands: set[str]
policy_confirm_hook: PolicyConfirmHook | None
policy_confirm_timeout_seconds: float
def __init__(self) -> None:
self.allowlist = []
self.temporary_owned = []
self.policy_allowed_commands = set()
self.policy_confirm_hook = None
self.policy_confirm_timeout_seconds = DEFAULT_POLICY_CONFIRM_TIMEOUT_SECONDS
allowlist: list[Path] = field(default_factory=list)
temporary_owned: list[Path] = field(default_factory=list)
policy_allowed_commands: set[str] = field(default_factory=set)
policy_confirm_hook: PolicyConfirmHook | None = None
policy_confirm_timeout_seconds: float = DEFAULT_POLICY_CONFIRM_TIMEOUT_SECONDS
_state = _WorkspaceState()
def _bound_instance() -> InstanceState | None:
from plyngent.tools.context import get_instance
return get_instance()
def require_bound_instance() -> InstanceState:
"""Return the bound instance or raise :class:`WorkspaceError`."""
instance = _bound_instance()
if instance is None:
msg = "instance state is not bound; host must set InstanceState around tool execution"
raise WorkspaceError(msg)
return instance
def active_workspace_policy() -> WorkspacePolicy:
"""Return the policy bag for the bound instance (required)."""
return require_bound_instance().workspace
def set_workspace_root(root: Path | str) -> Path:
"""Set the workspace root used by tools; returns the resolved root."""
"""Set the workspace root on the bound instance; returns the resolved root."""
path = Path(root).expanduser().resolve()
if not path.is_dir():
msg = f"workspace root is not a directory: {path}"
raise WorkspaceError(msg)
_state.root = path
instance = require_bound_instance()
instance.workspace.root = path
instance.workspace_root = path
return path
def get_workspace_root() -> Path:
"""Return the configured workspace root."""
if _state.root is None:
msg = "workspace root is not set; call set_workspace_root() first"
raise WorkspaceError(msg)
return _state.root
"""Return the bound instance workspace root."""
instance = require_bound_instance()
if instance.workspace_root is not None:
return instance.workspace_root
if instance.workspace.root is not None:
return instance.workspace.root
msg = "workspace root is not set on the bound instance"
raise WorkspaceError(msg)
def clear_workspace_root() -> None:
"""Clear workspace root (mainly for tests). Does not clear allowlist."""
_state.root = None
"""Clear workspace root on the bound instance (mainly for tests)."""
instance = require_bound_instance()
instance.workspace.root = None
instance.workspace_root = None
def set_path_denylist(patterns: list[str] | tuple[str, ...] | None) -> None:
"""Set path substring denylist (matched against resolved path strings)."""
_state.path_denylist = tuple(patterns or ())
active_workspace_policy().path_denylist = tuple(patterns or ())
def get_path_denylist() -> tuple[str, ...]:
"""Return the current path substring denylist."""
return _state.path_denylist
return active_workspace_policy().path_denylist
def set_command_denylist(names: list[str] | tuple[str, ...] | frozenset[str] | None) -> None:
"""Set denied command basenames (None restores defaults)."""
_state.command_denylist = DEFAULT_COMMAND_DENYLIST if names is None else frozenset(names)
# Session overrides only make sense against the active denylist.
_state.policy_allowed_commands &= _state.command_denylist
policy = active_workspace_policy()
policy.command_denylist = DEFAULT_COMMAND_DENYLIST if names is None else frozenset(names)
policy.policy_allowed_commands &= policy.command_denylist
def get_command_denylist() -> frozenset[str]:
return _state.command_denylist
return active_workspace_policy().command_denylist
def set_policy_confirm_hook(hook: PolicyConfirmHook | None) -> None:
"""Register a timed human confirm for denylisted commands (CLI installs this)."""
_state.policy_confirm_hook = hook
active_workspace_policy().policy_confirm_hook = hook
def get_policy_confirm_hook() -> PolicyConfirmHook | None:
return _state.policy_confirm_hook
return active_workspace_policy().policy_confirm_hook
def set_policy_confirm_timeout(seconds: float) -> None:
@@ -126,23 +150,23 @@ def set_policy_confirm_timeout(seconds: float) -> None:
if seconds <= 0:
msg = "policy confirm timeout must be > 0"
raise WorkspaceError(msg)
_state.policy_confirm_timeout_seconds = float(seconds)
active_workspace_policy().policy_confirm_timeout_seconds = float(seconds)
def get_policy_confirm_timeout() -> float:
return _state.policy_confirm_timeout_seconds
return active_workspace_policy().policy_confirm_timeout_seconds
def clear_policy_allowed_commands() -> None:
"""Drop session-scoped denylist overrides (tests / chat exit)."""
_state.policy_allowed_commands.clear()
active_workspace_policy().policy_allowed_commands.clear()
def grant_policy_command(basename: str) -> None:
"""Allow *basename* for the rest of this process despite the denylist."""
"""Allow *basename* for this instance despite the denylist."""
name = basename.strip()
if name:
_state.policy_allowed_commands.add(name)
active_workspace_policy().policy_allowed_commands.add(name)
def add_workspace_allowlist(root: Path | str, *, owned: bool = False) -> Path:
@@ -155,25 +179,27 @@ def add_workspace_allowlist(root: Path | str, *, owned: bool = False) -> Path:
if not path.is_dir():
msg = f"allowlist root is not a directory: {path}"
raise WorkspaceError(msg)
if path not in _state.allowlist:
if len(_state.allowlist) >= MAX_TEMPORARY_WORKSPACES and owned:
policy = active_workspace_policy()
if path not in policy.allowlist:
if len(policy.allowlist) >= MAX_TEMPORARY_WORKSPACES and owned:
msg = f"too many temporary workspaces (max {MAX_TEMPORARY_WORKSPACES})"
raise WorkspaceError(msg)
_state.allowlist.append(path)
if owned and path not in _state.temporary_owned:
_state.temporary_owned.append(path)
policy.allowlist.append(path)
if owned and path not in policy.temporary_owned:
policy.temporary_owned.append(path)
return path
def list_workspace_allowlist() -> list[Path]:
"""Return a copy of extra allowed roots (not including the primary workspace)."""
return list(_state.allowlist)
return list(active_workspace_policy().allowlist)
def clear_workspace_allowlist() -> None:
"""Clear allowlist and owned-temp registry (tests). Does not delete directories."""
_state.allowlist.clear()
_state.temporary_owned.clear()
policy = active_workspace_policy()
policy.allowlist.clear()
policy.temporary_owned.clear()
def pop_owned_temporary_workspaces() -> list[Path]:
@@ -182,23 +208,32 @@ def pop_owned_temporary_workspaces() -> list[Path]:
Paths remain on the allowlist until the caller removes them via
:func:`remove_workspace_allowlist`.
"""
owned = list(_state.temporary_owned)
_state.temporary_owned.clear()
policy = active_workspace_policy()
owned = list(policy.temporary_owned)
policy.temporary_owned.clear()
return owned
def remove_workspace_allowlist(root: Path | str) -> None:
"""Drop *root* from the allowlist if present."""
path = Path(root).expanduser().resolve()
while path in _state.allowlist:
_state.allowlist.remove(path)
policy = active_workspace_policy()
while path in policy.allowlist:
policy.allowlist.remove(path)
def _under_any_root(resolved: Path) -> bool:
def _primary_roots(instance: InstanceState, policy: WorkspacePolicy) -> list[Path]:
roots: list[Path] = []
if _state.root is not None:
roots.append(_state.root)
roots.extend(_state.allowlist)
if instance.workspace_root is not None:
roots.append(instance.workspace_root)
if policy.root is not None and policy.root not in roots:
roots.append(policy.root)
return roots
def _under_any_root(resolved: Path, instance: InstanceState, policy: WorkspacePolicy) -> bool:
roots = _primary_roots(instance, policy)
roots.extend(policy.allowlist)
for root in roots:
try:
_ = resolved.relative_to(root)
@@ -214,17 +249,19 @@ def resolve_path(path: str | Path) -> Path:
Relative paths resolve against the **primary** workspace root. Absolute
paths may also land under a temporary workspace allowlist entry.
"""
instance = require_bound_instance()
policy = instance.workspace
root = get_workspace_root()
candidate = Path(path)
if not candidate.is_absolute():
candidate = root / candidate
resolved = candidate.expanduser().resolve()
if not _under_any_root(resolved):
if not _under_any_root(resolved, instance, policy):
msg = f"path escapes workspace root ({root}): {path}"
raise WorkspaceError(msg)
# Normalize separators so denylist entries like ``/secrets/`` match on Windows.
resolved_str = str(resolved).replace("\\", "/")
for pattern in _state.path_denylist:
for pattern in policy.path_denylist:
if pattern and pattern.replace("\\", "/") in resolved_str:
msg = f"path denied by policy (matched {pattern!r}): {path}"
raise WorkspaceError(msg)
@@ -241,21 +278,22 @@ def check_command_allowed(argv: list[str]) -> None:
if not argv:
msg = "command argv must not be empty"
raise WorkspaceError(msg)
policy = active_workspace_policy()
binary = Path(argv[0]).name
if binary not in _state.command_denylist:
if binary not in policy.command_denylist:
return
if binary in _state.policy_allowed_commands:
if binary in policy.policy_allowed_commands:
return
hook = _state.policy_confirm_hook
hook = policy.policy_confirm_hook
if hook is not None:
timeout = _state.policy_confirm_timeout_seconds
timeout = policy.policy_confirm_timeout_seconds
try:
allowed = bool(hook(binary, list(argv), timeout))
except Exception as exc:
msg = f"command denied by policy (basename {binary!r}; confirm failed: {exc})"
raise WorkspaceError(msg) from exc
if allowed:
_state.policy_allowed_commands.add(binary)
policy.policy_allowed_commands.add(binary)
return
msg = (
f"command denied by policy (basename {binary!r} is blocked; user declined or timed out after {timeout:g}s)"
+6
View File
@@ -0,0 +1,6 @@
"""Shared pytest fixtures.
Ephemeral ``@tool`` tests should pass ``register=False`` so they do not pollute
the process catalog. Do not autouse an empty ``catalog_scope`` here: it would
hide builtins from ``default_tool_definitions`` during tests.
"""
+2 -2
View File
@@ -9,13 +9,13 @@ if TYPE_CHECKING:
from collections.abc import Mapping
@tool
@tool(register=False)
def delete_path(path: str, *, recursive: bool = False) -> str:
del recursive
return f"deleted {path}"
@tool
@tool(register=False)
def read_file(path: str) -> str:
return f"read {path}"
+7 -7
View File
@@ -268,7 +268,7 @@ async def test_chat_agent_accumulates_session_usage() -> None:
async def test_chat_agent_turn_usage_sums_tool_rounds() -> None:
"""Multi-round tool loop: turn usage is billing sum; last_request is final call."""
@tool
@tool(register=False)
def ping() -> str:
return "pong"
@@ -437,7 +437,7 @@ async def test_non_stream_yields_reasoning() -> None:
async def test_run_chat_loop_with_tools() -> None:
@tool
@tool(register=False)
def add(a: int, b: int) -> int:
return a + b
@@ -470,7 +470,7 @@ async def test_run_chat_loop_with_tools() -> None:
async def test_max_rounds() -> None:
@tool
@tool(register=False)
def ping() -> str:
return "pong"
@@ -494,7 +494,7 @@ async def test_max_rounds() -> None:
async def test_max_rounds_continue_hook() -> None:
@tool
@tool(register=False)
def ping() -> str:
return "pong"
@@ -537,7 +537,7 @@ async def test_max_rounds_continue_hook() -> None:
async def test_max_rounds_async_continue_hook() -> None:
@tool
@tool(register=False)
def ping() -> str:
return "pong"
@@ -721,7 +721,7 @@ async def test_retry_keeps_committed_tools_after_second_round_fails() -> None:
session = await store.create_session(name="t")
calls: list[str] = []
@tool
@tool(register=False)
def side_effect() -> str:
calls.append("ran")
return "done-once"
@@ -809,7 +809,7 @@ async def test_chat_agent_system_prompt_prepended() -> None:
async def test_tool_result_char_budget() -> None:
@tool
@tool(register=False)
def big() -> str:
return "x" * 100
+2 -2
View File
@@ -126,7 +126,7 @@ async def test_parallel_tool_calls_run_concurrently() -> None:
gate = asyncio.Event()
wait_timeout = 2.0
@tool
@tool(register=False)
async def slow_a() -> str:
nonlocal started
started += 1
@@ -135,7 +135,7 @@ async def test_parallel_tool_calls_run_concurrently() -> None:
_ = await asyncio.wait_for(gate.wait(), timeout=wait_timeout)
return "a"
@tool
@tool(register=False)
async def slow_b() -> str:
nonlocal started
started += 1
+211
View File
@@ -0,0 +1,211 @@
"""ChatAgent.run_aside: side turns leave main transcript/memory alone."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Literal, cast, overload
import pytest
from plyngent.agent import ChatAgent, ToolRegistry, ToolTag, tool
from plyngent.agent.todo_stack import TodoStack
from plyngent.config.models import DatabaseConfig
from plyngent.lmproto.openai_compatible.model import (
AnyChatMessage,
AssistantChatMessage,
ChatCompletionChoice,
ChatCompletionChunk,
ChatCompletionResponse,
ChatCompletionsParam,
UserChatMessage,
)
from plyngent.memory import MemoryStore
from plyngent.tools.context import InstanceState, SessionState
if TYPE_CHECKING:
from collections.abc import AsyncIterator
class ScriptedClient:
"""Returns a fixed assistant string each call."""
def __init__(self, replies: list[str] | None = None) -> None:
self.replies = list(replies or ["aside-answer"])
self.calls = 0
self.payloads: list[list[AnyChatMessage]] = []
@overload
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: Literal[False] = False
) -> ChatCompletionResponse: ...
@overload
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: Literal[True]
) -> AsyncIterator[ChatCompletionChunk]: ...
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: bool = False
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
del stream
self.calls += 1
self.payloads.append(list(param.messages))
text = self.replies[(self.calls - 1) % len(self.replies)]
return ChatCompletionResponse(
id="1",
object="chat.completion",
created=0,
model="m",
choices=[
ChatCompletionChoice(
index=0,
message=AssistantChatMessage(content=text),
logprobs={},
finish_reason="stop",
)
],
system_fingerprint="",
usage={},
)
@pytest.mark.asyncio
async def test_run_aside_does_not_mutate_main_or_memory() -> None:
memory = await MemoryStore.open(DatabaseConfig())
try:
session = await memory.create_session(name="main")
client = ScriptedClient(["main-reply", "aside-reply", "main-again"])
agent = ChatAgent(
cast("Any", client),
model="m",
memory=memory,
session_id=session.sid,
stream=False,
todo_stack=TodoStack(),
)
async for _ in agent.run("hello main"):
pass
main_len = len(agent.messages)
main_snapshot = list(agent.messages)
db_before = await memory.list_messages(session.sid)
texts: list[str] = []
async for event in agent.run_aside("side question?", include_history=True, tools=False):
from plyngent.agent.events import AssistantMessageEvent
if isinstance(event, AssistantMessageEvent) and event.message.content:
texts.append(str(event.message.content))
assert any("aside-reply" in t for t in texts)
assert len(agent.messages) == main_len
assert agent.messages == main_snapshot
db_after = await memory.list_messages(session.sid)
assert len(db_after) == len(db_before)
# Aside request saw main history + side user.
aside_payload = client.payloads[1]
assert any(isinstance(m, UserChatMessage) and "side question" in m.content for m in aside_payload)
assert any(isinstance(m, UserChatMessage) and "hello main" in m.content for m in aside_payload)
finally:
await memory.close()
@pytest.mark.asyncio
async def test_run_aside_fresh_skips_history() -> None:
client = ScriptedClient(["only"])
agent = ChatAgent(cast("Any", client), model="m", stream=False)
agent.messages.append(UserChatMessage(content="prior"))
async for _ in agent.run_aside("q", include_history=False, tools=False):
pass
payload = client.payloads[0]
users = [m.content for m in payload if isinstance(m, UserChatMessage)]
assert users == ["q"]
@pytest.mark.asyncio
async def test_run_aside_tools_clones_registry_with_fresh_session() -> None:
hits: list[str] = []
@tool(tags=ToolTag.LOCAL | ToolTag.SESSION_STATE, register=False)
async def note_session() -> str:
from plyngent.tools.context import get_session
session = get_session()
hits.append("ok" if session is not None else "none")
if session is not None:
session.extras["aside"] = True
return "noted"
main_session = SessionState(session_id="main")
instance = InstanceState()
registry = ToolRegistry(
[note_session],
auto_bind_state=True,
instance_state=instance,
session_state=main_session,
)
class ToolThenStop:
def __init__(self) -> None:
self.calls = 0
@overload
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: Literal[False] = False
) -> ChatCompletionResponse: ...
@overload
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: Literal[True]
) -> AsyncIterator[ChatCompletionChunk]: ...
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: bool = False
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
del stream, param
self.calls += 1
from plyngent.lmproto.openai_compatible.model import (
AssistantFunctionTool,
AssistantFunctionToolCall,
)
if self.calls == 1:
message = AssistantChatMessage(
content="",
tool_calls=[
AssistantFunctionToolCall(
id="c1",
function=AssistantFunctionTool(name="note_session", arguments="{}"),
)
],
)
finish = "tool_calls"
else:
message = AssistantChatMessage(content="done")
finish = "stop"
return ChatCompletionResponse(
id="1",
object="chat.completion",
created=0,
model="m",
choices=[
ChatCompletionChoice(
index=0,
message=message,
logprobs={},
finish_reason=finish,
)
],
system_fingerprint="",
usage={},
)
client = ToolThenStop()
agent = ChatAgent(cast("Any", client), model="m", tools=registry, stream=False)
async for _ in agent.run_aside(
"use tool",
tools=True,
instance_state=instance,
session_state=SessionState(session_id="aside"),
):
pass
assert hits == ["ok"]
assert "aside" not in main_session.extras
+231 -58
View File
@@ -12,6 +12,8 @@ from plyngent.config.models import DatabaseConfig
from plyngent.lmproto.openai_compatible.model import (
AnyChatMessage,
AssistantChatMessage,
AssistantFunctionTool,
AssistantFunctionToolCall,
ChatCompletionChoice,
ChatCompletionChunk,
ChatCompletionResponse,
@@ -21,12 +23,22 @@ from plyngent.lmproto.openai_compatible.model import (
UserChatMessage,
)
from plyngent.memory import MemoryStore
from plyngent.tools.todo import TODO_TOOLS, set_todo_stack
from plyngent.tools.context import SessionState
from plyngent.tools.todo import TODO_TOOLS
if TYPE_CHECKING:
from collections.abc import AsyncIterator
def _session_for(stack: TodoStack) -> SessionState:
"""Bind a live stack via SessionState (no process-global set_todo_stack)."""
return SessionState(session_id="test", todo=stack)
def _todo_registry(stack: TodoStack) -> ToolRegistry:
return ToolRegistry(list(TODO_TOOLS), session_state=_session_for(stack))
def test_parse_push_titles() -> None:
assert parse_push_titles("only") == ["only"]
assert parse_push_titles("T1\nT2") == ["T1", "T2"]
@@ -109,13 +121,14 @@ def test_todo_stack_needs_review() -> None:
assert not stack.needs_review()
item = stack.push("work")
stack.begin_turn()
# Open items always need review, even if todo_* was used this turn.
# Non-empty + untouched → nag (open or hygiene).
assert stack.needs_review()
# Any todo_* access this turn suppresses end-of-turn nag, even with open items.
stack.mark_touched()
assert stack.needs_review()
assert not stack.needs_review()
stack.update(item.id, status="done")
stack.begin_turn()
# Terminal-only stack: review only when untouched this turn.
# Terminal-only stack: same gate (untouched only).
assert stack.needs_review()
stack.mark_touched()
assert not stack.needs_review()
@@ -260,8 +273,7 @@ async def test_todo_tools_and_persist(tmp_path: object) -> None:
try:
session = await memory.create_session(name="t")
stack = TodoStack()
set_todo_stack(stack, on_change=None)
registry = ToolRegistry(list(TODO_TOOLS))
registry = _todo_registry(stack)
out = await registry.execute("todo_push", '{"titles": ["T1", "T2"]}')
assert "group" in out.lower() or "pushed" in out
assert stack.depth == 1
@@ -278,7 +290,6 @@ async def test_todo_tools_and_persist(tmp_path: object) -> None:
assert again.depth == 1
assert [i.title for i in again.groups[0].items] == ["T1", "T2"]
finally:
set_todo_stack(None)
await memory.close()
@@ -333,46 +344,100 @@ async def test_loop_injects_todo_review_when_untouched() -> None:
agent = ChatAgent(
client, # type: ignore[arg-type]
model="m",
tools=ToolRegistry(list(TODO_TOOLS)),
tools=_todo_registry(stack),
stream=False,
todo_stack=stack,
)
set_todo_stack(stack)
try:
async for _event in agent.run("do stuff"):
pass
assert client.calls >= 2
assert any(isinstance(m, DeveloperChatMessage) and "[TODO OPEN WORK]" in m.content for m in agent.messages)
assert not any(isinstance(m, UserChatMessage) and "[TODO OPEN WORK]" in m.content for m in agent.messages)
finally:
set_todo_stack(None)
async for _event in agent.run("do stuff"):
pass
assert client.calls >= 2
assert any(isinstance(m, DeveloperChatMessage) and "[TODO OPEN WORK]" in m.content for m in agent.messages)
assert not any(isinstance(m, UserChatMessage) and "[TODO OPEN WORK]" in m.content for m in agent.messages)
class ScriptedClientWithTodoListThenStop:
"""First completion: todo_list tool call; second: stop (no further nag round)."""
def __init__(self) -> None:
self.calls = 0
@overload
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: Literal[False] = False
) -> ChatCompletionResponse: ...
@overload
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: Literal[True]
) -> AsyncIterator[ChatCompletionChunk]: ...
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: bool = False
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
del stream, param
self.calls += 1
if self.calls == 1:
message = AssistantChatMessage(
content="",
tool_calls=[
AssistantFunctionToolCall(
id="tl1",
function=AssistantFunctionTool(name="todo_list", arguments="{}"),
)
],
)
finish: str = "tool_calls"
else:
message = AssistantChatMessage(content="done after list")
finish = "stop"
return ChatCompletionResponse(
id="1",
object="chat.completion",
created=0,
model="m",
choices=[
ChatCompletionChoice(
index=0,
message=message,
logprobs={},
finish_reason=finish,
)
],
system_fingerprint="",
usage={},
)
@pytest.mark.asyncio
async def test_loop_injects_todo_review_when_open_after_touch() -> None:
"""Open items still trigger end-of-turn review even if todo_* ran this turn."""
async def test_loop_skips_todo_review_when_touched_even_if_open() -> None:
"""todo_* access this turn suppresses end-of-turn nag despite open items.
begin_turn() resets touch at run start, so the model must actually call a
todo tool mid-turn; then stop without a third (nag) completion.
"""
stack = TodoStack()
_ = stack.push("still open")
stack.begin_turn()
stack.mark_touched() # simulates todo_list earlier in the turn
assert stack.needs_review()
client = ScriptedClient()
client = ScriptedClientWithTodoListThenStop()
agent = ChatAgent(
client, # type: ignore[arg-type]
model="m",
tools=ToolRegistry(list(TODO_TOOLS)),
tools=_todo_registry(stack),
stream=False,
todo_stack=stack,
# Avoid turn-start synthetic inject muddying counts; developer turn-start
# is fine (prose only). End-of-turn is what we assert against.
todo_nag_strategy="developer",
)
set_todo_stack(stack)
try:
async for _event in agent.run("do stuff"):
pass
assert client.calls >= 2
assert any(isinstance(m, DeveloperChatMessage) and "[TODO OPEN WORK]" in m.content for m in agent.messages)
finally:
set_todo_stack(None)
async for _event in agent.run("do stuff"):
pass
# call1: tool_calls todo_list; call2: stop — no call3 from end-of-turn nag.
assert client.calls == 2
assert stack.touched_this_turn
assert not stack.needs_review()
# End-of-turn OPEN WORK prose must not appear after a natural stop.
end_nags = [m for m in agent.messages if isinstance(m, DeveloperChatMessage) and "You stopped with" in m.content]
assert end_nags == []
@pytest.mark.asyncio
@@ -384,23 +449,19 @@ async def test_loop_synthetic_tool_nag_strategy() -> None:
agent = ChatAgent(
client, # type: ignore[arg-type]
model="m",
tools=ToolRegistry(list(TODO_TOOLS)),
tools=_todo_registry(stack),
stream=False,
todo_stack=stack,
todo_nag_strategy="synthetic_tool",
)
set_todo_stack(stack)
try:
async for _event in agent.run("do stuff"):
pass
# Result body is real todo_list shape (render), not OPEN WORK prose.
assert any(
isinstance(m, ToolChatMessage) and "open work" in m.content and "[TODO OPEN WORK]" not in m.content
for m in agent.messages
)
assert not any(isinstance(m, DeveloperChatMessage) and "[TODO OPEN WORK]" in m.content for m in agent.messages)
finally:
set_todo_stack(None)
async for _event in agent.run("do stuff"):
pass
# Result body is real todo_list shape (render), not OPEN WORK prose.
assert any(
isinstance(m, ToolChatMessage) and "open work" in m.content and "[TODO OPEN WORK]" not in m.content
for m in agent.messages
)
assert not any(isinstance(m, DeveloperChatMessage) and "[TODO OPEN WORK]" in m.content for m in agent.messages)
@pytest.mark.asyncio
@@ -412,20 +473,132 @@ async def test_loop_none_nag_strategy_skips_inject() -> None:
agent = ChatAgent(
client, # type: ignore[arg-type]
model="m",
tools=ToolRegistry(list(TODO_TOOLS)),
tools=_todo_registry(stack),
stream=False,
todo_stack=stack,
todo_nag_strategy="none",
)
set_todo_stack(stack)
try:
async for _event in agent.run("do stuff"):
pass
# One completion only — no end-of-turn continue from nag.
assert client.calls == 1
assert not any(
isinstance(m, (DeveloperChatMessage, ToolChatMessage)) and "[TODO OPEN WORK]" in getattr(m, "content", "")
for m in agent.messages
)
finally:
set_todo_stack(None)
async for _event in agent.run("do stuff"):
pass
# One completion only — no end-of-turn continue from nag.
assert client.calls == 1
assert not any(
isinstance(m, (DeveloperChatMessage, ToolChatMessage)) and "[TODO OPEN WORK]" in getattr(m, "content", "")
for m in agent.messages
)
def test_refresh_synthetic_todo_nags_updates_stale_results() -> None:
"""Forged nags keep call ids; results track the live stack (not a frozen dirty snapshot)."""
from plyngent.agent.todo_nag import (
inject_todo_nag_for_stack,
is_synthetic_todo_nag_call_id,
refresh_synthetic_todo_nags,
)
stack = TodoStack()
_ = stack.push("stale dirty item")
messages: list[AnyChatMessage] = []
assert inject_todo_nag_for_stack(messages, stack, kind="end_of_turn", strategy="synthetic_tool")
assert any(
isinstance(m, ToolChatMessage)
and "stale dirty item" in m.content
and is_synthetic_todo_nag_call_id(m.tool_call_id)
for m in messages
)
_ = stack.clear()
n = refresh_synthetic_todo_nags(messages, stack)
assert n >= 1
for m in messages:
if isinstance(m, ToolChatMessage) and is_synthetic_todo_nag_call_id(m.tool_call_id):
assert "stale dirty item" not in m.content
assert "empty" in m.content.lower()
@pytest.mark.asyncio
async def test_loop_synthetic_tool_refreshes_after_stack_cleared() -> None:
"""After a dirty stack is cleaned, later turns must not re-show old nag text."""
class CaptureClient:
def __init__(self) -> None:
self.calls = 0
self.payloads: list[list[AnyChatMessage]] = []
@overload
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: Literal[False] = False
) -> ChatCompletionResponse: ...
@overload
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: Literal[True]
) -> AsyncIterator[ChatCompletionChunk]: ...
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: bool = False
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
del stream
self.calls += 1
self.payloads.append(list(param.messages))
if self.calls == 1:
# First stop → end-of-turn synthetic nag → second call clears.
message = AssistantChatMessage(content="first stop")
finish = "stop"
elif self.calls == 2:
message = AssistantChatMessage(
content="",
tool_calls=[
AssistantFunctionToolCall(
id="clr",
function=AssistantFunctionTool(name="todo_clear", arguments="{}"),
)
],
)
finish = "tool_calls"
else:
message = AssistantChatMessage(content="after clear")
finish = "stop"
return ChatCompletionResponse(
id="1",
object="chat.completion",
created=0,
model="m",
choices=[
ChatCompletionChoice(
index=0,
message=message,
logprobs={},
finish_reason=finish,
)
],
system_fingerprint="",
usage={},
)
stack = TodoStack()
_ = stack.push("was dirty")
client = CaptureClient()
agent = ChatAgent(
client, # type: ignore[arg-type]
model="m",
tools=_todo_registry(stack),
stream=False,
todo_stack=stack,
todo_nag_strategy="synthetic_tool",
)
async for _event in agent.run("turn1"):
pass
assert stack.is_empty()
n_after_turn1 = client.calls
async for _event in agent.run("turn2 clean"):
pass
# Later request payloads must not re-present the old dirty item via synth nags.
for payload in client.payloads[n_after_turn1:]:
for msg in payload:
if isinstance(msg, ToolChatMessage) and msg.tool_call_id.startswith("todo-nag-"):
assert "was dirty" not in msg.content
# Durable history refreshed at turn start as well.
for msg in agent.messages:
if isinstance(msg, ToolChatMessage) and msg.tool_call_id.startswith("todo-nag-"):
assert "was dirty" not in msg.content
+7 -7
View File
@@ -4,7 +4,7 @@ from plyngent.agent import ToolRegistry, schema_from_callable, tool
def test_tool_decorator_infers_schema() -> None:
@tool
@tool(register=False)
def add(a: int, b: int = 0) -> int:
"""Add two numbers."""
return a + b
@@ -18,7 +18,7 @@ def test_tool_decorator_infers_schema() -> None:
def test_tool_decorator_overrides() -> None:
@tool(name="ping", description="health")
@tool(name="ping", description="health", register=False)
def health() -> str:
return "ok"
@@ -36,11 +36,11 @@ def test_schema_from_callable_optional() -> None:
async def test_registry_execute_sync_and_async() -> None:
@tool
@tool(register=False)
def echo(text: str) -> str:
return text
@tool
@tool(register=False)
async def upper(text: str) -> str:
return text.upper()
@@ -50,7 +50,7 @@ async def test_registry_execute_sync_and_async() -> None:
async def test_registry_unknown_and_bad_json() -> None:
@tool
@tool(register=False)
def noop() -> str:
return "ok"
@@ -60,7 +60,7 @@ async def test_registry_unknown_and_bad_json() -> None:
async def test_registry_handler_error() -> None:
@tool
@tool(register=False)
def boom() -> str:
msg = "nope"
raise RuntimeError(msg)
@@ -71,7 +71,7 @@ async def test_registry_handler_error() -> None:
def test_tool_items() -> None:
@tool
@tool(register=False)
def f(x: int) -> int:
return x
+8 -7
View File
@@ -18,7 +18,6 @@ from plyngent.lmproto.openai_compatible.model import (
UserChatMessage,
)
from plyngent.memory import MemoryStore
from plyngent.tools import set_workspace_root
if TYPE_CHECKING:
from collections.abc import AsyncIterator
@@ -59,7 +58,6 @@ class SummaryClient:
async def test_compact_to_new_session(tmp_path: Path) -> None:
_ = set_workspace_root(tmp_path)
memory = await MemoryStore.open(DatabaseConfig())
try:
provider = OpenAIProvider(access_key_or_token="sk-test")
@@ -131,7 +129,6 @@ async def test_compact_to_new_session(tmp_path: Path) -> None:
async def test_rebuild_client_preserves_persist_cursor(tmp_path: Path) -> None:
_ = set_workspace_root(tmp_path)
memory = await MemoryStore.open(DatabaseConfig())
try:
provider = OpenAIProvider(access_key_or_token="sk-test")
@@ -152,16 +149,20 @@ async def test_rebuild_client_preserves_persist_cursor(tmp_path: Path) -> None:
[UserChatMessage(content="hi"), AssistantChatMessage(content="yo")],
persisted=True,
)
assert state.agent.persist_from == 2
# Default system prompt is prepended locally (not a DB row); checkpoint
# shifts so the two stored turns stay in the persisted prefix.
n_msgs = len(state.agent.messages)
assert n_msgs >= 2
assert state.agent.persist_from == n_msgs
cursor = state.agent.persist_from
state.rebuild_client()
assert len(state.agent.messages) == 2
assert state.agent.persist_from == 2
assert len(state.agent.messages) == n_msgs
assert state.agent.persist_from == cursor
finally:
await memory.close()
async def test_compact_empty_fails(tmp_path: Path) -> None:
_ = set_workspace_root(tmp_path)
memory = await MemoryStore.open(DatabaseConfig())
try:
provider = OpenAIProvider(access_key_or_token="sk-test")
-4
View File
@@ -41,13 +41,9 @@ def test_format_tool_confirm_box_multiline() -> None:
def test_install_cli_limit_hooks() -> None:
from plyngent.tools.workspace import get_policy_confirm_hook, set_policy_confirm_hook
install_cli_limit_hooks()
assert callable(getattr(PtyManager, "_limit_continue", None))
assert get_policy_confirm_hook() is not None
PtyManager.set_limit_continue_hook(None)
set_policy_confirm_hook(None)
# Backend remains usable after install.
assert get_prompt_backend().is_interactive() or True
+17 -2
View File
@@ -69,15 +69,22 @@ access_key_or_token = "sk"
def test_chat_oneshot_success(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
config = tmp_path / "plyngent.toml"
# Ephemeral file DB under tmp_path — never the user's durable chat.db.
# (Unset/empty [database].url is rewritten to ~/.local/share/plyngent/chat.db.)
db_path = tmp_path / "test-chat.db"
_ = config.write_text(
"""
f"""
[database]
implementation = "sqlite"
url = "{db_path.as_posix()}"
[providers.local]
preset = "openai-compatible"
url = "https://example.com/v1"
access_key_or_token = "sk"
[providers.local.models]
"tiny" = {}
"tiny" = {{}}
""",
encoding="utf-8",
)
@@ -128,6 +135,11 @@ access_key_or_token = "sk"
lambda _p: DummyClient(),
)
from platformdirs import user_data_path
global_db = user_data_path("plyngent") / "chat.db"
global_mtime = global_db.stat().st_mtime if global_db.exists() else None
runner = CliRunner()
result = runner.invoke(
main,
@@ -149,6 +161,9 @@ access_key_or_token = "sk"
)
assert result.exit_code == EXIT_OK
assert "pong" in result.output
assert db_path.is_file()
if global_mtime is not None:
assert global_db.stat().st_mtime == global_mtime
def test_chat_help_mentions_prompt() -> None:
+105
View File
@@ -0,0 +1,105 @@
"""CLI ``plyngent plugins`` and slash ``/plugins``."""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import patch
import pytest
import tomlkit
from click.testing import CliRunner
from plyngent.cli.app import main
from plyngent.cli.slash import handle_slash
from plyngent.cli.state import ReplState
from plyngent.config.models import DatabaseConfig, OpenAIProvider
from plyngent.config.store import ConfigStore
from plyngent.memory import MemoryStore
from plyngent.tools.plugins import DiscoveredPlugin, PluginStatus
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from pathlib import Path
@pytest.fixture
async def state(tmp_path: Path) -> AsyncIterator[ReplState]:
memory = await MemoryStore.open(DatabaseConfig())
provider = OpenAIProvider(access_key_or_token="sk-test")
config = ConfigStore(path=tmp_path / "plyngent.toml", document=tomlkit.document())
config.providers = {"local": provider}
st = ReplState(
config=config,
memory=memory,
workspace=tmp_path,
provider_name="local",
provider=provider,
model="gpt-test",
tools_enabled=False,
)
# ReplState builds a real client; slash plugin tests only need config + agent.
await st.new_session("t")
yield st
await memory.close()
def _fake_statuses(*, enable: list[str], disable: list[str]) -> list[PluginStatus]:
del enable, disable
return [
PluginStatus(
plugin=DiscoveredPlugin(id="acme", value="acme_pkg:load", package="acme-pkg", version="1.0"),
enabled=True,
disabled=False,
),
PluginStatus(
plugin=DiscoveredPlugin(id="other", value="other:load", package=None, version=None),
enabled=False,
disabled=True,
),
]
def test_plugins_list_cmd(tmp_path: Path) -> None:
config = tmp_path / "plyngent.toml"
_ = config.write_text(
"""
[plugins]
enable = ["acme"]
disable = ["other"]
""",
encoding="utf-8",
)
runner = CliRunner()
with patch("plyngent.tools.plugins.list_plugin_statuses", side_effect=_fake_statuses):
result = runner.invoke(main, ["plugins", "list", "--config", str(config)])
assert result.exit_code == 0
assert "acme" in result.output
assert "enabled" in result.output
assert "other" in result.output
assert "disabled" in result.output
def test_plugins_enable_disable_write(tmp_path: Path) -> None:
config = tmp_path / "plyngent.toml"
_ = config.write_text("", encoding="utf-8")
runner = CliRunner()
result = runner.invoke(main, ["plugins", "enable", "acme", "--config", str(config)])
assert result.exit_code == 0
text = config.read_text(encoding="utf-8")
assert "acme" in text
result = runner.invoke(main, ["plugins", "disable", "acme", "--config", str(config)])
assert result.exit_code == 0
text = config.read_text(encoding="utf-8")
assert "disable" in text
async def test_slash_plugins_enable_reload(state: ReplState) -> None:
with patch("plyngent.tools.plugins.list_plugin_statuses", return_value=[]):
assert await handle_slash(state, "/plugins list") is True
assert await handle_slash(state, "/plugins enable acme") is True
assert "acme" in state.config.plugins_config.enable
assert await handle_slash(state, "/plugins disable acme") is True
assert "acme" in state.config.plugins_config.disable
assert await handle_slash(state, "/plugins undeny acme") is True
assert "acme" not in state.config.plugins_config.disable
assert await handle_slash(state, "/plugins reload") is True
+23 -5
View File
@@ -18,7 +18,6 @@ from plyngent.lmproto.openai_compatible.model import (
ChatCompletionsParam,
)
from plyngent.memory import MemoryStore
from plyngent.tools import set_workspace_root
if TYPE_CHECKING:
from collections.abc import AsyncIterator
@@ -68,7 +67,6 @@ class DummyClient:
@pytest.fixture
async def state(tmp_path: Path) -> AsyncIterator[ReplState]:
_ = set_workspace_root(tmp_path)
memory = await MemoryStore.open(DatabaseConfig())
provider = OpenAIProvider(access_key_or_token="sk-test")
config = ConfigStore(path=tmp_path / "plyngent.toml", document=tomlkit.document())
@@ -166,6 +164,23 @@ async def test_tools_toggle(state: ReplState) -> None:
assert state.tools_enabled is False
async def test_tools_list(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
assert await handle_slash(state, "/tools --list") is True
out_off = capsys.readouterr().out
assert "tools=off" in out_off or "no registry" in out_off
assert await handle_slash(state, "/tools on") is True
assert state.tools_enabled is True
assert state.agent.tools is not None
_ = capsys.readouterr()
assert await handle_slash(state, "/tools --list") is True
out = capsys.readouterr().out
assert "tools=on count=" in out
assert "read_file" in out
assert "LOCAL" in out
assert "builtin" in out
async def test_rename_slash(state: ReplState) -> None:
sid = state.session_id
assert sid is not None
@@ -416,7 +431,6 @@ async def test_yolo_toggle(state: ReplState, capsys: pytest.CaptureFixture[str])
async def test_yolo_rebuilds_tool_registry(tmp_path: Path) -> None:
_ = set_workspace_root(tmp_path)
memory = await MemoryStore.open(DatabaseConfig())
provider = OpenAIProvider(access_key_or_token="sk-test")
config = ConfigStore(path=tmp_path / "plyngent.toml", document=tomlkit.document())
@@ -432,16 +446,20 @@ async def test_yolo_rebuilds_tool_registry(tmp_path: Path) -> None:
)
try:
assert st.agent.tools is not None
# Soft-confirm hooks stay installed; YOLO only auto-allows YOLO-tagged tools.
assert st.agent.tools.soft_confirm is True
assert st.agent.tools.yolo is False
st.set_yolo("on")
assert st.agent.tools is not None
assert st.agent.tools.soft_confirm is False
assert st.agent.tools.soft_confirm is True
assert st.agent.tools.yolo is True
st.set_yolo("once")
assert st.agent.tools is not None
assert st.agent.tools.soft_confirm is False
assert st.agent.tools.yolo is True
st.set_yolo("off")
assert st.agent.tools is not None
assert st.agent.tools.soft_confirm is True
assert st.agent.tools.yolo is False
finally:
await memory.close()
-2
View File
@@ -19,7 +19,6 @@ from plyngent.lmproto.openai_compatible.model import (
)
from plyngent.memory import MemoryStore
from plyngent.memory.database.store import normalize_workspace
from plyngent.tools import set_workspace_root
if TYPE_CHECKING:
from collections.abc import AsyncIterator
@@ -70,7 +69,6 @@ class DummyClient:
def _make_state(memory: MemoryStore, workspace: Path) -> ReplState:
_ = set_workspace_root(workspace)
provider = OpenAIProvider(access_key_or_token="sk-test")
config = ConfigStore(path=workspace / "plyngent.toml", document=tomlkit.document())
config.providers = {"local": provider}
+115 -2
View File
@@ -2,7 +2,12 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from plyngent.config import load
from plyngent.config import (
DEFAULT_SYSTEM_PROMPT,
DEFAULT_TOOL_DIRECTIVES,
compose_agent_system_content,
load,
)
if TYPE_CHECKING:
from pathlib import Path
@@ -12,12 +17,35 @@ def test_agent_section_defaults(tmp_path: Path) -> None:
path = tmp_path / "c.toml"
_ = path.write_text("", encoding="utf-8")
store = load(path)
assert store.agent_config.system_prompt == ""
assert store.agent_config.system_prompt == DEFAULT_SYSTEM_PROMPT
assert store.agent_config.tool_directives == DEFAULT_TOOL_DIRECTIVES
assert "professional coding agent" in store.agent_config.system_prompt
assert "### Workspace" in store.agent_config.tool_directives
assert store.agent_config.max_tool_result_chars == 32_000
assert store.agent_config.parallel_tools is True
assert store.agent_config.confirm_destructive is True
assert store.agent_config.path_denylist == []
assert store.agent_config.max_context_tokens == 200_000
assert store.plugins_config.enable == []
assert store.plugins_config.disable == []
def test_compose_defaults_join_persona_and_directives() -> None:
body = compose_agent_system_content(DEFAULT_SYSTEM_PROMPT, DEFAULT_TOOL_DIRECTIVES)
assert body is not None
assert body.startswith("You are a professional coding agent")
assert "\n\n### Workspace" in body
assert "### Todos" in body
# No double-blank-line collapse issues between parts.
assert "\n\n\n" not in body
def test_compose_empty_combinations() -> None:
assert compose_agent_system_content("", "") is None
assert compose_agent_system_content(" ", "\n") is None
assert compose_agent_system_content("Only persona", "") == "Only persona"
assert compose_agent_system_content("", "Only tools") == "Only tools"
assert compose_agent_system_content("Persona", "Tools") == "Persona\n\nTools"
def test_agent_section_parse(tmp_path: Path) -> None:
@@ -26,6 +54,7 @@ def test_agent_section_parse(tmp_path: Path) -> None:
"""
[agent]
system_prompt = "Be brief."
tool_directives = "Use tools carefully."
max_tool_result_chars = 100
parallel_tools = false
confirm_destructive = false
@@ -36,8 +65,92 @@ max_context_tokens = 5000
)
store = load(path)
assert store.agent_config.system_prompt == "Be brief."
assert store.agent_config.tool_directives == "Use tools carefully."
assert store.agent_config.max_tool_result_chars == 100
assert store.agent_config.parallel_tools is False
assert store.agent_config.confirm_destructive is False
assert store.agent_config.path_denylist == ["/secrets/", ".ssh/"]
assert store.agent_config.max_context_tokens == 5000
composed = compose_agent_system_content(
store.agent_config.system_prompt,
store.agent_config.tool_directives,
)
assert composed == "Be brief.\n\nUse tools carefully."
def test_agent_system_prompt_empty_disables_persona_only(tmp_path: Path) -> None:
path = tmp_path / "c.toml"
_ = path.write_text(
"""
[agent]
system_prompt = ""
""",
encoding="utf-8",
)
store = load(path)
assert store.agent_config.system_prompt == ""
assert store.agent_config.tool_directives == DEFAULT_TOOL_DIRECTIVES
body = compose_agent_system_content(
store.agent_config.system_prompt,
store.agent_config.tool_directives,
)
assert body is not None
assert "### Workspace" in body
assert "professional coding agent" not in body
def test_agent_tool_directives_empty_disables_playbook_only(tmp_path: Path) -> None:
path = tmp_path / "c.toml"
_ = path.write_text(
"""
[agent]
tool_directives = ""
""",
encoding="utf-8",
)
store = load(path)
assert store.agent_config.tool_directives == ""
assert store.agent_config.system_prompt == DEFAULT_SYSTEM_PROMPT
body = compose_agent_system_content(
store.agent_config.system_prompt,
store.agent_config.tool_directives,
)
assert body is not None
assert "professional coding agent" in body
assert "### Workspace" not in body
def test_agent_both_empty_disables_system(tmp_path: Path) -> None:
path = tmp_path / "c.toml"
_ = path.write_text(
"""
[agent]
system_prompt = ""
tool_directives = ""
""",
encoding="utf-8",
)
store = load(path)
assert (
compose_agent_system_content(
store.agent_config.system_prompt,
store.agent_config.tool_directives,
)
is None
)
def test_plugins_section_parse(tmp_path: Path) -> None:
path = tmp_path / "c.toml"
_ = path.write_text(
"""
[plugins]
enable = ["acme", "*"]
disable = ["legacy"]
""",
encoding="utf-8",
)
store = load(path)
assert store.plugins_config.enable == ["acme", "*"]
assert store.plugins_config.disable == ["legacy"]
assert store.plugins["enable"] == ["acme", "*"]
+94
View File
@@ -3,6 +3,7 @@ from collections.abc import Mapping
from pathlib import Path
import pytest
import tomlkit
import plyngent
from plyngent.config import (
@@ -12,6 +13,7 @@ from plyngent.config import (
OpenAICompatibleProvider,
OpenAIProvider,
)
from plyngent.config.store import ConfigStore
@pytest.fixture
@@ -83,6 +85,46 @@ access_key_or_token = "sk-test"
provider = config.providers["oai"]
assert isinstance(provider, OpenAIProvider)
assert set(provider.models) == {"gpt-5.4", "gpt-5.4-mini", "gpt-5.4-nano"}
assert provider.timeout is None
def test_provider_timeout_float_from_toml(tmp_path: Path) -> None:
path = tmp_path / "timeout-float.toml"
_ = path.write_text(
"""
[providers.local]
preset = "openai-compatible"
url = "https://example.com/v1"
access_key_or_token = "sk-test"
timeout = 90
models = { "m" = { text = true } }
""",
encoding="utf-8",
)
config = plyngent.config.load(path)
provider = config.providers["local"]
assert isinstance(provider, OpenAICompatibleProvider)
assert provider.timeout == 90
def test_provider_timeout_table_from_toml(tmp_path: Path) -> None:
from plyngent.config import HttpTimeoutConfig
path = tmp_path / "timeout-table.toml"
_ = path.write_text(
"""
[providers.oai]
access_key_or_token = "sk-test"
timeout = { connect = 5, read = 120 }
""",
encoding="utf-8",
)
config = plyngent.config.load(path)
provider = config.providers["oai"]
assert isinstance(provider, OpenAIProvider)
assert isinstance(provider.timeout, HttpTimeoutConfig)
assert provider.timeout.connect == 5
assert provider.timeout.read == 120
def test_deepseek_explicit_models_override_defaults() -> None:
@@ -212,6 +254,58 @@ url = "https://example/v1"
assert set(config.providers["local"].models) == {"base", "extra", "remote-a", "remote-b"}
def test_write_models_as_inline_tables(tmp_path: Path) -> None:
"""Persisted models use inline tables, not dotted [providers.x.models.id] sections."""
from plyngent.config import ModelConfig, OpenAICompatibleProvider
path = tmp_path / "inline-models.toml"
config = ConfigStore(path=path, document=tomlkit.document())
config.providers = {
"local": OpenAICompatibleProvider(
access_key_or_token="sk-test",
url="https://example/v1",
models={
"base": ModelConfig(),
"gpt-test": ModelConfig(text=True, cost_factor=2),
},
)
}
config.write()
text = path.read_text(encoding="utf-8")
assert "[providers.local.models]" in text
assert "[providers.local.models.base]" not in text
assert "[providers.local.models.gpt-test]" not in text
assert "gpt-test" in text and "cost_factor" in text
# Round-trip still loads models.
again = plyngent.config.load(path)
assert set(again.providers["local"].models) == {"base", "gpt-test"}
assert again.providers["local"].models["gpt-test"].cost_factor == 2
def test_write_lf_strings_as_multiline(tmp_path: Path) -> None:
"""Strings containing LF are written as TOML multi-line strings (\"\"\"\"\"\")."""
from plyngent.config import AgentConfig
path = tmp_path / "ml-string.toml"
config = ConfigStore(path=path, document=tomlkit.document())
# AgentConfig has no public setter; exercise the TOML encoder via write path.
config._agent = AgentConfig(
system_prompt="Line one.\nLine two.",
tool_directives="Use tools.\nBe careful.",
)
config.write()
text = path.read_text(encoding="utf-8")
assert 'system_prompt = """' in text
assert "Line one." in text and "Line two." in text
assert 'tool_directives = """' in text
# Escaped single-line form should not appear for these values.
assert 'system_prompt = "Line one.\\nLine two."' not in text
again = plyngent.config.load(path)
assert again.agent_config.system_prompt == "Line one.\nLine two."
assert again.agent_config.tool_directives == "Use tools.\nBe careful."
def test_update_config() -> None:
file = Path(__file__).parent / "plyngent-edit-2.toml"
_ = shutil.copy(Path(__file__).parent / "plyngent-valid.toml", file)
+61
View File
@@ -0,0 +1,61 @@
"""[plugins] section mutators and parse."""
from __future__ import annotations
from typing import TYPE_CHECKING
import tomlkit
from plyngent.config import load
from plyngent.config.store import ConfigStore
if TYPE_CHECKING:
from pathlib import Path
def test_plugins_defaults_and_parse(tmp_path: Path) -> None:
path = tmp_path / "c.toml"
_ = path.write_text("", encoding="utf-8")
store = load(path)
assert store.plugins_config.enable == []
assert store.plugins_config.disable == []
path.write_text(
"""
[plugins]
enable = ["acme", "*"]
disable = ["legacy"]
""",
encoding="utf-8",
)
store = load(path)
assert store.plugins_config.enable == ["acme", "*"]
assert store.plugins_config.disable == ["legacy"]
def test_enable_disable_undeny_write(tmp_path: Path) -> None:
path = tmp_path / "c.toml"
store = ConfigStore(path=path, document=tomlkit.document())
_ = store.enable_plugin("acme")
assert store.plugins_config.enable == ["acme"]
store.write()
reloaded = load(path)
assert reloaded.plugins_config.enable == ["acme"]
_ = store.disable_plugin("acme")
assert "acme" not in store.plugins_config.enable
assert store.plugins_config.disable == ["acme"]
store.write()
reloaded = load(path)
assert reloaded.plugins_config.disable == ["acme"]
_ = store.undeny_plugin("acme")
assert store.plugins_config.disable == []
_ = store.enable_plugin("*")
assert store.plugins_config.enable == ["*"]
_ = store.enable_plugin("other")
# Already * — stay *
assert store.plugins_config.enable == ["*"]
_ = store.clear_plugins()
assert store.plugins_config.enable == []
assert store.plugins_config.disable == []
+116
View File
@@ -0,0 +1,116 @@
from __future__ import annotations
import math
import pytest
from plyngent.config.models import (
HttpTimeoutConfig,
OpenAICompatibleProvider,
OpenAIProvider,
)
from plyngent.lmproto.openai_compatible.config import (
DEFAULT_HTTP_CONNECT_TIMEOUT,
DEFAULT_HTTP_READ_TIMEOUT,
)
from plyngent.runtime import (
InvalidHttpTimeoutError,
ProviderNotSupportedError,
create_client,
normalize_http_timeout,
provider_to_openai_config,
)
def test_normalize_none_uses_product_defaults() -> None:
assert normalize_http_timeout(None) == (
DEFAULT_HTTP_CONNECT_TIMEOUT,
DEFAULT_HTTP_READ_TIMEOUT,
)
def test_normalize_float() -> None:
assert normalize_http_timeout(120) == 120.0
assert normalize_http_timeout(0.5) == 0.5
def test_normalize_table_partial_and_full() -> None:
assert normalize_http_timeout(HttpTimeoutConfig()) == (
DEFAULT_HTTP_CONNECT_TIMEOUT,
DEFAULT_HTTP_READ_TIMEOUT,
)
assert normalize_http_timeout(HttpTimeoutConfig(connect=5.0)) == (
5.0,
DEFAULT_HTTP_READ_TIMEOUT,
)
assert normalize_http_timeout(HttpTimeoutConfig(read=30.0)) == (
DEFAULT_HTTP_CONNECT_TIMEOUT,
30.0,
)
assert normalize_http_timeout(HttpTimeoutConfig(connect=3.0, read=90.0)) == (3.0, 90.0)
@pytest.mark.parametrize(
"bad",
[
0,
-1,
math.nan,
math.inf,
True, # bool is int subclass but rejected
HttpTimeoutConfig(connect=0),
HttpTimeoutConfig(read=-5),
HttpTimeoutConfig(connect=math.nan),
],
)
def test_normalize_rejects_invalid(bad: float | HttpTimeoutConfig) -> None:
with pytest.raises(InvalidHttpTimeoutError):
_ = normalize_http_timeout(bad)
def test_provider_to_openai_config_default_timeout() -> None:
provider = OpenAIProvider(access_key_or_token="sk-test")
config = provider_to_openai_config(provider)
assert config.timeout == (DEFAULT_HTTP_CONNECT_TIMEOUT, DEFAULT_HTTP_READ_TIMEOUT)
def test_provider_to_openai_config_float_timeout() -> None:
provider = OpenAICompatibleProvider(
access_key_or_token="sk-test",
url="https://example.com/v1",
timeout=45.0,
)
config = provider_to_openai_config(provider)
assert config.timeout == 45.0
def test_provider_to_openai_config_table_timeout() -> None:
provider = OpenAIProvider(
access_key_or_token="sk-test",
timeout=HttpTimeoutConfig(connect=2.0, read=99.0),
)
config = provider_to_openai_config(provider)
assert config.timeout == (2.0, 99.0)
def test_invalid_timeout_via_create_client() -> None:
provider = OpenAICompatibleProvider(
access_key_or_token="sk-test",
url="https://example.com/v1",
timeout=0,
)
with pytest.raises(ProviderNotSupportedError, match="timeout"):
_ = create_client(provider)
def test_client_session_receives_timeout() -> None:
from plyngent.lmproto.openai_compatible import OpenAICompatibleClient
provider = OpenAICompatibleProvider(
access_key_or_token="sk-test",
url="https://example.com/v1",
timeout=HttpTimeoutConfig(connect=7.0, read=11.0),
)
client = create_client(provider)
assert isinstance(client, OpenAICompatibleClient)
assert client.session.timeout == (7.0, 11.0)
+9 -6
View File
@@ -4,6 +4,7 @@ from typing import TYPE_CHECKING
import pytest
from plyngent.tools.context import InstanceState, bind_instance
from plyngent.tools.workspace import (
clear_workspace_allowlist,
clear_workspace_root,
@@ -17,9 +18,11 @@ if TYPE_CHECKING:
@pytest.fixture
def workspace(tmp_path: Path) -> Iterator[Path]:
clear_workspace_root()
clear_workspace_allowlist()
root = set_workspace_root(tmp_path)
yield root
clear_workspace_allowlist()
clear_workspace_root()
"""Bind an InstanceState workspace for tool tests (no process globals)."""
instance = InstanceState(workspace_root=tmp_path.resolve())
with bind_instance(instance):
clear_workspace_allowlist()
root = set_workspace_root(tmp_path)
yield root
clear_workspace_allowlist()
clear_workspace_root()
+14 -1
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import asyncio
import inspect
from typing import TYPE_CHECKING, Any, cast
@@ -8,8 +9,20 @@ if TYPE_CHECKING:
def call_sync(definition: ToolDefinition, *args: object, **kwargs: object) -> str:
"""Invoke a tool handler from a **sync** test.
Async handlers are run with ``asyncio.run``. Inside an already-running
event loop (async tests), use :func:`call_async` instead.
"""
result: Any = definition.handler(*args, **kwargs)
assert not inspect.isawaitable(result)
if inspect.isawaitable(result):
try:
asyncio.get_running_loop()
except RuntimeError:
result = asyncio.run(result)
else:
msg = "call_sync cannot await inside a running event loop; use call_async"
raise RuntimeError(msg)
return cast("str", result)
+99
View File
@@ -0,0 +1,99 @@
"""Tool catalog, tags, and default select parity."""
from __future__ import annotations
import inspect
from plyngent.agent import ToolTag, tool
from plyngent.tools import default_tool_definitions, register_builtin_tools
from plyngent.tools.catalog import ToolCatalog, ToolSource, catalog_scope, get_catalog, registration_source
from plyngent.tools.chat import CHAT_TOOLS
from plyngent.tools.file import FILE_TOOLS
from plyngent.tools.process import PROCESS_TOOLS
from plyngent.tools.todo import TODO_TOOLS
from plyngent.tools.vcs import VCS_TOOLS
def test_default_tool_names_match_group_lists() -> None:
register_builtin_tools()
selected = default_tool_definitions(surface="local")
groups = [*FILE_TOOLS, *PROCESS_TOOLS, *VCS_TOOLS, *CHAT_TOOLS, *TODO_TOOLS]
assert sorted(t.name for t in selected) == sorted(t.name for t in groups)
assert len(selected) == len(groups)
def test_all_builtins_are_async_and_tagged() -> None:
tools = default_tool_definitions()
assert tools
for definition in tools:
assert inspect.iscoroutinefunction(definition.handler), definition.name
assert definition.tags & (ToolTag.LOCAL | ToolTag.PUBLIC)
assert definition.tags & ToolTag.LOCAL or definition.tags & ToolTag.PUBLIC
def test_public_surface_is_subset() -> None:
local = {t.name for t in default_tool_definitions(surface="local")}
public = {t.name for t in default_tool_definitions(surface="public")}
assert public
assert public <= local
# Todo series is the main PUBLIC surface today.
assert "todo_list" in public
assert "read_file" not in public
def test_catalog_scope_empty_isolates_registration() -> None:
with catalog_scope(empty=True) as catalog:
@tool(name="only_in_scope", register=True)
async def only_in_scope() -> str:
return "ok"
_ = only_in_scope
assert catalog.get("only_in_scope") is not None
# default_tool_definitions re-seeds builtins into the override; plugin-like
# names from this scope stay local to the override and leave process catalog.
assert get_catalog().get("only_in_scope") is None
def test_collision_refuses_shadow() -> None:
catalog = ToolCatalog()
@tool(register=False)
async def alpha() -> str:
return "a"
catalog.register(alpha, source=ToolSource(kind="builtin"))
try:
catalog.register(alpha, source=ToolSource(kind="plugin", plugin_id="acme"))
raise AssertionError("expected collision")
except ValueError as exc:
assert "collision" in str(exc)
def test_registration_source_context() -> None:
with catalog_scope(empty=True) as catalog:
with registration_source(ToolSource(kind="plugin", plugin_id="acme")):
@tool(name="plugin_tool")
async def plugin_tool() -> str:
return "p"
_ = plugin_tool
entry = catalog.get("plugin_tool")
assert entry is not None
assert entry.source.kind == "plugin"
assert entry.source.plugin_id == "acme"
def test_tool_tags_reject_neither_surface() -> None:
try:
@tool(tags=ToolTag.YOLO, register=False) # type: ignore[arg-type]
async def bad() -> str:
return "x"
_ = bad
raise AssertionError("expected ValueError")
except ValueError as exc:
assert "LOCAL" in str(exc)
+147
View File
@@ -0,0 +1,147 @@
"""Tag-aware soft confirm: YOLO bit + TRUSTABLE grants."""
from __future__ import annotations
from plyngent.agent import ToolRegistry, ToolTag, tool
from plyngent.tools.context import SessionState, bind_tool_context
from plyngent.tools.grants import clear_grants, has_grant, hydrate_grants, persist_grants
from plyngent.tools.view import MemoryViewStore, session_data_view
async def test_yolo_only_skips_yolo_tagged_tools() -> None:
calls: list[str] = []
@tool(tags=ToolTag.LOCAL | ToolTag.YOLO, register=False)
async def yolo_ok() -> str:
return "y"
@tool(tags=ToolTag.LOCAL, register=False)
async def no_yolo() -> str:
return "n"
def danger(name: str, _args: object) -> str | None:
return "soft reason"
async def confirm(name: str, _args: object, _reason: str) -> bool:
calls.append(name)
return True
reg = ToolRegistry(
[yolo_ok, no_yolo],
danger=danger,
on_confirm=confirm,
yolo=True,
)
assert await reg.execute("yolo_ok", "{}") == "y"
assert calls == []
assert await reg.execute("no_yolo", "{}") == "n"
assert calls == ["no_yolo"]
async def test_trustable_grant_once() -> None:
calls: list[str] = []
@tool(tags=ToolTag.LOCAL | ToolTag.TRUSTABLE, register=False)
async def trust_me() -> str:
return "ok"
def danger(name: str, _args: object) -> str | None:
return "soft"
async def confirm(name: str, _args: object, _reason: str) -> bool:
calls.append(name)
return True
session = SessionState(session_id=1)
reg = ToolRegistry(
[trust_me],
danger=danger,
on_confirm=confirm,
yolo=False,
auto_bind_state=True,
session_state=session,
)
with bind_tool_context(session=session):
assert await reg.execute("trust_me", "{}") == "ok"
assert await reg.execute("trust_me", "{}") == "ok"
assert calls == ["trust_me"]
async def test_untagged_trustable_prompts_every_time() -> None:
calls: list[str] = []
@tool(tags=ToolTag.LOCAL, register=False)
async def always_ask() -> str:
return "ok"
def danger(name: str, _args: object) -> str | None:
return "soft"
async def confirm(name: str, _args: object, _reason: str) -> bool:
calls.append(name)
return True
reg = ToolRegistry([always_ask], danger=danger, on_confirm=confirm, yolo=False)
assert await reg.execute("always_ask", "{}") == "ok"
assert await reg.execute("always_ask", "{}") == "ok"
assert calls == ["always_ask", "always_ask"]
async def test_trustable_grant_persists_to_session_data() -> None:
calls: list[str] = []
@tool(tags=ToolTag.LOCAL | ToolTag.TRUSTABLE, register=False)
async def trust_me() -> str:
return "ok"
def danger(_name: str, _args: object) -> str | None:
return "soft"
async def confirm(name: str, _args: object, _reason: str) -> bool:
calls.append(name)
return True
store = MemoryViewStore({})
session = SessionState(session_id=1, data=session_data_view(store=store))
reg = ToolRegistry(
[trust_me],
danger=danger,
on_confirm=confirm,
yolo=False,
session_state=session,
)
assert await reg.execute("trust_me", "{}") == "ok"
assert has_grant(session, "trust_me")
loaded = await store.load()
assert isinstance(loaded, dict)
grants = loaded.get("grants")
assert isinstance(grants, dict)
assert grants.get("trust_me") is True
# Fresh session + same store: hydrate restores the grant (no second prompt).
session2 = SessionState(session_id=1, data=session_data_view(store=store))
await hydrate_grants(session2)
assert has_grant(session2, "trust_me")
reg2 = ToolRegistry(
[trust_me],
danger=danger,
on_confirm=confirm,
yolo=False,
session_state=session2,
)
assert await reg2.execute("trust_me", "{}") == "ok"
assert calls == ["trust_me"]
async def test_clear_grants_and_persist() -> None:
store = MemoryViewStore({})
session = SessionState(session_id=2, data=session_data_view(store=store))
session.add_grant("tool_a")
await persist_grants(session)
clear_grants(session)
assert not has_grant(session, "tool_a")
# Live clear does not rewrite the store; explicit persist does.
await persist_grants(session)
loaded = await store.load()
assert isinstance(loaded, dict)
assert loaded.get("grants") == {}
+7 -7
View File
@@ -18,10 +18,12 @@ def test_classify_copy() -> None:
def test_classify_write_overwrite_only(tmp_path: Path) -> None:
from plyngent.tools.workspace import clear_workspace_root, set_workspace_root
from plyngent.tools.context import InstanceState, bind_instance
from plyngent.tools.workspace import set_workspace_root
set_workspace_root(tmp_path)
try:
instance = InstanceState(workspace_root=tmp_path.resolve())
with bind_instance(instance):
_ = set_workspace_root(tmp_path)
# New file: no soft-confirm
assert classify_danger("write_file", {"path": "new.txt"}) is None
# Partial edits: never soft-confirm
@@ -36,8 +38,6 @@ def test_classify_write_overwrite_only(tmp_path: Path) -> None:
assert classify_danger("copy_path", {"src": "src.txt", "dst": "dst.txt", "overwrite": False}) is None
creason = classify_danger("copy_path", {"src": "src.txt", "dst": "dst.txt", "overwrite": True})
assert creason is not None and "overwrite" in creason
finally:
clear_workspace_root()
def test_classify_safe_tools() -> None:
@@ -100,7 +100,7 @@ async def test_confirm_deny_with_comment() -> None:
from plyngent.agent.tools import ToolRegistry, tool
from plyngent.tools.danger import classify_danger as danger
@tool
@tool(register=False)
def delete_path(path: str) -> str:
return f"deleted {path}"
@@ -114,6 +114,7 @@ async def test_confirm_deny_with_comment() -> None:
assert "user comment:" in out
assert "too destructive" in out
def test_shell_confirm_formats_command_placeholder() -> None:
script = chr(10).join(["line1", "line2", "line3"])
reason = classify_danger(
@@ -130,4 +131,3 @@ def test_shell_confirm_formats_command_placeholder() -> None:
body = lines[idx + 1 :]
assert body
assert all(ln.startswith(" ") for ln in body)
+72
View File
@@ -0,0 +1,72 @@
"""Plugin allowlist and registration source."""
from __future__ import annotations
from plyngent.agent import tool
from plyngent.tools.catalog import ToolSource, catalog_scope, get_catalog, registration_source
from plyngent.tools.plugins import load_plugin_tools, resolve_plugin_allowlist
def test_resolve_plugin_allowlist() -> None:
assert resolve_plugin_allowlist(None) == set()
assert resolve_plugin_allowlist([]) == set()
assert resolve_plugin_allowlist(["*"]) is None
assert resolve_plugin_allowlist(["acme", " beta "]) == {"acme", "beta"}
def test_plugin_would_load() -> None:
from plyngent.tools.plugins import plugin_would_load
assert plugin_would_load("acme", enable=["acme"], disable=[]) is True
assert plugin_would_load("acme", enable=["*"], disable=[]) is True
assert plugin_would_load("acme", enable=["*"], disable=["acme"]) is False
assert plugin_would_load("acme", enable=[], disable=[]) is False
def test_load_plugin_tools_default_loads_none() -> None:
with catalog_scope(empty=True):
loaded = load_plugin_tools(None)
assert loaded == []
assert get_catalog().names() == []
def test_plugin_registration_source_marks_entries() -> None:
with catalog_scope(empty=True) as catalog:
with registration_source(ToolSource(kind="plugin", plugin_id="acme")):
@tool(name="acme_ping")
async def acme_ping() -> str:
return "pong"
_ = acme_ping
entry = catalog.get("acme_ping")
assert entry is not None
assert entry.source.kind == "plugin"
assert entry.source.plugin_id == "acme"
def test_plugin_cannot_shadow_builtin_name() -> None:
with catalog_scope(empty=True) as catalog:
with registration_source(ToolSource(kind="builtin")):
@tool(name="read_file")
async def builtin_read() -> str:
return "b"
_ = builtin_read
try:
with registration_source(ToolSource(kind="plugin", plugin_id="acme")):
@tool(name="read_file")
async def plugin_read() -> str:
return "p"
_ = plugin_read
raise AssertionError("expected collision")
except ValueError as exc:
assert "collision" in str(exc)
entry = catalog.get("read_file")
assert entry is not None
assert entry.source.kind == "builtin"
+13 -13
View File
@@ -185,7 +185,7 @@ async def test_run_command_env(workspace: object) -> None:
async def test_pty_open_read_close(workspace: object) -> None:
del workspace
try:
opened = call_sync(open_pty, _py("import time; time.sleep(30)"))
opened = await call_async(open_pty, _py("import time; time.sleep(30)"))
assert "session_id=" in opened
session_id = _session_id(opened)
data = await call_async(read_pty, session_id, timeout=0.05)
@@ -207,7 +207,7 @@ def test_pty_denied(workspace: object) -> None:
async def test_pty_echo_output(workspace: object) -> None:
del workspace
try:
opened = call_sync(open_pty, _py("print('hello-pty')"))
opened = await call_async(open_pty, _py("print('hello-pty')"))
session_id = _session_id(opened)
text = await call_async(read_pty, session_id, timeout=2.0, until="hello-pty")
assert "hello-pty" in text
@@ -222,7 +222,7 @@ async def test_write_pty(workspace: object) -> None:
del workspace
try:
# Line-oriented echo (portable stand-in for ``cat``).
opened = call_sync(
opened = await call_async(
open_pty,
_py(
"import sys\n"
@@ -235,7 +235,7 @@ async def test_write_pty(workspace: object) -> None:
),
)
session_id = _session_id(opened)
written = call_sync(write_pty, session_id, "pty-input\n")
written = await call_async(write_pty, session_id, "pty-input\n")
assert "wrote=" in written
text = await call_async(read_pty, session_id, timeout=2.0, until="pty-input")
assert "pty-input" in text
@@ -258,7 +258,7 @@ async def test_ask_into_pty_writes_without_leaking_secret(workspace: object) ->
secret = "super-secret-password-xyz"
backend = ScriptedBackend([], secrets=[secret])
try:
opened = call_sync(
opened = await call_async(
open_pty,
_py(
"import sys\n"
@@ -302,7 +302,7 @@ async def test_ask_into_pty_empty_cancels(workspace: object) -> None:
backend = ScriptedBackend([], secrets=[""])
try:
opened = call_sync(open_pty, _py("import time; time.sleep(30)"))
opened = await call_async(open_pty, _py("import time; time.sleep(30)"))
session_id = _session_id(opened)
with temporary_backend(backend):
result = await call_async(ask_into_pty, session_id, "Pass?", secret=True)
@@ -319,7 +319,7 @@ async def test_ask_into_pty_nonsecret_line(workspace: object) -> None:
backend = ScriptedBackend(["yes"])
try:
opened = call_sync(
opened = await call_async(
open_pty,
_py("import sys\nline = sys.stdin.readline()\nsys.stdout.write(line)\nsys.stdout.flush()\n"),
)
@@ -338,7 +338,7 @@ async def test_ask_into_pty_nonsecret_line(workspace: object) -> None:
async def test_pty_exec_failure_surfaces(workspace: object) -> None:
del workspace
try:
opened = call_sync(open_pty, ["definitely-not-a-real-binary-xyz"])
opened = await call_async(open_pty, ["definitely-not-a-real-binary-xyz"])
# Windows ConPTY may fail at open; POSIX may open then fail on exec.
if "error:" in opened and "session_id=" not in opened:
assert "not found" in opened.lower() or "failed" in opened.lower()
@@ -394,7 +394,7 @@ async def test_pty_output_budget(workspace: object) -> None:
try:
PtyManager.set_limit_continue_hook(None)
PtyManager.configure(session_output_budget=64)
opened = call_sync(open_pty, _py("print('x' * 1000)"))
opened = await call_async(open_pty, _py("print('x' * 1000)"))
session_id = _session_id(opened)
# Drain until budget exhausted or process ends.
budget_hit = False
@@ -423,7 +423,7 @@ async def test_pty_output_budget_is_per_session(workspace: object) -> None:
PtyManager.configure(session_output_budget=1024)
class_budget = PtyManager.session_output_budget
PtyManager.set_limit_continue_hook(lambda _reason: True)
opened = call_sync(open_pty, _py("print('x' * 200)"))
opened = await call_async(open_pty, _py("print('x' * 200)"))
session_id = _session_id(opened)
session = PtyManager.get(session_id)
assert session is not None
@@ -495,7 +495,7 @@ def test_close_all_empty_is_noop() -> None:
async def test_read_pty_sanitizes_esc(workspace: object) -> None:
del workspace
try:
opened = call_sync(open_pty, _py("print(chr(0x1B)+'[31mred'+chr(0x1B)+'[0m')"))
opened = await call_async(open_pty, _py("print(chr(0x1B)+'[31mred'+chr(0x1B)+'[0m')"))
session_id = _session_id(opened)
text = await call_async(read_pty, session_id, timeout=2.0, until="red")
assert "red" in text
@@ -510,7 +510,7 @@ async def test_read_pty_sanitizes_esc(workspace: object) -> None:
async def test_write_pty_keys_ctrl_escape(workspace: object) -> None:
del workspace
try:
opened = call_sync(
opened = await call_async(
open_pty,
_py(
"import sys\n"
@@ -520,7 +520,7 @@ async def test_write_pty_keys_ctrl_escape(workspace: object) -> None:
),
)
session_id = _session_id(opened)
written = call_sync(write_pty_keys, session_id, "ctrl+c")
written = await call_async(write_pty_keys, session_id, "ctrl+c")
assert "wrote=1" in written
text = await call_async(read_pty, session_id, timeout=2.0)
assert "error" not in text.lower() or "alive=" in text
+107
View File
@@ -0,0 +1,107 @@
"""Todo tools via session.data PersistentDataView (session-bound only)."""
from __future__ import annotations
from plyngent.agent import ToolRegistry
from plyngent.agent.todo_stack import TodoStack
from plyngent.tools.context import SessionState, bind_tool_context
from plyngent.tools.todo import TODO_TOOLS
from plyngent.tools.view import MemoryViewStore, session_data_view
async def test_todo_tools_session_data() -> None:
store = MemoryViewStore({})
session = SessionState(session_id="s1", data=session_data_view(store=store))
registry = ToolRegistry(list(TODO_TOOLS), session_state=session)
out = await registry.execute("todo_push", '{"titles": ["A", "B"]}')
assert "pushed" in out
assert session.todo is not None
assert session.todo.depth == 1
loaded = await store.load()
assert isinstance(loaded, dict)
raw = loaded.get("todo")
assert isinstance(raw, dict)
assert "groups" in raw
restored = TodoStack.from_raw(raw)
assert [i.title for i in restored.groups[0].items] == ["A", "B"]
out2 = await registry.execute("todo_list", "{}")
assert "A" in out2 and "B" in out2
async def test_todo_tools_prefer_session_todo_facet() -> None:
stack = TodoStack()
store = MemoryViewStore({})
session = SessionState(session_id="s2", data=session_data_view(store=store), todo=stack)
registry = ToolRegistry(list(TODO_TOOLS), session_state=session)
_ = await registry.execute("todo_push", '{"titles": ["X"]}')
assert stack.depth == 1
assert session.todo is stack
loaded = await store.load()
assert isinstance(loaded, dict)
assert isinstance(loaded.get("todo"), dict)
async def test_todo_tools_view_isolation_two_sessions() -> None:
store_a = MemoryViewStore({})
store_b = MemoryViewStore({})
session_a = SessionState(session_id="a", data=session_data_view(store=store_a))
session_b = SessionState(session_id="b", data=session_data_view(store=store_b))
reg_a = ToolRegistry(list(TODO_TOOLS), session_state=session_a)
reg_b = ToolRegistry(list(TODO_TOOLS), session_state=session_b)
_ = await reg_a.execute("todo_push", '{"titles": ["only-a"]}')
_ = await reg_b.execute("todo_push", '{"titles": ["only-b"]}')
loaded_a = await store_a.load()
loaded_b = await store_b.load()
assert isinstance(loaded_a, dict)
assert isinstance(loaded_b, dict)
raw_a = TodoStack.from_raw(loaded_a.get("todo"))
raw_b = TodoStack.from_raw(loaded_b.get("todo"))
assert [i.title for i in raw_a.all_items()] == ["only-a"]
assert [i.title for i in raw_b.all_items()] == ["only-b"]
assert session_a.todo is not session_b.todo
async def test_with_bound_context_without_registry_session() -> None:
"""Handlers honor contextvars when registry does not hold session_state."""
store = MemoryViewStore({})
session = SessionState(session_id="ctx", data=session_data_view(store=store))
registry = ToolRegistry(list(TODO_TOOLS), auto_bind_state=False)
with bind_tool_context(session=session):
out = await registry.execute("todo_push", '{"titles": ["via-ctx"]}')
assert "pushed" in out
loaded = await store.load()
assert isinstance(loaded, dict)
assert isinstance(loaded.get("todo"), dict)
async def test_session_on_todo_change_fires() -> None:
hits: list[str] = []
def session_hook() -> None:
hits.append("session")
stack = TodoStack()
store = MemoryViewStore({})
session = SessionState(
session_id="hook",
data=session_data_view(store=store),
todo=stack,
on_todo_change=session_hook,
)
registry = ToolRegistry(list(TODO_TOOLS), session_state=session)
_ = await registry.execute("todo_push", '{"titles": ["H"]}')
assert hits == ["session"]
async def test_todo_without_session_errors() -> None:
registry = ToolRegistry(list(TODO_TOOLS))
out = await registry.execute("todo_list", "{}")
assert "error" in out.lower()
+65
View File
@@ -0,0 +1,65 @@
"""PersistentDataView transaction behavior."""
from __future__ import annotations
import pytest
from plyngent.agent.todo_stack import TodoStack
from plyngent.tools.view import MemoryViewStore, PersistentDataView
async def test_view_txn_commit() -> None:
store = MemoryViewStore({})
root: PersistentDataView[dict[str, object]] = PersistentDataView(store, bound_type=dict)
async with root as data:
data["todo"].store({"groups": [], "next_id": 1})
loaded = await store.load()
assert isinstance(loaded, dict)
assert "todo" in loaded
async def test_view_rollback_on_error() -> None:
store = MemoryViewStore({"keep": 1})
root: PersistentDataView[dict[str, object]] = PersistentDataView(store)
with pytest.raises(RuntimeError, match="boom"):
async with root as data:
data.store({"keep": 2})
msg = "boom"
raise RuntimeError(msg)
assert await store.load() == {"keep": 1}
async def test_typed_todo_stack_commits_raw() -> None:
store = MemoryViewStore({})
root: PersistentDataView[dict[str, object]] = PersistentDataView(store, bound_type=dict)
async with root as data:
todo = data["todo"].typed(TodoStack)
_ = todo.push_group(["A", "B"])
assert todo.depth == 1
loaded = await store.load()
assert isinstance(loaded, dict)
raw_todo = loaded.get("todo")
assert isinstance(raw_todo, dict)
assert "groups" in raw_todo
restored = TodoStack.from_raw(raw_todo)
assert restored.depth == 1
assert [i.title for i in restored.groups[0].items] == ["A", "B"]
async def test_typed_todo_stack_roundtrip_from_raw() -> None:
store = MemoryViewStore({"todo": {"groups": [], "next_id": 1}})
root: PersistentDataView[dict[str, object]] = PersistentDataView(store, bound_type=dict)
async with root as data:
todo = data["todo"].typed(TodoStack)
_ = todo.push_group(["only"])
loaded = await store.load()
assert isinstance(loaded, dict)
again = TodoStack.from_raw(loaded["todo"])
assert [i.title for i in again.all_items()] == ["only"]
async def test_mutate_outside_txn_errors() -> None:
store = MemoryViewStore({})
root: PersistentDataView[dict[str, object]] = PersistentDataView(store)
with pytest.raises(RuntimeError, match="transaction"):
root.store({"a": 1})
+8 -3
View File
@@ -5,7 +5,6 @@ import pytest
from plyngent.tools import (
WorkspaceError,
check_command_allowed,
clear_workspace_root,
get_workspace_root,
resolve_path,
set_command_denylist,
@@ -129,6 +128,12 @@ def test_command_denylist_policy_confirm_timeout_value(workspace: object) -> Non
def test_root_required() -> None:
clear_workspace_root()
with pytest.raises(WorkspaceError, match="not set"):
from plyngent.tools.context import InstanceState, bind_instance
with bind_instance(InstanceState()), pytest.raises(WorkspaceError, match="workspace root is not set"):
_ = get_workspace_root()
def test_unbound_instance_errors() -> None:
with pytest.raises(WorkspaceError, match="instance state is not bound"):
_ = get_workspace_root()
@@ -0,0 +1,59 @@
"""Workspace policy requires bound InstanceState."""
from __future__ import annotations
from pathlib import Path
import pytest
from plyngent.tools.context import InstanceState, bind_instance
from plyngent.tools.workspace import (
WorkspaceError,
clear_workspace_root,
get_workspace_root,
resolve_path,
set_workspace_root,
)
def test_unbound_get_workspace_errors() -> None:
with pytest.raises(WorkspaceError, match="instance state is not bound"):
_ = get_workspace_root()
def test_set_workspace_on_bound_instance(tmp_path: Path) -> None:
instance = InstanceState()
with bind_instance(instance):
path = set_workspace_root(tmp_path)
assert instance.workspace_root == path
assert instance.workspace.root == path
assert get_workspace_root() == path
def test_clear_clears_bound_instance(tmp_path: Path) -> None:
instance = InstanceState()
with bind_instance(instance):
_ = set_workspace_root(tmp_path)
clear_workspace_root()
assert instance.workspace_root is None
with pytest.raises(WorkspaceError, match="workspace root is not set"):
_ = get_workspace_root()
def test_resolve_path_uses_instance_root(tmp_path: Path) -> None:
target = tmp_path / "note.txt"
_ = target.write_text("hi", encoding="utf-8")
instance = InstanceState(workspace_root=tmp_path.resolve())
with bind_instance(instance):
resolved = resolve_path("note.txt")
assert resolved == target.resolve()
def test_path_denylist_uses_instance_policy(tmp_path: Path) -> None:
secret = tmp_path / "secrets"
secret.mkdir()
_ = (secret / "x.txt").write_text("no", encoding="utf-8")
instance = InstanceState(workspace_root=tmp_path.resolve())
instance.workspace.path_denylist = ("/secrets/",)
with bind_instance(instance), pytest.raises(WorkspaceError, match="denied by policy"):
_ = resolve_path("secrets/x.txt")