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

229 lines
9.9 KiB
Python

"""Secretary service for long-term policy capture and lightweight governance."""
from __future__ import annotations
import json
import uuid
from typing import Any
from loguru import logger
from opc.database.store import OPCStore
from opc.layer5_memory.memory_manager import MemoryManager
from opc.layer5_memory.preference import PreferenceManager
from opc.layer5_memory.secretary_policy import SecretaryPolicyManager
from opc.layer5_memory.skill_importer import ExternalSkillImporter, SkillImportError
from opc.layer5_memory.skill_library import SkillLibrary
from opc.llm.provider import LLMProvider
from opc.llm.retry import LLMRetryError, call_llm_json_with_retry
class SecretaryService:
"""Direct secretary interface with long-term memory and policy updates."""
def __init__(
self,
llm: LLMProvider,
store: OPCStore,
memory: MemoryManager,
preferences: PreferenceManager,
skills: SkillLibrary,
policies: SecretaryPolicyManager,
) -> None:
self.llm = llm
self.store = store
self.memory = memory
self.preferences = preferences
self.skills = skills
self.policies = policies
self.skill_importer = ExternalSkillImporter(skill_library=skills, policies=policies)
async def handle_message(
self,
content: str,
*,
project_id: str | None = None,
session_id: str | None = None,
) -> dict[str, Any]:
secretary_session_id = session_id or str(uuid.uuid4())
await self.memory.ensure_session(
secretary_session_id,
project_id=project_id or "default",
title=(content[:120] or "Secretary Session").strip(),
mode="primary",
metadata={"interface": "secretary"},
)
await self.memory.record_user_turn(secretary_session_id, content, project_id=project_id or "default")
prompt = await self._build_prompt(content, project_id=project_id, session_id=secretary_session_id)
raw_fallback_text = ""
try:
parsed = await call_llm_json_with_retry(
self.llm,
system=self._system_prompt(),
payload=prompt,
task_type="quick_tasks",
label="secretary",
)
except LLMRetryError as exc:
logger.warning(
f"Secretary LLM returned invalid JSON after retries: {exc}; "
"falling back to plain-text echo."
)
raw_fallback_text = str(exc.last_raw or "").strip()
parsed = {"response": raw_fallback_text, "actions": []}
applied_updates: list[str] = []
applied_actions = await self._apply_actions(parsed.get("actions", []), project_id=project_id)
reply = str(parsed.get("response", "")).strip() or raw_fallback_text
if applied_updates:
reply += "\n\nApplied secretary updates:\n" + "\n".join(f"- {item}" for item in applied_updates)
if applied_actions:
reply += "\n\nApplied secretary actions:\n" + "\n".join(f"- {item}" for item in applied_actions)
await self.memory.record_assistant_turn(
secretary_session_id,
reply,
project_id=project_id or "default",
metadata={"kind": "secretary_reply"},
)
return {
"response": reply,
"session_id": secretary_session_id,
"applied_updates": applied_updates,
"applied_actions": applied_actions,
}
async def list_sessions(self, project_id: str | None, limit: int = 20) -> list[Any]:
sessions = await self.store.list_sessions(project_id=project_id or "default", parent_session_id=None, limit=limit * 3)
return [item for item in sessions if item.metadata.get("interface") == "secretary"][:limit]
def describe_policies(self, project_id: str | None = None) -> str:
return self.policies.summarize_policies(project_id=project_id)
async def _build_prompt(self, content: str, project_id: str | None, session_id: str) -> str:
policy_summary = self.policies.summarize_policies(project_id=project_id)
project_knowledge = await self.memory.build_project_knowledge_context(project_id=project_id)
maybe_compact = getattr(self.memory, "maybe_compact_session_history", None)
if callable(maybe_compact):
await maybe_compact(session_id, project_id=project_id)
session_history = await self.memory.build_session_prompt_context(
session_id,
include_latest_user_turn=False,
)
recent_events = await self.store.get_events(limit=12)
event_lines: list[str] = []
for event in reversed(recent_events[-8:]):
payload = str(event.get("payload", ""))
event_lines.append(f"- {event.get('event_type', '')}: {payload}")
skill_names = [skill.name for skill in self.skills.list_skills()]
current_preferences = self.preferences.load_merged(project_id=project_id)
context = {
"project_id": project_id or "default",
"user_message": content,
"current_secretary_policies": policy_summary,
"current_preferences": {
"communication_style": current_preferences.get("communication_style", ""),
"preferred_language": current_preferences.get("preferred_language", ""),
"decision_preferences": current_preferences.get("decision_preferences", {}),
},
"project_knowledge": project_knowledge,
"secretary_session_history": session_history,
"recent_structured_events": event_lines,
"available_skill_names": skill_names[:80],
}
return json.dumps(context, ensure_ascii=False)
def _system_prompt(self) -> str:
prompt = (
"You are the long-term secretary of the OPC system.\n"
"Your job is to answer as a practical assistant. Durable memory and policy updates are handled by agents through the memory skill, not by the secretary.\n"
"Important constraints:\n"
"- Do not create memory notes, authorization rules, workspace guardrails, skill injection rules, or preferences.\n"
"- Use actions only for explicit skill imports.\n"
"- Return strict JSON only.\n\n"
"JSON schema:\n"
"{\n"
' "response": "assistant reply",\n'
' "actions": [\n'
" {\n"
' "kind": "import_skill",\n'
' "scope": "project",\n'
' "source": "clawhub" | "path",\n'
' "query": "natural language search terms or exact slug",\n'
' "slug": "exact-skill-slug-if-known",\n'
' "path": "/absolute/path/to/downloaded/skill/folder",\n'
' "domains": ["coding"],\n'
' "enable": true,\n'
' "rationale": "why this import is needed"\n'
" }\n"
" ]\n"
"}"
)
return prompt
def _parse_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)
if isinstance(data, dict):
return data
except Exception as e:
logger.debug(f"Secretary JSON parse failed: {e}")
return {"response": raw.strip(), "actions": []}
async def _apply_updates(self, updates: list[Any], project_id: str | None) -> list[str]:
_ = (updates, project_id)
return []
async def _apply_actions(self, actions: list[Any], project_id: str | None) -> list[str]:
applied: list[str] = []
for action in actions:
if not isinstance(action, dict):
continue
kind = str(action.get("kind", "")).strip()
if kind == "update_preferences":
applied.append("skipped preference update because secretary memory writes are disabled")
continue
if kind != "import_skill":
continue
if not project_id:
applied.append("skipped skill import because the secretary needs a project context")
continue
source = str(action.get("source", "clawhub")).strip().lower() or "clawhub"
query = str(action.get("query", "")).strip()
slug = str(action.get("slug", "")).strip()
path = str(action.get("path", "")).strip()
if source == "clawhub" and not query and not slug:
applied.append("skipped skill import because no skill query or slug was provided")
continue
if source in {"path", "directory", "local"} and not path:
applied.append("skipped skill import because no local skill path was provided")
continue
domains = [str(item).strip() for item in action.get("domains", []) if str(item).strip()]
enable = bool(action.get("enable", True))
try:
result = await self.skill_importer.import_skill(
project_id=project_id,
source=source,
query=query,
slug=slug,
path=path,
domains=domains,
enable=enable,
)
summary = f"imported skill `{result.skill_name}` and made it available in project `{project_id}`"
if result.enabled_domains:
summary += f"; auto-injected for {', '.join(result.enabled_domains)}"
applied.append(summary)
except SkillImportError as exc:
applied.append(f"skill import failed: {exc}")
return applied