feat(runtime): replace per-round history trimming with threshold-triggered LLM compaction
Align native context management with the Claude Code / Codex model: entry-capped tool results, history frozen below the threshold, one high-quality summary at the wall — instead of the old pipeline that microcompacted old messages from 60% usage and hid everything past 40 messages behind a snip marker with no summary. - context pipeline: history below the hard threshold is never rewritten (model quality and prompt-cache prefixes depend on byte-identical old messages); the 60% tool-aware microcompact and the 40-message history snip move to an emergency-only fallback used under overflow pressure when the summarizer is unavailable or circuit-broken. - durable compaction (was a stub): at usage >= context_guard.hard_threshold (now 0.90, soft_threshold removed) the old span is folded into a 9-section summary via the new HistoryCompactor.summarize_runtime_history, keeping the system head, the seed user request verbatim on every round (injected session-memory/artifact messages shift the stale base_prefix_len, so the fold start is structure-aware), and a pairing-safe recent tail. A previous summary stays foldable, so exactly one summary exists at a time and rounds chain. - token accounting anchors on the provider-reported prompt size of the latest request (max with the local estimate). - reactive_compaction.circuit_breaker_failures (previously unread) now stops repeated summarizer failures; provider overflow errors retry through the same pipeline, summary-first. - tool-result budget clip keeps head and tail instead of tail-chopping. - chat-side transcripts get the same treatment: new MemoryManager.maybe_compact_session_history wires the threshold-gated maybe_compact_session into secretary, office_ui dispatcher, and context_loader before prompt building, closing the unbounded-growth path; dead no-op compactor entries (maybe_compact_after_message, should_compact_prompt) removed. Verified by 13 new tests (history sanctity below threshold, multi-round single-summary/seed-verbatim/chain invariants, breaker, emergency fallback, provider-overflow end-to-end recovery) plus a live-provider probe: multi-round compaction with the model completing correctly from summarized context. Full suite: 1859 passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,6 @@ from opc.core.models import (
|
||||
AgentMemorySnapshotRecord,
|
||||
SessionCompactionRecord,
|
||||
SessionMemorySnapshotRecord,
|
||||
SessionMessageRecord,
|
||||
)
|
||||
|
||||
|
||||
@@ -39,9 +38,55 @@ class HistoryCompactor:
|
||||
self.task_type = task_type
|
||||
self.compression_threshold = compression_threshold
|
||||
|
||||
async def maybe_compact_after_message(self, message: SessionMessageRecord) -> None:
|
||||
_ = message
|
||||
return
|
||||
async def summarize_runtime_history(
|
||||
self,
|
||||
*,
|
||||
project_id: str,
|
||||
session_id: str,
|
||||
messages: list[dict[str, Any]],
|
||||
existing_summary: str = "",
|
||||
) -> str:
|
||||
"""Summarize in-memory runtime messages for durable context compaction.
|
||||
|
||||
Used by NativeRuntimeV2 when live context reaches the hard threshold:
|
||||
the returned summary replaces the folded span of the message list.
|
||||
Raises on non-recoverable LLM errors so the caller can count failures.
|
||||
"""
|
||||
if not messages:
|
||||
return ""
|
||||
if not self.llm:
|
||||
return self._fallback_session_summary(messages, existing_summary)["history_summary"]
|
||||
payload = {
|
||||
"project_id": project_id,
|
||||
"session_id": session_id,
|
||||
"existing_summary": existing_summary,
|
||||
"messages": messages,
|
||||
}
|
||||
raw = await self._simple_chat_with_retry(
|
||||
payload=payload,
|
||||
system=(
|
||||
"You are compacting the live working context of an agent that must "
|
||||
"continue its task seamlessly from your output.\n"
|
||||
"Return strict JSON with a single key `history_summary`.\n"
|
||||
"`history_summary` must be detailed markdown with sections:\n"
|
||||
"1. Primary Request and Intent\n"
|
||||
"2. Key Technical Concepts\n"
|
||||
"3. Files and Code Sections\n"
|
||||
"4. Errors and Fixes (especially user corrections)\n"
|
||||
"5. Problem Solving\n"
|
||||
"6. All User Messages\n"
|
||||
"7. Pending Tasks\n"
|
||||
"8. Current Work\n"
|
||||
"9. Next Step\n"
|
||||
"Quote exact identifiers, paths, commands, and values the agent will "
|
||||
"need to continue; do not invent details."
|
||||
),
|
||||
)
|
||||
parsed = self._parse_json_response(raw)
|
||||
summary = str((parsed or {}).get("history_summary", "")).strip()
|
||||
if summary:
|
||||
return summary
|
||||
return self._fallback_session_summary(messages, existing_summary)["history_summary"]
|
||||
|
||||
async def maybe_compact_session(
|
||||
self,
|
||||
@@ -276,19 +321,6 @@ class HistoryCompactor:
|
||||
threshold = max(0, threshold - reserve_tokens)
|
||||
return threshold
|
||||
|
||||
def should_compact_prompt(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
*,
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
force: bool = False,
|
||||
reserve_tokens: int = 0,
|
||||
) -> bool:
|
||||
_ = messages
|
||||
_ = tools
|
||||
_ = force
|
||||
_ = reserve_tokens
|
||||
return False
|
||||
|
||||
def _is_context_overflow_error(self, error: Exception) -> bool:
|
||||
detector = getattr(self.llm, "is_context_overflow_error", None)
|
||||
|
||||
@@ -65,6 +65,33 @@ class MemoryManager:
|
||||
def set_history_compactor(self, compactor: Any | None) -> None:
|
||||
self.history_compactor = compactor
|
||||
|
||||
async def maybe_compact_session_history(
|
||||
self,
|
||||
session_id: str,
|
||||
project_id: str | None = None,
|
||||
) -> bool:
|
||||
"""Threshold-gated session-transcript compaction.
|
||||
|
||||
Chat-style callers invoke this before building prompt context so a
|
||||
long transcript is folded into a summary snapshot instead of growing
|
||||
without bound. Best-effort: failures never block prompt building.
|
||||
"""
|
||||
compactor = self.history_compactor
|
||||
maybe_compact = getattr(compactor, "maybe_compact_session", None) if compactor else None
|
||||
if not callable(maybe_compact) or not session_id:
|
||||
return False
|
||||
try:
|
||||
return bool(
|
||||
await maybe_compact(
|
||||
project_id=self._resolve_project_id(project_id),
|
||||
session_id=session_id,
|
||||
force=False,
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug(f"Session history compaction skipped: {exc}")
|
||||
return False
|
||||
|
||||
def _resolve_project_id(self, project_id: str | None = None) -> str:
|
||||
return str(project_id or self.project_id or "default")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user