mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 23:47:54 +08:00
Compare commits
7 Commits
41ba0f9dda
...
v0.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 9961bdea6f | |||
| a1455989f2 | |||
| 4f142f1db9 | |||
| 5f28a49bc1 | |||
| a4baf38a52 | |||
| c638f6a5da | |||
| ede614aa9e |
@@ -214,3 +214,6 @@ __marimo__/
|
|||||||
|
|
||||||
# Streamlit
|
# Streamlit
|
||||||
.streamlit/secrets.toml
|
.streamlit/secrets.toml
|
||||||
|
|
||||||
|
# codegraph (local code index)
|
||||||
|
.codegraph/
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# CLAUDE.md
|
# AGENTS.md
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ TOML load/store (`ConfigStore`): `[providers]` tagged union presets, `[database]
|
|||||||
|
|
||||||
`create_client(provider)` maps config `Provider` → protocol client by effective preset (model-level `preset`/`url` overrides apply). `normalize_http_timeout` feeds OpenAI-compatible configs and Anthropic. Mapping: `openai` → `OpenAIClient` (agent uses Responses), `openai-compatible` / `deepseek` → chat-completions clients (deepseek with effective `convention = "responses"` → `DeepseekResponsesClient`, `"anthropic"` → `DeepseekAnthropicClient`), `anthropic` → `AnthropicClient`.
|
`create_client(provider)` maps config `Provider` → protocol client by effective preset (model-level `preset`/`url` overrides apply). `normalize_http_timeout` feeds OpenAI-compatible configs and Anthropic. Mapping: `openai` → `OpenAIClient` (agent uses Responses), `openai-compatible` / `deepseek` → chat-completions clients (deepseek with effective `convention = "responses"` → `DeepseekResponsesClient`, `"anthropic"` → `DeepseekAnthropicClient`), `anthropic` → `AnthropicClient`.
|
||||||
|
|
||||||
MCP (`runtime/mcp_client.py`): `McpManager` owns one `McpServerConnection` per `[mcp]` server — a spawned stdio subprocess speaking newline-delimited JSON-RPC 2.0: initialize handshake, `tools/list` (cursor pagination), `tools/call`, `notifications/tools/list_changed` → `tools_stale`. Spawn/handshake failures record `connection.error` (status string), never raise; `ensure_started` / `restart(config)` / `aclose` / `refresh_tools`; `statuses()` → `(server, status, tool_count)` rows; per-request timeout from config (default 30s).
|
MCP (`runtime/mcp_client.py`): `McpManager` owns one `McpServerConnection` per `[mcp]` server — a spawned stdio subprocess speaking newline-delimited JSON-RPC 2.0: initialize handshake, `tools/list` (cursor pagination), `tools/call`, `notifications/tools/list_changed` → `tools_stale`. Spawn/handshake failures record `connection.error` (status string), never raise; `ensure_started` / `restart(config)` / `aclose` / `refresh_tools`; `statuses()` → `(server, status, tool_count)` rows; `instructions()` → `(server, text)` for every connected server that returned optional `InitializeResult.instructions` (usage guidance captured per connection); per-request timeout from config (default 30s).
|
||||||
|
|
||||||
### Memory (`memory/`)
|
### Memory (`memory/`)
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `
|
|||||||
- **`/compact`**: soft-compact tool dumps → model summary (no tools) via the same kind dispatch → **new** session seeded with summary message. Soft-compact keeps the last **12** tool results full-size by default (`DEFAULT_RECENT_TOOL_RESULTS`); older tool payloads shrink first.
|
- **`/compact`**: soft-compact tool dumps → model summary (no tools) via the same kind dispatch → **new** session seeded with summary message. Soft-compact keeps the last **12** tool results full-size by default (`DEFAULT_RECENT_TOOL_RESULTS`); older tool payloads shrink first.
|
||||||
- Events: text_delta, **reasoning_delta**, assistant_message, tool_call/result, max_rounds, **error** (`retryable`/`source`), **cancelled** (`reason`), **usage** (`TokenUsage`).
|
- Events: text_delta, **reasoning_delta**, assistant_message, tool_call/result, max_rounds, **error** (`retryable`/`source`), **cancelled** (`reason`), **usage** (`TokenUsage`).
|
||||||
- Usage: API `usage` from completions (stream with `include_usage`); **char≈token fallback** (~4 chars/token) when omitted; **context size** = last request ``prompt_tokens`` (API preferred); `last_turn_usage` / `session_usage` are **billed sums** (tool rounds re-send history); CLI end-of-turn + `/status`.
|
- Usage: API `usage` from completions (stream with `include_usage`); **char≈token fallback** (~4 chars/token) when omitted; **context size** = last request ``prompt_tokens`` (API preferred); `last_turn_usage` / `session_usage` are **billed sums** (tool rounds re-send history); CLI end-of-turn + `/status`.
|
||||||
- Config ``[agent]``: `system_prompt` (persona; default `DEFAULT_SYSTEM_PROMPT`; `""` omits persona), `tool_directives` (tool playbook; default `DEFAULT_TOOL_DIRECTIVES`; `""` omits playbook; both empty → no system), `max_tool_result_chars`, `parallel_tools`, `confirm_destructive`, `path_denylist`, `max_context_tokens` (default 200k est. tokens). Truncated tool results carry a model hint (`[Truncated (N chars max; M omitted). You may ask for user to increase the limit via configuration file.]`) so the model can ask the user to raise `max_tool_result_chars`; request-time compact shrinks (`hint=False`) keep the terse `...[truncated N characters]` marker.
|
- Config ``[agent]``: `system_prompt` (persona; default `DEFAULT_SYSTEM_PROMPT`; `""` omits persona), `tool_directives` (tool playbook; default `DEFAULT_TOOL_DIRECTIVES`; `""` omits playbook; both empty → no system), `mcp_instructions` (fold connected MCP servers' initialize ``instructions`` into the system prompt when tools on; default true), `max_tool_result_chars`, `parallel_tools`, `confirm_destructive`, `path_denylist`, `max_context_tokens` (default 200k est. tokens). Truncated tool results carry a model hint (`[Truncated (N chars max; M omitted). You may ask for user to increase the limit via configuration file.]`) so the model can ask the user to raise `max_tool_result_chars`; request-time compact shrinks (`hint=False`) keep the terse `...[truncated N characters]` marker.
|
||||||
|
|
||||||
### Tools (`tools/`)
|
### Tools (`tools/`)
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ Shared interactive I/O: `ask` / `choose` / `form` / `confirm` with pluggable bac
|
|||||||
Click app + readline REPL. Entry: `plyngent` / `python -m plyngent`.
|
Click app + readline REPL. Entry: `plyngent` / `python -m plyngent`.
|
||||||
|
|
||||||
- **`plyngent chat`**: provider/model (flags or interactive; Tab via readline in `prompting`); sessions store `provider_name`/`model` and restore on resume; SQLite via `[database]` (file DB under user data when url unset; explicit `url = ":memory:"` kept + warn); workspace-bound; resume latest for cwd/`--workspace` by default (`--new` / `--session`). One-shot: `-p/--prompt` and non-TTY stdin; exit codes 0/1/2/3; `--yes` (YOLO on), `--stream/--no-stream`, `--quiet`. Root `--log-level`. Enabled `[mcp]` servers start before the REPL (`_start_mcp_manager`, per-server failures warn on stderr) and are closed on exit (`McpManager.aclose`).
|
- **`plyngent chat`**: provider/model (flags or interactive; Tab via readline in `prompting`); sessions store `provider_name`/`model` and restore on resume; SQLite via `[database]` (file DB under user data when url unset; explicit `url = ":memory:"` kept + warn); workspace-bound; resume latest for cwd/`--workspace` by default (`--new` / `--session`). One-shot: `-p/--prompt` and non-TTY stdin; exit codes 0/1/2/3; `--yes` (YOLO on), `--stream/--no-stream`, `--quiet`. Root `--log-level`. Enabled `[mcp]` servers start before the REPL (`_start_mcp_manager`, per-server failures warn on stderr) and are closed on exit (`McpManager.aclose`).
|
||||||
- Slash: Click group in `cli/slash.py` + `awaitlet` for async work; Tab completer from registry + ParamType `shell_complete`. Multiline `"""` … `"""`; `/edit` via `$VISUAL`/`$EDITOR` (blocking only). `/yolo on|off|once` for soft destructive confirms. `/model --persist` / `/models --persist` write model catalog entries into TOML. `/todos` for human show/push/pop/clear of the todo stack. `/mcp` (`list` default) prints per-server status/tool counts + last stderr lines; `/mcp reconnect` re-reads config, restarts every enabled server, and rebuilds the tool registry.
|
- Slash: Click group in `cli/slash.py` + `awaitlet` for async work; Tab completer from registry + ParamType `shell_complete`. Multiline `"""` … `"""`; `/edit` via `$VISUAL`/`$EDITOR` (blocking only). `/yolo on|off|once` for soft destructive confirms. `/model --persist` / `/models --persist` write model catalog entries into TOML. `/todos` for human show/push/pop/clear of the todo stack. `/mcp` (`list` default) prints per-server status/tool counts + an `instructions` preview when the server sent usage guidance, + last stderr lines; `/mcp reconnect` re-reads config, restarts every enabled server, and rebuilds the tool registry.
|
||||||
- `/btw` side questions: forked session; main transcript/DB untouched. `--tools=read` (default) exposes only `READ_ONLY` tools under `read_only_context()` (fetch=GET) with the main session view; `--tools=no` off; `--tools=full` whole-registry clone with fresh session. Tools off: default falls back to no tools; explicit `read`/`full` errors.
|
- `/btw` side questions: forked session; main transcript/DB untouched. `--tools=read` (default) exposes only `READ_ONLY` tools under `read_only_context()` (fetch=GET) with the main session view; `--tools=no` off; `--tools=full` whole-registry clone with fresh session. Tools off: default falls back to no tools; explicit `read`/`full` errors.
|
||||||
- Explicit `/resume` or `--session` from another workspace prompts: **keep** / **update** / **abort**.
|
- Explicit `/resume` or `--session` from another workspace prompts: **keep** / **update** / **abort**.
|
||||||
- Failed/cancelled turns: user kept; **committed tool rounds kept** (side effects not re-run on `/retry`); only unfinished assistant rolled back; Ctrl+C cancels; TTY confirms off-loop; auto-retry 10s/20s/30s then `/retry`.
|
- Failed/cancelled turns: user kept; **committed tool rounds kept** (side effects not re-run on `/retry`); only unfinished assistant rolled back; Ctrl+C cancels; TTY confirms off-loop; auto-retry 10s/20s/30s then `/retry`.
|
||||||
@@ -139,7 +139,7 @@ Basedpyright `recommended`. Ruff includes `ANN` (private return types `ANN202` i
|
|||||||
- G3: multiline `"""` … `"""` input (`cli/input_text.py`); `/edit` via `$EDITOR` (`edit_text_in_editor`)
|
- G3: multiline `"""` … `"""` input (`cli/input_text.py`); `/edit` via `$EDITOR` (`edit_text_in_editor`)
|
||||||
- G4: `plyngent chat -p/--prompt` (+ non-TTY stdin); exit codes 0/1/2/3; `--yes` / non-interactive confirm deny; `--stream/--no-stream`, `--quiet`
|
- G4: `plyngent chat -p/--prompt` (+ non-TTY stdin); exit codes 0/1/2/3; `--yes` / non-interactive confirm deny; `--stream/--no-stream`, `--quiet`
|
||||||
- G5: PTY master FD non-inheritable; `read_pty`/`close_pty` via `to_thread`; `PtyManager.close_all()` on chat exit; `--log-level`; clearer invalid TOML errors; export/status stay secret-free
|
- G5: PTY master FD non-inheritable; `read_pty`/`close_pty` via `to_thread`; `PtyManager.close_all()` on chat exit; `--log-level`; clearer invalid TOML errors; export/status stay secret-free
|
||||||
- G6: README, `doc/plyngent.example.toml`, CLAUDE overview/CLI notes
|
- G6: README, `doc/plyngent.example.toml`, AGENTS overview/CLI notes
|
||||||
|
|
||||||
Phase G complete for single-user CLI polish. Next roadmap work is Phase H or optional F (cost/tokenizer).
|
Phase G complete for single-user CLI polish. Next roadmap work is Phase H or optional F (cost/tokenizer).
|
||||||
|
|
||||||
@@ -147,6 +147,9 @@ access_key_or_token = "sk-..."
|
|||||||
# tool_directives = '''### Workspace ...'''
|
# tool_directives = '''### Workspace ...'''
|
||||||
confirm_destructive = true
|
confirm_destructive = true
|
||||||
max_context_tokens = 200000
|
max_context_tokens = 200000
|
||||||
|
# Fold MCP server initialize ``instructions`` (usage guidance) into the
|
||||||
|
# system prompt when tools are on (default true; false ignores server text).
|
||||||
|
# mcp_instructions = true
|
||||||
|
|
||||||
# Optional plugins (entry-point names); default load none. See doc/plugins.md.
|
# Optional plugins (entry-point names); default load none. See doc/plugins.md.
|
||||||
# [plugins]
|
# [plugins]
|
||||||
@@ -157,7 +160,7 @@ Per-provider **`timeout`** is passed to the HTTP session for chat/completions, R
|
|||||||
|
|
||||||
Third-party **plugins**: install a package that declares `project.entry-points."plyngent.tools"` (and later other groups), then allowlist the entry-point name under **`[plugins].enable`**. Details: [doc/plugins.md](doc/plugins.md).
|
Third-party **plugins**: install a package that declares `project.entry-points."plyngent.tools"` (and later other groups), then allowlist the entry-point name under **`[plugins].enable`**. Details: [doc/plugins.md](doc/plugins.md).
|
||||||
|
|
||||||
**MCP servers** (Model Context Protocol over stdio): define servers under `[mcp.servers.<name>]` (`command` + `args`, optional `env`/`cwd`/`timeout`/`read_only`) and their tools become agent tools namespaced `mcp__<server>__<tool>` (LOCAL tags; `read_only = true` also marks them READ_ONLY, eligible for `/btw --tools=read`). Names in `[mcp].disable` stay disconnected. In the REPL, `/mcp` lists per-server status and tool counts; `/mcp reconnect` re-reads the config file and restarts every enabled server (adopts newly added ones).
|
**MCP servers** (Model Context Protocol over stdio): define servers under `[mcp.servers.<name>]` (`command` + `args`, optional `env`/`cwd`/`timeout`/`read_only`) and their tools become agent tools namespaced `mcp__<server>__<tool>` (LOCAL tags; `read_only = true` also marks them READ_ONLY, eligible for `/btw --tools=read`). Names in `[mcp].disable` stay disconnected. A server may also return optional `instructions` (usage guidance) in its MCP `initialize` response; when tools are on those are folded into the agent system prompt as `MCP server <name> instructions:` blocks (`[agent] mcp_instructions = false` disables), and `/mcp` previews them. In the REPL, `/mcp` lists per-server status and tool counts; `/mcp reconnect` re-reads the config file and restarts every enabled server (adopts newly added ones).
|
||||||
|
|
||||||
Supported provider presets today:
|
Supported provider presets today:
|
||||||
|
|
||||||
@@ -302,7 +305,7 @@ plyngent --log-level INFO chat ...
|
|||||||
|
|
||||||
## Architecture (short)
|
## Architecture (short)
|
||||||
|
|
||||||
See [doc/architecture.md](doc/architecture.md) and [CLAUDE.md](CLAUDE.md) for developers.
|
See [doc/architecture.md](doc/architecture.md) and [AGENTS.md](AGENTS.md) for developers.
|
||||||
|
|
||||||
- **`lmproto/`** — OpenAI-compatible, OpenAI Responses, Anthropic Messages, DeepSeek msgspec models and async SSE clients
|
- **`lmproto/`** — OpenAI-compatible, OpenAI Responses, Anthropic Messages, DeepSeek msgspec models and async SSE clients
|
||||||
- **`agent/`** — kind-based tool loop (`chat_completions` / `responses` / `messages`), streaming, usage, compact
|
- **`agent/`** — kind-based tool loop (`chat_completions` / `responses` / `messages`), streaming, usage, compact
|
||||||
|
|||||||
@@ -29,6 +29,9 @@
|
|||||||
# MCP (Model Context Protocol) servers exposed as agent tools.
|
# MCP (Model Context Protocol) servers exposed as agent tools.
|
||||||
# Defining a server opts it in; tools appear as mcp__<server>__<tool> with
|
# Defining a server opts it in; tools appear as mcp__<server>__<tool> with
|
||||||
# LOCAL tags (confirm flow applies). Names in disable stay disconnected.
|
# LOCAL tags (confirm flow applies). Names in disable stay disconnected.
|
||||||
|
# A server's initialize ``instructions`` (usage guidance) is folded into the
|
||||||
|
# agent system prompt when tools are on ([agent] mcp_instructions = false
|
||||||
|
# disables; /mcp previews what each server sent).
|
||||||
# [mcp.servers.docs]
|
# [mcp.servers.docs]
|
||||||
# command = "npx"
|
# command = "npx"
|
||||||
# args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
# args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
||||||
@@ -57,6 +60,9 @@ confirm_destructive = true
|
|||||||
path_denylist = ["/secrets/", ".ssh/", ".gnupg/"]
|
path_denylist = ["/secrets/", ".ssh/", ".gnupg/"]
|
||||||
# Soft context budget in estimated tokens (~4 chars/token when API usage missing).
|
# Soft context budget in estimated tokens (~4 chars/token when API usage missing).
|
||||||
max_context_tokens = 200000
|
max_context_tokens = 200000
|
||||||
|
# Fold connected MCP servers' initialize ``instructions`` (usage guidance)
|
||||||
|
# into the agent system prompt when tools are on. false = ignore server text.
|
||||||
|
# mcp_instructions = true
|
||||||
# How open-todo nags are injected into model context:
|
# How open-todo nags are injected into model context:
|
||||||
# developer (default) | user | synthetic_tool | none
|
# developer (default) | user | synthetic_tool | none
|
||||||
# (legacy system is treated as developer)
|
# (legacy system is treated as developer)
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "plyngent"
|
name = "plyngent"
|
||||||
version = "0.4.1"
|
version = "0.5.0"
|
||||||
description = "LLM chat and agent toolkit"
|
description = "LLM chat and agent toolkit"
|
||||||
authors = [
|
authors = [
|
||||||
{ name = "HivertMoZara", email = "worldmozara@163.com" },
|
{ name = "HivertMoZara", email = "worldmozara@163.com" },
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ DEFAULT_WIN_PYTHON = "3.14"
|
|||||||
DEFAULT_WIN_UV_VERSION = "0.11.28"
|
DEFAULT_WIN_UV_VERSION = "0.11.28"
|
||||||
# typings/ is required for Wine basedpyright: pywinpty's shipped types are incomplete
|
# typings/ is required for Wine basedpyright: pywinpty's shipped types are incomplete
|
||||||
# (e.g. kill(sig) required); pyright default stubPath is ``typings/``.
|
# (e.g. kill(sig) required); pyright default stubPath is ``typings/``.
|
||||||
VIEW_LINKS = ("src", "tests", "scripts", "typings", "README.md", "LICENSE", "CLAUDE.md", "doc")
|
VIEW_LINKS = ("src", "tests", "scripts", "typings", "README.md", "LICENSE", "AGENTS.md", "doc")
|
||||||
VIEW_COPIES = ("pyproject.toml", "pdm.lock")
|
VIEW_COPIES = ("pyproject.toml", "pdm.lock")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1147,6 +1147,9 @@ def plugins_slash_cmd(state: ReplState, action: str | None, name: str | None) ->
|
|||||||
raise click.UsageError(str(exc)) from exc
|
raise click.UsageError(str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
_MCP_PREVIEW_MAX_CHARS = 120
|
||||||
|
|
||||||
|
|
||||||
def _print_mcp_status(state: ReplState) -> None:
|
def _print_mcp_status(state: ReplState) -> None:
|
||||||
manager = state.mcp_manager
|
manager = state.mcp_manager
|
||||||
if manager is None:
|
if manager is None:
|
||||||
@@ -1162,6 +1165,14 @@ def _print_mcp_status(state: ReplState) -> None:
|
|||||||
(conn for conn in manager.connections() if conn.name == name),
|
(conn for conn in manager.connections() if conn.name == name),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
if connection is not None and connection.instructions:
|
||||||
|
preview = " ".join(connection.instructions.split())
|
||||||
|
if len(preview) > _MCP_PREVIEW_MAX_CHARS:
|
||||||
|
preview = f"{preview[: _MCP_PREVIEW_MAX_CHARS - 3]}..."
|
||||||
|
click.secho(
|
||||||
|
f" instructions ({len(connection.instructions)} chars): {preview}",
|
||||||
|
fg="bright_black",
|
||||||
|
)
|
||||||
if connection is not None and connection.error is not None:
|
if connection is not None and connection.error is not None:
|
||||||
for line in connection.stderr_tail()[-3:]:
|
for line in connection.stderr_tail()[-3:]:
|
||||||
click.secho(f" {line}", fg="bright_black")
|
click.secho(f" {line}", fg="bright_black")
|
||||||
|
|||||||
@@ -237,14 +237,28 @@ class ReplState:
|
|||||||
session_state=self.session_state,
|
session_state=self.session_state,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _mcp_instructions_text(self) -> str:
|
||||||
|
"""Compose connected MCP servers' initialize ``instructions`` into one block.
|
||||||
|
|
||||||
|
Only meaningful when tools are on (MCP tools are registered under the
|
||||||
|
same condition); the text is attributed per server so the model can
|
||||||
|
weigh its provenance. Gated by ``[agent] mcp_instructions`` in callers.
|
||||||
|
"""
|
||||||
|
if self.mcp_manager is None or not self.tools_enabled:
|
||||||
|
return ""
|
||||||
|
parts = [f"MCP server {name!r} instructions:\n{text}" for name, text in self.mcp_manager.instructions()]
|
||||||
|
return "\n\n".join(parts)
|
||||||
|
|
||||||
def _make_agent(self) -> ChatAgent:
|
def _make_agent(self) -> ChatAgent:
|
||||||
from plyngent.cli.limits import prompt_continue_limit_async
|
from plyngent.cli.limits import prompt_continue_limit_async
|
||||||
from plyngent.config import compose_agent_system_content
|
from plyngent.config import compose_agent_system_content
|
||||||
|
|
||||||
agent_cfg = self.config.agent_config
|
agent_cfg = self.config.agent_config
|
||||||
|
mcp_text = self._mcp_instructions_text() if agent_cfg.mcp_instructions else ""
|
||||||
system_prompt = compose_agent_system_content(
|
system_prompt = compose_agent_system_content(
|
||||||
agent_cfg.system_prompt,
|
agent_cfg.system_prompt,
|
||||||
agent_cfg.tool_directives,
|
agent_cfg.tool_directives,
|
||||||
|
mcp_text,
|
||||||
)
|
)
|
||||||
on_limit = prompt_continue_limit_async if self.interactive_limits else None
|
on_limit = prompt_continue_limit_async if self.interactive_limits else None
|
||||||
peak = 0
|
peak = 0
|
||||||
|
|||||||
@@ -70,19 +70,15 @@ call `todo_clear` (or pop finished TOP groups) so no hygiene noise is left behin
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def compose_agent_system_content(
|
def compose_agent_system_content(*parts: str) -> str | None:
|
||||||
system_prompt: str,
|
"""Join persona + tool playbook + extra blocks into one system body.
|
||||||
tool_directives: str,
|
|
||||||
) -> str | None:
|
|
||||||
"""Join persona + tool playbook into one system body, or ``None`` if both empty.
|
|
||||||
|
|
||||||
Non-empty parts are stripped and joined with a blank line. Either field may
|
Returns ``None`` when every part is empty. Non-empty parts are stripped and
|
||||||
be ``""`` to disable that part while keeping the other.
|
joined with a blank line. Any part may be ``""`` to disable that block
|
||||||
|
(e.g. ``system_prompt = ""`` keeps the tool playbook).
|
||||||
"""
|
"""
|
||||||
parts = [part.strip() for part in (system_prompt, tool_directives) if part and part.strip()]
|
joined = "\n\n".join(part.strip() for part in parts if part and part.strip())
|
||||||
if not parts:
|
return joined or None
|
||||||
return None
|
|
||||||
return "\n\n".join(parts)
|
|
||||||
|
|
||||||
|
|
||||||
class DatabaseConfig(Struct, omit_defaults=True):
|
class DatabaseConfig(Struct, omit_defaults=True):
|
||||||
@@ -106,6 +102,9 @@ class AgentConfig(Struct, omit_defaults=True):
|
|||||||
system_prompt: str = DEFAULT_SYSTEM_PROMPT
|
system_prompt: str = DEFAULT_SYSTEM_PROMPT
|
||||||
# Tool how-to (omit → DEFAULT_TOOL_DIRECTIVES; "" disables playbook only).
|
# Tool how-to (omit → DEFAULT_TOOL_DIRECTIVES; "" disables playbook only).
|
||||||
tool_directives: str = DEFAULT_TOOL_DIRECTIVES
|
tool_directives: str = DEFAULT_TOOL_DIRECTIVES
|
||||||
|
# Fold connected MCP servers' initialize ``instructions`` (usage guidance)
|
||||||
|
# into the agent system prompt when tools are on. false = ignore server text.
|
||||||
|
mcp_instructions: bool = True
|
||||||
max_tool_result_chars: int = 32_000
|
max_tool_result_chars: int = 32_000
|
||||||
parallel_tools: bool = True
|
parallel_tools: bool = True
|
||||||
confirm_destructive: bool = True
|
confirm_destructive: bool = True
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ class McpServerConnection:
|
|||||||
error: str | None
|
error: str | None
|
||||||
tools: list[McpTool]
|
tools: list[McpTool]
|
||||||
tools_stale: bool
|
tools_stale: bool
|
||||||
|
# Server usage guidance from the initialize result (InitializeResult.instructions).
|
||||||
|
instructions: str | None
|
||||||
|
|
||||||
_config: McpServerConfig
|
_config: McpServerConfig
|
||||||
_proc: asyncio.subprocess.Process | None
|
_proc: asyncio.subprocess.Process | None
|
||||||
@@ -111,6 +113,7 @@ class McpServerConnection:
|
|||||||
self.error = None
|
self.error = None
|
||||||
self.tools = []
|
self.tools = []
|
||||||
self.tools_stale = False
|
self.tools_stale = False
|
||||||
|
self.instructions = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def status(self) -> str:
|
def status(self) -> str:
|
||||||
@@ -258,7 +261,7 @@ class McpServerConnection:
|
|||||||
self._send(payload)
|
self._send(payload)
|
||||||
|
|
||||||
async def _initialize(self) -> None:
|
async def _initialize(self) -> None:
|
||||||
_ = await self._request(
|
result = await self._request(
|
||||||
"initialize",
|
"initialize",
|
||||||
{
|
{
|
||||||
"protocolVersion": MCP_PROTOCOL_VERSION,
|
"protocolVersion": MCP_PROTOCOL_VERSION,
|
||||||
@@ -266,6 +269,10 @@ class McpServerConnection:
|
|||||||
"clientInfo": {"name": MCP_CLIENT_NAME, "version": _client_version()},
|
"clientInfo": {"name": MCP_CLIENT_NAME, "version": _client_version()},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
# Optional server usage guidance (MCP spec: InitializeResult.instructions).
|
||||||
|
# Hosts surface this to the model when the server's tools are in play.
|
||||||
|
instructions = result.get("instructions")
|
||||||
|
self.instructions = instructions.strip() if isinstance(instructions, str) and instructions.strip() else None
|
||||||
await self._notify("notifications/initialized")
|
await self._notify("notifications/initialized")
|
||||||
|
|
||||||
async def list_tools(self) -> list[McpTool]:
|
async def list_tools(self) -> list[McpTool]:
|
||||||
@@ -343,6 +350,21 @@ class McpManager:
|
|||||||
rows.append((name, connection.status, len(connection.tools)))
|
rows.append((name, connection.status, len(connection.tools)))
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
def instructions(self) -> list[tuple[str, str]]:
|
||||||
|
"""(server, text) initialize ``instructions`` for every connected server.
|
||||||
|
|
||||||
|
Only non-empty guidance is returned, in config order. Hosts may fold
|
||||||
|
these into the agent system context (see cli.state.ReplState).
|
||||||
|
"""
|
||||||
|
rows: list[tuple[str, str]] = []
|
||||||
|
for name in self.enabled_servers():
|
||||||
|
connection = self._connections.get(name)
|
||||||
|
if connection is not None and connection.connected:
|
||||||
|
text = (connection.instructions or "").strip()
|
||||||
|
if text:
|
||||||
|
rows.append((name, text))
|
||||||
|
return rows
|
||||||
|
|
||||||
def connections(self) -> list[McpServerConnection]:
|
def connections(self) -> list[McpServerConnection]:
|
||||||
return list(self._connections.values())
|
return list(self._connections.values())
|
||||||
|
|
||||||
|
|||||||
@@ -120,3 +120,111 @@ async def test_mcp_tools_registered_on_tools_rebuild(state: ReplState) -> None:
|
|||||||
assert out == "echo: hi"
|
assert out == "echo: hi"
|
||||||
finally:
|
finally:
|
||||||
await manager.aclose()
|
await manager.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_slash_mcp_list_shows_instructions(
|
||||||
|
state: ReplState,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
guidance = "Prefer `echo` for repeating text back."
|
||||||
|
manager = McpManager(
|
||||||
|
McpConfig(
|
||||||
|
servers={
|
||||||
|
"fake": _server_config(env={"PLYNGENT_MCP_FAKE_INSTRUCTIONS": guidance}),
|
||||||
|
},
|
||||||
|
disable=[],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await manager.ensure_started()
|
||||||
|
state.mcp_manager = manager
|
||||||
|
try:
|
||||||
|
assert await handle_slash(state, "/mcp list") is True
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert f"instructions ({len(guidance)} chars)" in out
|
||||||
|
assert guidance in out
|
||||||
|
finally:
|
||||||
|
await manager.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def _make_tools_state(
|
||||||
|
tmp_path: Path,
|
||||||
|
*,
|
||||||
|
agent_toml: str,
|
||||||
|
manager: McpManager,
|
||||||
|
) -> tuple[ReplState, MemoryStore]:
|
||||||
|
"""ReplState with tools on + a started MCP manager (agent built from TOML)."""
|
||||||
|
memory = await MemoryStore.open(DatabaseConfig())
|
||||||
|
provider = OpenAIProvider(access_key_or_token="sk-test")
|
||||||
|
document = tomlkit.parse(agent_toml) if agent_toml else tomlkit.document()
|
||||||
|
config = ConfigStore(path=tmp_path / "plyngent.toml", document=document)
|
||||||
|
config.providers = {"local": provider}
|
||||||
|
st = ReplState(
|
||||||
|
config=config,
|
||||||
|
memory=memory,
|
||||||
|
workspace=tmp_path,
|
||||||
|
provider_name="local",
|
||||||
|
provider=provider,
|
||||||
|
model="gpt-test",
|
||||||
|
tools_enabled=True,
|
||||||
|
mcp_manager=manager,
|
||||||
|
)
|
||||||
|
return st, memory
|
||||||
|
|
||||||
|
|
||||||
|
async def test_mcp_instructions_folded_into_agent_system_prompt(tmp_path: Path) -> None:
|
||||||
|
from plyngent.tools.catalog import catalog_scope
|
||||||
|
|
||||||
|
guidance = "Prefer `echo` for repeating text back to the user."
|
||||||
|
manager = McpManager(
|
||||||
|
McpConfig(
|
||||||
|
servers={
|
||||||
|
"fake": _server_config(env={"PLYNGENT_MCP_FAKE_INSTRUCTIONS": guidance}),
|
||||||
|
},
|
||||||
|
disable=[],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await manager.ensure_started()
|
||||||
|
try:
|
||||||
|
with catalog_scope(empty=True):
|
||||||
|
state, memory = await _make_tools_state(tmp_path, agent_toml="", manager=manager)
|
||||||
|
try:
|
||||||
|
prompt = state.agent.system_prompt
|
||||||
|
assert prompt is not None
|
||||||
|
assert "MCP server 'fake' instructions:" in prompt
|
||||||
|
assert guidance in prompt
|
||||||
|
finally:
|
||||||
|
await memory.close()
|
||||||
|
finally:
|
||||||
|
await manager.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_mcp_instructions_flag_off_skips_server_text(tmp_path: Path) -> None:
|
||||||
|
from plyngent.tools.catalog import catalog_scope
|
||||||
|
|
||||||
|
guidance = "Prefer `echo` for repeating text back to the user."
|
||||||
|
manager = McpManager(
|
||||||
|
McpConfig(
|
||||||
|
servers={
|
||||||
|
"fake": _server_config(env={"PLYNGENT_MCP_FAKE_INSTRUCTIONS": guidance}),
|
||||||
|
},
|
||||||
|
disable=[],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await manager.ensure_started()
|
||||||
|
try:
|
||||||
|
with catalog_scope(empty=True):
|
||||||
|
state, memory = await _make_tools_state(
|
||||||
|
tmp_path,
|
||||||
|
agent_toml="[agent]\nmcp_instructions = false\n",
|
||||||
|
manager=manager,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
prompt = state.agent.system_prompt
|
||||||
|
assert prompt is not None
|
||||||
|
assert guidance not in prompt
|
||||||
|
assert "MCP server" not in prompt
|
||||||
|
assert "### Workspace" in prompt # host tool playbook still composed
|
||||||
|
finally:
|
||||||
|
await memory.close()
|
||||||
|
finally:
|
||||||
|
await manager.aclose()
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ def test_agent_section_defaults(tmp_path: Path) -> None:
|
|||||||
store = load(path)
|
store = load(path)
|
||||||
assert store.agent_config.system_prompt == DEFAULT_SYSTEM_PROMPT
|
assert store.agent_config.system_prompt == DEFAULT_SYSTEM_PROMPT
|
||||||
assert store.agent_config.tool_directives == DEFAULT_TOOL_DIRECTIVES
|
assert store.agent_config.tool_directives == DEFAULT_TOOL_DIRECTIVES
|
||||||
|
assert store.agent_config.mcp_instructions is True
|
||||||
assert "professional coding agent" in store.agent_config.system_prompt
|
assert "professional coding agent" in store.agent_config.system_prompt
|
||||||
assert "### Workspace" in store.agent_config.tool_directives
|
assert "### Workspace" in store.agent_config.tool_directives
|
||||||
assert store.agent_config.max_tool_result_chars == 32_000
|
assert store.agent_config.max_tool_result_chars == 32_000
|
||||||
@@ -64,6 +65,9 @@ def test_compose_empty_combinations() -> None:
|
|||||||
assert compose_agent_system_content("Only persona", "") == "Only persona"
|
assert compose_agent_system_content("Only persona", "") == "Only persona"
|
||||||
assert compose_agent_system_content("", "Only tools") == "Only tools"
|
assert compose_agent_system_content("", "Only tools") == "Only tools"
|
||||||
assert compose_agent_system_content("Persona", "Tools") == "Persona\n\nTools"
|
assert compose_agent_system_content("Persona", "Tools") == "Persona\n\nTools"
|
||||||
|
assert compose_agent_system_content("", "", "") is None
|
||||||
|
assert compose_agent_system_content("Persona", "Tools", "Extra") == "Persona\n\nTools\n\nExtra"
|
||||||
|
assert compose_agent_system_content("Persona", "", "Extra") == "Persona\n\nExtra"
|
||||||
|
|
||||||
|
|
||||||
def test_agent_section_parse(tmp_path: Path) -> None:
|
def test_agent_section_parse(tmp_path: Path) -> None:
|
||||||
@@ -158,6 +162,19 @@ tool_directives = ""
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_mcp_instructions_flag(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "c.toml"
|
||||||
|
_ = path.write_text(
|
||||||
|
"""
|
||||||
|
[agent]
|
||||||
|
mcp_instructions = false
|
||||||
|
""",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
store = load(path)
|
||||||
|
assert store.agent_config.mcp_instructions is False
|
||||||
|
|
||||||
|
|
||||||
def test_plugins_section_parse(tmp_path: Path) -> None:
|
def test_plugins_section_parse(tmp_path: Path) -> None:
|
||||||
path = tmp_path / "c.toml"
|
path = tmp_path / "c.toml"
|
||||||
_ = path.write_text(
|
_ = path.write_text(
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
"""Minimal MCP stdio server used by the client tests (echo + sleep tools)."""
|
"""Minimal MCP stdio server used by the client tests (echo + sleep tools).
|
||||||
|
|
||||||
|
Set ``PLYNGENT_MCP_FAKE_INSTRUCTIONS`` (env) to have the server return that
|
||||||
|
text as ``InitializeResult.instructions`` (usage guidance).
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
_FAKE_INSTRUCTIONS = os.environ.get("PLYNGENT_MCP_FAKE_INSTRUCTIONS", "")
|
||||||
|
|
||||||
|
|
||||||
def respond(request_id: int, result: object) -> None:
|
def respond(request_id: int, result: object) -> None:
|
||||||
sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": request_id, "result": result}) + "\n")
|
sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": request_id, "result": result}) + "\n")
|
||||||
@@ -22,14 +29,14 @@ def main() -> None:
|
|||||||
if request_id is None:
|
if request_id is None:
|
||||||
continue # notification (notifications/initialized)
|
continue # notification (notifications/initialized)
|
||||||
if method == "initialize":
|
if method == "initialize":
|
||||||
respond(
|
result: dict[str, object] = {
|
||||||
int(request_id),
|
|
||||||
{
|
|
||||||
"protocolVersion": "2025-06-18",
|
"protocolVersion": "2025-06-18",
|
||||||
"capabilities": {"tools": {}},
|
"capabilities": {"tools": {}},
|
||||||
"serverInfo": {"name": "fake-mcp", "version": "0.1.0"},
|
"serverInfo": {"name": "fake-mcp", "version": "0.1.0"},
|
||||||
},
|
}
|
||||||
)
|
if _FAKE_INSTRUCTIONS:
|
||||||
|
result["instructions"] = _FAKE_INSTRUCTIONS
|
||||||
|
respond(int(request_id), result)
|
||||||
elif method == "tools/list":
|
elif method == "tools/list":
|
||||||
respond(
|
respond(
|
||||||
int(request_id),
|
int(request_id),
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ async def test_connection_handshake_lists_and_calls_tools() -> None:
|
|||||||
try:
|
try:
|
||||||
assert connection.connected
|
assert connection.connected
|
||||||
assert connection.error is None
|
assert connection.error is None
|
||||||
|
assert connection.instructions is None
|
||||||
assert [tool.name for tool in connection.tools] == ["echo", "fail", "slow"]
|
assert [tool.name for tool in connection.tools] == ["echo", "fail", "slow"]
|
||||||
echo = connection.tools[0]
|
echo = connection.tools[0]
|
||||||
assert echo.description == "Echo the given text back."
|
assert echo.description == "Echo the given text back."
|
||||||
@@ -43,6 +44,20 @@ async def test_connection_handshake_lists_and_calls_tools() -> None:
|
|||||||
await connection.aclose()
|
await connection.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_initialize_captures_server_instructions() -> None:
|
||||||
|
guidance = "Use echo to repeat text back to the user."
|
||||||
|
connection = McpServerConnection(
|
||||||
|
"fake",
|
||||||
|
_server_config(env={"PLYNGENT_MCP_FAKE_INSTRUCTIONS": guidance}),
|
||||||
|
)
|
||||||
|
await connection.start()
|
||||||
|
try:
|
||||||
|
assert connection.connected
|
||||||
|
assert connection.instructions == guidance
|
||||||
|
finally:
|
||||||
|
await connection.aclose()
|
||||||
|
|
||||||
|
|
||||||
async def test_tool_error_returns_error_text() -> None:
|
async def test_tool_error_returns_error_text() -> None:
|
||||||
connection = McpServerConnection("fake", _server_config())
|
connection = McpServerConnection("fake", _server_config())
|
||||||
await connection.start()
|
await connection.start()
|
||||||
@@ -87,12 +102,31 @@ async def test_manager_start_status_and_call() -> None:
|
|||||||
await manager.ensure_started()
|
await manager.ensure_started()
|
||||||
try:
|
try:
|
||||||
assert [(name, status, count) for name, status, count in manager.statuses()] == [("fake", "connected", 3)]
|
assert [(name, status, count) for name, status, count in manager.statuses()] == [("fake", "connected", 3)]
|
||||||
|
assert manager.instructions() == []
|
||||||
assert await manager.call("fake", "echo", {"text": "hi"}) == "echo: hi"
|
assert await manager.call("fake", "echo", {"text": "hi"}) == "echo: hi"
|
||||||
finally:
|
finally:
|
||||||
await manager.aclose()
|
await manager.aclose()
|
||||||
assert manager.statuses() == [("fake", "not started", 0)]
|
assert manager.statuses() == [("fake", "not started", 0)]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_manager_instructions_after_restart() -> None:
|
||||||
|
guidance = "Always answer with a haiku."
|
||||||
|
plain = McpManager(McpConfig(servers={"fake": _server_config()}, disable=[]))
|
||||||
|
await plain.ensure_started()
|
||||||
|
assert plain.instructions() == []
|
||||||
|
await plain.restart(
|
||||||
|
McpConfig(
|
||||||
|
servers={"fake": _server_config(env={"PLYNGENT_MCP_FAKE_INSTRUCTIONS": guidance})},
|
||||||
|
disable=[],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await plain.ensure_started()
|
||||||
|
try:
|
||||||
|
assert plain.instructions() == [("fake", guidance)]
|
||||||
|
finally:
|
||||||
|
await plain.aclose()
|
||||||
|
|
||||||
|
|
||||||
async def test_manager_disable_and_restart() -> None:
|
async def test_manager_disable_and_restart() -> None:
|
||||||
manager = McpManager(McpConfig(servers={"fake": _server_config()}, disable=["fake"]))
|
manager = McpManager(McpConfig(servers={"fake": _server_config()}, disable=["fake"]))
|
||||||
await manager.ensure_started()
|
await manager.ensure_started()
|
||||||
|
|||||||
Reference in New Issue
Block a user