Files
OpenOPC/opc/layer5_memory/history_compactor.py
T
2026-07-01 17:56:31 +08:00

608 lines
24 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,
SessionMessageRecord,
)
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 maybe_compact_after_message(self, message: SessionMessageRecord) -> None:
_ = message
return
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 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)
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 {}