mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-25 08:04:57 +08:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| acdc62f2a9 | |||
| 908e68665b | |||
| 559a3410bc | |||
| da4d3d097a | |||
| 03e04f9a38 | |||
| 1d718582ac | |||
| 4215a8cea0 | |||
| 278c930fae | |||
| ac3230647a | |||
| 8baaa3f2e1 | |||
| 6c6cec05c4 | |||
| 3c0eb8ffad | |||
| 7a3704edc1 | |||
| 90c07061f3 | |||
| 3d5380a3d4 | |||
| e82a226f91 | |||
| 96518974be | |||
| 07e3c5caa5 | |||
| 662e95f693 | |||
| 7253ca84dd | |||
| 12c8adda3a | |||
| 2f6133f685 | |||
| 15dde7cdaf | |||
| 4feb0e0d26 | |||
| 30beec6600 | |||
| db7c097466 | |||
| b06bb1d409 | |||
| b51d4780ab | |||
| 6d74d31cea | |||
| 767578723e | |||
| 71b9f91e14 | |||
| 879e28ea4c | |||
| 614ffaaf8f | |||
| 7bdd9ff8b7 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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/`)
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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 }
|
||||
|
||||
@@ -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]]
|
||||
|
||||
@@ -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
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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 = ".",
|
||||
*,
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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)"
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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}"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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", "*"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 == []
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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") == {}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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})
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user