diff --git a/doc/plyngent.example.toml b/doc/plyngent.example.toml index 536a5d5..4e7bdeb 100644 --- a/doc/plyngent.example.toml +++ b/doc/plyngent.example.toml @@ -17,6 +17,11 @@ path_denylist = ["/secrets/", ".ssh/", ".gnupg/"] # Soft context budget in estimated tokens (~4 chars/token when API usage missing). max_context_tokens = 200000 +# Optional: custom compact/summarisation prompts (empty = built-in defaults). +# compact_system_prompt = "" +# compact_user_prefix = "Summarize the following conversation:\n\n{transcript}" +# compact_seed_text = "Compacted from {src}:\n\n{summary}\n\nCarry on." + # --- OpenAI-compatible (vLLM, LiteLLM, OpenAI, …) --- [providers.openai_compat] preset = "openai-compatible" diff --git a/src/plyngent/agent/compact.py b/src/plyngent/agent/compact.py index 3b2bf49..78eff8e 100644 --- a/src/plyngent/agent/compact.py +++ b/src/plyngent/agent/compact.py @@ -33,6 +33,13 @@ _SUMMARY_SYSTEM = ( _SUMMARY_USER_PREFIX = ( "Summarize the following conversation for continued agent work. " "Output only the summary (no preamble).\n\n--- transcript ---\n" + "{transcript}" +) + +_SEED_MESSAGE_TEMPLATE = ( + "Conversation summary (compacted from {src}):\n\n" + "{summary}\n\n" + "Continue from this summary. Prefer not to re-ask for information already covered." ) @@ -88,8 +95,21 @@ async def summarize_messages( temperature: float | None = 0.2, prompt_tokens_hint: int | None = None, sent_estimate_tokens: int | None = None, + system_prompt: str | None = None, + user_prefix: str | None = None, ) -> str: - """Soft-compact history and ask the model for a dense summary (no tools).""" + """Soft-compact history and ask the model for a dense summary (no tools). + + Parameters + ---------- + system_prompt: + Override for the summarizer system prompt. + ``None`` or empty string uses the built-in default. + user_prefix: + Override for the user-message prefix (appended before the transcript). + ``None`` or empty string uses the built-in default. + The placeholder ``{transcript}`` is substituted with the rendered transcript. + """ if not messages: msg = "nothing to compact" raise ValueError(msg) @@ -102,10 +122,19 @@ async def summarize_messages( if not transcript.strip(): msg = "nothing to compact" raise ValueError(msg) + + sys_content = system_prompt.strip() if system_prompt else _SUMMARY_SYSTEM + user_prefix_resolved = user_prefix.strip() if user_prefix else _SUMMARY_USER_PREFIX + if "{transcript}" in user_prefix_resolved: + user_content = user_prefix_resolved.replace("{transcript}", transcript) + else: + # Custom prefix without placeholder: append transcript (legacy/default shape). + user_content = user_prefix_resolved + transcript + param = ChatCompletionsParam( messages=[ - SystemChatMessage(content=_SUMMARY_SYSTEM), - UserChatMessage(content=_SUMMARY_USER_PREFIX + transcript), + SystemChatMessage(content=sys_content), + UserChatMessage(content=user_content), ], model=model, temperature=temperature if temperature is not None else UNSET, @@ -126,20 +155,26 @@ def build_compacted_seed_messages( *, system_prompt: str | None = None, source_session_id: int | None = None, + seed_text: str | None = None, ) -> list[AnyChatMessage]: """Messages to seed a new session after compact. Summary is an assistant message so history does not end with a user turn (which would look like an incomplete /retry-able request). + + Parameters + ---------- + seed_text: + Override template for the seed assistant message body. + ``None`` or empty string uses the built-in default. + Placeholders ``{src}`` and ``{summary}`` are substituted. """ out: list[AnyChatMessage] = [] if system_prompt: out.append(SystemChatMessage(content=system_prompt)) src = f"session {source_session_id}" if source_session_id is not None else "prior session" - body = ( - f"Conversation summary (compacted from {src}):\n\n" - f"{summary}\n\n" - "Continue from this summary. Prefer not to re-ask for information already covered." - ) + template = seed_text.strip() if seed_text else _SEED_MESSAGE_TEMPLATE + # Use replace (not str.format) so braces inside the model summary are safe. + body = template.replace("{src}", src).replace("{summary}", summary) out.append(AssistantChatMessage(content=body)) return out diff --git a/src/plyngent/cli/editor.py b/src/plyngent/cli/editor.py index 09293a7..603c669 100644 --- a/src/plyngent/cli/editor.py +++ b/src/plyngent/cli/editor.py @@ -30,6 +30,11 @@ _MINIMAL_CONFIG = """\ # confirm_destructive = true # path_denylist = ["/secrets/", ".ssh/"] # max_context_tokens = 200000 +# +# # Optional compact prompts (empty = use built-in defaults). +# # compact_system_prompt = "" +# # compact_user_prefix = "Summarize:\n\n{transcript}" +# # compact_seed_text = "Compacted from {src}:\n\n{summary}" # [providers.example] # preset = "openai-compatible" diff --git a/src/plyngent/cli/state.py b/src/plyngent/cli/state.py index 5a9a7c7..d32e4fe 100644 --- a/src/plyngent/cli/state.py +++ b/src/plyngent/cli/state.py @@ -303,6 +303,8 @@ class ReplState: max_context_tokens=self.agent.max_context_tokens, prompt_tokens_hint=hint, sent_estimate_tokens=sent_est, + system_prompt=self.config.agent_config.compact_system_prompt or None, + user_prefix=self.config.agent_config.compact_user_prefix or None, ) session_name = name or f"compact-from-{old_id}" await self.new_session(name=session_name) @@ -315,6 +317,7 @@ class ReplState: summary, system_prompt=self.agent.system_prompt, source_session_id=old_id, + seed_text=self.config.agent_config.compact_seed_text or None, ) self.agent.messages = list(seed) for message in seed: diff --git a/src/plyngent/config/models.py b/src/plyngent/config/models.py index 4c1f407..b8508e7 100644 --- a/src/plyngent/config/models.py +++ b/src/plyngent/config/models.py @@ -20,6 +20,11 @@ class AgentConfig(Struct, omit_defaults=True): path_denylist: list[str] = field(default_factory=list) max_context_tokens: int = 200_000 + # Compact / summarisation prompts (empty = use built-in defaults). + compact_system_prompt: str = "" + compact_user_prefix: str = "" + compact_seed_text: str = "" + class ModelConfig(Struct, omit_defaults=True): """Capability flags for a model within a provider.""" diff --git a/tests/test_agent/test_compact_summarize.py b/tests/test_agent/test_compact_summarize.py index 46ec4e3..290460b 100644 --- a/tests/test_agent/test_compact_summarize.py +++ b/tests/test_agent/test_compact_summarize.py @@ -59,6 +59,19 @@ def test_build_compacted_seed_messages() -> None: assert "session 3" in content +def test_build_compacted_seed_custom_template_and_braces_in_summary() -> None: + seed = build_compacted_seed_messages( + "use {braces} carefully", + source_session_id=1, + seed_text="From {src}:\n{summary}", + ) + assert isinstance(seed[0], AssistantChatMessage) + content = seed[0].content + assert isinstance(content, str) + assert "session 1" in content + assert "use {braces} carefully" in content + + class SummaryClient: last: ChatCompletionsParam | None @@ -111,3 +124,27 @@ async def test_summarize_messages() -> None: from msgspec import UNSET assert client.last.tools is UNSET + user_msg = client.last.messages[1] + assert isinstance(user_msg, UserChatMessage) + assert "hello" in user_msg.content + assert "world" in user_msg.content + + +async def test_summarize_custom_prompts() -> None: + client = SummaryClient() + _ = await summarize_messages( + client, + [UserChatMessage(content="hello")], + model="m", + system_prompt="SYS-CUSTOM", + user_prefix="PREFIX:\n{transcript}\nEND", + ) + assert client.last is not None + sys_msg = client.last.messages[0] + user_msg = client.last.messages[1] + assert isinstance(sys_msg, SystemChatMessage) + assert sys_msg.content == "SYS-CUSTOM" + assert isinstance(user_msg, UserChatMessage) + assert user_msg.content.startswith("PREFIX:") + assert "hello" in user_msg.content + assert user_msg.content.endswith("END")