core/agent+config: configurable compact prompts

Add agent config compact_system_prompt, compact_user_prefix, and
compact_seed_text; wire through summarize and seed messages. Fix default
user prefix to include {transcript}; use safe seed substitution so braces
in summaries do not break formatting.
This commit is contained in:
2026-07-15 14:45:12 +08:00
parent 9cc2216807
commit 95df11713d
6 changed files with 98 additions and 8 deletions
+5
View File
@@ -17,6 +17,11 @@ 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
# 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, …) --- # --- OpenAI-compatible (vLLM, LiteLLM, OpenAI, …) ---
[providers.openai_compat] [providers.openai_compat]
preset = "openai-compatible" preset = "openai-compatible"
+43 -8
View File
@@ -33,6 +33,13 @@ _SUMMARY_SYSTEM = (
_SUMMARY_USER_PREFIX = ( _SUMMARY_USER_PREFIX = (
"Summarize the following conversation for continued agent work. " "Summarize the following conversation for continued agent work. "
"Output only the summary (no preamble).\n\n--- transcript ---\n" "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, temperature: float | None = 0.2,
prompt_tokens_hint: int | None = None, prompt_tokens_hint: int | None = None,
sent_estimate_tokens: int | None = None, sent_estimate_tokens: int | None = None,
system_prompt: str | None = None,
user_prefix: str | None = None,
) -> str: ) -> 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: if not messages:
msg = "nothing to compact" msg = "nothing to compact"
raise ValueError(msg) raise ValueError(msg)
@@ -102,10 +122,19 @@ async def summarize_messages(
if not transcript.strip(): if not transcript.strip():
msg = "nothing to compact" msg = "nothing to compact"
raise ValueError(msg) 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( param = ChatCompletionsParam(
messages=[ messages=[
SystemChatMessage(content=_SUMMARY_SYSTEM), SystemChatMessage(content=sys_content),
UserChatMessage(content=_SUMMARY_USER_PREFIX + transcript), UserChatMessage(content=user_content),
], ],
model=model, model=model,
temperature=temperature if temperature is not None else UNSET, temperature=temperature if temperature is not None else UNSET,
@@ -126,20 +155,26 @@ def build_compacted_seed_messages(
*, *,
system_prompt: str | None = None, system_prompt: str | None = None,
source_session_id: int | None = None, source_session_id: int | None = None,
seed_text: str | None = None,
) -> list[AnyChatMessage]: ) -> list[AnyChatMessage]:
"""Messages to seed a new session after compact. """Messages to seed a new session after compact.
Summary is an assistant message so history does not end with a user turn Summary is an assistant message so history does not end with a user turn
(which would look like an incomplete /retry-able request). (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] = [] out: list[AnyChatMessage] = []
if system_prompt: if system_prompt:
out.append(SystemChatMessage(content=system_prompt)) out.append(SystemChatMessage(content=system_prompt))
src = f"session {source_session_id}" if source_session_id is not None else "prior session" src = f"session {source_session_id}" if source_session_id is not None else "prior session"
body = ( template = seed_text.strip() if seed_text else _SEED_MESSAGE_TEMPLATE
f"Conversation summary (compacted from {src}):\n\n" # Use replace (not str.format) so braces inside the model summary are safe.
f"{summary}\n\n" body = template.replace("{src}", src).replace("{summary}", summary)
"Continue from this summary. Prefer not to re-ask for information already covered."
)
out.append(AssistantChatMessage(content=body)) out.append(AssistantChatMessage(content=body))
return out return out
+5
View File
@@ -30,6 +30,11 @@ _MINIMAL_CONFIG = """\
# confirm_destructive = true # confirm_destructive = true
# path_denylist = ["/secrets/", ".ssh/"] # path_denylist = ["/secrets/", ".ssh/"]
# max_context_tokens = 200000 # 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] # [providers.example]
# preset = "openai-compatible" # preset = "openai-compatible"
+3
View File
@@ -303,6 +303,8 @@ class ReplState:
max_context_tokens=self.agent.max_context_tokens, max_context_tokens=self.agent.max_context_tokens,
prompt_tokens_hint=hint, prompt_tokens_hint=hint,
sent_estimate_tokens=sent_est, 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}" session_name = name or f"compact-from-{old_id}"
await self.new_session(name=session_name) await self.new_session(name=session_name)
@@ -315,6 +317,7 @@ class ReplState:
summary, summary,
system_prompt=self.agent.system_prompt, system_prompt=self.agent.system_prompt,
source_session_id=old_id, source_session_id=old_id,
seed_text=self.config.agent_config.compact_seed_text or None,
) )
self.agent.messages = list(seed) self.agent.messages = list(seed)
for message in seed: for message in seed:
+5
View File
@@ -20,6 +20,11 @@ class AgentConfig(Struct, omit_defaults=True):
path_denylist: list[str] = field(default_factory=list) path_denylist: list[str] = field(default_factory=list)
max_context_tokens: int = 200_000 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): class ModelConfig(Struct, omit_defaults=True):
"""Capability flags for a model within a provider.""" """Capability flags for a model within a provider."""
@@ -59,6 +59,19 @@ def test_build_compacted_seed_messages() -> None:
assert "session 3" in content 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: class SummaryClient:
last: ChatCompletionsParam | None last: ChatCompletionsParam | None
@@ -111,3 +124,27 @@ async def test_summarize_messages() -> None:
from msgspec import UNSET from msgspec import UNSET
assert client.last.tools is 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")