core/config: split agent system_prompt and tool_directives

This commit is contained in:
2026-07-21 22:55:51 +08:00
parent db7c097466
commit 30beec6600
8 changed files with 132 additions and 16 deletions
+1 -1
View File
@@ -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. - **`/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`). - 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` (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/`) ### Tools (`tools/`)
+4 -3
View File
@@ -136,10 +136,11 @@ access_key_or_token = "sk-..."
"gpt-4o-mini" = { text = true } "gpt-4o-mini" = { text = true }
[agent] [agent]
# system_prompt defaults to a built-in coding-agent guide (omit to use it). # system_prompt = persona (omit → built-in). tool_directives = tool playbook.
# system_prompt = "" # disable # system_prompt = "" / tool_directives = "" disable each part; both "" → no system.
# Or multi-line override (prefer '''...'''): # Or multi-line override (prefer '''...'''):
# system_prompt = '''Your custom prompt...''' # system_prompt = '''Your custom persona...'''
# tool_directives = '''### Workspace ...'''
confirm_destructive = true confirm_destructive = true
max_context_tokens = 200000 max_context_tokens = 200000
``` ```
+9 -4
View File
@@ -11,12 +11,17 @@
# # url = ":memory:" # # url = ":memory:"
[agent] [agent]
# system_prompt defaults to the built-in coding-agent guide when omitted. # Persona (omit → built-in coding-agent line). tool_directives is the tool playbook.
# Override with a multi-line literal (prefer ''' so nested " is fine): # Override with multi-line literals (prefer ''' so nested " is fine):
# system_prompt = ''' # 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 max_tool_result_chars = 32000
parallel_tools = true parallel_tools = true
confirm_destructive = true confirm_destructive = true
+3 -2
View File
@@ -29,8 +29,9 @@ _MINIMAL_CONFIG = """\
# # url = ":memory:" # # url = ":memory:"
# [agent] # [agent]
# # system_prompt defaults to the built-in coding-agent guide when omitted. # # system_prompt = persona (omit → built-in). tool_directives = tool playbook.
# # system_prompt = "" # disable; or multi-line '''...''' to override # # system_prompt = "" / tool_directives = "" disable each part; both "" → no system.
# # Or multi-line '''...''' to override.
# max_tool_result_chars = 32000 # max_tool_result_chars = 32000
# parallel_tools = true # parallel_tools = true
# confirm_destructive = true # confirm_destructive = true
+5 -1
View File
@@ -169,9 +169,13 @@ class ReplState:
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
agent_cfg = self.config.agent_config 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 on_limit = prompt_continue_limit_async if self.interactive_limits else None
return ChatAgent( return ChatAgent(
self.client, self.client,
+2
View File
@@ -6,6 +6,7 @@ import tomlkit
from tomlkit.exceptions import TOMLKitError from tomlkit.exceptions import TOMLKitError
from .models import DEFAULT_SYSTEM_PROMPT as DEFAULT_SYSTEM_PROMPT 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 AgentConfig as AgentConfig
from .models import AnthropicProvider as AnthropicProvider from .models import AnthropicProvider as AnthropicProvider
from .models import DatabaseConfig as DatabaseConfig 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 OpenAICompatibleProvider as OpenAICompatibleProvider
from .models import OpenAIProvider as OpenAIProvider from .models import OpenAIProvider as OpenAIProvider
from .models import ProviderConfig as ProviderConfig 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 .path import get_default_path as _get_default_path
from .store import ConfigFormatError as ConfigFormatError from .store import ConfigFormatError as ConfigFormatError
from .store import ConfigStore as ConfigStore from .store import ConfigStore as ConfigStore
+25 -3
View File
@@ -2,12 +2,16 @@ from typing import Any
from msgspec import Struct, field from msgspec import Struct, field
# Built-in agent system prompt when ``[agent].system_prompt`` is omitted. # Built-in persona when ``[agent].system_prompt`` is omitted.
# Set ``system_prompt = ""`` in TOML to disable. Override with a multi-line # Set ``system_prompt = ""`` to omit the persona block only.
# TOML literal (prefer '''...''' so nested " quotes are fine). # Override with a multi-line TOML literal (prefer ''' so nested " is fine).
DEFAULT_SYSTEM_PROMPT = """\ DEFAULT_SYSTEM_PROMPT = """\
You are a professional coding agent in a workspace-bound tool environment. 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 ### Workspace
- Explore with `tree`/`listdir`/`glob_paths`/`grep_files`/`read_file` when unsure. - 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. - 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): class DatabaseConfig(Struct, omit_defaults=True):
"""Database connection configuration. """Database connection configuration.
@@ -58,7 +77,10 @@ class DatabaseConfig(Struct, omit_defaults=True):
class AgentConfig(Struct, omit_defaults=True): class AgentConfig(Struct, omit_defaults=True):
"""Single-user agent profile defaults.""" """Single-user agent profile defaults."""
# Persona / role (omit → DEFAULT_SYSTEM_PROMPT; "" disables persona only).
system_prompt: str = DEFAULT_SYSTEM_PROMPT 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 max_tool_result_chars: int = 32_000
parallel_tools: bool = True parallel_tools: bool = True
confirm_destructive: bool = True confirm_destructive: bool = True
+83 -2
View File
@@ -2,7 +2,12 @@ from __future__ import annotations
from typing import TYPE_CHECKING 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: if TYPE_CHECKING:
from pathlib import Path from pathlib import Path
@@ -13,7 +18,9 @@ def test_agent_section_defaults(tmp_path: Path) -> None:
_ = path.write_text("", encoding="utf-8") _ = path.write_text("", encoding="utf-8")
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 "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 store.agent_config.max_tool_result_chars == 32_000 assert store.agent_config.max_tool_result_chars == 32_000
assert store.agent_config.parallel_tools is True assert store.agent_config.parallel_tools is True
assert store.agent_config.confirm_destructive 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 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: def test_agent_section_parse(tmp_path: Path) -> None:
path = tmp_path / "c.toml" path = tmp_path / "c.toml"
_ = path.write_text( _ = path.write_text(
""" """
[agent] [agent]
system_prompt = "Be brief." system_prompt = "Be brief."
tool_directives = "Use tools carefully."
max_tool_result_chars = 100 max_tool_result_chars = 100
parallel_tools = false parallel_tools = false
confirm_destructive = false confirm_destructive = false
@@ -37,14 +63,20 @@ max_context_tokens = 5000
) )
store = load(path) store = load(path)
assert store.agent_config.system_prompt == "Be brief." 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.max_tool_result_chars == 100
assert store.agent_config.parallel_tools is False assert store.agent_config.parallel_tools is False
assert store.agent_config.confirm_destructive is False assert store.agent_config.confirm_destructive is False
assert store.agent_config.path_denylist == ["/secrets/", ".ssh/"] assert store.agent_config.path_denylist == ["/secrets/", ".ssh/"]
assert store.agent_config.max_context_tokens == 5000 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 = tmp_path / "c.toml"
_ = path.write_text( _ = path.write_text(
""" """
@@ -55,3 +87,52 @@ system_prompt = ""
) )
store = load(path) store = load(path)
assert store.agent_config.system_prompt == "" 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
)