"""Beaver session 子系统对 runtime 暴露的统一门面。""" from __future__ import annotations from pathlib import Path from typing import Any from .models import MessageRecord from .search import SessionSearchService from .store import SessionStore class SessionManager: """供 AgentLoop / services / MCP tools 使用的统一 session facade。""" def __init__(self, workspace: str | Path, db_path: str | Path | None = None) -> None: self.workspace = Path(workspace) self.sessions_dir = self.workspace / "sessions" self.sessions_dir.mkdir(parents=True, exist_ok=True) self.db_path = Path(db_path) if db_path is not None else self.sessions_dir / "state.db" self.store = SessionStore(self.db_path) self.search = SessionSearchService(self.store) def close(self) -> None: self.store.close() def ensure_session( self, session_id: str, *, source: str = "unknown", model: str | None = None, title: str | None = None, user_id: str | None = None, parent_session_id: str | None = None, ) -> str: return self.store.ensure_session( session_id, source=source, model=model, title=title, user_id=user_id, parent_session_id=parent_session_id, ) def get_session(self, session_id: str) -> dict[str, Any] | None: record = self.store.get_session_record(session_id) return record.to_dict() if record is not None else None def get_or_create( self, session_id: str, *, source: str = "unknown", model: str | None = None, title: str | None = None, user_id: str | None = None, parent_session_id: str | None = None, ) -> dict[str, Any]: self.ensure_session( session_id, source=source, model=model, title=title, user_id=user_id, parent_session_id=parent_session_id, ) session = self.get_session(session_id) if session is None: raise RuntimeError(f"Failed to create session {session_id!r}") return session def append_message(self, session_id: str, **kwargs: Any) -> int: return self.store.append_message(session_id, **kwargs) def get_event_records(self, session_id: str) -> list[MessageRecord]: """返回当前 session 的完整事件流。 这里和 `get_messages_as_conversation()` 的区别很关键: - `get_event_records()` 面向 runtime / replay / audit,保留隐藏系统事件 - `get_messages_as_conversation()` 面向 prompt builder,只暴露可进上下文的事件 第 6 阶段开始后,session 已不再只是“聊天消息存储”,而是在逐步收敛成 “外部事件流 + 上层投影视图”。 """ return self.store.get_event_records(session_id) def get_run_event_records(self, session_id: str, run_id: str) -> list[MessageRecord]: """返回某一次 direct run / future bus run 对应的事件片段。""" return self.store.get_run_event_records(session_id, run_id) def update_latest_assistant_event_payload( self, session_id: str, run_id: str, updates: dict[str, Any], ) -> None: """把 run 级 UI 状态投影回最新 assistant 可见消息。""" self.store.update_latest_assistant_event_payload(session_id, run_id, updates) def set_run_context_visible(self, session_id: str, run_id: str, visible: bool) -> None: self.store.set_run_context_visible(session_id, run_id, visible) def list_run_ids(self, session_id: str) -> list[str]: """按出现顺序列出当前 session 的所有 run_id。""" return self.store.list_run_ids(session_id) def get_messages_as_conversation(self, session_id: str) -> list[dict[str, Any]]: return self.store.get_messages_as_conversation(session_id) def get_visible_history(self, session_id: str, max_messages: int = 500) -> list[dict[str, Any]]: """返回适合注入 prompt 的可见历史切片。 这里故意不直接暴露完整事件流,而是继续提供“模型可消费历史”这个投影视图: 1. 只包含 `context_visible=True` 的事件 2. 继续保留旧式窗口裁剪逻辑,避免当前主链行为突然变化 3. 让 `ContextBuilder` 明确消费的是“上游裁剪后的可见片段” """ records = self.get_event_records(session_id) completed_run_ids = { record.run_id for record in records if record.run_id and record.event_type == "run_completed" } failed_run_ids = { record.run_id for record in records if record.run_id and record.event_type == "run_completed" and ( record.finish_reason == "error" or (record.event_payload or {}).get("finish_reason") == "error" ) } history = [] for record in records: if not record.context_visible or record.role == "system": continue if record.role == "tool": continue if record.role == "assistant" and record.tool_calls: continue if record.run_id and record.run_id not in completed_run_ids: continue if record.run_id and record.run_id in failed_run_ids: continue if record.role == "assistant" and record.finish_reason == "error": continue history.append(record.to_conversation_message()) sliced = history[-max_messages:] for index, message in enumerate(sliced): if message.get("role") == "user": sliced = sliced[index:] break return sliced def get_history(self, session_id: str, max_messages: int = 500) -> list[dict[str, Any]]: """兼容旧名称,实际返回可见历史切片。""" return self.get_visible_history(session_id, max_messages=max_messages) def update_system_prompt(self, session_id: str, system_prompt: str) -> None: self.store.update_system_prompt(session_id, system_prompt) def update_usage(self, session_id: str, **kwargs: Any) -> None: self.store.update_usage(session_id, **kwargs) def end_session(self, session_id: str, end_reason: str) -> None: self.store.end_session(session_id, end_reason) def reopen_session(self, session_id: str) -> None: self.store.reopen_session(session_id) def list_sessions_rich(self, **kwargs: Any) -> list[dict[str, Any]]: return self.search.list_sessions_rich(**kwargs) def search_messages(self, **kwargs: Any) -> list[dict[str, Any]]: return self.search.search_messages(**kwargs) def resolve_session_id(self, session_id_or_prefix: str) -> str | None: return self.search.resolve_session_id(session_id_or_prefix)