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:
LZH-YS1998
2026-07-27 00:04:42 +08:00
parent 76c530a9e5
commit 6fc5ad6be9
8 changed files with 848 additions and 53 deletions
+49 -17
View File
@@ -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)
+27
View File
@@ -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")