655 lines
22 KiB
Python
655 lines
22 KiB
Python
"""Conversation compaction service.
|
||
|
||
Mirrors the npm ``src/services/compact/compact.ts`` and
|
||
``src/services/compact/prompt.ts`` modules. Provides:
|
||
|
||
- The 9-section summarisation prompt (``get_compact_prompt``).
|
||
- XML-tag formatting/stripping (``format_compact_summary``).
|
||
- The post-compact user summary message builder
|
||
(``get_compact_user_summary_message``).
|
||
- The core ``compact_conversation`` entry point that an
|
||
``/compact`` slash command or auto-compact subsystem can call.
|
||
- PTL retry loop: drops oldest API-round groups when the compact
|
||
request itself hits prompt-too-long (up to ``MAX_PTL_RETRIES``).
|
||
- Circuit-breaker tracking for consecutive failures.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from dataclasses import dataclass, field
|
||
from typing import TYPE_CHECKING, Any
|
||
|
||
from .agent_context_usage import estimate_tokens
|
||
from .agent_types import UsageStats
|
||
from .agent_session import AgentMessage
|
||
|
||
if TYPE_CHECKING:
|
||
from .agent_runtime import LocalCodingAgent
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Constants
|
||
# ---------------------------------------------------------------------------
|
||
|
||
AUTOCOMPACT_BUFFER_TOKENS = 13_000
|
||
"""How many tokens to reserve below the effective context window before
|
||
auto-compact fires (same as the npm ``AUTOCOMPACT_BUFFER_TOKENS``)."""
|
||
|
||
ERROR_NOT_ENOUGH_MESSAGES = 'Not enough messages to compact.'
|
||
ERROR_INCOMPLETE_RESPONSE = (
|
||
'The summary response was incomplete. '
|
||
'The conversation was not compacted.'
|
||
)
|
||
ERROR_USER_ABORT = 'Compaction canceled.'
|
||
ERROR_PROMPT_TOO_LONG = (
|
||
'The compact request itself was too long even after retry truncation.'
|
||
)
|
||
|
||
MAX_COMPACT_FAILURES = 3
|
||
"""Circuit-breaker – stop retrying auto-compact after this many consecutive
|
||
failures (mirrors the npm implementation)."""
|
||
|
||
MAX_PTL_RETRIES = 3
|
||
"""Maximum number of prompt-too-long retry attempts during compaction."""
|
||
|
||
PTL_RETRY_MARKER = '[compact_ptl_retry_marker]'
|
||
"""Synthetic user message prepended when dropping the first API-round group
|
||
leaves an assistant message at position 0."""
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Prompt construction (npm ``src/services/compact/prompt.ts``)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_NO_TOOLS_PREAMBLE = """\
|
||
CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.
|
||
|
||
- Do NOT use Read, Bash, Grep, Glob, Edit, Write, or ANY other tool.
|
||
- You already have all the context you need in the conversation above.
|
||
- Tool calls will be REJECTED and will waste your only turn — you will fail the task.
|
||
- Your entire response must be plain text: an <analysis> block followed by a <summary> block.
|
||
|
||
"""
|
||
|
||
_DETAILED_ANALYSIS_INSTRUCTION = """\
|
||
Before providing your final summary, wrap your analysis in <analysis> tags to \
|
||
organize your thoughts and ensure you've covered all necessary points. In your \
|
||
analysis process:
|
||
|
||
1. Chronologically analyze each message and section of the conversation. \
|
||
For each section thoroughly identify:
|
||
- The user's explicit requests and intents
|
||
- Your approach to addressing the user's requests
|
||
- Key decisions, technical concepts and code patterns
|
||
- Specific details like:
|
||
- file names
|
||
- full code snippets
|
||
- function signatures
|
||
- file edits
|
||
- Errors that you ran into and how you fixed them
|
||
- Pay special attention to specific user feedback that you received, \
|
||
especially if the user told you to do something differently.
|
||
2. Double-check for technical accuracy and completeness, addressing each \
|
||
required element thoroughly."""
|
||
|
||
_BASE_COMPACT_PROMPT = f"""\
|
||
Your task is to create a detailed summary of the conversation so far, paying \
|
||
close attention to the user's explicit requests and your previous actions.
|
||
This summary should be thorough in capturing technical details, code patterns, \
|
||
and architectural decisions that would be essential for continuing development \
|
||
work without losing context.
|
||
|
||
{_DETAILED_ANALYSIS_INSTRUCTION}
|
||
|
||
Your summary should include the following sections:
|
||
|
||
1. Primary Request and Intent: Capture all of the user's explicit requests \
|
||
and intents in detail
|
||
2. Key Technical Concepts: List all important technical concepts, technologies, \
|
||
and frameworks discussed.
|
||
3. Files and Code Sections: Enumerate specific files and code sections examined, \
|
||
modified, or created. Pay special attention to the most recent messages and \
|
||
include full code snippets where applicable and include a summary of why this \
|
||
file read or edit is important.
|
||
4. Errors and fixes: List all errors that you ran into, and how you fixed them. \
|
||
Pay special attention to specific user feedback that you received, especially if \
|
||
the user told you to do something differently.
|
||
5. Problem Solving: Document problems solved and any ongoing troubleshooting \
|
||
efforts.
|
||
6. All user messages: List ALL user messages that are not tool results. These \
|
||
are critical for understanding the users' feedback and changing intent.
|
||
7. Pending Tasks: Outline any pending tasks that you have explicitly been asked \
|
||
to work on.
|
||
8. Current Work: Describe in detail precisely what was being worked on \
|
||
immediately before this summary request, paying special attention to the most \
|
||
recent messages from both user and assistant. Include file names and code \
|
||
snippets where applicable.
|
||
9. Optional Next Step: List the next step that you will take that is related to \
|
||
the most recent work you were doing. IMPORTANT: ensure that this step is \
|
||
DIRECTLY in line with the user's most recent explicit requests, and the task \
|
||
you were working on immediately before this summary request. If your last task \
|
||
was concluded, then only list next steps if they are explicitly in line with the \
|
||
users request. Do not start on tangential requests or really old requests that \
|
||
were already completed without confirming with the user first.
|
||
If there is a next step, include direct quotes from the \
|
||
most recent conversation showing exactly what task you were working on and where \
|
||
you left off. This should be verbatim to ensure there's no drift in task \
|
||
interpretation.
|
||
|
||
Here's an example of how your output should be structured:
|
||
|
||
<example>
|
||
<analysis>
|
||
[Your thought process, ensuring all points are covered thoroughly and accurately]
|
||
</analysis>
|
||
|
||
<summary>
|
||
1. Primary Request and Intent:
|
||
[Detailed description]
|
||
|
||
2. Key Technical Concepts:
|
||
- [Concept 1]
|
||
- [Concept 2]
|
||
- [...]
|
||
|
||
3. Files and Code Sections:
|
||
- [File Name 1]
|
||
- [Summary of why this file is important]
|
||
- [Summary of the changes made to this file, if any]
|
||
- [Important Code Snippet]
|
||
- [File Name 2]
|
||
- [Important Code Snippet]
|
||
- [...]
|
||
|
||
4. Errors and fixes:
|
||
- [Detailed description of error 1]:
|
||
- [How you fixed the error]
|
||
- [User feedback on the error if any]
|
||
- [...]
|
||
|
||
5. Problem Solving:
|
||
[Description of solved problems and ongoing troubleshooting]
|
||
|
||
6. All user messages:
|
||
- [Detailed non tool use user message]
|
||
- [...]
|
||
|
||
7. Pending Tasks:
|
||
- [Task 1]
|
||
- [Task 2]
|
||
- [...]
|
||
|
||
8. Current Work:
|
||
[Precise description of current work]
|
||
|
||
9. Optional Next Step:
|
||
[Optional Next step to take]
|
||
|
||
</summary>
|
||
</example>
|
||
|
||
Please provide your summary based on the conversation so far, following this \
|
||
structure and ensuring precision and thoroughness in your response.
|
||
|
||
There may be additional summarization instructions provided in the included \
|
||
context. If so, remember to follow these instructions when creating the above \
|
||
summary. Examples of instructions include:
|
||
<example>
|
||
## Compact Instructions
|
||
When summarizing the conversation focus on typescript code changes and also \
|
||
remember the mistakes you made and how you fixed them.
|
||
</example>
|
||
|
||
<example>
|
||
# Summary instructions
|
||
When you are using compact - please focus on test output and code changes. \
|
||
Include file reads verbatim.
|
||
</example>
|
||
"""
|
||
|
||
_NO_TOOLS_TRAILER = (
|
||
'\n\nREMINDER: Do NOT call any tools. Respond with plain text only — '
|
||
'an <analysis> block followed by a <summary> block. '
|
||
'Tool calls will be rejected and you will fail the task.'
|
||
)
|
||
|
||
|
||
def get_compact_prompt(custom_instructions: str | None = None) -> str:
|
||
"""Build the full compact prompt, optionally appending user instructions."""
|
||
prompt = _NO_TOOLS_PREAMBLE + _BASE_COMPACT_PROMPT
|
||
if custom_instructions and custom_instructions.strip():
|
||
prompt += f'\n\nAdditional Instructions:\n{custom_instructions}'
|
||
prompt += _NO_TOOLS_TRAILER
|
||
return prompt
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Summary formatting
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def format_compact_summary(summary: str) -> str:
|
||
"""Strip the ``<analysis>`` scratchpad and unwrap ``<summary>`` tags.
|
||
|
||
Mirrors the npm ``formatCompactSummary`` helper.
|
||
"""
|
||
formatted = re.sub(r'<analysis>[\s\S]*?</analysis>', '', summary)
|
||
|
||
match = re.search(r'<summary>([\s\S]*?)</summary>', formatted)
|
||
if match:
|
||
content = match.group(1).strip()
|
||
formatted = re.sub(
|
||
r'<summary>[\s\S]*?</summary>',
|
||
f'Summary:\n{content}',
|
||
formatted,
|
||
)
|
||
|
||
# Collapse runs of blank lines.
|
||
formatted = re.sub(r'\n\n+', '\n\n', formatted)
|
||
return formatted.strip()
|
||
|
||
|
||
def get_compact_user_summary_message(
|
||
summary: str,
|
||
*,
|
||
suppress_follow_up: bool = False,
|
||
transcript_path: str | None = None,
|
||
) -> str:
|
||
"""Build the user-facing summary that replaces compacted messages.
|
||
|
||
Mirrors the npm ``getCompactUserSummaryMessage`` helper.
|
||
"""
|
||
formatted = format_compact_summary(summary)
|
||
|
||
base = (
|
||
'This session is being continued from a previous conversation that '
|
||
'ran out of context. The summary below covers the earlier portion '
|
||
f'of the conversation.\n\n{formatted}'
|
||
)
|
||
|
||
if transcript_path:
|
||
base += (
|
||
'\n\nIf you need specific details from before compaction '
|
||
'(like exact code snippets, error messages, or content you '
|
||
'generated), read the full transcript at: '
|
||
f'{transcript_path}'
|
||
)
|
||
|
||
if suppress_follow_up:
|
||
base += (
|
||
'\nContinue the conversation from where it left off without '
|
||
'asking the user any further questions. Resume directly — do '
|
||
'not acknowledge the summary, do not recap what was happening, '
|
||
'do not preface with "I\'ll continue" or similar. Pick up the '
|
||
'last task as if the break never happened.'
|
||
)
|
||
|
||
return base
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# API-round grouping (mirrors npm groupMessagesByApiRound)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def group_messages_by_api_round(
|
||
messages: list[AgentMessage],
|
||
) -> list[list[AgentMessage]]:
|
||
"""Group messages at API-round boundaries.
|
||
|
||
A new group starts when an assistant message with a different
|
||
``message_id`` than the previous assistant message is encountered.
|
||
"""
|
||
groups: list[list[AgentMessage]] = []
|
||
current: list[AgentMessage] = []
|
||
last_assistant_id: str | None = None
|
||
|
||
for msg in messages:
|
||
if (
|
||
msg.role == 'assistant'
|
||
and msg.message_id != last_assistant_id
|
||
and current
|
||
):
|
||
groups.append(current)
|
||
current = [msg]
|
||
else:
|
||
current.append(msg)
|
||
if msg.role == 'assistant':
|
||
last_assistant_id = msg.message_id
|
||
|
||
if current:
|
||
groups.append(current)
|
||
return groups
|
||
|
||
|
||
def truncate_head_for_ptl_retry(
|
||
messages: list[AgentMessage],
|
||
model: str = '',
|
||
token_gap: int | None = None,
|
||
) -> list[AgentMessage] | None:
|
||
"""Drop oldest API-round groups to free space for the compact request.
|
||
|
||
If *token_gap* is provided, drops enough groups to cover that many
|
||
tokens. Otherwise falls back to dropping ~20% of groups.
|
||
|
||
Returns ``None`` if nothing can be dropped without emptying the list.
|
||
"""
|
||
# Strip a previous PTL_RETRY_MARKER to prevent stalling
|
||
working = list(messages)
|
||
if (
|
||
working
|
||
and working[0].role == 'user'
|
||
and working[0].content == PTL_RETRY_MARKER
|
||
):
|
||
working = working[1:]
|
||
|
||
groups = group_messages_by_api_round(working)
|
||
if len(groups) < 2:
|
||
return None
|
||
|
||
if token_gap is not None and token_gap > 0:
|
||
accumulated = 0
|
||
drop_count = 0
|
||
for group in groups:
|
||
group_tokens = sum(estimate_tokens(m.content, model) for m in group)
|
||
accumulated += group_tokens
|
||
drop_count += 1
|
||
if accumulated >= token_gap:
|
||
break
|
||
else:
|
||
# Fallback: drop ~20% of groups
|
||
drop_count = max(1, len(groups) // 5)
|
||
|
||
drop_count = min(drop_count, len(groups) - 1)
|
||
if drop_count < 1:
|
||
return None
|
||
|
||
remaining: list[AgentMessage] = []
|
||
for group in groups[drop_count:]:
|
||
remaining.extend(group)
|
||
|
||
if not remaining:
|
||
return None
|
||
|
||
# If the first remaining message is an assistant message, prepend a
|
||
# synthetic user marker so the API contract is satisfied.
|
||
if remaining[0].role == 'assistant':
|
||
marker = AgentMessage(
|
||
role='user',
|
||
content=PTL_RETRY_MARKER,
|
||
message_id='ptl_retry_marker',
|
||
metadata={'kind': 'ptl_retry_marker', 'is_meta': True},
|
||
)
|
||
remaining = [marker] + remaining
|
||
|
||
return remaining
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Compaction result
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@dataclass
|
||
class CompactionResult:
|
||
"""Outcome of a ``compact_conversation`` call."""
|
||
|
||
boundary_message: AgentMessage
|
||
summary_messages: list[AgentMessage] = field(default_factory=list)
|
||
messages_to_keep: list[AgentMessage] = field(default_factory=list)
|
||
pre_compact_token_count: int = 0
|
||
post_compact_token_count: int = 0
|
||
true_post_compact_token_count: int = 0
|
||
summary_text: str = ''
|
||
usage: UsageStats = field(default_factory=UsageStats)
|
||
error: str | None = None
|
||
ptl_retries: int = 0
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Core compaction logic
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _is_prompt_too_long_response(content: str) -> bool:
|
||
"""Check if a model response indicates a prompt-too-long error.
|
||
|
||
Some models embed the error in the response text rather than raising.
|
||
"""
|
||
lower = content.lower()
|
||
return (
|
||
'prompt is too long' in lower
|
||
or 'prompt_too_long' in lower
|
||
or 'context_length_exceeded' in lower
|
||
)
|
||
|
||
|
||
def _call_compact_model(
|
||
agent: 'LocalCodingAgent',
|
||
api_messages: list[dict[str, Any]],
|
||
) -> tuple[str | None, 'UsageStats', str | None]:
|
||
"""Call the model for compaction, returning (content, usage, error).
|
||
|
||
Returns (None, usage, error_string) on failure.
|
||
"""
|
||
try:
|
||
turn = agent.client.complete(api_messages, tools=[])
|
||
except Exception as exc:
|
||
error_str = str(exc)
|
||
if 'prompt' in error_str.lower() and 'long' in error_str.lower():
|
||
return None, UsageStats(), 'prompt_too_long'
|
||
return None, UsageStats(), error_str
|
||
|
||
raw = turn.content or ''
|
||
if not raw.strip():
|
||
return None, turn.usage, 'empty_response'
|
||
if _is_prompt_too_long_response(raw):
|
||
return None, turn.usage, 'prompt_too_long'
|
||
return raw, turn.usage, None
|
||
|
||
|
||
def compact_conversation(
|
||
agent: 'LocalCodingAgent',
|
||
custom_instructions: str | None = None,
|
||
) -> CompactionResult:
|
||
"""Perform conversation compaction.
|
||
|
||
Tries session-memory-based compaction first (free, no API call),
|
||
then falls back to LLM-backed compaction.
|
||
|
||
1. If no custom instructions, try session memory compact.
|
||
2. Otherwise, build the compact prompt (9-section template).
|
||
3. Collect the session messages to summarise.
|
||
4. Send them + the compact prompt to the model.
|
||
5. On prompt-too-long, retry by dropping oldest API-round groups
|
||
(up to ``MAX_PTL_RETRIES`` attempts).
|
||
6. Parse ``<summary>`` from the response.
|
||
7. Replace session messages with:
|
||
boundary marker → summary user message → preserved tail.
|
||
|
||
Returns a :class:`CompactionResult` with diagnostics.
|
||
"""
|
||
session = agent.last_session
|
||
if session is None or len(session.messages) == 0:
|
||
return CompactionResult(
|
||
boundary_message=_build_boundary('No session to compact.'),
|
||
error=ERROR_NOT_ENOUGH_MESSAGES,
|
||
)
|
||
|
||
# --- Try session-memory-based compact first (no API call) ---
|
||
if custom_instructions is None:
|
||
from .session_memory_compact import try_session_memory_compaction
|
||
|
||
last_summarized_id = getattr(agent, '_last_summarized_message_id', None)
|
||
sm_result = try_session_memory_compaction(
|
||
messages=list(session.messages),
|
||
model=agent.model_config.model,
|
||
last_summarized_message_id=last_summarized_id,
|
||
)
|
||
if sm_result is not None:
|
||
# Apply the session-memory compaction to the session
|
||
prefix_count = 0
|
||
for msg in session.messages:
|
||
if msg.metadata.get('kind') == 'compact_boundary':
|
||
prefix_count += 1
|
||
else:
|
||
break
|
||
session.messages = (
|
||
session.messages[:prefix_count]
|
||
+ [sm_result.boundary_message]
|
||
+ sm_result.summary_messages
|
||
+ sm_result.messages_to_keep
|
||
)
|
||
# Reset the summarized ID
|
||
agent._last_summarized_message_id = None
|
||
return sm_result
|
||
|
||
# ---- Determine which messages to compact vs preserve ----
|
||
preserve_count = max(
|
||
getattr(agent.runtime_config, 'compact_preserve_messages', 4), 1
|
||
)
|
||
|
||
prefix_count = 0
|
||
for msg in session.messages:
|
||
if msg.metadata.get('kind') == 'compact_boundary':
|
||
prefix_count += 1
|
||
else:
|
||
break
|
||
|
||
total = len(session.messages)
|
||
tail_count = min(preserve_count, max(total - prefix_count, 0))
|
||
compact_end = total - tail_count
|
||
|
||
if compact_end <= prefix_count:
|
||
return CompactionResult(
|
||
boundary_message=_build_boundary('Not enough messages after prefix.'),
|
||
error=ERROR_NOT_ENOUGH_MESSAGES,
|
||
)
|
||
|
||
candidates = list(session.messages[prefix_count:compact_end])
|
||
preserved_tail = list(session.messages[compact_end:])
|
||
|
||
if not candidates:
|
||
return CompactionResult(
|
||
boundary_message=_build_boundary('Nothing to compact.'),
|
||
error=ERROR_NOT_ENOUGH_MESSAGES,
|
||
)
|
||
|
||
# ---- Estimate pre-compact token count ----
|
||
model = agent.model_config.model
|
||
pre_tokens = sum(estimate_tokens(m.content, model) for m in session.messages)
|
||
|
||
# ---- Build the compact request ----
|
||
compact_prompt = get_compact_prompt(custom_instructions)
|
||
|
||
# ---- PTL retry loop ----
|
||
messages_to_summarize = candidates
|
||
ptl_retries = 0
|
||
total_usage = UsageStats()
|
||
raw_summary: str | None = None
|
||
|
||
for attempt in range(MAX_PTL_RETRIES + 1):
|
||
api_messages: list[dict[str, Any]] = []
|
||
|
||
for part in session.system_prompt_parts:
|
||
if part.strip():
|
||
api_messages.append({'role': 'system', 'content': part})
|
||
|
||
for msg in messages_to_summarize:
|
||
api_messages.append(msg.to_openai_message())
|
||
|
||
api_messages.append({'role': 'user', 'content': compact_prompt})
|
||
|
||
content, usage, error = _call_compact_model(agent, api_messages)
|
||
total_usage = total_usage + usage
|
||
|
||
if error != 'prompt_too_long':
|
||
raw_summary = content
|
||
break
|
||
|
||
# PTL error — try truncating oldest API-round groups
|
||
ptl_retries += 1
|
||
if attempt >= MAX_PTL_RETRIES:
|
||
return CompactionResult(
|
||
boundary_message=_build_boundary(
|
||
f'Compact request was too long after {ptl_retries} retries.'
|
||
),
|
||
error=ERROR_PROMPT_TOO_LONG,
|
||
usage=total_usage,
|
||
ptl_retries=ptl_retries,
|
||
)
|
||
|
||
truncated = truncate_head_for_ptl_retry(
|
||
messages_to_summarize, model=model,
|
||
)
|
||
if truncated is None:
|
||
return CompactionResult(
|
||
boundary_message=_build_boundary(
|
||
'Cannot truncate further for compact retry.'
|
||
),
|
||
error=ERROR_PROMPT_TOO_LONG,
|
||
usage=total_usage,
|
||
ptl_retries=ptl_retries,
|
||
)
|
||
messages_to_summarize = truncated
|
||
|
||
if raw_summary is None:
|
||
error_msg = error or ERROR_INCOMPLETE_RESPONSE
|
||
return CompactionResult(
|
||
boundary_message=_build_boundary(f'Compaction failed: {error_msg}'),
|
||
error=error_msg,
|
||
usage=total_usage,
|
||
ptl_retries=ptl_retries,
|
||
)
|
||
|
||
# ---- Format the summary ----
|
||
summary_text = format_compact_summary(raw_summary)
|
||
user_summary_content = get_compact_user_summary_message(raw_summary)
|
||
|
||
# ---- Build post-compact messages ----
|
||
boundary = _build_boundary(
|
||
f'Earlier conversation ({len(candidates)} messages, ~{pre_tokens} tokens) '
|
||
f'was compacted.',
|
||
)
|
||
|
||
summary_msg = AgentMessage(
|
||
role='user',
|
||
content=user_summary_content,
|
||
message_id='compact_summary',
|
||
metadata={'kind': 'compact_summary', 'is_compact_summary': True},
|
||
)
|
||
|
||
# Replace session messages in-place
|
||
session.messages = (
|
||
session.messages[:prefix_count]
|
||
+ [boundary, summary_msg]
|
||
+ preserved_tail
|
||
)
|
||
|
||
# ---- Post-compact token estimate ----
|
||
post_tokens = sum(estimate_tokens(m.content, model) for m in session.messages)
|
||
|
||
return CompactionResult(
|
||
boundary_message=boundary,
|
||
summary_messages=[summary_msg],
|
||
messages_to_keep=preserved_tail,
|
||
pre_compact_token_count=pre_tokens,
|
||
post_compact_token_count=post_tokens,
|
||
true_post_compact_token_count=sum(
|
||
estimate_tokens(m.content, model)
|
||
for m in [boundary, summary_msg] + preserved_tail
|
||
),
|
||
summary_text=summary_text,
|
||
usage=total_usage,
|
||
ptl_retries=ptl_retries,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _build_boundary(note: str) -> AgentMessage:
|
||
"""Create a compact-boundary system message."""
|
||
return AgentMessage(
|
||
role='user',
|
||
content=f'<system-reminder>\n{note}\n</system-reminder>',
|
||
message_id='compact_boundary',
|
||
metadata={'kind': 'compact_boundary'},
|
||
)
|