Files
LZH-YS1998 6fc5ad6be9 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>
2026-07-27 00:04:42 +08:00

640 lines
26 KiB
Python

"""Persistent history compaction for session and employee memory."""
from __future__ import annotations
import json
from datetime import datetime
from typing import Any
from loguru import logger
from opc.core.models import (
AgentCompactionRecord,
AgentMemorySnapshotRecord,
SessionCompactionRecord,
SessionMemorySnapshotRecord,
)
class HistoryCompactor:
"""Compacts persisted history into summary + memory snapshots."""
_COMPACTION_MESSAGE_CHAR_BUDGET = 4_000
_COMPACTION_TRUNCATION_MARKER = "[history compaction input truncated]"
_RETRY_TRUNCATION_MARKER = "[history compaction retry truncated]"
def __init__(
self,
*,
llm: Any | None,
store: Any | None,
memory_manager: Any,
task_type: str = "quick_tasks",
compression_threshold: float = 0.85,
) -> None:
self.llm = llm
self.store = store
self.memory_manager = memory_manager
self.task_type = task_type
self.compression_threshold = compression_threshold
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,
*,
project_id: str,
session_id: str,
force: bool = False,
) -> bool:
if not self.llm or not self.store:
return False
visible_items = await self.memory_manager._get_visible_session_transcript(session_id)
if not visible_items:
return False
raw_items = [item for item in visible_items if not getattr(item["message"], "summary_flag", False)]
if not raw_items:
return False
visible_messages = self._items_to_messages(visible_items)
if not self._should_compact(visible_messages, force=force):
return False
compact_items, boundary_message_id = self._select_compaction_items(raw_items, force=force)
if not compact_items or not boundary_message_id:
return False
messages = self._items_to_messages(
compact_items,
per_message_budget=self._COMPACTION_MESSAGE_CHAR_BUDGET,
)
existing = await self.store.get_latest_session_memory_snapshot(session_id)
result = await self._summarize_session(
project_id=project_id,
session_id=session_id,
messages=messages,
existing_memory=(existing.memory_text if existing else ""),
existing_summary=(existing.summary_text if existing else ""),
)
summary_message = await self.memory_manager.append_session_message(
session_id=session_id,
role="assistant",
text=result["history_summary"],
project_id=project_id,
summary_flag=True,
parent_message_id=boundary_message_id,
metadata={
"kind": "session_history_summary",
"summary_scope": "session",
"skip_compaction": True,
},
)
if not summary_message:
return False
await self.store.save_session_compaction(
SessionCompactionRecord(
session_id=session_id,
compaction_message_id=summary_message.message_id,
source_boundary_message_id=boundary_message_id,
metadata={
"project_id": project_id,
"raw_message_count": len(compact_items),
"summary_scope": "session",
},
)
)
await self.store.save_session_memory_snapshot(
SessionMemorySnapshotRecord(
project_id=project_id,
session_id=session_id,
summary_message_id=summary_message.message_id,
source_boundary_message_id=boundary_message_id,
summary_text=result["history_summary"],
memory_text=result["memory_summary"],
metadata={
"summary_scope": "session",
"raw_message_count": len(compact_items),
},
)
)
await self.memory_manager.update_session_summary(session_id, result["history_summary"])
return True
async def maybe_compact_agent(
self,
*,
project_id: str,
session_id: str,
employee_id: str,
role_id: str = "",
force: bool = False,
) -> bool:
if not self.llm or not self.store or not employee_id:
return False
visible_items = await self.memory_manager._get_visible_agent_transcript(
project_id=project_id,
session_id=session_id,
employee_id=employee_id,
)
if not visible_items:
return False
raw_items = [item for item in visible_items if not getattr(item["message"], "summary_flag", False)]
if not raw_items:
return False
visible_messages = self._items_to_messages(visible_items)
if not self._should_compact(visible_messages, force=force):
return False
compact_items, boundary_message_id = self._select_compaction_items(raw_items, force=force)
if not compact_items or not boundary_message_id:
return False
messages = self._items_to_messages(
compact_items,
per_message_budget=self._COMPACTION_MESSAGE_CHAR_BUDGET,
)
existing = await self.store.get_agent_memory_snapshot(
project_id=project_id,
session_id=session_id,
employee_id=employee_id,
memory_kind="process",
memory_scope="session",
)
result = await self._summarize_agent_process(
project_id=project_id,
session_id=session_id,
employee_id=employee_id,
role_id=role_id,
messages=messages,
existing_memory=(existing.memory_text if existing else ""),
existing_summary=(existing.summary_text if existing else ""),
)
summary_message = await self.memory_manager.append_session_message(
session_id=session_id,
role="assistant",
text=result["history_summary"],
project_id=project_id,
summary_flag=True,
parent_message_id=boundary_message_id,
metadata={
"kind": "agent_history_summary",
"summary_scope": "agent",
"employee_id": employee_id,
"role_id": role_id,
"skip_compaction": True,
},
)
if not summary_message:
return False
await self.store.save_agent_compaction(
AgentCompactionRecord(
project_id=project_id,
session_id=session_id,
employee_id=employee_id,
role_id=role_id,
compaction_message_id=summary_message.message_id,
source_boundary_message_id=boundary_message_id,
metadata={
"summary_scope": "agent",
"raw_message_count": len(compact_items),
},
)
)
await self.store.save_agent_memory_snapshot(
AgentMemorySnapshotRecord(
project_id=project_id,
session_id=session_id,
employee_id=employee_id,
role_id=role_id,
memory_scope="session",
memory_kind="process",
summary_message_id=summary_message.message_id,
source_boundary_message_id=boundary_message_id,
summary_text=result["history_summary"],
memory_text=result["memory_summary"],
metadata={
"summary_scope": "agent",
"raw_message_count": len(compact_items),
},
)
)
return True
async def finalize_agent_memory(
self,
*,
project_id: str,
session_id: str,
employee_id: str,
role_id: str,
process_memory: str,
reflection_payload: dict[str, Any],
) -> dict[str, Any]:
if not self.llm:
return self._fallback_final_agent_memory(process_memory, reflection_payload)
prompt_payload = {
"project_id": project_id,
"session_id": session_id,
"employee_id": employee_id,
"role_id": role_id,
"process_memory": process_memory,
"reflection": reflection_payload,
}
raw = await self.llm.simple_chat(
prompt=json.dumps(prompt_payload, ensure_ascii=False),
system=(
"You are finalizing employee memory for a multi-agent runtime.\n"
"Return strict JSON with keys `summary_text`, `memory_text`, and `metadata`.\n"
"`memory_text` must be concise markdown with sections:\n"
"## Effective Patterns\n## Watchouts\n## Preferred Tools\n## Reviewer Preferences\n## Reusable Checklist\n"
"`metadata` must contain arrays with keys `effective_patterns`, `watchouts`, "
"`preferred_tools`, `reviewer_preferences`, `reusable_checklist`.\n"
"Merge the process memory with the reflection, remove duplication, and keep only durable guidance."
),
task_type=self.task_type,
)
parsed = self._parse_json_response(raw)
if not parsed:
return self._fallback_final_agent_memory(process_memory, reflection_payload)
metadata = parsed.get("metadata", {})
return {
"summary_text": str(parsed.get("summary_text", "")).strip() or str(parsed.get("memory_text", "")).strip(),
"memory_text": str(parsed.get("memory_text", "")).strip() or process_memory.strip(),
"metadata": metadata if isinstance(metadata, dict) else {},
}
def _get_token_threshold(self, *, reserve_tokens: int = 0) -> int | None:
if not self.llm:
return None
context_limit = self.llm.get_context_window(task_type=self.task_type)
if context_limit is None:
return None
threshold = int(context_limit * self.compression_threshold)
if reserve_tokens:
threshold = max(0, threshold - reserve_tokens)
return threshold
def _is_context_overflow_error(self, error: Exception) -> bool:
detector = getattr(self.llm, "is_context_overflow_error", None)
if callable(detector):
try:
return bool(detector(error))
except Exception:
return False
return False
@staticmethod
def _truncate_messages_for_retry(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
if len(messages) <= 1:
return messages
drop_count = max(1, len(messages) // 5)
if drop_count >= len(messages):
drop_count = len(messages) - 1
return messages[drop_count:]
@classmethod
def _truncate_message_content(cls, content: str, *, budget: int, marker: str) -> str:
if len(content) <= budget:
return content
clipped = max(120, budget - len(marker) - 1)
return content[:clipped].rstrip() + "\n" + marker
@classmethod
def _compact_messages_for_retry(
cls,
messages: list[dict[str, Any]],
*,
budget: int,
) -> list[dict[str, Any]]:
compacted: list[dict[str, Any]] = []
for message in messages:
compacted.append({
**message,
"content": cls._truncate_message_content(
str(message.get("content", "") or ""),
budget=budget,
marker=cls._RETRY_TRUNCATION_MARKER,
),
})
return compacted
async def _simple_chat_with_retry(
self,
*,
payload: dict[str, Any],
system: str,
) -> str:
retries = 0
working_payload = dict(payload)
while True:
try:
return await self.llm.simple_chat(
prompt=json.dumps(working_payload, ensure_ascii=False),
system=system,
task_type=self.task_type,
)
except Exception as exc:
messages = list(working_payload.get("messages", []) or [])
if retries >= 3 or not self._is_context_overflow_error(exc) or not messages:
raise
retries += 1
retry_budget = max(1_200, 4_000 // (2 ** (retries - 1)))
retry_messages = self._compact_messages_for_retry(messages, budget=retry_budget)
if len(retry_messages) > 1:
retry_messages = self._truncate_messages_for_retry(retry_messages)
working_payload["messages"] = retry_messages
logger.debug(
"History compactor retrying after context overflow with "
f"{len(working_payload['messages'])} messages preserved and budget={retry_budget} chars."
)
def _should_compact(self, messages: list[dict[str, Any]], *, force: bool = False) -> bool:
if force:
return True
if not messages or not self.llm:
return False
counted_tokens = self.llm.count_input_tokens(messages, task_type=self.task_type)
threshold_tokens = self._get_token_threshold()
if counted_tokens is None or threshold_tokens is None:
return False
return counted_tokens >= threshold_tokens
def _select_compaction_items(
self,
raw_items: list[dict[str, Any]],
*,
force: bool = False,
) -> tuple[list[dict[str, Any]], str]:
if not raw_items:
return [], ""
if force or not self.llm:
return raw_items, raw_items[-1]["message"].message_id
threshold_tokens = self._get_token_threshold()
if threshold_tokens is None:
return [], ""
for keep_start in range(len(raw_items)):
tail_messages = self._items_to_messages(raw_items[keep_start:])
tail_tokens = self.llm.count_input_tokens(tail_messages, task_type=self.task_type)
if tail_tokens is None:
continue
if tail_tokens < threshold_tokens:
compact_items = raw_items if keep_start == 0 else raw_items[:keep_start]
return compact_items, compact_items[-1]["message"].message_id
return raw_items, raw_items[-1]["message"].message_id
def _items_to_messages(
self,
items: list[dict[str, Any]],
*,
per_message_budget: int | None = None,
) -> list[dict[str, Any]]:
messages: list[dict[str, Any]] = []
for item in items:
message = item["message"]
content = self.memory_manager._render_session_parts(item["parts"]).strip()
if not content:
continue
if per_message_budget:
content = self._truncate_message_content(
content,
budget=per_message_budget,
marker=self._COMPACTION_TRUNCATION_MARKER,
)
role = "user" if message.role == "user" else "assistant"
messages.append({"role": role, "content": content})
return messages
async def _summarize_session(
self,
*,
project_id: str,
session_id: str,
messages: list[dict[str, Any]],
existing_memory: str,
existing_summary: str,
) -> dict[str, str]:
if not self.llm:
return self._fallback_session_summary(messages, existing_memory)
payload = {
"project_id": project_id,
"session_id": session_id,
"existing_memory": existing_memory,
"existing_summary": existing_summary,
"messages": messages,
}
raw = await self._simple_chat_with_retry(
payload=payload,
system=(
"You are compacting persisted session history.\n"
"Return strict JSON with keys `history_summary` and `memory_summary`.\n"
"`history_summary` should help another agent continue the session after restart.\n"
"`memory_summary` should be concise markdown with sections:\n"
"## Primary Goal\n## Active Rules\n## Key Progress\n## Current State\n## Open Risks\n"
"Merge with existing memory and remove duplication."
),
)
parsed = self._parse_json_response(raw)
if not parsed:
return self._fallback_session_summary(messages, existing_memory)
return {
"history_summary": str(parsed.get("history_summary", "")).strip() or self._fallback_session_summary(messages, existing_memory)["history_summary"],
"memory_summary": str(parsed.get("memory_summary", "")).strip() or self._fallback_session_summary(messages, existing_memory)["memory_summary"],
}
async def _summarize_agent_process(
self,
*,
project_id: str,
session_id: str,
employee_id: str,
role_id: str,
messages: list[dict[str, Any]],
existing_memory: str,
existing_summary: str,
) -> dict[str, str]:
if not self.llm:
return self._fallback_agent_summary(messages, existing_memory)
payload = {
"project_id": project_id,
"session_id": session_id,
"employee_id": employee_id,
"role_id": role_id,
"existing_memory": existing_memory,
"existing_summary": existing_summary,
"messages": messages,
}
raw = await self._simple_chat_with_retry(
payload=payload,
system=(
"You are compacting employee-level process history.\n"
"Return strict JSON with keys `history_summary` and `memory_summary`.\n"
"`history_summary` should summarize what this employee already did in this session.\n"
"`memory_summary` should be concise markdown with sections:\n"
"## Effective Patterns\n## Watchouts\n## Current Progress\n## Current State\n"
"Keep it durable, specific, and deduplicated."
),
)
parsed = self._parse_json_response(raw)
if not parsed:
return self._fallback_agent_summary(messages, existing_memory)
return {
"history_summary": str(parsed.get("history_summary", "")).strip() or self._fallback_agent_summary(messages, existing_memory)["history_summary"],
"memory_summary": str(parsed.get("memory_summary", "")).strip() or self._fallback_agent_summary(messages, existing_memory)["memory_summary"],
}
def _fallback_session_summary(self, messages: list[dict[str, Any]], existing_memory: str) -> dict[str, str]:
snippets = [str(item.get("content", "")).strip() for item in messages if str(item.get("content", "")).strip()]
summary = "\n".join(f"- {snippet}" for snippet in snippets[-6:]) or "- No prior details captured."
memory_parts = [
"## Primary Goal",
f"- {snippets[0]}" if snippets else "- (unknown)",
"",
"## Active Rules",
"- Reuse durable constraints from earlier turns.",
"",
"## Key Progress",
*([f"- {snippet}" for snippet in snippets[-4:]] or ["- (none)"]),
"",
"## Current State",
f"- Existing memory length: {len(existing_memory.strip())} characters",
"",
"## Open Risks",
"- Re-check older transcript if the summary omits key details.",
]
return {
"history_summary": summary.strip(),
"memory_summary": "\n".join(memory_parts).strip(),
}
def _fallback_agent_summary(self, messages: list[dict[str, Any]], existing_memory: str) -> dict[str, str]:
snippets = [str(item.get("content", "")).strip() for item in messages if str(item.get("content", "")).strip()]
memory_parts = [
"## Effective Patterns",
*([f"- {snippet}" for snippet in snippets[-3:]] or ["- (none yet)"]),
"",
"## Watchouts",
"- Avoid repeating failed or already-compacted paths without new evidence.",
"",
"## Current Progress",
*([f"- {snippet}" for snippet in snippets[-2:]] or ["- (none)"]),
"",
"## Current State",
f"- Existing process memory length: {len(existing_memory.strip())} characters",
]
return {
"history_summary": "\n".join(f"- {snippet}" for snippet in snippets[-5:]) or "- No agent history captured.",
"memory_summary": "\n".join(memory_parts).strip(),
}
def _fallback_final_agent_memory(
self,
process_memory: str,
reflection_payload: dict[str, Any],
) -> dict[str, Any]:
what_worked = [str(item).strip() for item in reflection_payload.get("what_worked", []) if str(item).strip()]
watchouts = [str(item).strip() for item in reflection_payload.get("mistakes_to_avoid", []) if str(item).strip()]
preferred_tools = [str(item).strip() for item in reflection_payload.get("tool_preferences", []) if str(item).strip()]
reviewer_preferences = [str(item).strip() for item in reflection_payload.get("reviewer_preferences", []) if str(item).strip()]
checklist = [str(item).strip() for item in reflection_payload.get("reusable_checklist", []) if str(item).strip()]
parts = [
"## Effective Patterns",
*([f"- {item}" for item in what_worked[:6]] or ["- (none)"]),
"",
"## Watchouts",
*([f"- {item}" for item in watchouts[:6]] or ["- (none)"]),
"",
"## Preferred Tools",
*([f"- {item}" for item in preferred_tools[:6]] or ["- (none)"]),
"",
"## Reviewer Preferences",
*([f"- {item}" for item in reviewer_preferences[:6]] or ["- (none)"]),
"",
"## Reusable Checklist",
*([f"- {item}" for item in checklist[:6]] or ["- (none)"]),
]
summary_text = str(reflection_payload.get("project_summary", "")).strip() or process_memory.strip()
return {
"summary_text": summary_text,
"memory_text": "\n".join(parts).strip(),
"metadata": {
"effective_patterns": what_worked[:6],
"watchouts": watchouts[:6],
"preferred_tools": preferred_tools[:6],
"reviewer_preferences": reviewer_preferences[:6],
"reusable_checklist": checklist[:6],
},
}
def _parse_json_response(self, raw: str) -> dict[str, Any]:
text = raw.strip()
if text.startswith("```"):
parts = text.split("\n", 1)
text = parts[1] if len(parts) == 2 else text[3:]
if text.endswith("```"):
text = text[:-3]
text = text.strip()
try:
data = json.loads(text)
return data if isinstance(data, dict) else {}
except Exception:
start = text.find("{")
end = text.rfind("}")
if start >= 0 and end > start:
try:
data = json.loads(text[start : end + 1])
return data if isinstance(data, dict) else {}
except Exception as e:
logger.warning(f"Failed to parse compaction JSON: {e}")
return {}