diff --git a/CLAUDE.md b/CLAUDE.md index e6ec26d..830a2de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,7 +72,7 @@ Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite ` - **`/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` (default: built-in coding-agent guide `DEFAULT_SYSTEM_PROMPT`; `""` disables), `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/`) diff --git a/README.md b/README.md index 6047e71..db9f79f 100644 --- a/README.md +++ b/README.md @@ -136,10 +136,11 @@ access_key_or_token = "sk-..." "gpt-4o-mini" = { text = true } [agent] -# system_prompt defaults to a built-in coding-agent guide (omit to use it). -# system_prompt = "" # disable +# 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 prompt...''' +# system_prompt = '''Your custom persona...''' +# tool_directives = '''### Workspace ...''' confirm_destructive = true max_context_tokens = 200000 ``` diff --git a/doc/plyngent.example.toml b/doc/plyngent.example.toml index 3f36f1a..2b894b0 100644 --- a/doc/plyngent.example.toml +++ b/doc/plyngent.example.toml @@ -11,12 +11,17 @@ # # url = ":memory:" [agent] -# system_prompt defaults to the built-in coding-agent guide when omitted. -# Override with a multi-line literal (prefer ''' so nested " is fine): +# 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 system prompt... +# Your custom persona... # ''' -# system_prompt = "" # disable system prompt entirely +# 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 diff --git a/src/plyngent/cli/editor.py b/src/plyngent/cli/editor.py index 0f1681c..bff7548 100644 --- a/src/plyngent/cli/editor.py +++ b/src/plyngent/cli/editor.py @@ -29,8 +29,9 @@ _MINIMAL_CONFIG = """\ # # url = ":memory:" # [agent] -# # system_prompt defaults to the built-in coding-agent guide when omitted. -# # system_prompt = "" # disable; or multi-line '''...''' to override +# # 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 diff --git a/src/plyngent/cli/state.py b/src/plyngent/cli/state.py index 13a9621..93c9306 100644 --- a/src/plyngent/cli/state.py +++ b/src/plyngent/cli/state.py @@ -169,9 +169,13 @@ class ReplState: 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, diff --git a/src/plyngent/config/__init__.py b/src/plyngent/config/__init__.py index fe8ddad..19fa9f2 100644 --- a/src/plyngent/config/__init__.py +++ b/src/plyngent/config/__init__.py @@ -6,6 +6,7 @@ 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 @@ -15,6 +16,7 @@ from .models import ModelConfig as ModelConfig from .models import OpenAICompatibleProvider as OpenAICompatibleProvider from .models import OpenAIProvider as OpenAIProvider 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 diff --git a/src/plyngent/config/models.py b/src/plyngent/config/models.py index 7b29a97..28b944a 100644 --- a/src/plyngent/config/models.py +++ b/src/plyngent/config/models.py @@ -2,12 +2,16 @@ from typing import Any from msgspec import Struct, field -# Built-in agent system prompt when ``[agent].system_prompt`` is omitted. -# Set ``system_prompt = ""`` in TOML to disable. Override with a multi-line -# TOML literal (prefer '''...''' so nested " quotes are fine). +# 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. @@ -41,6 +45,21 @@ 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. @@ -58,7 +77,10 @@ class DatabaseConfig(Struct, omit_defaults=True): class AgentConfig(Struct, omit_defaults=True): """Single-user agent profile defaults.""" + # 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 diff --git a/tests/test_config/test_agent_section.py b/tests/test_config/test_agent_section.py index 054cc39..f625878 100644 --- a/tests/test_config/test_agent_section.py +++ b/tests/test_config/test_agent_section.py @@ -2,7 +2,12 @@ from __future__ import annotations from typing import TYPE_CHECKING -from plyngent.config import DEFAULT_SYSTEM_PROMPT, load +from plyngent.config import ( + DEFAULT_SYSTEM_PROMPT, + DEFAULT_TOOL_DIRECTIVES, + compose_agent_system_content, + load, +) if TYPE_CHECKING: from pathlib import Path @@ -13,7 +18,9 @@ def test_agent_section_defaults(tmp_path: Path) -> None: _ = path.write_text("", encoding="utf-8") store = load(path) 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 @@ -21,12 +28,31 @@ def test_agent_section_defaults(tmp_path: Path) -> None: assert store.agent_config.max_context_tokens == 200_000 +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: path = tmp_path / "c.toml" _ = path.write_text( """ [agent] system_prompt = "Be brief." +tool_directives = "Use tools carefully." max_tool_result_chars = 100 parallel_tools = false confirm_destructive = false @@ -37,14 +63,20 @@ 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(tmp_path: Path) -> None: +def test_agent_system_prompt_empty_disables_persona_only(tmp_path: Path) -> None: path = tmp_path / "c.toml" _ = path.write_text( """ @@ -55,3 +87,52 @@ system_prompt = "" ) 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 + )