6fc5ad6be9
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>
229 lines
9.9 KiB
Python
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
|