Files
OpenViking/openviking/session/session.py
T
996128abcc fix(session): split JSONL on newline only, not Unicode line boundaries (#3984) (#3988)
* fix(session): split JSONL on newline only, not Unicode line boundaries (#3984)

* test(session): consolidate unicode JSONL regression coverage

---------

Co-authored-by: mac <bishopapril850965@yahoo.com>
Co-authored-by: qin-ctx <qinhaojie.exe@bytedance.com>
2026-08-14 14:46:43 +08:00

5268 lines
218 KiB
Python

# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd.
# SPDX-License-Identifier: AGPL-3.0
"""Session management for OpenViking.
Session as Context: Sessions integrated into L0/L1/L2 system.
"""
import asyncio
import inspect
import json
import re
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, List, Literal, Optional
from uuid import uuid4
from openviking.core.namespace import canonical_session_uri
from openviking.core.peer_id import normalize_peer_id, safe_peer_id
from openviking.message import Message, Part
from openviking.message.part import ContextPart, TextPart, ToolPart
from openviking.pyagfs.exceptions import AGFSClientError, AGFSHTTPError, AGFSNotFoundError
from openviking.server.config import ToolOutputExternalizationConfig
from openviking.server.identity import RequestContext, Role
from openviking.session.auto_commit_policy import AutoCommitPolicy
from openviking.session.memory.constants import AGENT_EVOLUTION_MEMORY_TYPES
from openviking.session.memory_policy import MemoryPolicy
from openviking.session.retention import (
RETENTION_MODE_TURN_BUDGET,
RetentionPlan,
build_turns,
fit_active_messages_to_budget,
is_user_query,
plan_retention,
)
from openviking.session.tool_result_store import (
ToolResultStore,
build_tool_result_id,
make_preview,
render_preview_from_synopsis,
sha256_text,
)
from openviking.session.tool_result_synopsis import (
ToolResultSynopsis,
generate_tool_result_synopsis,
)
from openviking.telemetry import get_current_telemetry, tracer
from openviking.telemetry.request_wait_tracker import get_request_wait_tracker
from openviking.utils.model_retry import is_retryable_api_error, retry_async
from openviking.utils.time_utils import get_current_timestamp
from openviking.utils.token_estimation import estimate_text_tokens, truncate_text_to_token_budget
from openviking_cli.exceptions import (
FailedPreconditionError,
NotFoundError,
)
from openviking_cli.session.user_id import UserIdentifier
from openviking_cli.utils import get_logger, run_async
from openviking_cli.utils.config import get_openviking_config
if TYPE_CHECKING:
from openviking.session.compressor_v3 import SessionCompressorV3 as SessionCompressor
from openviking.storage import VikingDBManager
from openviking.storage.queuefs.session_commit_msg import SessionCommitMsg
from openviking.storage.viking_fs import VikingFS
from openviking.usage_reporter import UsageReporter
logger = get_logger(__name__)
_PHASE2_QUEUE_WAIT_TIMEOUT_SECONDS = 1800.0
_MEMORY_EXTRACTION_MAX_RETRIES = 3
_MEMORY_EXTRACTION_RETRY_BASE_DELAY_SECONDS = 1.0
_MEMORY_EXTRACTION_RETRY_MAX_DELAY_SECONDS = 8.0
_AGENT_TRAINING_REQUIRED_MEMORY_TYPES = frozenset({"experiences"})
_SESSION_PHASE1_LOCK_TIMEOUT_SECONDS = 30.0
_MEMORY_STEP_NAMES = ("long_term",)
_CUMULATIVE_CHECKPOINT_VERSION = 2
class _ArchiveMessagesCorruptError(ValueError):
"""Raised when an archive messages file cannot be deserialized."""
def _is_storage_not_found(exc: BaseException) -> bool:
if isinstance(exc, AGFSClientError):
return isinstance(exc, AGFSNotFoundError) or (
isinstance(exc, AGFSHTTPError) and exc.status_code == 404
)
if isinstance(exc, (FileNotFoundError, NotFoundError)):
nested = exc.__cause__ or exc.__context__
return nested is None or _is_storage_not_found(nested)
return False
def _wm_debug(msg: str) -> None:
"""Log a WM v2 debug message via the standard logger."""
logger.debug("wm_v2: %s", msg)
def _enabled_memory_types() -> set[str]:
"""Return enabled memory type names registered for extraction."""
from openviking.session.memory.memory_type_registry import MemoryTypeRegistry
return set(MemoryTypeRegistry().list_names(include_disabled=False))
def _validate_memory_policy_types(policy: MemoryPolicy) -> None:
if policy.memory_types is None:
return
policy.validate_memory_types(_enabled_memory_types())
def _apply_agent_evolution_setting(
policy: MemoryPolicy,
*,
agent_evolution_enabled: bool,
) -> MemoryPolicy:
if agent_evolution_enabled:
return policy
effective_types = (
_enabled_memory_types() if policy.memory_types is None else set(policy.memory_types)
)
effective_types -= AGENT_EVOLUTION_MEMORY_TYPES
return MemoryPolicy(
self_enabled=policy.self_enabled,
peer_enabled=policy.peer_enabled,
memory_types=effective_types,
working_memory_enabled=policy.working_memory_enabled,
)
def _effective_memory_types(policy: MemoryPolicy) -> set[str]:
if policy.memory_types is None:
return _enabled_memory_types()
return set(policy.memory_types)
def _agent_memory_skip_reason(
*,
agent_evolution_enabled: bool,
effective_memory_types: set[str],
user_config_error: Optional[str] = None,
) -> Optional[str]:
if user_config_error:
return "invalid_user_config"
if not agent_evolution_enabled:
return "agent_evolution_disabled"
if not _AGENT_TRAINING_REQUIRED_MEMORY_TYPES.issubset(effective_memory_types):
return "memory_types_filtered"
return None
def _default_memory_counts() -> Dict[str, int]:
return {"total": 0}
def _resolve_event_search_tags(
commit_tags: Optional[List[str]],
session_default_tags: Optional[List[str]],
) -> List[str]:
"""Resolve the event tags for a commit against the session default.
Three-state precedence:
- ``commit_tags is None`` -> use the session default (may be empty)
- ``commit_tags == []`` -> do not inject default tags for this commit
- non-empty ``commit_tags`` -> override the session default
The chosen list is normalized to canonical ``key=value`` tags.
"""
from openviking.utils.tags import normalize_search_tags
chosen = commit_tags if commit_tags is not None else session_default_tags
return normalize_search_tags(chosen)
def _message_peer_ids(messages: List[Message]) -> set[str]:
return {
peer_id
for message in messages
if (peer_id := safe_peer_id(getattr(message, "peer_id", None)))
}
@dataclass(frozen=True)
class _MemoryExtractionScope:
allow_self_memory: bool
allowed_peer_ids: set[str]
include_session_skills: bool
memory_types: Optional[set[str]]
def _resolve_memory_extraction_scope(
ctx: RequestContext,
policy: MemoryPolicy,
messages: List[Message],
*,
config_session_skill_extraction_enabled: bool,
) -> _MemoryExtractionScope:
allow_self_memory = policy.self_enabled
allowed_peer_ids = _message_peer_ids(messages) if policy.peer_enabled else set()
return _MemoryExtractionScope(
allow_self_memory=allow_self_memory,
allowed_peer_ids=allowed_peer_ids,
include_session_skills=config_session_skill_extraction_enabled and allow_self_memory,
memory_types=policy.memory_types,
)
# =====================================================================
# Working Memory v2
# ---------------------------------------------------------------------
# Phase 2 of a commit generates / updates a structured 7-section Working
# Memory document stored at archive_NNN/.overview.md.
#
# First commit: call `compression.ov_wm_v2` with a plain completion unless
# partial-Turn retention also needs checkpoint summaries. In that case the
# same call uses `create_working_memory` and returns both products.
# Subsequent commits: call `compression.ov_wm_v2_update` with the
# `update_working_memory` tool to get a per-section decision plus any requested
# checkpoint summaries, then let the server do section-level merge against the
# previous WM.
# =====================================================================
WM_SEVEN_SECTIONS: List[str] = [
"Session Title",
"Current State",
"Task & Goals",
"Key Facts & Decisions",
"Files & Context",
"Errors & Corrections",
"Open Issues",
]
_WM_SECTION_OP_SCHEMA: Dict[str, Any] = {
"oneOf": [
{
"type": "object",
"required": ["op"],
"additionalProperties": False,
"properties": {"op": {"type": "string", "enum": ["KEEP"]}},
},
{
"type": "object",
"required": ["op", "content"],
"additionalProperties": False,
"properties": {
"op": {"type": "string", "enum": ["UPDATE"]},
"content": {
"type": "string",
"description": (
"FULL replacement content for this section, markdown, "
"WITHOUT the '## <section>' header line."
),
},
},
},
{
"type": "object",
"required": ["op", "items"],
"additionalProperties": False,
"properties": {
"op": {"type": "string", "enum": ["APPEND"]},
"items": {
"type": "array",
"items": {"type": "string"},
"description": (
"New bullet-style items to append under the existing "
"section body. Omit heading / bullet markers; the "
"server renders each item as '- <item>'."
),
},
},
},
]
}
WM_UPDATE_TOOL: Dict[str, Any] = {
"type": "function",
"function": {
"name": "update_working_memory",
"description": (
"Emit a per-section decision (KEEP / UPDATE / APPEND) for the "
"7-section Working Memory document."
),
"parameters": {
"type": "object",
"required": ["sections"],
"additionalProperties": False,
"properties": {
"sections": {
"type": "object",
"required": list(WM_SEVEN_SECTIONS),
"additionalProperties": False,
"properties": dict.fromkeys(WM_SEVEN_SECTIONS, _WM_SECTION_OP_SCHEMA),
},
"checkpoint_summaries": {
"type": "array",
"items": {"type": "string"},
"description": (
"When checkpoint sources are present, one bounded cumulative "
"continuation summary per checkpoint_source index, in ascending "
"index order."
),
},
},
},
},
}
WM_CREATE_WITH_CHECKPOINTS_TOOL: Dict[str, Any] = {
"type": "function",
"function": {
"name": "create_working_memory",
"description": (
"Create the complete Working Memory and the requested checkpoint summaries "
"from the same model pass."
),
"parameters": {
"type": "object",
"required": ["working_memory", "checkpoint_summaries"],
"additionalProperties": False,
"properties": {
"working_memory": {
"type": "string",
"description": "Complete 7-section Working Memory markdown.",
},
"checkpoint_summaries": {
"type": "array",
"items": {"type": "string"},
"description": (
"One bounded cumulative continuation summary per checkpoint_source "
"index, in ascending index order."
),
},
},
},
},
}
@dataclass(frozen=True)
class _CheckpointRequest:
"""Server-owned mapping for one checkpoint summary requested from Phase 2."""
turn_anchor_message_id: str
source_message_ids: tuple[str, ...]
retained_message_token_budget: int
estimated_active_tokens: int
previous_checkpoint_abstract: str = ""
previous_checkpoint_source_message_ids: tuple[str, ...] = ()
@dataclass(frozen=True)
class _CheckpointSnapshot:
"""Effective completed checkpoint state for one retained User Turn."""
turn_anchor_message_id: str
source_message_ids: tuple[str, ...]
abstract: str
archive_id: str
archive_uri: str
@dataclass(frozen=True)
class _ArchiveSummaryResult:
"""The two products emitted by the existing Working-Memory model call."""
overview: str
checkpoint_summaries: tuple[str, ...] = ()
@dataclass
class SessionCompression:
"""Session compression information."""
summary: str = ""
original_count: int = 0
compressed_count: int = 0
compression_index: int = 0
@dataclass
class SessionStats:
"""Session statistics information."""
total_turns: int = 0
total_tokens: int = 0
compression_count: int = 0
contexts_used: int = 0
skills_used: int = 0
memories_extracted: int = 0
@dataclass
class ArchiveState:
"""Filesystem-derived state for one archive directory."""
archive_id: str
archive_uri: str
index: int
state: Literal["pending", "completed", "failed"]
overview: str = ""
done: Dict[str, Any] = field(default_factory=dict)
failed: Dict[str, Any] = field(default_factory=dict)
@property
def coverage_start_index(self) -> int:
raw = self.done.get("coverage_start_archive")
if isinstance(raw, str):
match = re.fullmatch(r"archive_(\d+)", raw)
if match:
return int(match.group(1))
return self.index
@property
def coverage_end_index(self) -> int:
raw = self.done.get("coverage_end_archive")
if isinstance(raw, str):
match = re.fullmatch(r"archive_(\d+)", raw)
if match:
return int(match.group(1))
return self.index
@dataclass
class SessionMeta:
"""Session metadata persisted in .meta.json."""
session_id: str = ""
created_at: str = ""
updated_at: str = ""
created_by_account_id: str = ""
created_by_user_id: str = ""
message_count: int = 0
total_message_count: Optional[int] = 0
commit_count: int = 0
memories_extracted: Dict[str, int] = field(default_factory=_default_memory_counts)
last_commit_at: str = ""
llm_token_usage: Dict[str, int] = field(
default_factory=lambda: {
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0,
"cached_tokens": 0,
"reasoning_tokens": 0,
}
)
embedding_token_usage: Dict[str, int] = field(
default_factory=lambda: {
"total_tokens": 0,
}
)
# Working-Memory v2: token accounting for sliding window + keep window.
# pending_tokens is the cumulative estimated_tokens of messages that fall
# OUTSIDE the recent-keep window and will be archived on the next commit.
# Maintained O(1) inside add_message(); rebuilt from messages on load.
pending_tokens: int = 0
# keep_recent_count is the last value passed from the plugin through
# POST /sessions/{id}/commit body. It is remembered so subsequent
# add_message calls can maintain pending_tokens consistently across
# process restarts.
keep_recent_count: int = 0
# Opt-in Turn-aware retention. Empty mode preserves the physical-message
# keep_recent_count behavior for existing integrations.
retention_mode: str = ""
keep_recent_turn_count: int = 0
retained_message_token_budget: int = 0
min_raw_tail_steps: int = 1
memory_policy: Optional[Dict[str, Any]] = None
# Automatic-commit policy. None keeps auto-commit disabled; a dict enables
# it with the stored bounds. Session config PATCH may update it.
auto_commit_policy: Optional[Dict[str, Any]] = None
# Timestamp of the most recent add_message, used by the idle scan to decide
# whether an idle-timeout commit is due.
last_message_at: str = ""
# Timestamp of the most recent successful auto-commit, surfaced via session
# GET and used to throttle auto-commit frequency.
last_auto_commit_at: str = ""
# Default custom scalar tags applied to event memories extracted from this
# session. Maps to config.memory_extraction_config.events.tags in the API.
# None means no session default; a commit may still override per-call.
event_search_tags: Optional[List[str]] = None
def to_dict(self) -> Dict[str, Any]:
data = {
"session_id": self.session_id,
"created_at": self.created_at,
"updated_at": self.updated_at,
"created_by_account_id": self.created_by_account_id,
"created_by_user_id": self.created_by_user_id,
"message_count": self.message_count,
"commit_count": self.commit_count,
"memories_extracted": dict(self.memories_extracted),
"last_commit_at": self.last_commit_at,
"llm_token_usage": dict(self.llm_token_usage),
"embedding_token_usage": dict(self.embedding_token_usage),
"pending_tokens": self.pending_tokens,
"keep_recent_count": self.keep_recent_count,
"retention_mode": self.retention_mode,
"keep_recent_turn_count": self.keep_recent_turn_count,
"retained_message_token_budget": self.retained_message_token_budget,
"min_raw_tail_steps": self.min_raw_tail_steps,
"memory_policy": dict(self.memory_policy) if self.memory_policy is not None else None,
"auto_commit_policy": (
dict(self.auto_commit_policy) if self.auto_commit_policy is not None else None
),
"last_message_at": self.last_message_at,
"last_auto_commit_at": self.last_auto_commit_at,
}
if self.total_message_count is not None:
data["total_message_count"] = self.total_message_count
if self.event_search_tags is not None:
data["event_search_tags"] = list(self.event_search_tags)
return data
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "SessionMeta":
llm_token_usage = data.get("llm_token_usage", {})
embedding_token_usage = data.get("embedding_token_usage", {})
memories = data.get("memories_extracted", {})
memory_counts = _default_memory_counts()
for key, value in memories.items():
try:
memory_counts[key] = int(value or 0)
except (TypeError, ValueError):
memory_counts[key] = 0
return cls(
session_id=data.get("session_id", ""),
created_at=data.get("created_at", ""),
updated_at=data.get("updated_at", ""),
created_by_account_id=data.get("created_by_account_id", "")
or data.get("account_id", ""),
created_by_user_id=data.get("created_by_user_id", ""),
message_count=data.get("message_count", 0),
total_message_count=data.get("total_message_count"),
commit_count=data.get("commit_count", 0),
memories_extracted=memory_counts,
last_commit_at=data.get("last_commit_at", ""),
llm_token_usage={
"prompt_tokens": llm_token_usage.get("prompt_tokens", 0),
"completion_tokens": llm_token_usage.get("completion_tokens", 0),
"total_tokens": llm_token_usage.get("total_tokens", 0),
"cached_tokens": llm_token_usage.get("cached_tokens", 0),
"reasoning_tokens": llm_token_usage.get("reasoning_tokens", 0),
},
embedding_token_usage={
"total_tokens": embedding_token_usage.get("total_tokens", 0),
},
pending_tokens=max(0, int(data.get("pending_tokens", 0) or 0)),
keep_recent_count=max(0, int(data.get("keep_recent_count", 0) or 0)),
retention_mode=str(data.get("retention_mode", "") or ""),
keep_recent_turn_count=max(0, int(data.get("keep_recent_turn_count", 0) or 0)),
retained_message_token_budget=max(
0, int(data.get("retained_message_token_budget", 0) or 0)
),
min_raw_tail_steps=max(0, int(data.get("min_raw_tail_steps", 1) or 0)),
memory_policy=data.get("memory_policy"),
auto_commit_policy=data.get("auto_commit_policy"),
last_message_at=data.get("last_message_at", ""),
last_auto_commit_at=data.get("last_auto_commit_at", ""),
event_search_tags=data.get("event_search_tags"),
)
@dataclass
class Usage:
"""Usage record."""
uri: str
type: str # "context" | "skill"
contribution: float = 0.0
input: str = ""
output: str = ""
success: bool = True
timestamp: str = field(default_factory=get_current_timestamp)
class Session:
"""Session management class - Message = role + parts."""
def __init__(
self,
viking_fs: "VikingFS",
vikingdb_manager: Optional["VikingDBManager"] = None,
session_compressor: Optional["SessionCompressor"] = None,
user: Optional["UserIdentifier"] = None,
ctx: Optional[RequestContext] = None,
session_id: Optional[str] = None,
session_uri: Optional[str] = None,
auto_commit_threshold: int = 8000,
tool_output_externalization_config: Optional[ToolOutputExternalizationConfig] = None,
agent_evolution_enabled: bool = True,
usage_reporter: Optional["UsageReporter"] = None,
agent_evolution_enabled_provider: Optional[Callable[[], bool | Awaitable[bool]]] = None,
):
self._viking_fs = viking_fs
self._vikingdb_manager = vikingdb_manager
self._session_compressor = session_compressor
self.user = user or UserIdentifier.the_default_user()
self.ctx = ctx or RequestContext(user=self.user, role=Role.ROOT)
self.session_id = (
session_id or f"{datetime.now(timezone.utc):%Y%m%d-%H%M%S}-{uuid4().hex[:16]}"
)
self.created_at = int(datetime.now(timezone.utc).timestamp() * 1000)
self._auto_commit_threshold = auto_commit_threshold
self._session_uri = session_uri or canonical_session_uri(self.ctx, self.session_id)
self._messages: List[Message] = []
self._usage_records: List[Usage] = []
self._archive_meta_merge_lock = asyncio.Lock()
self._compression: SessionCompression = SessionCompression()
self._stats: SessionStats = SessionStats()
self._meta = SessionMeta(
session_id=self.session_id,
created_at=get_current_timestamp(),
created_by_account_id=self.ctx.account_id,
created_by_user_id=self.ctx.user.user_id,
)
self._loaded = False
self._tool_output_externalization_config = (
tool_output_externalization_config.model_copy(deep=True)
if tool_output_externalization_config is not None
else ToolOutputExternalizationConfig()
)
self._agent_evolution_enabled = agent_evolution_enabled
self._agent_evolution_enabled_provider = agent_evolution_enabled_provider
self._usage_reporter = usage_reporter
async def load(self):
"""Load session data from storage."""
if self._loaded:
return
try:
content = await self._viking_fs.read_file(
f"{self._session_uri}/messages.jsonl", ctx=self.ctx
)
self._messages = [
Message.from_dict(json.loads(line))
for line in content.strip().split("\n")
if line.strip()
]
except Exception as exc:
if not _is_storage_not_found(exc):
raise
logger.debug(f"Session {self.session_id} not found, starting fresh")
# Load .meta.json
try:
meta_content = await self._viking_fs.read_file(
f"{self._session_uri}/.meta.json", ctx=self.ctx
)
self._meta = SessionMeta.from_dict(json.loads(meta_content))
self._compression.compression_index = max(0, int(self._meta.commit_count))
self._stats.compression_count = self._compression.compression_index
except Exception as exc:
if not _is_storage_not_found(exc):
raise
# Old session without meta — derive from existing data
try:
history_items = await self._viking_fs.ls(
f"{self._session_uri}/history", ctx=self.ctx
)
archive_indices = [
int(match.group(1))
for item in history_items
if (match := re.fullmatch(r"archive_(\d+)", item["name"]))
]
if archive_indices:
self._compression.compression_index = max(archive_indices)
self._stats.compression_count = len(archive_indices)
except Exception as exc:
if not _is_storage_not_found(exc):
raise
self._meta.commit_count = self._compression.compression_index
self._meta.total_message_count = None
# message_count mirrors the live message list, maintained by every write
# path. Recompute on load so a stale persisted value can't drift.
self._meta.message_count = len(self._messages)
if not self._meta.created_by_account_id:
self._meta.created_by_account_id = self.ctx.account_id
if not self._meta.created_by_user_id:
self._meta.created_by_user_id = self.ctx.user.user_id
# Auto-commit stays disabled when no policy is stored. When present,
# normalize the stored policy so missing fields are filled and bounds
# are clamped. The policy's keep_recent_count is a commit-time
# reservation only and is intentionally NOT mirrored onto
# meta.keep_recent_count.
if self._meta.auto_commit_policy is not None:
self._meta.auto_commit_policy = AutoCommitPolicy.from_dict(
self._meta.auto_commit_policy
).to_dict()
# WM v2: always rebuild pending_tokens from current messages so the
# counter stays consistent across restarts and is also backfilled for
# legacy sessions whose .meta.json predates these fields. O(n) once,
# subsequent add_message() maintains it in O(1).
self._rebuild_pending_tokens()
self._loaded = True
def _rebuild_pending_tokens(self) -> None:
"""Recompute ``pending_tokens`` from the current message list.
Used on load and as a safety net after rollbacks. Respects the
currently remembered ``keep_recent_count`` from meta.
"""
if (
self._meta.retention_mode == RETENTION_MODE_TURN_BUDGET
and self._meta.keep_recent_turn_count > 0
and self._meta.retained_message_token_budget > 0
):
plan = plan_retention(
self._messages,
keep_recent_turn_count=self._meta.keep_recent_turn_count,
token_budget=self._meta.retained_message_token_budget,
min_raw_tail_steps=self._meta.min_raw_tail_steps,
)
retained_ids = {message.id for message in plan.retained_messages}
self._meta.pending_tokens = sum(
int(message.estimated_tokens or 0)
for message in plan.archive_messages
if message.id not in retained_ids
)
self._meta.pending_tokens = max(0, self._meta.pending_tokens)
return
keep = max(0, int(self._meta.keep_recent_count or 0))
total = len(self._messages)
if keep <= 0:
self._meta.pending_tokens = sum(int(m.estimated_tokens or 0) for m in self._messages)
elif total > keep:
self._meta.pending_tokens = sum(
int(m.estimated_tokens or 0) for m in self._messages[: total - keep]
)
else:
self._meta.pending_tokens = 0
self._meta.pending_tokens = max(0, self._meta.pending_tokens)
async def exists(self) -> bool:
"""Check whether this session already exists in storage."""
try:
await self._viking_fs.stat(self._session_uri, ctx=self.ctx)
return True
except Exception as exc:
if not _is_storage_not_found(exc):
raise
return False
async def is_materialized(self) -> bool:
"""Check whether the session's authoritative live-message file exists."""
try:
await self._viking_fs.stat(
f"{self._session_uri}/messages.jsonl",
ctx=self.ctx,
)
return True
except Exception as exc:
if not _is_storage_not_found(exc):
raise
return False
async def ensure_exists(self) -> None:
"""Materialize session root and messages file if missing."""
if await self.exists():
return
await self._viking_fs.mkdir(self._session_uri, exist_ok=True, ctx=self.ctx)
await self._viking_fs.write_file(
f"{self._session_uri}/messages.jsonl",
"",
ctx=self.ctx,
)
await self._save_meta()
async def _save_meta(self, lease_ref: Optional[Any] = None) -> None:
"""Persist .meta.json to storage using an optional held PathLock lease."""
if not self._viking_fs:
return
self._meta.updated_at = get_current_timestamp()
await self._viking_fs.write_file(
uri=f"{self._session_uri}/.meta.json",
content=json.dumps(self._meta.to_dict(), ensure_ascii=False),
ctx=self.ctx,
lease_ref=lease_ref,
)
async def update_config(
self,
*,
event_search_tags: Optional[List[str]] = None,
auto_commit_policy: Optional[Dict[str, Any]] = None,
update_auto_commit_policy: bool = False,
) -> None:
"""Update mutable session config without overwriting concurrent meta changes."""
update_auto_commit_policy = (
update_auto_commit_policy or auto_commit_policy is not None
)
session_path = self._viking_fs._uri_to_path(self._session_uri, ctx=self.ctx)
lease = await self._viking_fs._async_agfs.pathlock_acquire_tree(
session_path, timeout_secs=_SESSION_PHASE1_LOCK_TIMEOUT_SECONDS
)
try:
try:
meta_content = await self._viking_fs.read_file(
f"{self._session_uri}/.meta.json",
ctx=self.ctx,
)
self._meta = SessionMeta.from_dict(json.loads(meta_content))
except Exception as exc:
if not _is_storage_not_found(exc):
raise
if event_search_tags is not None:
self._meta.event_search_tags = list(event_search_tags)
if update_auto_commit_policy:
if auto_commit_policy is None:
self._meta.auto_commit_policy = None
else:
existing = dict(self._meta.auto_commit_policy or {})
existing.update(auto_commit_policy)
self._meta.auto_commit_policy = AutoCommitPolicy.from_dict(existing).to_dict()
await self._save_meta(lease_ref=lease)
finally:
await self._viking_fs._async_agfs.pathlock_release(lease)
async def update_event_search_tags(self, event_search_tags: List[str]) -> None:
"""Update event-memory default tags."""
await self.update_config(event_search_tags=event_search_tags)
@property
def messages(self) -> List[Message]:
"""Get message list."""
return self._messages
@property
def meta(self) -> SessionMeta:
"""Get session metadata."""
return self._meta
# ============= Core methods =============
def used(
self,
contexts: Optional[List[str]] = None,
skill: Optional[Dict[str, Any]] = None,
) -> None:
"""Record actually used contexts and skills."""
if contexts:
for uri in contexts:
usage = Usage(uri=uri, type="context")
self._usage_records.append(usage)
self._stats.contexts_used += 1
logger.debug(f"Tracked context usage: {uri}")
try:
from openviking.metrics.datasources.session import SessionLifecycleDataSource
SessionLifecycleDataSource.record_contexts_used(
action="context", delta=len(contexts)
)
except Exception:
pass
if skill:
usage = Usage(
uri=skill.get("uri", ""),
type="skill",
input=skill.get("input", ""),
output=skill.get("output", ""),
success=skill.get("success", True),
)
self._usage_records.append(usage)
self._stats.skills_used += 1
logger.debug(f"Tracked skill usage: {skill.get('uri')}")
try:
from openviking.metrics.datasources.session import SessionLifecycleDataSource
SessionLifecycleDataSource.record_contexts_used(action="skill", delta=1)
except Exception:
pass
def _tool_result_store(self) -> Optional[ToolResultStore]:
if not self._viking_fs:
return None
return ToolResultStore(
self._viking_fs,
self._session_uri,
self.session_id,
self.ctx,
)
async def _hydrate_tool_outputs_for_extraction(
self,
messages: List[Message],
) -> List[Message]:
"""Return a memory-only copy with externalized tool outputs restored."""
hydrated = [Message.from_dict(m.to_dict()) for m in messages]
store = self._tool_result_store()
if not store:
return hydrated
for msg in hydrated:
for part in msg.parts:
if not isinstance(part, ToolPart):
continue
if not part.tool_output_ref:
continue
if not (part.tool_output_truncated or part.tool_output_source_ref):
continue
ref = part.tool_output_source_ref or part.tool_output_ref
tool_result_id = ref.rstrip("/").split("/")[-1]
offset = part.tool_output_source_offset if part.tool_output_source_ref else 0
limit = part.tool_output_source_limit if part.tool_output_source_ref else -1
if (
part.tool_output_source_ref
and limit is None
and part.tool_output_original_chars is not None
):
limit = part.tool_output_original_chars
try:
result = await store.read(
tool_result_id,
offset=max(0, int(offset or 0)),
limit=int(limit) if limit is not None else -1,
include_metadata=False,
)
except Exception as exc:
logger.warning(
"Failed to hydrate externalized tool output for extraction: "
"session=%s message_id=%s tool_id=%s ref=%s error=%s",
self.session_id,
msg.id,
part.tool_id,
ref,
exc,
)
continue
part.tool_output = result.get("content", "")
return hydrated
def _effective_tool_preview_chars(
self,
cfg: ToolOutputExternalizationConfig,
externalized_count: int,
) -> int:
if externalized_count <= 0:
return cfg.preview_chars
group_share = cfg.assistant_turn_preview_budget_chars // externalized_count
return max(0, min(cfg.preview_chars, max(cfg.min_preview_chars, group_share)))
def _rewrite_source_read_tool_output(
self,
part: ToolPart,
cfg: ToolOutputExternalizationConfig,
*,
group_id: str,
group_original_chars: int,
) -> bool:
"""Rewrite read-back tool output as a source reference, not a new result."""
if part.tool_name != "openviking_tool_result_read":
return False
tool_input = part.tool_input if isinstance(part.tool_input, dict) else {}
source_ref = str(
tool_input.get("tool_output_ref")
or tool_input.get("ref")
or tool_input.get("uri")
or ""
)
if not source_ref.startswith(f"{self._session_uri}/tool-results/"):
return False
output = part.tool_output or ""
preview_chars = max(cfg.min_preview_chars, cfg.preview_chars)
preview = make_preview(
output,
preview_chars=preview_chars,
ref=source_ref,
tool_name=part.tool_name,
sha256=sha256_text(output) if output else "",
reason="source_read",
original_chars=len(output),
mime_type=part.tool_output_mime_type or "text/plain",
)
part.tool_output = preview
part.tool_output_ref = source_ref
part.tool_output_truncated = len(output) > len(preview)
part.tool_output_original_chars = len(output)
part.tool_output_preview_chars = len(preview)
part.tool_output_sha256 = sha256_text(output) if output else ""
part.tool_output_storage_uri = source_ref
part.tool_output_source_ref = source_ref
part.tool_output_source_offset = tool_input.get("offset")
part.tool_output_source_limit = tool_input.get("limit")
part.tool_output_group_id = group_id
part.tool_output_externalized_reason = "source_read"
part.tool_output_group_original_chars = group_original_chars
part.tool_output_group_budget_chars = cfg.assistant_turn_inline_budget_chars
return True
def _externalize_tool_part(
self,
msg: Message,
part: ToolPart,
cfg: ToolOutputExternalizationConfig,
*,
preview_chars: int,
reason: str,
group_id: str,
group_original_chars: int,
synopsis: Optional[ToolResultSynopsis] = None,
) -> None:
store = self._tool_result_store()
original_output = part.tool_output or ""
if not store or not original_output:
return
digest = sha256_text(original_output)
try:
stored = run_async(
store.write(
content=original_output,
tool_id=part.tool_id,
tool_name=part.tool_name,
message_id=msg.id,
user_id=self.ctx.user.user_id if self.ctx and self.ctx.user else None,
peer_id=msg.peer_id,
created_at=msg.created_at,
preview_chars=preview_chars,
mime_type=part.tool_output_mime_type or "text/plain",
synopsis=synopsis,
)
)
except Exception as exc:
error = f"{type(exc).__name__}: {exc}"
part.tool_output_externalization_error = error
if cfg.failure_mode == "reject":
raise FailedPreconditionError(
"Failed to externalize tool output",
details={"tool_id": part.tool_id, "error": error},
) from exc
if cfg.failure_mode == "preview_only":
part.tool_output = make_preview(
original_output,
preview_chars=preview_chars,
tool_name=part.tool_name,
sha256=digest,
reason=f"{reason}:externalization_failed",
original_chars=len(original_output),
mime_type=part.tool_output_mime_type or "text/plain",
)
part.tool_output_ref = ""
part.tool_output_truncated = True
part.tool_output_original_chars = len(original_output)
part.tool_output_preview_chars = len(part.tool_output)
part.tool_output_sha256 = digest
part.tool_output_externalized_reason = reason
return
ref = stored.storage_uri
part.tool_output = render_preview_from_synopsis(
stored.synopsis,
ref=ref,
tool_name=part.tool_name,
sha256=digest,
reason=reason,
original_chars=len(original_output),
preview_chars=min(len(original_output), max(preview_chars, 0)),
)
part.tool_output_ref = ref
part.tool_output_truncated = True
part.tool_output_original_chars = len(original_output)
part.tool_output_preview_chars = len(part.tool_output)
part.tool_output_sha256 = digest
part.tool_output_storage_uri = ref
part.tool_output_mime_type = stored.metadata.get("mime_type", "text/plain")
part.tool_output_group_id = group_id
part.tool_output_externalized_reason = reason
part.tool_output_group_original_chars = group_original_chars
part.tool_output_group_budget_chars = cfg.assistant_turn_inline_budget_chars
def _externalize_large_tool_output_group(self, messages: List[Message]) -> None:
cfg = self._tool_output_externalization_config
if not cfg.enabled:
return
tool_parts = [
(msg, p)
for msg in messages
for p in msg.parts
if isinstance(p, ToolPart) and (p.tool_output or "")
]
if not tool_parts:
return
group_id = messages[0].id
group_original_chars = sum(
(
int(p.tool_output_original_chars)
if p.tool_output_ref
and p.tool_output_truncated
and p.tool_output_original_chars is not None
else len(p.tool_output or "")
)
for _, p in tool_parts
)
normal_indices: List[int] = []
selected: set[int] = set()
externalized_preview_cache: Dict[tuple[int, int, str], tuple[ToolResultSynopsis, int]] = {}
for idx, (_msg, part) in enumerate(tool_parts):
part.tool_output_group_id = group_id
part.tool_output_group_original_chars = group_original_chars
part.tool_output_group_budget_chars = cfg.assistant_turn_inline_budget_chars
if self._rewrite_source_read_tool_output(
part,
cfg,
group_id=group_id,
group_original_chars=group_original_chars,
):
continue
if part.tool_output_ref and part.tool_output_truncated:
continue
normal_indices.append(idx)
if len(part.tool_output or "") > cfg.threshold_chars:
selected.add(idx)
def prepared_externalized_preview(
idx: int, part: ToolPart, preview_chars: int
) -> tuple[ToolResultSynopsis, int]:
content = part.tool_output or ""
reason = "single_threshold" if len(content) > cfg.threshold_chars else "turn_budget"
cache_key = (idx, preview_chars, reason)
cached = externalized_preview_cache.get(cache_key)
if cached is not None:
return cached
synopsis = generate_tool_result_synopsis(
content,
preview_chars=preview_chars,
tool_name=part.tool_name,
mime_type=part.tool_output_mime_type or "text/plain",
)
digest = sha256_text(content)
ref = f"{self._session_uri}/tool-results/{build_tool_result_id(part.tool_id, digest)}"
rendered = render_preview_from_synopsis(
synopsis,
ref=ref,
tool_name=part.tool_name,
sha256=digest,
reason=reason,
original_chars=len(content),
preview_chars=min(len(content), max(preview_chars, 0)),
)
prepared = (synopsis, len(rendered))
externalized_preview_cache[cache_key] = prepared
return prepared
def projected_inline_chars(selected_indices: set[int]) -> int:
preview_chars = self._effective_tool_preview_chars(cfg, len(selected_indices))
total = 0
for idx, (_, part) in enumerate(tool_parts):
output_len = len(part.tool_output or "")
if idx in selected_indices:
_synopsis, rendered_len = prepared_externalized_preview(
idx, part, preview_chars
)
total += rendered_len
else:
total += output_len
return total
remaining = sorted(
[idx for idx in normal_indices if idx not in selected],
key=lambda idx: len(tool_parts[idx][1].tool_output or ""),
reverse=True,
)
while (
projected_inline_chars(selected) >= cfg.assistant_turn_inline_budget_chars and remaining
):
baseline = projected_inline_chars(selected)
chosen_pos = None
for pos, idx in enumerate(remaining):
candidate = set(selected)
candidate.add(idx)
if projected_inline_chars(candidate) < baseline:
chosen_pos = pos
break
if chosen_pos is None:
break
selected.add(remaining.pop(chosen_pos))
preview_chars = self._effective_tool_preview_chars(cfg, len(selected))
for idx in sorted(selected):
msg, part = tool_parts[idx]
reason = (
"single_threshold"
if len(part.tool_output or "") > cfg.threshold_chars
else "turn_budget"
)
synopsis, _rendered_len = prepared_externalized_preview(idx, part, preview_chars)
self._externalize_tool_part(
msg,
part,
cfg,
preview_chars=preview_chars,
reason=reason,
group_id=group_id,
group_original_chars=group_original_chars,
synopsis=synopsis,
)
def _externalize_large_tool_outputs(self, msg: Message) -> None:
self._externalize_large_tool_output_group([msg])
def _is_tool_result_aggregate(self, role: str, parts: List[Part]) -> bool:
return (
role == "user" and len(parts) > 1 and all(isinstance(part, ToolPart) for part in parts)
)
def _append_messages(self, messages: List[Message]) -> None:
"""Append messages through the same authoritative lock as commit Phase 1."""
run_async(self._append_messages_authoritatively(messages))
async def _append_messages_authoritatively(self, messages: List[Message]) -> None:
"""Reload and append under the session path lock.
Different workers can hold stale Session objects. Without sharing the
commit lock, an append between commit's root read and root rewrite can
be overwritten even though add_message already returned successfully.
"""
if not messages:
return
if not self._viking_fs:
self._apply_appended_messages_to_state(messages)
return
session_path = self._viking_fs._uri_to_path(self._session_uri, ctx=self.ctx)
lease = await self._viking_fs._async_agfs.pathlock_acquire_tree(
session_path, timeout_secs=_SESSION_PHASE1_LOCK_TIMEOUT_SECONDS
)
try:
live_messages_missing = False
try:
self._messages = await self._read_live_messages_strict()
except Exception as exc:
if not _is_storage_not_found(exc):
raise
self._messages = []
live_messages_missing = True
in_memory_meta = self._meta
try:
meta_content = await self._viking_fs.read_file(
f"{self._session_uri}/.meta.json",
ctx=self.ctx,
)
self._meta = SessionMeta.from_dict(json.loads(meta_content))
except Exception:
# Legacy/malformed metadata must not prevent an otherwise safe
# append. Message correctness remains rooted in messages.jsonl.
self._meta = in_memory_meta
self._apply_appended_messages_to_state(messages)
batch_content = "".join(message.to_jsonl() + "\n" for message in messages)
if live_messages_missing:
await self._viking_fs.write_file(
f"{self._session_uri}/messages.jsonl",
batch_content,
ctx=self.ctx,
lease_ref=lease,
)
else:
await self._viking_fs.append_file(
f"{self._session_uri}/messages.jsonl",
batch_content,
ctx=self.ctx,
lease_ref=lease,
)
await self._save_meta(lease_ref=lease)
finally:
await self._viking_fs._async_agfs.pathlock_release(lease)
def _apply_appended_messages_to_state(self, messages: List[Message]) -> None:
"""Update in-memory counters after an authoritative root reload."""
for msg in messages:
self._messages.append(msg)
if is_user_query(msg):
self._stats.total_turns += 1
msg_tokens = int(msg.estimated_tokens or 0)
self._stats.total_tokens += msg_tokens
if self._meta.retention_mode != RETENTION_MODE_TURN_BUDGET:
keep = int(self._meta.keep_recent_count or 0)
if keep <= 0:
self._meta.pending_tokens += msg_tokens
elif len(self._messages) > keep:
pushed_out = self._messages[-(keep + 1)]
self._meta.pending_tokens += int(pushed_out.estimated_tokens or 0)
if self._meta.retention_mode == RETENTION_MODE_TURN_BUDGET:
self._rebuild_pending_tokens()
self._meta.message_count = len(self._messages)
if self._meta.total_message_count is not None:
self._meta.total_message_count += len(messages)
if messages:
# Track the newest activity so the idle scan can decide when an
# idle-timeout auto-commit is due. Written under the same append
# path lock as the counters above.
self._meta.last_message_at = get_current_timestamp()
def _build_messages(
self,
messages_spec: List[dict],
) -> List[Message]:
"""Validate message specs and build their durable Message objects.
Args:
messages_spec: List of dicts, each with keys:
role, parts, peer_id/created_at and optional semantic fields.
"""
all_messages = []
for i, spec in enumerate(messages_spec):
if "role" not in spec:
raise ValueError(f"messages_spec[{i}]: missing required key 'role'")
if "parts" not in spec:
raise ValueError(f"messages_spec[{i}]: missing required key 'parts'")
role = spec["role"]
parts = spec["parts"]
created_at = spec.get("created_at") or datetime.now(timezone.utc).isoformat()
turn_id = spec.get("turn_id")
message_kind = spec.get("message_kind")
source_message_ids = spec.get("source_message_ids")
try:
peer_id = normalize_peer_id(spec.get("peer_id"))
except ValueError as exc:
from openviking_cli.exceptions import InvalidArgumentError
raise InvalidArgumentError(str(exc)) from exc
if self._is_tool_result_aggregate(role, parts):
msgs = [
Message(
id=f"msg_{uuid4().hex}",
role=role,
parts=[part],
peer_id=peer_id,
created_at=created_at,
turn_id=turn_id,
message_kind=message_kind or "tool_transport",
source_message_ids=(
list(source_message_ids) if source_message_ids is not None else None
),
)
for part in parts
]
self._externalize_large_tool_output_group(msgs)
all_messages.extend(msgs)
else:
msg = Message(
id=f"msg_{uuid4().hex}",
role=role,
parts=parts,
peer_id=peer_id,
created_at=created_at,
turn_id=turn_id,
message_kind=message_kind,
source_message_ids=(
list(source_message_ids) if source_message_ids is not None else None
),
)
self._externalize_large_tool_outputs(msg)
all_messages.append(msg)
return all_messages
def add_messages(
self,
messages_spec: List[dict],
) -> List[Message]:
"""Synchronously add multiple messages in one authoritative batch."""
messages = self._build_messages(messages_spec)
self._append_messages(messages)
return messages
async def add_messages_async(
self,
messages_spec: List[dict],
) -> List[Message]:
"""Asynchronously add multiple messages without blocking the caller loop."""
messages = self._build_messages(messages_spec)
await self._append_messages_authoritatively(messages)
return messages
def add_message(
self,
role: str,
parts: List[Part],
peer_id: Optional[str] = None,
created_at: str = None,
turn_id: Optional[str] = None,
message_kind: Optional[str] = None,
source_message_ids: Optional[List[str]] = None,
) -> Message:
"""Add a message.
A user message containing only multiple tool results is treated as a
transport aggregate and stored as one message per tool result.
"""
msgs = self.add_messages(
[
{
"role": role,
"parts": parts,
"peer_id": peer_id,
"created_at": created_at,
"turn_id": turn_id,
"message_kind": message_kind,
"source_message_ids": source_message_ids,
}
]
)
return msgs[0]
async def add_message_async(
self,
role: str,
parts: List[Part],
peer_id: Optional[str] = None,
created_at: str = None,
turn_id: Optional[str] = None,
message_kind: Optional[str] = None,
source_message_ids: Optional[List[str]] = None,
) -> Message:
"""Asynchronously add one message through the authoritative path lock."""
msgs = await self.add_messages_async(
[
{
"role": role,
"parts": parts,
"peer_id": peer_id,
"created_at": created_at,
"turn_id": turn_id,
"message_kind": message_kind,
"source_message_ids": source_message_ids,
}
]
)
return msgs[0]
async def read_tool_result(
self,
tool_result_id: str,
*,
offset: int = 0,
limit: int = 20_000,
include_metadata: bool = True,
) -> Dict[str, Any]:
store = self._tool_result_store()
if not store:
from openviking_cli.exceptions import NotFoundError
raise NotFoundError(tool_result_id, "tool result")
return await store.read(
tool_result_id,
offset=offset,
limit=limit,
include_metadata=include_metadata,
)
async def search_tool_result(
self,
tool_result_id: str,
*,
query: str,
limit: int = 20,
context_chars: int = 300,
) -> Dict[str, Any]:
store = self._tool_result_store()
if not store:
from openviking_cli.exceptions import NotFoundError
raise NotFoundError(tool_result_id, "tool result")
return await store.search(
tool_result_id,
query=query,
limit=limit,
context_chars=context_chars,
)
async def list_tool_results(
self,
*,
tool_name: Optional[str] = None,
limit: int = 50,
) -> Dict[str, Any]:
store = self._tool_result_store()
if not store:
return {"tool_results": []}
return await store.list(tool_name=tool_name, limit=limit)
def _remember_retention_policy(
self,
*,
keep_recent_count: int,
retention_mode: Optional[str],
keep_recent_turn_count: int,
retained_message_token_budget: int,
min_raw_tail_steps: int,
) -> None:
"""Persist the policy used by the latest Phase 1 decision."""
self._meta.keep_recent_count = keep_recent_count
self._meta.retention_mode = retention_mode or ""
self._meta.keep_recent_turn_count = keep_recent_turn_count
self._meta.retained_message_token_budget = retained_message_token_budget
self._meta.min_raw_tail_steps = min_raw_tail_steps
async def _merge_archive_meta(
self,
archive_uri: str,
updates: Dict[str, Any],
lease_ref: Optional[Any] = None,
) -> None:
"""Merge archive metadata so Phase 2 cannot erase Phase 1 planning data."""
if not self._viking_fs:
return
# Summary generation and memory extraction run concurrently. Serialize
# their read/merge/write cycles so overview token metadata, retention
# planning and extraction progress cannot overwrite one another.
async with self._archive_meta_merge_lock:
meta: Dict[str, Any] = {}
try:
content = await self._viking_fs.read_file(f"{archive_uri}/.meta.json", ctx=self.ctx)
parsed = json.loads(content)
if isinstance(parsed, dict):
meta = parsed
except Exception:
pass
meta.update(updates)
await self._viking_fs.write_file(
uri=f"{archive_uri}/.meta.json",
content=json.dumps(meta, ensure_ascii=False),
ctx=self.ctx,
lease_ref=lease_ref,
)
@staticmethod
def _retention_plan_meta(
plan: RetentionPlan,
*,
keep_recent_turn_count: int,
retained_message_token_budget: int,
min_raw_tail_steps: int,
) -> Dict[str, Any]:
return {
"mode": RETENTION_MODE_TURN_BUDGET,
"keep_recent_turn_count": keep_recent_turn_count,
"retained_message_token_budget": retained_message_token_budget,
"min_raw_tail_steps": min_raw_tail_steps,
"partial_turn": plan.partial_turn,
"turn_anchor_message_id": plan.turn_anchor.id if plan.turn_anchor else None,
"checkpoint_source_message_ids": list(plan.checkpoint_source_message_ids),
"raw_tail_start_message_id": plan.raw_tail_start_message_id,
"estimated_active_tokens": plan.estimated_active_tokens,
"budget_exceeded": plan.budget_exceeded,
}
async def _write_phase1_marker(
self,
archive_uri: str,
*,
queue_message: Dict[str, Any],
original_messages: List[Message],
archived_messages: List[Message],
retained_messages: List[Message],
keep_recent_count: int,
retention_mode: Optional[str],
keep_recent_turn_count: int,
retained_message_token_budget: int,
min_raw_tail_steps: int,
agent_evolution_enabled: bool = True,
agent_memory_skip_reason: Optional[str] = None,
lease_ref: Optional[Any] = None,
) -> None:
"""Persist the Phase 1 intent before any destructive root rewrite."""
payload = {
"version": 1,
"status": "preparing",
"created_at": get_current_timestamp(),
"queue_message": queue_message,
"original_message_ids": [message.id for message in original_messages],
"archived_message_ids": [message.id for message in archived_messages],
"retained_message_ids": [message.id for message in retained_messages],
"keep_recent_count": keep_recent_count,
"retention_mode": retention_mode or "",
"keep_recent_turn_count": keep_recent_turn_count,
"retained_message_token_budget": retained_message_token_budget,
"min_raw_tail_steps": min_raw_tail_steps,
}
await self._merge_archive_meta(
archive_uri,
{
"phase1": payload,
"agent_evolution": {
"enabled": agent_evolution_enabled,
"skip_reason": agent_memory_skip_reason,
},
},
lease_ref=lease_ref,
)
async def _write_phase1_ready_marker(
self,
archive_uri: str,
lease_ref: Optional[Any] = None,
) -> None:
"""Persist that Phase 1 is ready using an optional held PathLock lease."""
phase1 = await self._read_phase1_meta(archive_uri)
phase1.update(
{
"status": "ready",
"ready_at": get_current_timestamp(),
}
)
await self._merge_archive_meta(archive_uri, {"phase1": phase1}, lease_ref=lease_ref)
async def _archive_file_exists(self, archive_uri: str, file_name: str) -> bool:
try:
return await self._viking_fs.exists(f"{archive_uri}/{file_name}", ctx=self.ctx)
except Exception:
return False
async def _read_phase1_meta(self, archive_uri: str) -> Dict[str, Any]:
phase1 = (await self._read_archive_meta(archive_uri)).get("phase1")
return dict(phase1) if isinstance(phase1, dict) else {}
async def _ensure_phase1_ready(self, archive_uri: str) -> bool:
"""Verify or recover a queued Phase 1 before Phase 2 consumes it.
New commits enqueue while holding the session lock and before rewriting
root messages. A consumer therefore acquires the same lock, then either
observes ``phase1.status=ready`` in archive metadata or
deterministically reconciles a process crash from the persisted intent.
"""
marker = await self._read_phase1_meta(archive_uri)
if not marker:
# Archives created by older OpenViking versions have no Phase 1
# metadata and keep their previous processing contract.
return True
if marker.get("status") == "ready":
return True
if await self._archive_file_exists(archive_uri, ".failed.json"):
return False
session_path = self._viking_fs._uri_to_path(self._session_uri, ctx=self.ctx)
lease = await self._viking_fs._async_agfs.pathlock_acquire_tree(
session_path, timeout_secs=_SESSION_PHASE1_LOCK_TIMEOUT_SECONDS
)
try:
marker = await self._read_phase1_meta(archive_uri)
if marker.get("status") == "ready":
return True
if await self._archive_file_exists(archive_uri, ".failed.json"):
return False
queue_message = marker.get("queue_message")
task_id = queue_message.get("task_id") if isinstance(queue_message, dict) else None
from openviking.service.task_tracker import get_task_tracker
tracker = get_task_tracker()
if not task_id or not tracker.has_work(str(task_id)):
error = "Phase 1 has no QueueFS work to resume"
await self._write_failed_marker(
archive_uri,
stage="phase1_recovery",
error=error,
lease_ref=lease,
)
if task_id:
await tracker.fail(
str(task_id),
error,
account_id=self.ctx.account_id,
user_id=self.ctx.user.user_id,
)
return False
try:
if not marker:
raise ValueError("Phase 1 metadata is missing")
retained_ids = marker.get("retained_message_ids")
archived_ids = marker.get("archived_message_ids")
if not isinstance(retained_ids, list) or not isinstance(archived_ids, list):
raise ValueError("Phase 1 metadata has invalid message ID lists")
retained_ids = [item for item in retained_ids if isinstance(item, str)]
archived_ids = [item for item in archived_ids if isinstance(item, str)]
live_messages = await self._read_live_messages_strict()
except Exception as exc:
await self._write_failed_marker(
archive_uri,
stage="phase1_recovery",
error=f"Cannot verify Phase 1 state: {exc}",
lease_ref=lease,
)
return False
live_ids = [message.id for message in live_messages]
archived_only_ids = set(archived_ids) - set(retained_ids)
phase1_applied = live_ids[
: len(retained_ids)
] == retained_ids and not archived_only_ids.intersection(live_ids)
if not phase1_applied:
await self._write_failed_marker(
archive_uri,
stage="phase1_recovery",
error="Root rewrite was not durably completed before process interruption",
lease_ref=lease,
)
return False
# Root is authoritative and proves the rewrite completed. Reconcile
# metadata that may have been interrupted immediately afterwards.
try:
meta_content = await self._viking_fs.read_file(
f"{self._session_uri}/.meta.json",
ctx=self.ctx,
)
self._meta = SessionMeta.from_dict(json.loads(meta_content))
except Exception:
pass
self._messages = live_messages
self._remember_retention_policy(
keep_recent_count=max(0, int(marker.get("keep_recent_count", 0) or 0)),
retention_mode=str(marker.get("retention_mode", "") or "") or None,
keep_recent_turn_count=max(0, int(marker.get("keep_recent_turn_count", 0) or 0)),
retained_message_token_budget=max(
0, int(marker.get("retained_message_token_budget", 0) or 0)
),
min_raw_tail_steps=max(0, int(marker.get("min_raw_tail_steps", 1) or 0)),
)
self._meta.message_count = len(live_messages)
self._meta.commit_count = max(
self._meta.commit_count,
self._archive_index_from_uri(archive_uri),
)
self._meta.last_commit_at = get_current_timestamp()
self._rebuild_pending_tokens()
await self._save_meta(lease_ref=lease)
await self._write_phase1_ready_marker(archive_uri, lease_ref=lease)
logger.warning("Recovered interrupted Session Phase 1: %s", archive_uri)
return True
finally:
await self._viking_fs._async_agfs.pathlock_release(lease)
def commit(
self,
keep_recent_count: int = 0,
*,
memory_policy: Optional[Dict[str, Any]] = None,
retention_mode: Optional[str] = None,
keep_recent_turn_count: Optional[int] = None,
retained_message_token_budget: Optional[int] = None,
min_raw_tail_steps: Optional[int] = None,
) -> Dict[str, Any]:
"""Sync wrapper for commit_async()."""
return run_async(
self.commit_async(
keep_recent_count=keep_recent_count,
memory_policy=memory_policy,
retention_mode=retention_mode,
keep_recent_turn_count=keep_recent_turn_count,
retained_message_token_budget=retained_message_token_budget,
min_raw_tail_steps=min_raw_tail_steps,
)
)
@tracer("session.commit.phase1")
async def commit_async(
self,
keep_recent_count: int = 0,
*,
memory_policy: Optional[Dict[str, Any]] = None,
retention_mode: Optional[str] = None,
keep_recent_turn_count: Optional[int] = None,
retained_message_token_budget: Optional[int] = None,
min_raw_tail_steps: Optional[int] = None,
persist_keep_recent_count: bool = True,
record_auto_commit_success: bool = False,
event_tags: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""Archive immediately and enqueue restart-safe Phase 2 processing.
Phase 1 (Archive prep, path-lock protected): Split messages into
archive/retain parts, persist a recoverable intent and archive raw,
enqueue Phase 2, then publish the retained root state with
``phase1.status=ready``. Uses a distributed filesystem lock across
workers and processes.
Phase 2 (Memory extraction): Runs through the persistent QueueFS queue.
Args:
keep_recent_count: Number of most-recent messages to keep in the
live session after commit. ``0`` (default) preserves the old
behavior of archiving everything. The plugin's afterTurn path
typically passes its configured value (default 10); the compact
path passes ``0``.
persist_keep_recent_count: When ``True`` (default), ``keep_recent_count``
is remembered in meta for subsequent add_message() accounting.
The idle full-commit path passes ``False`` with
``keep_recent_count=0`` so a one-off full archive does not wipe
the stored keep preference.
record_auto_commit_success: When ``True``, clear the auto-commit
error fields and stamp ``last_auto_commit_at`` in the same
lock-protected meta update as the commit boundary.
event_tags: Per-commit override for the custom scalar tags applied
to event memories. ``None`` uses the session default
(``meta.event_search_tags``); an empty list disables default-tag
injection for this commit; a non-empty list overrides it.
Returns a task_id for tracking Phase 2 progress.
"""
from openviking.service.task_tracker import get_task_tracker
from openviking.storage.queuefs import QueueManager, get_queue_manager
from openviking.storage.queuefs.session_commit_msg import SessionCommitMsg
trace_id = tracer.get_trace_id()
keep_recent_count = max(0, int(keep_recent_count or 0))
if retention_mode not in (None, RETENTION_MODE_TURN_BUDGET):
raise ValueError(f"Unsupported retention_mode: {retention_mode}")
if retention_mode is None and any(
value is not None
for value in (
keep_recent_turn_count,
retained_message_token_budget,
min_raw_tail_steps,
)
):
raise ValueError(
"retention_mode='turn_budget' is required when Turn retention fields are set"
)
turn_mode = retention_mode == RETENTION_MODE_TURN_BUDGET
effective_keep_turns = max(
0, int(3 if keep_recent_turn_count is None else keep_recent_turn_count)
)
effective_token_budget = max(
0,
int(12_000 if retained_message_token_budget is None else retained_message_token_budget),
)
effective_min_tail = max(0, int(1 if min_raw_tail_steps is None else min_raw_tail_steps))
if turn_mode and effective_token_budget <= 0:
raise ValueError("retained_message_token_budget must be greater than 0")
in_memory_default_memory_policy = self._meta.memory_policy
effective_policy = MemoryPolicy.from_dict(
memory_policy if memory_policy is not None else self._meta.memory_policy
)
_validate_memory_policy_types(effective_policy)
agent_evolution_enabled = self._agent_evolution_enabled
if self._agent_evolution_enabled_provider is not None:
provided_enabled = self._agent_evolution_enabled_provider()
agent_evolution_enabled = (
await provided_enabled
if inspect.isawaitable(provided_enabled)
else provided_enabled
)
effective_policy = _apply_agent_evolution_setting(
effective_policy,
agent_evolution_enabled=agent_evolution_enabled,
)
effective_memory_policy = effective_policy.to_dict()
effective_memory_types = sorted(_effective_memory_types(effective_policy))
agent_memory_skip_reason = _agent_memory_skip_reason(
agent_evolution_enabled=agent_evolution_enabled,
effective_memory_types=set(effective_memory_types),
)
logger.info(
f"[TRACER] session_commit started, trace_id={trace_id}, "
f"keep_recent_count={keep_recent_count}, retention_mode={retention_mode}, "
f"keep_recent_turn_count={effective_keep_turns}, "
f"retained_message_token_budget={effective_token_budget}"
)
# ===== Phase 1: authoritative snapshot + split (path-lock protected) =====
# Use a waiting filesystem lock and reload inside it. Different workers
# can hold stale Session objects, so in-memory emptiness is never a
# correctness boundary.
session_path = self._viking_fs._uri_to_path(self._session_uri, ctx=self.ctx)
lease = await self._viking_fs._async_agfs.pathlock_acquire_tree(
session_path, timeout_secs=_SESSION_PHASE1_LOCK_TIMEOUT_SECONDS
)
try:
self._messages = await self._read_live_messages_strict()
try:
meta_content = await self._viking_fs.read_file(
f"{self._session_uri}/.meta.json",
ctx=self.ctx,
)
self._meta = SessionMeta.from_dict(json.loads(meta_content))
if (
memory_policy is None
and self._meta.memory_policy is None
and in_memory_default_memory_policy is not None
):
self._meta.memory_policy = in_memory_default_memory_policy
except Exception:
# The root JSONL remains authoritative for message correctness;
# legacy sessions may not have metadata yet.
pass
effective_event_tags = _resolve_event_search_tags(
event_tags, self._meta.event_search_tags
)
# keep_recent_count controls how many live messages survive this
# commit. stored_keep_recent_count is what we persist for future
# add_message accounting: the idle full-commit path archives
# everything once (keep_recent_count=0) without discarding the
# caller's stored keep preference. Read it from the freshly reloaded
# meta so a concurrent manual commit's value is not reverted.
stored_keep_recent_count = (
keep_recent_count
if persist_keep_recent_count
else max(0, int(self._meta.keep_recent_count or 0))
)
# A Session object may have been loaded by another worker before a
# different worker updated the persisted default policy. Phase 2
# must use the policy from the same lock-protected snapshot as the
# messages being archived, unless this commit supplied an explicit
# override.
if memory_policy is None:
effective_policy = MemoryPolicy.from_dict(self._meta.memory_policy)
_validate_memory_policy_types(effective_policy)
effective_policy = _apply_agent_evolution_setting(
effective_policy,
agent_evolution_enabled=agent_evolution_enabled,
)
effective_memory_policy = effective_policy.to_dict()
effective_memory_types = sorted(_effective_memory_types(effective_policy))
agent_memory_skip_reason = _agent_memory_skip_reason(
agent_evolution_enabled=agent_evolution_enabled,
effective_memory_types=set(effective_memory_types),
)
self._compression.compression_index = max(
self._compression.compression_index,
int(self._meta.commit_count),
)
while await self._viking_fs.exists(
(
f"{self._session_uri}/history/"
f"archive_{self._compression.compression_index + 1:03d}"
),
ctx=self.ctx,
):
self._compression.compression_index += 1
if not self._messages:
self._meta.pending_tokens = 0
self._remember_retention_policy(
keep_recent_count=stored_keep_recent_count,
retention_mode=retention_mode,
keep_recent_turn_count=effective_keep_turns if turn_mode else 0,
retained_message_token_budget=effective_token_budget if turn_mode else 0,
min_raw_tail_steps=effective_min_tail,
)
await self._save_meta(lease_ref=lease)
get_current_telemetry().set("memory.extracted", 0)
return {
"session_id": self.session_id,
"status": "skipped",
"task_id": None,
"archive_uri": None,
"archived": False,
"reason": "no_messages",
"trace_id": trace_id,
}
total = len(self._messages)
retention_plan: Optional[RetentionPlan] = None
if turn_mode:
# The externalization budget belongs to a logical Turn, not one
# physical assistant message. This catches N small tool outputs
# whose aggregate exceeds the configured inline budget.
for turn in build_turns(self._messages):
self._externalize_large_tool_output_group(turn.messages)
retention_plan = plan_retention(
self._messages,
keep_recent_turn_count=effective_keep_turns,
token_budget=effective_token_budget,
min_raw_tail_steps=effective_min_tail,
)
messages_to_archive = retention_plan.archive_messages
retained_messages = retention_plan.retained_messages
elif keep_recent_count > 0:
split_idx = max(0, total - keep_recent_count)
messages_to_archive = self._messages[:split_idx]
retained_messages = self._messages[split_idx:]
else:
messages_to_archive = self._messages.copy()
retained_messages = []
# No archive work: persist possible Turn-wide externalization and
# remember the policy for subsequent add_message accounting.
if not messages_to_archive:
self._messages = retained_messages
await self._write_to_agfs_async(messages=self._messages, lease_ref=lease)
self._meta.pending_tokens = 0
self._meta.message_count = total
self._remember_retention_policy(
keep_recent_count=stored_keep_recent_count,
retention_mode=retention_mode,
keep_recent_turn_count=effective_keep_turns if turn_mode else 0,
retained_message_token_budget=effective_token_budget if turn_mode else 0,
min_raw_tail_steps=effective_min_tail,
)
await self._save_meta(lease_ref=lease)
get_current_telemetry().set("memory.extracted", 0)
return {
"session_id": self.session_id,
"status": "skipped",
"task_id": None,
"archive_uri": None,
"archived": False,
"reason": "all_within_keep_window",
"trace_id": trace_id,
"estimated_active_tokens": (
retention_plan.estimated_active_tokens if retention_plan else 0
),
"budget_exceeded": retention_plan.budget_exceeded if retention_plan else False,
}
self._compression.compression_index += 1
archive_uri = (
f"{self._session_uri}/history/archive_{self._compression.compression_index:03d}"
)
original_messages = list(self._messages)
usage_snapshot = self._usage_records.copy()
task_id = str(uuid4())
queue_msg = SessionCommitMsg(
task_id=task_id,
session_id=self.session_id,
session_uri=self._session_uri,
archive_uri=archive_uri,
user=self.ctx.user.to_dict(),
memory_policy=effective_memory_policy,
usage_uris=list(dict.fromkeys(u.uri for u in usage_snapshot if u.uri)),
record_auto_commit_success=record_auto_commit_success,
event_search_tags=list(effective_event_tags),
)
phase1_stage = "phase1_persist"
try:
await self._write_phase1_marker(
archive_uri,
queue_message=queue_msg.to_dict(),
original_messages=original_messages,
archived_messages=messages_to_archive,
retained_messages=retained_messages,
keep_recent_count=keep_recent_count,
retention_mode=retention_mode,
keep_recent_turn_count=effective_keep_turns if turn_mode else 0,
retained_message_token_budget=effective_token_budget if turn_mode else 0,
min_raw_tail_steps=effective_min_tail,
agent_evolution_enabled=agent_evolution_enabled,
agent_memory_skip_reason=agent_memory_skip_reason,
lease_ref=lease,
)
# Archive raw remains durable and recoverable before any live
# conversation history is removed from the root JSONL.
if self._viking_fs:
lines = [m.to_jsonl() for m in messages_to_archive]
await self._viking_fs.write_file(
uri=f"{archive_uri}/messages.jsonl",
content="\n".join(lines) + "\n",
ctx=self.ctx,
lease_ref=lease,
)
if retention_plan is not None:
await self._merge_archive_meta(
archive_uri,
{
"retention_plan": self._retention_plan_meta(
retention_plan,
keep_recent_turn_count=effective_keep_turns,
retained_message_token_budget=effective_token_budget,
min_raw_tail_steps=effective_min_tail,
)
},
lease_ref=lease,
)
phase1_stage = "queue_enqueue"
await get_queue_manager().enqueue(
QueueManager.SESSION_COMMIT,
queue_msg.to_dict(),
)
phase1_stage = "task_tracker_create"
await get_task_tracker().create(
"session_commit",
resource_id=self.session_id,
account_id=self.ctx.account_id,
user_id=self.ctx.user.user_id,
task_id=task_id,
)
phase1_stage = "phase1_persist"
self._messages = retained_messages
await self._write_to_agfs_async(messages=self._messages, lease_ref=lease)
self._meta.message_count = len(self._messages)
self._meta.pending_tokens = 0
self._remember_retention_policy(
keep_recent_count=stored_keep_recent_count,
retention_mode=retention_mode,
keep_recent_turn_count=effective_keep_turns if turn_mode else 0,
retained_message_token_budget=effective_token_budget if turn_mode else 0,
min_raw_tail_steps=effective_min_tail,
)
self._meta.commit_count = max(
self._meta.commit_count,
self._compression.compression_index,
)
self._meta.last_commit_at = get_current_timestamp()
if record_auto_commit_success:
# Stamp success in the same lock-protected meta write as the
# commit boundary, so an idle scan and a concurrent worker
# never see a stale state.
self._meta.last_auto_commit_at = get_current_timestamp()
await self._save_meta(lease_ref=lease)
await self._write_phase1_ready_marker(archive_uri, lease_ref=lease)
except Exception as e:
logger.error(f"[commit] Failed during {phase1_stage}: {e}")
# Whether the queue write failed or a queued Phase 1 stopped
# before publication, a terminal marker makes archive raw
# logically live and prevents a permanent pending directory.
try:
await self._write_failed_marker(
archive_uri,
stage=phase1_stage,
error=str(e),
lease_ref=lease,
)
except Exception:
logger.exception(
"Failed to mark archive after Phase 1 persistence failure: %s",
archive_uri,
)
self._messages = original_messages
self._compression.compression_index -= 1
raise
finally:
await self._viking_fs._async_agfs.pathlock_release(lease)
# Lock released; Phase 1 intent, queue item, retained root, metadata and
# ready metadata are all durable.
self._compression.original_count += len(messages_to_archive)
logger.info(
f"Archived: {len(messages_to_archive)} messages → "
f"history/archive_{self._compression.compression_index:03d}/"
)
return {
"session_id": self.session_id,
"status": "accepted",
"task_id": task_id,
"archive_uri": archive_uri,
"archived": True,
"trace_id": trace_id,
"estimated_active_tokens": (
retention_plan.estimated_active_tokens if retention_plan else 0
),
"budget_exceeded": retention_plan.budget_exceeded if retention_plan else False,
}
async def finalize_cancelled_commit(self, archive_uri: str) -> None:
"""Make a cancelled queued commit terminal without discarding its raw archive."""
await self._write_failed_marker(
archive_uri,
stage="cancelled",
error="session commit cancelled",
)
async def resume_queued_commit(self, msg: "SessionCommitMsg") -> bool:
"""Run one durable Phase 2 job from its archived messages."""
from openviking.service.task_tracker import get_task_tracker
tracker = get_task_tracker()
task = await tracker.create(
"session_commit",
resource_id=self.session_id,
account_id=self.ctx.account_id,
user_id=self.ctx.user.user_id,
task_id=msg.task_id,
)
try:
await self._viking_fs.read_file(f"{msg.archive_uri}/.done", ctx=self.ctx)
except Exception as exc:
if not _is_storage_not_found(exc):
raise
else:
if task.status.value == "completed":
return True
await tracker.complete(
msg.task_id,
{"session_id": self.session_id, "archive_uri": msg.archive_uri},
account_id=self.ctx.account_id,
user_id=self.ctx.user.user_id,
)
return True
try:
failed = json.loads(
await self._viking_fs.read_file(f"{msg.archive_uri}/.failed.json", ctx=self.ctx)
)
except Exception as exc:
if not _is_storage_not_found(exc):
raise
else:
await tracker.fail(
msg.task_id,
str(failed.get("error") or "session commit failed"),
account_id=self.ctx.account_id,
user_id=self.ctx.user.user_id,
)
return True
if not await self._ensure_phase1_ready(msg.archive_uri):
try:
failed = json.loads(
await self._viking_fs.read_file(
f"{msg.archive_uri}/.failed.json",
ctx=self.ctx,
)
)
error = str(failed.get("error") or "session commit Phase 1 is not ready")
except Exception:
error = "session commit Phase 1 is not ready"
await tracker.fail(
msg.task_id,
error,
account_id=self.ctx.account_id,
user_id=self.ctx.user.user_id,
)
return True
if not await self._can_run_archive(self._archive_index_from_uri(msg.archive_uri)):
return False
archive_error = ""
try:
archive_messages = await self._read_archive_messages(msg.archive_uri)
except _ArchiveMessagesCorruptError as exc:
archive_messages = []
archive_error = f"session commit archive has invalid messages: {exc}"
except Exception as exc:
if not _is_storage_not_found(exc):
raise
archive_messages = []
if not archive_messages:
error = archive_error or "session commit archive has no messages"
await self._write_failed_marker(
msg.archive_uri,
stage="archive_read",
error=error,
)
await tracker.fail(
msg.task_id,
error,
account_id=self.ctx.account_id,
user_id=self.ctx.user.user_id,
)
return True
queued_policy = MemoryPolicy.from_dict(msg.memory_policy)
archive_meta = await self._read_archive_meta(msg.archive_uri)
agent_evolution_snapshot = archive_meta.get("agent_evolution")
if not isinstance(agent_evolution_snapshot, dict):
# Compatibility with jobs created by pre-merge development builds
# that temporarily stored this snapshot in the queue payload.
phase1 = archive_meta.get("phase1")
queue_snapshot = phase1.get("queue_message") if isinstance(phase1, dict) else None
agent_evolution_snapshot = queue_snapshot if isinstance(queue_snapshot, dict) else {}
snapshot_enabled = agent_evolution_snapshot.get("enabled")
if snapshot_enabled is None:
snapshot_enabled = agent_evolution_snapshot.get("agent_evolution_enabled")
# Archives created before this setting existed preserve their original
# behavior, where Agent memory extraction was enabled by default.
agent_evolution_enabled = snapshot_enabled if isinstance(snapshot_enabled, bool) else True
agent_memory_skip_reason = agent_evolution_snapshot.get("skip_reason")
if agent_memory_skip_reason is None:
agent_memory_skip_reason = agent_evolution_snapshot.get("agent_memory_skip_reason")
if agent_memory_skip_reason is None:
agent_memory_skip_reason = _agent_memory_skip_reason(
agent_evolution_enabled=agent_evolution_enabled,
effective_memory_types=_effective_memory_types(queued_policy),
)
user_config_error = agent_evolution_snapshot.get("user_config_error")
if user_config_error is not None:
user_config_error = str(user_config_error)
await self._run_memory_extraction(
task_id=msg.task_id,
archive_uri=msg.archive_uri,
messages=archive_messages,
usage_records=[Usage(uri=uri, type="context") for uri in msg.usage_uris],
first_message_id=archive_messages[0].id,
last_message_id=archive_messages[-1].id,
memory_policy=msg.memory_policy,
agent_evolution_enabled=agent_evolution_enabled,
agent_memory_skip_reason=agent_memory_skip_reason,
user_config_error=user_config_error,
record_auto_commit_success=msg.record_auto_commit_success,
event_search_tags=list(msg.event_search_tags or []),
)
return True
async def _run_usage_reporting(
self,
*,
task_id: str,
archive_uri: str,
messages: List[Message],
) -> list[Any]:
reporter = getattr(self, "_usage_reporter", None)
if reporter is None:
return []
from openviking.usage_reporter import UsageContext
context = UsageContext(
account_id=self.ctx.account_id,
user_id=self.ctx.user.user_id,
session_id=self.session_id,
archive_uri=archive_uri,
task_id=task_id,
)
return await reporter.extract_and_report(messages=messages, context=context)
@tracer("session.commit.phase2", ignore_result=True, ignore_args=True)
async def _run_memory_extraction(
self,
task_id: str,
archive_uri: str,
messages: List[Message],
usage_records: List["Usage"],
first_message_id: str,
last_message_id: str,
memory_policy: Optional[Dict[str, Any]],
agent_evolution_enabled: bool = True,
agent_memory_skip_reason: Optional[str] = None,
user_config_error: Optional[str] = None,
record_auto_commit_success: bool = False,
event_search_tags: Optional[List[str]] = None,
) -> None:
"""Phase 2: Extract memories, write relations, enqueue — runs in background."""
from openviking.service.task_tracker import get_task_tracker
from openviking.telemetry import OperationTelemetry, bind_telemetry
from openviking.telemetry.registry import register_telemetry, unregister_telemetry
tracker = get_task_tracker()
request_wait_tracker = get_request_wait_tracker()
memories_extracted: Dict[str, int] = {}
usage_events_extracted = 0
extracted_skill_results: list[dict] = []
active_count_updated = 0
memory_diff_uri: Optional[str] = None
completed_memory_steps: Dict[str, set[str]] = {}
telemetry = OperationTelemetry(operation="session_commit_phase2", enabled=True)
archive_index = self._archive_index_from_uri(archive_uri)
try:
(
messages,
coverage_start_archive,
coverage_end_archive,
covered_failed_archives,
completed_memory_steps,
) = await self._prepare_phase2_archive_messages(archive_uri, messages)
if not messages:
raise ValueError("session commit archive has no recoverable messages")
first_message_id = messages[0].id
last_message_id = messages[-1].id
await tracker.start(
task_id,
account_id=self.ctx.account_id,
user_id=self.ctx.user.user_id,
)
request_wait_tracker.register_request(telemetry.telemetry_id)
register_telemetry(telemetry)
try:
with bind_telemetry(telemetry):
ov_config = get_openviking_config()
effective_policy = MemoryPolicy.from_dict(memory_policy)
working_memory_enabled = effective_policy.working_memory_enabled
checkpoint_requests = (
await self._collect_checkpoint_requests_for_phase2(
archive_uri,
covered_failed_archives,
messages,
)
if working_memory_enabled
else []
)
latest_archive_overview = (
await self._get_latest_completed_archive_overview(
exclude_archive_uri=archive_uri,
before_archive_index=archive_index,
)
if working_memory_enabled
else ""
)
extraction_messages = await self._hydrate_tool_outputs_for_extraction(messages)
usage_events_extracted = len(
await self._run_usage_reporting(
task_id=task_id,
archive_uri=archive_uri,
messages=extraction_messages,
)
)
async def _run_archive_summary() -> None:
if not working_memory_enabled:
logger.info(
"Working Memory summary skipped "
"(memory_policy.working_memory.enabled=false)"
)
return
summary_kwargs: Dict[str, Any] = {
"latest_archive_overview": latest_archive_overview,
}
if checkpoint_requests:
summary_kwargs["checkpoint_requests"] = checkpoint_requests
generated = await self._generate_archive_summary_async(
extraction_messages,
**summary_kwargs,
)
summary_result = (
generated
if isinstance(generated, _ArchiveSummaryResult)
else _ArchiveSummaryResult(overview=str(generated or ""))
)
checkpoint_records = self._build_checkpoint_records(
checkpoint_requests,
summary_result.checkpoint_summaries,
)
summary = summary_result.overview
if checkpoint_requests and not summary.strip():
raise ValueError(
"Working Memory output is empty for a required checkpoint"
)
if self._viking_fs and summary:
abstract = self._extract_abstract_from_summary(summary)
await self._viking_fs.write_file(
uri=f"{archive_uri}/.abstract.md",
content=abstract,
ctx=self.ctx,
)
await self._viking_fs.write_file(
uri=f"{archive_uri}/.overview.md",
content=summary,
ctx=self.ctx,
)
await self._merge_archive_meta(
archive_uri,
{
"overview_tokens": estimate_text_tokens(summary),
"abstract_tokens": estimate_text_tokens(abstract),
"checkpoints": checkpoint_records,
},
)
async def _run_retryable_phase2_step(
operation_name: str,
fn: Callable[[], Awaitable[Any]],
) -> Any:
# Secondary safety net on top of the per-call retry that the
# VLM/embedding layer already performs. Reuses the shared
# transient-error classifier so permanent failures (auth,
# quota, content-safety, 400, oversized input) fail fast
# instead of being retried pointlessly.
return await retry_async(
fn,
max_retries=_MEMORY_EXTRACTION_MAX_RETRIES,
base_delay=_MEMORY_EXTRACTION_RETRY_BASE_DELAY_SECONDS,
max_delay=_MEMORY_EXTRACTION_RETRY_MAX_DELAY_SECONDS,
is_retryable=is_retryable_api_error,
logger=logger,
operation_name=operation_name,
)
async def _run_recorded_memory_step(
operation_name: str,
step: str,
step_messages: List[Message],
fn: Callable[[], Awaitable[Any]],
) -> Any:
result = await _run_retryable_phase2_step(operation_name, fn)
completed_memory_steps.setdefault(step, set()).update(
message.id for message in step_messages
)
# Persist progress before waiting for sibling Phase 2
# tasks. A process restart or a sibling failure can then
# resume without applying this memory step twice.
await self._merge_archive_meta(
archive_uri,
{
"completed_memory_steps": (
self._serialize_completed_memory_steps(completed_memory_steps)
)
},
)
return result
# Summary and V3 long-term memory extraction run concurrently.
memory_extraction_enabled = ov_config.memory.extraction_enabled
config_session_skill_extraction_enabled = (
ov_config.memory.session_skill_extraction_enabled
)
extraction_scope = _resolve_memory_extraction_scope(
self.ctx,
effective_policy,
extraction_messages,
config_session_skill_extraction_enabled=(
config_session_skill_extraction_enabled
),
)
self_memory_enabled = extraction_scope.allow_self_memory
allowed_peer_ids = extraction_scope.allowed_peer_ids
long_term_memory_types = extraction_scope.memory_types
long_term_messages = [
message
for message in extraction_messages
if message.id not in completed_memory_steps.get("long_term", set())
]
long_term_has_work = (
memory_extraction_enabled
and (self_memory_enabled or allowed_peer_ids)
and (long_term_memory_types is None or bool(long_term_memory_types))
and bool(long_term_messages)
)
if working_memory_enabled or (self._session_compressor and long_term_has_work):
logger.info(
"Starting post-commit extraction from %s archived messages",
len(messages),
)
extraction_tasks: List[Any] = []
extraction_labels: List[str] = []
if working_memory_enabled:
extraction_tasks.append(
_run_retryable_phase2_step("archive_summary", _run_archive_summary)
)
extraction_labels.append("archive_summary")
if self._session_compressor and long_term_has_work:
async def _run_long_term_memory_extraction() -> Any:
# strict_extract_errors=True lets transient failures
# surface so _run_retryable_phase2_step can retry them
# (and so a final failure is recorded as a skipped
# archive instead of silently dropping the memory).
return await self._session_compressor.extract_long_term_memories(
messages=long_term_messages,
user=self.user,
session_id=self.session_id,
ctx=self.ctx,
strict_extract_errors=True,
latest_archive_overview=latest_archive_overview,
archive_uri=archive_uri,
allowed_memory_types=long_term_memory_types,
agent_evolution_enabled=agent_evolution_enabled,
allow_self_memory=self_memory_enabled,
allowed_peer_ids=allowed_peer_ids,
event_search_tags=event_search_tags,
)
extraction_tasks.append(
_run_recorded_memory_step(
"long_term_memory_extraction",
"long_term",
long_term_messages,
_run_long_term_memory_extraction,
)
)
extraction_labels.append("long_term")
_results = await asyncio.gather(
*extraction_tasks,
return_exceptions=True,
)
# The archive outcome is binary: if any Phase 2 step
# still fails after retries, no .done marker is
# published. Successful steps retain message IDs so the
# same archive can resume without repeating them.
extraction_error: Optional[BaseException] = None
for label, result in zip(extraction_labels, _results, strict=True):
if isinstance(result, Exception):
logger.error(
"Phase 2 step %s failed: %s",
label,
result,
exc_info=result,
)
if extraction_error is None:
extraction_error = result
if extraction_error is not None:
raise extraction_error
if long_term_has_work and self._viking_fs:
candidate_memory_diff_uri = f"{archive_uri}/memory_diff.json"
if await self._viking_fs.exists(
candidate_memory_diff_uri,
ctx=self.ctx,
):
memory_diff_uri = candidate_memory_diff_uri
total_extracted = 0
for label, result in zip(extraction_labels, _results, strict=True):
if label == "archive_summary":
continue
if isinstance(result, dict):
target_contexts = list(result.get("contexts", []))
target_skills = list(result.get("session_skills", []))
else:
target_contexts = list(result or [])
target_skills = []
logger.info(
"Extracted %s memories for %s",
len(target_contexts),
label,
)
total_extracted += len(target_contexts)
for ctx_item in target_contexts:
cat = getattr(ctx_item, "category", "") or "unknown"
memories_extracted[cat] = memories_extracted.get(cat, 0) + 1
if target_skills:
extracted_skill_results.extend(target_skills)
if total_extracted:
self._stats.memories_extracted += total_extracted
if extracted_skill_results:
logger.info(
"Extracted %s session skills",
len(extracted_skill_results),
)
get_current_telemetry().set("memory.extracted", total_extracted)
else:
if self._session_compressor:
logger.info(
"Memory and session skill extraction skipped "
"(disabled by config or memory_policy)"
)
if working_memory_enabled:
await _run_retryable_phase2_step(
"archive_summary", _run_archive_summary
)
else:
await _run_archive_summary()
# Write relations (using snapshot, not self._usage_records)
if self._viking_fs:
for usage in usage_records:
try:
await self._viking_fs.link(
self._session_uri, usage.uri, ctx=self.ctx
)
except Exception as e:
logger.warning(f"Failed to create relation to {usage.uri}: {e}")
# Update active_count (using snapshot, not self._usage_records)
if self._vikingdb_manager:
uris = [u.uri for u in usage_records if u.uri]
try:
active_count_updated = (
await self._vikingdb_manager.increment_active_count(self.ctx, uris)
)
except Exception as e:
logger.debug(f"Could not update active_count for usage URIs: {e}")
if active_count_updated > 0:
logger.info(
f"Updated active_count for {active_count_updated} contexts/skills"
)
try:
await request_wait_tracker.wait_for_request(
telemetry.telemetry_id,
timeout=_PHASE2_QUEUE_WAIT_TIMEOUT_SECONDS,
)
except TimeoutError as exc:
telemetry.set_error(
"session.commit.phase2.wait_for_request",
"DEADLINE_EXCEEDED",
str(exc),
)
logger.warning(
"Timed out waiting for request-scoped queues for "
"telemetry_id=%s after %.1fs; continuing phase2 completion",
telemetry.telemetry_id,
_PHASE2_QUEUE_WAIT_TIMEOUT_SECONDS,
)
finally:
request_wait_tracker.cleanup(telemetry.telemetry_id)
unregister_telemetry(telemetry.telemetry_id)
# Phase 2 complete — update meta with telemetry and commit info
snapshot = telemetry.finish("ok")
await self._merge_and_save_commit_meta(
archive_index=archive_index,
memories_extracted=memories_extracted,
telemetry_snapshot=snapshot,
record_auto_commit_success=record_auto_commit_success,
)
# Write .done last so a recovered queue item can skip completed work.
await self._write_done_file(
archive_uri,
first_message_id,
last_message_id,
working_memory_enabled=working_memory_enabled,
coverage_start_archive=coverage_start_archive,
coverage_end_archive=coverage_end_archive,
covered_failed_archives=covered_failed_archives,
completed_memory_steps=self._serialize_completed_memory_steps(
completed_memory_steps
),
)
result_payload = {
"session_id": self.session_id,
"archive_uri": archive_uri,
"memories_extracted": memories_extracted,
"session_skills_extracted": len(extracted_skill_results),
"session_skill_uris": [
item.get("uri") or item.get("root_uri")
for item in extracted_skill_results
if isinstance(item, dict) and (item.get("uri") or item.get("root_uri"))
],
"usage_events_extracted": usage_events_extracted,
"active_count_updated": active_count_updated,
"effective_memory_types": sorted(
_effective_memory_types(MemoryPolicy.from_dict(memory_policy))
),
"agent_evolution_enabled": agent_evolution_enabled,
"agent_memory_skip_reason": agent_memory_skip_reason,
"token_usage": {
"llm": dict(self._meta.llm_token_usage),
"embedding": dict(self._meta.embedding_token_usage),
"total": {
"total_tokens": self._meta.llm_token_usage["total_tokens"]
+ self._meta.embedding_token_usage["total_tokens"],
"cached_tokens": self._meta.llm_token_usage["cached_tokens"],
"reasoning_tokens": self._meta.llm_token_usage["reasoning_tokens"],
},
},
}
if memory_diff_uri:
result_payload["memory_diff_uri"] = memory_diff_uri
if user_config_error:
result_payload["user_config_error"] = user_config_error
await tracker.complete(
task_id,
result_payload,
account_id=self.ctx.account_id,
user_id=self.ctx.user.user_id,
)
logger.info(f"Session {self.session_id} memory extraction completed")
except asyncio.CancelledError:
await self._write_failed_marker(
archive_uri,
stage="cancelled",
error="session commit cancelled",
)
raise
except Exception as e:
await self._write_failed_marker(
archive_uri,
stage="memory_extraction",
error=str(e),
completed_memory_steps=self._serialize_completed_memory_steps(
completed_memory_steps
),
)
await tracker.fail(
task_id, str(e), account_id=self.ctx.account_id, user_id=self.ctx.user.user_id
)
logger.exception(f"Memory extraction failed for session {self.session_id}")
async def _write_done_file(
self,
archive_uri: str,
first_message_id: str,
last_message_id: str,
*,
working_memory_enabled: Optional[bool] = None,
coverage_start_archive: Optional[str] = None,
coverage_end_archive: Optional[str] = None,
covered_failed_archives: Optional[List[str]] = None,
completed_memory_steps: Optional[Dict[str, List[str]]] = None,
) -> None:
"""Write .done marker file to the archive directory."""
if not self._viking_fs:
return
archive_id = archive_uri.rstrip("/").split("/")[-1]
content = json.dumps(
{
"starting_message_id": first_message_id,
"ending_message_id": last_message_id,
"working_memory_enabled": working_memory_enabled,
"coverage_start_archive": coverage_start_archive or archive_id,
"coverage_end_archive": coverage_end_archive or archive_id,
"covered_failed_archives": list(covered_failed_archives or []),
"completed_memory_steps": dict(completed_memory_steps or {}),
},
ensure_ascii=False,
)
await self._viking_fs.write_file(
uri=f"{archive_uri}/.done",
content=content,
ctx=self.ctx,
)
async def _write_failed_marker(
self,
archive_uri: str,
stage: str,
error: str,
blocked_by: str = "",
skipped: bool = True,
completed_memory_steps: Optional[Dict[str, List[str]]] = None,
lease_ref: Optional[Any] = None,
) -> None:
"""Persist a terminal failure marker for the archive."""
if not self._viking_fs:
return
payload = {
"stage": stage,
"error": error,
"failed_at": get_current_timestamp(),
"skipped": skipped,
"completed_memory_steps": dict(completed_memory_steps or {}),
}
if blocked_by:
payload["blocked_by"] = blocked_by
await self._viking_fs.write_file(
uri=f"{archive_uri}/.failed.json",
content=json.dumps(payload, ensure_ascii=False),
ctx=self.ctx,
lease_ref=lease_ref,
)
async def get_session_context(self, token_budget: int = 128_000) -> Dict[str, Any]:
"""Get assembled session context with the latest summary archive and merged messages."""
if token_budget < 0:
raise ValueError("token_budget must be greater than or equal to 0")
context = await self._collect_session_context_components()
merged_messages = context["messages"]
budgeted = fit_active_messages_to_budget(
merged_messages,
token_budget=token_budget,
)
merged_messages = budgeted.messages
message_tokens = budgeted.estimated_tokens
if budgeted.dropped_message_ids or budgeted.truncated_message_ids:
logger.info(
"[get_session_context] active budget applied: session_id=%s, "
"budget=%s, dropped=%s, truncated=%s",
self.session_id,
token_budget,
len(budgeted.dropped_message_ids),
len(budgeted.truncated_message_ids),
)
# 精简日志:只打印关键信息
logger.info(
f"[get_session_context] session_id={self.session_id}, "
f"messages={len(merged_messages)}, tokens={message_tokens}"
)
remaining_budget = max(0, token_budget - message_tokens)
latest_archive = context["latest_archive"]
include_latest_overview = bool(
latest_archive and latest_archive["overview_tokens"] <= remaining_budget
)
latest_archive_tokens = latest_archive["overview_tokens"] if include_latest_overview else 0
if include_latest_overview:
remaining_budget -= latest_archive_tokens
# pre_archive_abstracts: 保留字段返回空数组,保持 API 向下兼容
included_pre_archive_abstracts: List[Dict[str, str]] = []
pre_archive_tokens = 0
archive_tokens = latest_archive_tokens + pre_archive_tokens
included_archives = len(included_pre_archive_abstracts)
dropped_archives = max(
0, context["total_archives"] - context["failed_archives"] - included_archives
)
return {
"latest_archive_overview": (
latest_archive["overview"] if include_latest_overview else ""
),
"pre_archive_abstracts": [], # 保持 API 向后兼容,返回空数组
"messages": [m.to_dict() for m in merged_messages],
"estimatedTokens": message_tokens + archive_tokens,
"stats": {
"totalArchives": context["total_archives"],
"includedArchives": included_archives,
"droppedArchives": dropped_archives,
"failedArchives": context["failed_archives"],
"activeTokens": message_tokens,
"archiveTokens": archive_tokens,
},
}
async def get_context_for_search(self, query: str, max_messages: int = 20) -> Dict[str, Any]:
"""Get session context for intent analysis."""
del query # Current query no longer affects historical archive selection.
context = await self._collect_session_context_components()
current_messages = context["messages"]
if max_messages > 0:
current_messages = current_messages[-max_messages:]
else:
current_messages = []
return {
"latest_archive_overview": (
context["latest_archive"]["overview"] if context["latest_archive"] else ""
),
"current_messages": current_messages,
}
async def get_session_archive(self, archive_id: str) -> Dict[str, Any]:
"""Get one completed archive by archive ID."""
from openviking_cli.exceptions import NotFoundError
for archive in await self._get_completed_archive_refs():
if archive["archive_id"] != archive_id:
continue
overview = await self._read_archive_overview(archive["archive_uri"])
if not overview:
break
abstract = await self._read_archive_abstract(archive["archive_uri"], overview)
return {
"archive_id": archive_id,
"abstract": abstract,
"overview": overview,
"messages": [
m.to_dict() for m in await self._read_archive_messages(archive["archive_uri"])
],
}
raise NotFoundError(archive_id, "session archive")
# ============= Internal methods =============
async def _collect_session_context_components(self) -> Dict[str, Any]:
"""Collect overview and messages by stopping at the newest terminal archive.
Archive history grows without bound, so the current-format read path scans
newest → oldest and stops at the first terminal marker (``.done`` or
``.failed.json``):
- newest terminal is ``completed``: inject that archive's overview when
readable, plus raw messages from the newer non-terminal archives;
- newest terminal is ``failed``: no overview, and only the newer
non-terminal archives contribute raw messages;
- no terminal at all: no overview, every archive is still non-terminal
so all of their raw messages are returned.
Nothing at or older than that terminal is normally read, which is a
deliberate deviation from the RFC #3330 recovery formula: an uncovered
``failed`` archive no longer replays its raw messages. Cumulative v2
checkpoints are restored from the terminal archive alone; only a
terminal legacy v1 delta checkpoint invokes an older-history compatibility
scan. Public ``pre_archive_abstracts`` stay empty; abstracts are not read.
"""
archive_refs = await self._list_archive_refs()
newer_pending: List[Dict[str, Any]] = []
terminal: Optional[Dict[str, Any]] = None
terminal_state = ""
for archive in archive_refs: # newest → oldest
state = await self._archive_terminal_state(archive["archive_uri"])
if state == "pending":
newer_pending.append(archive)
continue
terminal = archive
terminal_state = state
break
latest_archive = None
failed_archives = 0
if terminal is not None and terminal_state == "completed":
overview = (await self._read_archive_overview(terminal["archive_uri"])).strip()
if overview:
latest_archive = {
"archive_id": terminal["archive_id"],
"archive_uri": terminal["archive_uri"],
"overview": overview,
"overview_tokens": await self._read_archive_overview_tokens(
terminal["archive_uri"], overview
),
}
else:
# A required overview that is missing or unreadable still keeps
# the archive terminal here; the warning is emitted by the full
# scan used for Phase 2 bookkeeping.
logger.warning(
"Completed archive has no readable overview: %s",
terminal["archive_uri"],
)
failed_archives = 1
elif terminal is not None:
failed_archives = 1
archive_messages: List[Message] = []
# newer_pending was collected newest-first; restore chronological order.
for archive in reversed(newer_pending):
try:
archive_messages.extend(await self._read_archive_messages(archive["archive_uri"]))
except Exception as exc:
if not _is_storage_not_found(exc):
raise
logger.warning(
"Skipping pending archive %s because messages.jsonl is missing",
archive["archive_uri"],
)
merged_messages = self._stable_deduplicate_messages(archive_messages + list(self._messages))
merged_messages = await self._insert_terminal_checkpoints(
merged_messages,
terminal if terminal_state == "completed" else None,
)
return {
"latest_archive": latest_archive,
"pre_archive_abstracts": [],
# Directory listing only; deriving an exact count would require the
# per-archive marker scan this read path exists to avoid.
"total_archives": len(archive_refs),
"failed_archives": failed_archives,
"messages": merged_messages,
}
async def _archive_terminal_state(self, archive_uri: str) -> str:
"""Return ``completed``, ``failed``, or ``pending`` for one archive."""
if not self._viking_fs:
return "pending"
for marker, state in ((".done", "completed"), (".failed.json", "failed")):
try:
if await self._viking_fs.exists(f"{archive_uri}/{marker}", ctx=self.ctx):
return state
except Exception:
continue
return "pending"
async def _list_archive_refs(self) -> List[Dict[str, Any]]:
"""List archive refs sorted by archive index descending."""
if not self._viking_fs:
return []
try:
history_items = await self._viking_fs.ls(f"{self._session_uri}/history", ctx=self.ctx)
except Exception:
return []
refs: List[Dict[str, Any]] = []
for item in history_items:
name = item.get("name") if isinstance(item, dict) else item
if not name or not name.startswith("archive_"):
continue
try:
index = int(name.split("_")[1])
except Exception:
continue
refs.append(
{
"archive_id": name,
"archive_uri": f"{self._session_uri}/history/{name}",
"index": index,
}
)
return sorted(refs, key=lambda item: item["index"], reverse=True)
async def _scan_archive_states(self) -> List[ArchiveState]:
"""Derive every archive state exclusively from its directory markers."""
states: List[ArchiveState] = []
refs = sorted(await self._list_archive_refs(), key=lambda item: item["index"])
for archive in refs:
done_uri = f"{archive['archive_uri']}/.done"
try:
done_exists = await self._viking_fs.exists(done_uri, ctx=self.ctx)
except Exception:
done_exists = False
done: Dict[str, Any] = {}
if done_exists:
try:
raw_done = await self._viking_fs.read_file(done_uri, ctx=self.ctx)
parsed_done = json.loads(raw_done or "{}")
if isinstance(parsed_done, dict):
done = parsed_done
except Exception as exc:
# Marker existence still means completion, but unreadable
# contents cannot extend coverage to earlier archives.
logger.warning(
"Unreadable archive done marker %s: %s", archive["archive_uri"], exc
)
if done_exists:
# Only validate overview when Working Memory required one.
# Otherwise leave overview empty here and let context assembly
# lazy-load the newest terminal completed archive's overview.
overview = ""
if done.get("working_memory_enabled") is True:
overview = await self._read_archive_overview(archive["archive_uri"])
if not overview.strip():
# New markers distinguish an intentionally overview-less
# working_memory=false commit from a missing/corrupt
# required overview. The latter remains logically live and
# can be rolled forward by a later successful archive.
logger.warning(
"Completed archive has no readable required overview: %s",
archive["archive_uri"],
)
states.append(
ArchiveState(
archive_id=archive["archive_id"],
archive_uri=archive["archive_uri"],
index=archive["index"],
state="failed",
done=done,
failed={
"stage": "archive_overview",
"error": "required overview is missing or unreadable",
},
)
)
continue
# working_memory=false legitimately writes .done without an
# overview. Legacy markers lack the explicit flag, so retain
# their established completed semantics for compatibility.
states.append(
ArchiveState(
archive_id=archive["archive_id"],
archive_uri=archive["archive_uri"],
index=archive["index"],
state="completed",
overview=overview,
done=done,
)
)
continue
failed: Dict[str, Any] = {}
failed_uri = f"{archive['archive_uri']}/.failed.json"
try:
failed_exists = await self._viking_fs.exists(failed_uri, ctx=self.ctx)
except Exception:
failed_exists = False
if failed_exists:
try:
parsed_failed = json.loads(
await self._viking_fs.read_file(failed_uri, ctx=self.ctx) or "{}"
)
if isinstance(parsed_failed, dict):
failed = parsed_failed
except Exception as exc:
logger.warning("Unreadable archive failed marker %s: %s", failed_uri, exc)
states.append(
ArchiveState(
archive_id=archive["archive_id"],
archive_uri=archive["archive_uri"],
index=archive["index"],
state="failed" if failed_exists else "pending",
failed=failed,
)
)
return states
@staticmethod
def _covered_archive_ids(states: List[ArchiveState]) -> set[str]:
"""Return archives covered by an authoritative completion marker."""
existing = {state.archive_id: state for state in states}
covered: set[str] = set()
for state in states:
if state.state != "completed":
continue
start = max(
1,
min(state.coverage_start_index, state.coverage_end_index, state.index),
)
end = min(
state.index,
max(state.coverage_start_index, state.coverage_end_index),
)
for candidate in states:
# A pending archive still has a live Phase 2 owner and is never
# valid coverage input. Even malformed/manual range metadata
# must not make its raw messages disappear.
if start <= candidate.index <= end and candidate.state != "pending":
covered.add(candidate.archive_id)
explicit = state.done.get("covered_failed_archives", [])
if isinstance(explicit, list):
covered.update(
archive_id
for archive_id in explicit
if isinstance(archive_id, str)
and archive_id in existing
and existing[archive_id].index <= state.index
and existing[archive_id].state == "failed"
)
return covered
@staticmethod
def _stable_deduplicate_messages(messages: List[Message]) -> List[Message]:
"""Stable-deduplicate crash/recovery overlaps by durable message id."""
seen: set[str] = set()
result: List[Message] = []
for message in messages:
if message.id in seen:
continue
seen.add(message.id)
result.append(message)
return result
@staticmethod
def _merge_completed_memory_steps(
target: Dict[str, set[str]],
raw: Any,
) -> None:
"""Merge durable per-step message coverage from archive metadata."""
if not isinstance(raw, dict):
return
for step in _MEMORY_STEP_NAMES:
message_ids = raw.get(step)
if not isinstance(message_ids, list):
continue
target.setdefault(step, set()).update(
item for item in message_ids if isinstance(item, str) and item
)
@staticmethod
def _serialize_completed_memory_steps(
completed: Dict[str, set[str]],
) -> Dict[str, List[str]]:
return {
step: sorted(completed.get(step, set()))
for step in _MEMORY_STEP_NAMES
if completed.get(step)
}
async def _get_completed_archive_refs(
self,
exclude_archive_uri: Optional[str] = None,
before_archive_index: Optional[int] = None,
) -> List[Dict[str, Any]]:
"""Return completed archive refs sorted by archive index descending."""
completed: List[Dict[str, Any]] = []
exclude = exclude_archive_uri.rstrip("/") if exclude_archive_uri else None
for state in reversed(await self._scan_archive_states()):
if exclude and state.archive_uri == exclude:
continue
if before_archive_index is not None and state.index >= before_archive_index:
continue
if state.state != "completed":
continue
completed.append(
{
"archive_id": state.archive_id,
"archive_uri": state.archive_uri,
"index": state.index,
}
)
return completed
async def _read_archive_overview(self, archive_uri: str) -> str:
"""Read archive overview text."""
try:
overview = await self._viking_fs.read_file(f"{archive_uri}/.overview.md", ctx=self.ctx)
except Exception:
return ""
return overview or ""
async def _read_archive_abstract(self, archive_uri: str, overview: str = "") -> str:
"""Read archive abstract text, falling back to summary extraction."""
try:
abstract = await self._viking_fs.read_file(f"{archive_uri}/.abstract.md", ctx=self.ctx)
except Exception:
abstract = ""
if abstract:
return abstract
if not overview:
overview = await self._read_archive_overview(archive_uri)
return self._extract_abstract_from_summary(overview)
async def _read_archive_overview_tokens(self, archive_uri: str, overview: str) -> int:
"""Read overview token estimate from archive metadata."""
overview_tokens = estimate_text_tokens(overview)
try:
meta_content = await self._viking_fs.read_file(
f"{archive_uri}/.meta.json", ctx=self.ctx
)
meta_tokens = int(json.loads(meta_content).get("overview_tokens", overview_tokens))
overview_tokens = max(overview_tokens, meta_tokens)
except Exception:
pass
return overview_tokens
async def _read_archive_messages(self, archive_uri: str) -> List[Message]:
"""Read archived messages from one archive."""
content = await self._viking_fs.read_file(f"{archive_uri}/messages.jsonl", ctx=self.ctx)
messages: List[Message] = []
for line in content.strip().split("\n"):
if not line.strip():
continue
try:
messages.append(Message.from_dict(json.loads(line)))
except (json.JSONDecodeError, AttributeError, KeyError, TypeError, ValueError) as exc:
raise _ArchiveMessagesCorruptError("invalid message record") from exc
return messages
async def _get_latest_completed_archive_summary(
self,
exclude_archive_uri: Optional[str] = None,
before_archive_index: Optional[int] = None,
) -> Optional[Dict[str, Any]]:
"""Return the newest readable completed archive summary."""
for archive in await self._get_completed_archive_refs(
exclude_archive_uri,
before_archive_index,
):
overview = await self._read_archive_overview(archive["archive_uri"])
if not overview:
continue
return {
"archive_id": archive["archive_id"],
"archive_uri": archive["archive_uri"],
"overview": overview,
"abstract": await self._read_archive_abstract(archive["archive_uri"], overview),
"overview_tokens": await self._read_archive_overview_tokens(
archive["archive_uri"], overview
),
}
return None
async def _get_latest_completed_archive_overview(
self,
exclude_archive_uri: Optional[str] = None,
before_archive_index: Optional[int] = None,
) -> str:
"""Return the newest completed archive overview, skipping incomplete archives."""
summary = await self._get_latest_completed_archive_summary(
exclude_archive_uri,
before_archive_index,
)
return summary["overview"] if summary else ""
async def _read_archive_meta(self, archive_uri: str) -> Dict[str, Any]:
try:
content = await self._viking_fs.read_file(f"{archive_uri}/.meta.json", ctx=self.ctx)
parsed = json.loads(content)
return parsed if isinstance(parsed, dict) else {}
except Exception:
return {}
@staticmethod
def _checkpoint_records_for_anchors(
meta: Dict[str, Any],
anchor_ids: set[str],
) -> Dict[str, List[Dict[str, Any]]]:
"""Return structurally valid checkpoint records grouped by requested anchor."""
grouped: Dict[str, List[Dict[str, Any]]] = {}
checkpoints = meta.get("checkpoints")
if not isinstance(checkpoints, list):
return grouped
for checkpoint in checkpoints:
if not isinstance(checkpoint, dict):
continue
anchor_id = checkpoint.get("turn_anchor_message_id")
source_ids = checkpoint.get("source_message_ids")
abstract = checkpoint.get("abstract")
if not isinstance(anchor_id, str) or anchor_id not in anchor_ids:
continue
if not isinstance(source_ids, list) or not source_ids:
continue
if not isinstance(abstract, str) or not abstract.strip():
continue
valid_source_ids = tuple(item for item in source_ids if isinstance(item, str) and item)
if not valid_source_ids:
continue
raw_version = checkpoint.get("checkpoint_version", 1)
try:
checkpoint_version = max(1, int(raw_version))
except (TypeError, ValueError):
checkpoint_version = 1
grouped.setdefault(anchor_id, []).append(
{
"source_message_ids": valid_source_ids,
"abstract": abstract.strip(),
"checkpoint_version": checkpoint_version,
}
)
return grouped
async def _get_effective_completed_checkpoints(
self,
anchor_ids: set[str],
*,
before_archive_index: Optional[int] = None,
) -> Dict[str, _CheckpointSnapshot]:
"""Resolve completed checkpoint history for the requested retained Turns.
Version 2 records are cumulative, so the first one found while scanning
newest to oldest is authoritative. Version 1 records are deltas; they are
collected until a v2 base (or the beginning of history) and merged in
chronological order. This keeps new sessions bounded while preserving
legacy histories during migration.
"""
if not anchor_ids:
return {}
histories: Dict[str, Dict[str, Any]] = {
anchor_id: {
"base": None,
"legacy_chunks": [],
"resolved": False,
}
for anchor_id in anchor_ids
}
refs = await self._get_completed_archive_refs(before_archive_index=before_archive_index)
for archive in refs: # newest → oldest
unresolved = {
anchor_id for anchor_id, history in histories.items() if not history["resolved"]
}
if not unresolved:
break
grouped = self._checkpoint_records_for_anchors(
await self._read_archive_meta(archive["archive_uri"]),
unresolved,
)
for anchor_id, records in grouped.items():
history = histories[anchor_id]
cumulative = [
record
for record in records
if record["checkpoint_version"] >= _CUMULATIVE_CHECKPOINT_VERSION
]
if cumulative:
# A v2 record already includes every older compressed prefix.
record = cumulative[-1]
history["base"] = {
**record,
"archive_id": archive["archive_id"],
"archive_uri": archive["archive_uri"],
}
history["resolved"] = True
continue
history["legacy_chunks"].append(
{
"source_message_ids": tuple(
dict.fromkeys(
source_id
for record in records
for source_id in record["source_message_ids"]
)
),
"abstract": "\n\n".join(record["abstract"] for record in records),
"archive_id": archive["archive_id"],
"archive_uri": archive["archive_uri"],
}
)
snapshots: Dict[str, _CheckpointSnapshot] = {}
for anchor_id, history in histories.items():
base = history["base"]
legacy_chunks = list(reversed(history["legacy_chunks"]))
chronological_chunks = ([base] if base else []) + legacy_chunks
if not chronological_chunks:
continue
source_message_ids: List[str] = []
abstracts: List[str] = []
for chunk in chronological_chunks:
seen = set(source_message_ids)
new_source_ids = [
source_id for source_id in chunk["source_message_ids"] if source_id not in seen
]
if not new_source_ids:
continue
source_message_ids.extend(new_source_ids)
abstracts.append(chunk["abstract"])
if not source_message_ids or not abstracts:
continue
newest = history["legacy_chunks"][0] if history["legacy_chunks"] else base
snapshots[anchor_id] = _CheckpointSnapshot(
turn_anchor_message_id=anchor_id,
source_message_ids=tuple(source_message_ids),
abstract="\n\n".join(abstracts),
archive_id=newest["archive_id"],
archive_uri=newest["archive_uri"],
)
return snapshots
async def _collect_checkpoint_requests_for_phase2(
self,
archive_uri: str,
covered_failed_archives: List[str],
messages: List[Message],
) -> List[_CheckpointRequest]:
"""Collect and validate partial-Turn checkpoint work owned by this Phase 2.
Failed archives rolled into the current commit contribute their pending
checkpoint sources. Requests sharing one retained user anchor are merged
before the LLM call, so context assembly inserts one checkpoint per Turn.
"""
archive_root = archive_uri.rstrip("/").rsplit("/", 1)[0]
current_archive_id = archive_uri.rstrip("/").split("/")[-1]
archive_ids = list(
dict.fromkeys(
[
archive_id
for archive_id in [*covered_failed_archives, current_archive_id]
if isinstance(archive_id, str) and re.fullmatch(r"archive_\d+", archive_id)
]
)
)
archive_ids.sort(key=lambda item: int(item.split("_")[1]))
message_by_id = {message.id: message for message in messages}
message_ids = set(message_by_id)
message_order = {message.id: index for index, message in enumerate(messages)}
turn_anchor_by_message_id: Dict[str, Optional[str]] = {}
for turn in build_turns(messages):
owner_anchor_id = turn.anchor.id if turn.anchor is not None else None
for message in turn.messages:
turn_anchor_by_message_id[message.id] = owner_anchor_id
merged: Dict[str, Dict[str, Any]] = {}
for archive_id in archive_ids:
meta = await self._read_archive_meta(f"{archive_root}/{archive_id}")
plan = meta.get("retention_plan")
if not isinstance(plan, dict) or not plan.get("partial_turn"):
continue
anchor_id = plan.get("turn_anchor_message_id")
raw_source_ids = plan.get("checkpoint_source_message_ids")
if not isinstance(anchor_id, str) or not anchor_id:
raise ValueError(f"{archive_id} has a partial Turn without a valid anchor")
if not isinstance(raw_source_ids, list) or not raw_source_ids:
raise ValueError(
f"{archive_id} has a partial Turn without checkpoint source messages"
)
source_ids = [
source_id
for source_id in raw_source_ids
if isinstance(source_id, str) and source_id
]
if len(source_ids) != len(raw_source_ids):
raise ValueError(f"{archive_id} has invalid checkpoint source message IDs")
missing_ids = [
message_id
for message_id in [anchor_id, *source_ids]
if message_id not in message_ids
]
if missing_ids:
raise ValueError(
f"{archive_id} checkpoint source is missing messages: {missing_ids}"
)
invalid_source_ids = [
source_id
for source_id in source_ids
if is_user_query(message_by_id[source_id])
or turn_anchor_by_message_id.get(source_id) != anchor_id
]
if invalid_source_ids:
raise ValueError(
f"{archive_id} checkpoint source is outside its Assistant/Tool prefix: "
f"{invalid_source_ids}"
)
request = merged.setdefault(
anchor_id,
{
"source_message_ids": [],
"retained_message_token_budget": 0,
"estimated_active_tokens": 0,
},
)
request["source_message_ids"] = list(
dict.fromkeys([*request["source_message_ids"], *source_ids])
)
# The newest plan for the same still-active Turn is authoritative.
request["retained_message_token_budget"] = max(
0, int(plan.get("retained_message_token_budget", 0) or 0)
)
request["estimated_active_tokens"] = max(
0, int(plan.get("estimated_active_tokens", 0) or 0)
)
requests: List[_CheckpointRequest] = []
for anchor_id, request in merged.items():
source_ids = sorted(
request["source_message_ids"],
key=lambda message_id: message_order[message_id],
)
requests.append(
_CheckpointRequest(
turn_anchor_message_id=anchor_id,
source_message_ids=tuple(source_ids),
retained_message_token_budget=request["retained_message_token_budget"],
estimated_active_tokens=request["estimated_active_tokens"],
)
)
requests.sort(
key=lambda request: min(
message_order[source_id] for source_id in request.source_message_ids
)
)
previous_by_anchor = await self._get_effective_completed_checkpoints(
{request.turn_anchor_message_id for request in requests},
before_archive_index=self._archive_index_from_uri(archive_uri),
)
return [
_CheckpointRequest(
turn_anchor_message_id=request.turn_anchor_message_id,
source_message_ids=request.source_message_ids,
retained_message_token_budget=request.retained_message_token_budget,
estimated_active_tokens=request.estimated_active_tokens,
previous_checkpoint_abstract=(
previous_by_anchor[request.turn_anchor_message_id].abstract
if request.turn_anchor_message_id in previous_by_anchor
else ""
),
previous_checkpoint_source_message_ids=(
previous_by_anchor[request.turn_anchor_message_id].source_message_ids
if request.turn_anchor_message_id in previous_by_anchor
else ()
),
)
for request in requests
]
@staticmethod
def _build_checkpoint_records(
requests: List[_CheckpointRequest],
summaries: tuple[str, ...],
) -> List[Dict[str, Any]]:
"""Bind ordinal LLM outputs to server-owned IDs and enforce local budgets."""
if len(summaries) != len(requests):
raise ValueError(
"Working Memory output returned "
f"{len(summaries)} checkpoint summaries for {len(requests)} requests"
)
records: List[Dict[str, Any]] = []
for request, raw_summary in zip(requests, summaries, strict=True):
summary = raw_summary.strip() if isinstance(raw_summary, str) else ""
if not summary:
raise ValueError("Working Memory output contains an empty checkpoint summary")
configured_budget = request.retained_message_token_budget
if configured_budget > 0:
available = configured_budget - request.estimated_active_tokens
checkpoint_budget = (
min(1024, available) if available > 0 else min(256, configured_budget)
)
else:
checkpoint_budget = 1024
abstract = truncate_text_to_token_budget(summary, max(1, checkpoint_budget))
if not abstract:
raise ValueError("Checkpoint summary is empty after local token truncation")
records.append(
{
"checkpoint_version": _CUMULATIVE_CHECKPOINT_VERSION,
"turn_anchor_message_id": request.turn_anchor_message_id,
"source_message_ids": list(
dict.fromkeys(
[
*request.previous_checkpoint_source_message_ids,
*request.source_message_ids,
]
)
),
"abstract": abstract,
"estimated_tokens": estimate_text_tokens(abstract),
}
)
return records
async def _insert_terminal_checkpoints(
self,
messages: List[Message],
terminal: Optional[Dict[str, Any]],
) -> List[Message]:
"""Insert completed checkpoints after their retained User anchors.
New v2 checkpoints are cumulative, so the newest terminal archive is a
constant-cost first hit. A terminal v1 checkpoint triggers the legacy
compatibility scan and merges its older delta records chronologically.
Pending or failed terminal archives are never passed to this method.
"""
if not messages or terminal is None:
return messages
message_ids = {message.id for message in messages}
candidates: Dict[str, Dict[str, Any]] = {}
meta = await self._read_archive_meta(terminal["archive_uri"])
grouped = self._checkpoint_records_for_anchors(meta, message_ids)
legacy_anchor_ids: set[str] = set()
for anchor_id, records in grouped.items():
cumulative = [
record
for record in records
if record["checkpoint_version"] >= _CUMULATIVE_CHECKPOINT_VERSION
]
if not cumulative:
legacy_anchor_ids.add(anchor_id)
continue
record = cumulative[-1]
candidates[anchor_id] = {
"archive_id": terminal["archive_id"],
"archive_uri": terminal["archive_uri"],
"source_message_ids": list(record["source_message_ids"]),
"abstract": record["abstract"],
}
if legacy_anchor_ids:
legacy = await self._get_effective_completed_checkpoints(
legacy_anchor_ids,
before_archive_index=terminal["index"] + 1,
)
for anchor_id, snapshot in legacy.items():
candidates[anchor_id] = {
"archive_id": snapshot.archive_id,
"archive_uri": snapshot.archive_uri,
"source_message_ids": list(snapshot.source_message_ids),
"abstract": snapshot.abstract,
}
if not candidates:
return messages
result: List[Message] = []
for message in messages:
result.append(message)
candidate = candidates.get(message.id)
if not candidate:
continue
abstract = candidate["abstract"]
if not abstract:
continue
result.append(
Message(
id=f"checkpoint_{candidate['archive_id']}_{message.id}",
role="assistant",
parts=[
ContextPart(
uri=candidate["archive_uri"],
context_type="memory",
abstract=abstract,
)
],
# The checkpoint is synthesized by OpenViking, not authored
# by the user who owns the retained anchor.
peer_id=None,
created_at=message.created_at,
turn_id=message.turn_id,
message_kind="checkpoint",
source_message_ids=candidate["source_message_ids"],
)
)
return result
async def _get_uncovered_archive_messages(
self,
states: Optional[List[ArchiveState]] = None,
) -> List[Message]:
"""Return pending/failed raw messages not covered by a completed archive.
Kept as the RFC #3330 compatibility helper. Current context assembly
and Phase 2 both skip failed archive raw messages.
"""
states = states if states is not None else await self._scan_archive_states()
covered = self._covered_archive_ids(states)
messages: List[Message] = []
for state in states:
if state.archive_id in covered or state.state == "completed":
continue
try:
messages.extend(await self._read_archive_messages(state.archive_uri))
except Exception as exc:
if not _is_storage_not_found(exc):
raise
logger.warning(
"Skipping pending archive %s because messages.jsonl is missing",
state.archive_uri,
)
return self._stable_deduplicate_messages(messages)
async def _get_pending_archive_messages(self) -> List[Message]:
"""Compatibility wrapper; uncovered includes pending and failed archives."""
return await self._get_uncovered_archive_messages()
@staticmethod
def _archive_index_from_uri(archive_uri: str) -> int:
"""Parse archive_NNN suffix into an integer index."""
match = re.search(r"archive_(\d+)$", archive_uri.rstrip("/"))
if not match:
raise ValueError(f"Invalid archive URI: {archive_uri}")
return int(match.group(1))
async def _can_run_archive(self, archive_index: int) -> bool:
"""Resolve an orphaned direct predecessor before this Archive runs."""
if archive_index <= 1 or not self._viking_fs:
return True
predecessor_uri = (
f"{self._session_uri}/history/archive_{archive_index - 1:03d}"
)
if not await self._viking_fs.exists(predecessor_uri, ctx=self.ctx):
return True
if await self._archive_terminal_state(predecessor_uri) != "pending":
return True
phase1 = await self._read_phase1_meta(predecessor_uri)
if not phase1:
return False
if phase1.get("status") != "ready":
return not await self._ensure_phase1_ready(predecessor_uri)
queue_message = phase1.get("queue_message")
task_id = queue_message.get("task_id") if isinstance(queue_message, dict) else None
if not task_id:
return False
from openviking.service.task_tracker import get_task_tracker
tracker = get_task_tracker()
if tracker.has_work(str(task_id)):
return False
error = "Session commit queue work is missing"
await self._write_failed_marker(
predecessor_uri,
stage="queue_missing",
error=error,
)
await tracker.fail(
str(task_id),
error,
account_id=self.ctx.account_id,
user_id=self.ctx.user.user_id,
)
logger.warning(
"Skipped orphaned Session archive without QueueFS work: %s",
predecessor_uri,
)
return True
async def _prepare_phase2_archive_messages(
self,
archive_uri: str,
current_messages: List[Message],
) -> tuple[List[Message], str, str, List[str], Dict[str, set[str]]]:
"""Prepare only the current archive, preserving its retry progress."""
current_archive_id = archive_uri.rstrip("/").split("/")[-1]
completed_memory_steps: Dict[str, set[str]] = {}
current_meta = await self._read_archive_meta(archive_uri)
self._merge_completed_memory_steps(
completed_memory_steps,
current_meta.get("completed_memory_steps"),
)
return (
current_messages,
current_archive_id,
current_archive_id,
[],
completed_memory_steps,
)
async def _merge_and_save_commit_meta(
self,
archive_index: int,
memories_extracted: Dict[str, int],
telemetry_snapshot: Any,
*,
record_auto_commit_success: bool = False,
) -> None:
"""Merge Phase 2 results without overwriting concurrent root updates."""
session_path = self._viking_fs._uri_to_path(self._session_uri, ctx=self.ctx)
lease = await self._viking_fs._async_agfs.pathlock_acquire_tree(
session_path, timeout_secs=_SESSION_PHASE1_LOCK_TIMEOUT_SECONDS
)
try:
latest_meta = self._meta
try:
meta_content = await self._viking_fs.read_file(
f"{self._session_uri}/.meta.json",
ctx=self.ctx,
)
latest_meta = SessionMeta.from_dict(json.loads(meta_content))
except Exception:
latest_meta = self._meta
if telemetry_snapshot:
llm = telemetry_snapshot.summary.get("tokens", {}).get("llm", {})
latest_meta.llm_token_usage["prompt_tokens"] += llm.get("input", 0)
latest_meta.llm_token_usage["completion_tokens"] += llm.get("output", 0)
latest_meta.llm_token_usage["total_tokens"] += llm.get("total", 0)
latest_meta.llm_token_usage["cached_tokens"] += llm.get("prompt_cached", 0)
latest_meta.llm_token_usage["reasoning_tokens"] += llm.get(
"completion_reasoning", 0
)
embedding = telemetry_snapshot.summary.get("tokens", {}).get("embedding", {})
latest_meta.embedding_token_usage["total_tokens"] += embedding.get("total", 0)
latest_meta.commit_count = max(latest_meta.commit_count, archive_index)
for cat, count in memories_extracted.items():
latest_meta.memories_extracted[cat] = (
latest_meta.memories_extracted.get(cat, 0) + count
)
latest_meta.memories_extracted["total"] = (
latest_meta.memories_extracted.get("total", 0) + count
)
latest_meta.last_commit_at = get_current_timestamp()
latest_meta.message_count = await self._read_live_message_count()
if record_auto_commit_success:
# Mirror the Phase 1 success stamp so the persisted meta reflects
# a clean auto-commit even after Phase 2 reloads the latest meta.
latest_meta.last_auto_commit_at = get_current_timestamp()
self._meta = latest_meta
await self._save_meta(lease_ref=lease)
finally:
await self._viking_fs._async_agfs.pathlock_release(lease)
async def _read_live_message_count(self) -> int:
"""Count current live session messages from persisted storage."""
if not self._viking_fs:
return len(self._messages)
try:
content = await self._viking_fs.read_file(
f"{self._session_uri}/messages.jsonl",
ctx=self.ctx,
)
except Exception:
return len(self._messages)
return len([line for line in content.strip().split("\n") if line.strip()])
async def _read_live_messages_strict(self) -> List[Message]:
"""Read the authoritative root JSONL without silently dropping corrupt rows."""
if not self._viking_fs:
return list(self._messages)
content = await self._viking_fs.read_file(
f"{self._session_uri}/messages.jsonl",
ctx=self.ctx,
)
messages: List[Message] = []
# Split on "\n" only, not str.splitlines(): the latter also treats
# U+2028 / U+2029 / NEL (\x85) / \r / \v / \f as line boundaries, which
# would cut a JSONL record in half when those characters appear inside a
# JSON string value (e.g. assistant tool_output) and break json.loads()
# with "Unterminated string". Normalize CRLF first so a trailing "\r"
# does not leak into the record. See issue #3984.
for line_number, line in enumerate(content.replace("\r\n", "\n").split("\n"), start=1):
if not line.strip():
continue
try:
messages.append(Message.from_dict(json.loads(line)))
except Exception as exc:
raise ValueError(
f"Invalid live message JSONL at line {line_number}: {exc}"
) from exc
return messages
def _extract_abstract_from_summary(self, summary: str) -> str:
"""Extract one-sentence overview from structured summary."""
if not summary:
return ""
match = re.search(r"^\*\*[^*]+\*\*:\s*(.+)$", summary, re.MULTILINE)
if match:
return match.group(1).strip()
first_line = summary.split("\n")[0].strip()
return first_line if first_line else ""
@staticmethod
def _format_message_for_wm(m: Message) -> str:
"""Format a single message for WM generation, including all parts.
Includes TextPart, ToolPart (name + status + full output), and
ContextPart so the WM LLM sees the complete conversation.
"""
lines: List[str] = []
for p in m.parts:
if isinstance(p, TextPart) and p.text.strip():
lines.append(p.text)
elif isinstance(p, ToolPart) and p.tool_name:
status = p.tool_status or "completed"
output = p.tool_output or ""
lines.append(f"[tool:{p.tool_name} ({status})] {output}")
elif isinstance(p, ContextPart) and p.abstract:
lines.append(f"[context] {p.abstract}")
body = "\n".join(lines) if lines else "(no content)"
return f"[{m.role}]: {body}"
@classmethod
def _format_messages_for_wm(
cls,
messages: List[Message],
checkpoint_requests: List[_CheckpointRequest],
) -> str:
"""Format WM input plus prior cumulative checkpoints using ordinal-only tags."""
source_indexes: Dict[str, int] = {}
for index, request in enumerate(checkpoint_requests):
for message_id in request.source_message_ids:
previous = source_indexes.setdefault(message_id, index)
if previous != index:
raise ValueError(
f"Checkpoint source message {message_id} belongs to multiple requests"
)
lines: List[str] = []
for index, request in enumerate(checkpoint_requests):
if not request.previous_checkpoint_abstract.strip():
continue
lines.extend(
[
f'<checkpoint_previous index="{index}">',
request.previous_checkpoint_abstract.strip(),
"</checkpoint_previous>",
]
)
open_index: Optional[int] = None
for message in messages:
index = source_indexes.get(message.id)
if index != open_index:
if open_index is not None:
lines.append("</checkpoint_source>")
if index is not None:
lines.append(f'<checkpoint_source index="{index}">')
open_index = index
lines.append(cls._format_message_for_wm(message))
if open_index is not None:
lines.append("</checkpoint_source>")
return "\n".join(lines)
@staticmethod
def _checkpoint_prompt_instructions(request_count: int) -> str:
if request_count <= 0:
return ""
return (
"# CHECKPOINT OUTPUT\n\n"
f"The session content contains checkpoint_source blocks indexed 0 through "
f"{request_count - 1}. In the SAME tool call, return checkpoint_summaries "
f"with exactly {request_count} strings in index order. For an index that "
"also has checkpoint_previous, rewrite that previous summary together with "
"its newly marked checkpoint_source block into one bounded cumulative "
"continuation note. Without checkpoint_previous, summarize the marked block "
"as the initial cumulative note. Preserve the assistant's intent, important "
"tool actions and results, conclusions, corrections, and unfinished work; "
"prefer newer facts when they supersede older ones, omit raw output bulk, "
"and do not mention archiving, checkpointing, or this instruction. Never "
"return only the new delta when checkpoint_previous is present."
)
@staticmethod
def _parse_required_checkpoint_summaries(
args: Dict[str, Any],
request_count: int,
) -> tuple[str, ...]:
raw = args.get("checkpoint_summaries")
if not isinstance(raw, list):
raise ValueError("tool_call arguments.checkpoint_summaries missing")
if len(raw) != request_count or not all(isinstance(item, str) for item in raw):
raise ValueError(
f"tool_call checkpoint_summaries must contain exactly {request_count} strings"
)
return tuple(raw)
async def _generate_archive_summary_async(
self,
messages: List[Message],
latest_archive_overview: str = "",
checkpoint_requests: Optional[List[_CheckpointRequest]] = None,
) -> str | _ArchiveSummaryResult:
"""Generate Working Memory document for the current archive (async).
Two paths:
* No prior WM -> call ``compression.ov_wm_v2`` with a plain completion
and return the full 7-section markdown.
* Has prior WM -> call ``compression.ov_wm_v2_update`` with the
``update_working_memory`` tool forced on; parse per-section
decisions and merge them against the previous WM. On any
tool_call / JSON / schema anomaly, fall back to the creation
prompt so we never persist malformed output as WM.
"""
_wm_debug(
f"_generate_archive_summary_async called "
f"messages={len(messages)} prior_wm={len(latest_archive_overview)}B"
)
checkpoint_requests = list(checkpoint_requests or [])
if not messages:
if checkpoint_requests:
raise ValueError("Cannot generate checkpoints without archive messages")
return ""
formatted = self._format_messages_for_wm(messages, checkpoint_requests)
checkpoint_instructions = self._checkpoint_prompt_instructions(len(checkpoint_requests))
vlm = get_openviking_config().vlm
if not (vlm and vlm.is_available()):
if checkpoint_requests:
raise ValueError("A configured VLM is required to generate checkpoint summaries")
turn_count = len([m for m in messages if is_user_query(m)])
return (
f"# Session Summary\n\n**Overview**: {turn_count} turns, {len(messages)} messages"
)
try:
from openviking.prompts import render_prompt
except Exception as e:
if checkpoint_requests:
raise RuntimeError("Prompt module is required to generate checkpoints") from e
logger.warning(f"Prompt module unavailable: {e}")
turn_count = len([m for m in messages if is_user_query(m)])
return (
f"# Session Summary\n\n**Overview**: {turn_count} turns, {len(messages)} messages"
)
# -------- Detect WM v2 format --------
_is_wm_v2 = latest_archive_overview and any(
f"## {s}" in latest_archive_overview for s in WM_SEVEN_SECTIONS
)
# -------- Branch 1: no prior WM (or legacy format) -> full creation --------
if not latest_archive_overview or not _is_wm_v2:
_wm_debug(
f"branch=CREATE (prior={'legacy' if latest_archive_overview else 'none'} "
f"{len(latest_archive_overview or '')}B)"
)
try:
prompt = render_prompt(
"compression.ov_wm_v2",
{
"messages": formatted,
"latest_archive_overview": latest_archive_overview or "",
"checkpoint_instructions": checkpoint_instructions,
},
)
if checkpoint_requests:
response = await vlm.get_completion_async(
prompt=prompt,
tools=[WM_CREATE_WITH_CHECKPOINTS_TOOL],
tool_choice={
"type": "function",
"function": {"name": "create_working_memory"},
},
)
if not (
getattr(response, "has_tool_calls", False)
and getattr(response, "tool_calls", None)
):
raise ValueError(
"Working Memory creation returned no create_working_memory tool call"
)
args = response.tool_calls[0].arguments
if isinstance(args, str):
args = json.loads(args)
if not isinstance(args, dict):
raise ValueError("create_working_memory arguments must be an object")
working_memory = args.get("working_memory")
if not isinstance(working_memory, str) or not working_memory.strip():
raise ValueError("create_working_memory.working_memory is empty")
return _ArchiveSummaryResult(
overview=working_memory,
checkpoint_summaries=self._parse_required_checkpoint_summaries(
args,
len(checkpoint_requests),
),
)
return await vlm.get_completion_async(prompt)
except Exception as e:
_wm_debug(f"creation failed: {e}")
logger.warning(f"WM creation failed: {e}")
if checkpoint_requests:
raise
turn_count = len([m for m in messages if is_user_query(m)])
return (
f"# Session Summary\n\n"
f"**Overview**: {turn_count} turns, {len(messages)} messages"
)
# -------- Branch 2: has prior WM v2 -> tool_call incremental update --------
_wm_debug(f"branch=UPDATE (prior WM={len(latest_archive_overview)}B)")
try:
reminders = Session._build_wm_section_reminders(latest_archive_overview)
if reminders:
_wm_debug(f"section_reminders injected ({len(reminders)}B)")
update_prompt = render_prompt(
"compression.ov_wm_v2_update",
{
"messages": formatted,
"latest_archive_overview": latest_archive_overview,
"wm_section_reminders": reminders,
"checkpoint_instructions": checkpoint_instructions,
},
)
resp = await vlm.get_completion_async(
prompt=update_prompt,
tools=[WM_UPDATE_TOOL],
tool_choice={
"type": "function",
"function": {"name": "update_working_memory"},
},
)
except Exception as e:
import traceback as _tb
_wm_debug(f"tool_call raised: {type(e).__name__}: {e} tb={_tb.format_exc()[-400:]}")
if checkpoint_requests:
raise
logger.warning("WM update tool_call failed (%s); falling back to creation prompt", e)
return await self._fallback_generate_wm_creation(
formatted, messages, latest_archive_overview
)
has_tc = bool(getattr(resp, "has_tool_calls", False) and getattr(resp, "tool_calls", None))
_preview = (str(resp)[:200]).replace(chr(10), " ")
_finish = getattr(resp, "finish_reason", "n/a")
_usage = getattr(resp, "usage", {}) or {}
_wm_debug(
f"resp type={type(resp).__name__} has_tool_calls={has_tc} "
f"finish_reason={_finish!r} usage={_usage} preview={_preview!r}"
)
if not has_tc:
if checkpoint_requests:
raise ValueError("Working Memory update returned no tool call for checkpoints")
logger.warning("WM update: LLM returned no tool_call; falling back to creation prompt")
return await self._fallback_generate_wm_creation(
formatted, messages, latest_archive_overview
)
checkpoint_summaries: tuple[str, ...] = ()
try:
raw_args = resp.tool_calls[0].arguments
_wm_debug(f"raw_args type={type(raw_args).__name__} preview={str(raw_args)[:400]!r}")
args = raw_args
if isinstance(args, str):
args = json.loads(args)
if not isinstance(args, dict):
raise ValueError(f"tool_call arguments is not a dict: {type(args).__name__}")
# OV's VLM backend wraps unparseable JSON strings as {"raw": "..."}.
# Try a best-effort recovery: json.loads the raw string; if that
# still fails, attempt a tolerant parse (add a closing brace if the
# string looks truncated, extract up to the last valid JSON object).
if list(args.keys()) == ["raw"] and isinstance(args["raw"], str):
raw_str = args["raw"]
_wm_debug(f"args has only 'raw' key; attempting recovery len={len(raw_str)}")
recovered = None
try:
recovered = json.loads(raw_str)
except Exception:
# Try to close a truncated JSON by appending closing braces
# for every unmatched opener.
try:
opens = raw_str.count("{") - raw_str.count("}")
if opens > 0:
patched = raw_str.rstrip().rstrip(",") + ("}" * opens)
recovered = json.loads(patched)
_wm_debug(
f"recovered by closing {opens} brace(s); patched_len={len(patched)}"
)
except Exception as e2:
_wm_debug(f"brace-close recovery failed: {e2}")
if isinstance(recovered, dict):
args = recovered
_wm_debug(f"recovered args keys={list(args.keys())}")
_wm_debug(f"args keys={list(args.keys())}")
if checkpoint_requests:
checkpoint_summaries = self._parse_required_checkpoint_summaries(
args,
len(checkpoint_requests),
)
# Tolerant: if LLM returned {"Session Title": {...}, ...} without
# the outer "sections" wrapper, treat the top-level as ops.
if "sections" in args and isinstance(args["sections"], dict):
ops = args["sections"]
elif all(k in args for k in WM_SEVEN_SECTIONS):
_wm_debug("args has section keys directly; accepting as ops")
ops = args
else:
raise ValueError(f"tool_call arguments.sections missing; keys={list(args.keys())}")
if not isinstance(ops, dict):
raise ValueError("ops is not a dict")
except Exception as e:
if checkpoint_requests:
raise
_wm_debug(
f"args parse failed: {type(e).__name__}: {e}; attempting regex recovery from raw"
)
# Regex salvage: when the LLM emits slightly-broken JSON (curly
# quote, unescaped newline, truncated string), OV's VLM backend
# wraps it as {"raw": "..."} and all structural parsing fails. We
# still try to pull each section's op directly via regex before
# falling back to the creation prompt. Missing sections default
# to KEEP in _merge_wm_sections so old content is preserved.
raw_for_recovery = ""
if isinstance(raw_args, str):
raw_for_recovery = raw_args
elif isinstance(raw_args, dict):
if isinstance(raw_args.get("raw"), str):
raw_for_recovery = raw_args["raw"]
else:
try:
raw_for_recovery = json.dumps(raw_args, ensure_ascii=False)
except Exception:
raw_for_recovery = str(raw_args)
salvaged = Session._wm_recover_ops_from_raw(raw_for_recovery)
if salvaged:
_wm_debug(
f"regex recovery salvaged {len(salvaged)}/"
f"{len(WM_SEVEN_SECTIONS)} sections: "
f"{[(k, v.get('op')) for k, v in salvaged.items()]}"
)
logger.info(
"WM update: regex recovery salvaged %d/%d sections; "
"proceeding with partial ops",
len(salvaged),
len(WM_SEVEN_SECTIONS),
)
return self._merge_wm_sections(latest_archive_overview, salvaged)
_wm_debug("regex recovery salvaged 0 sections; falling back to creation prompt")
logger.warning(
"WM update: tool_call arguments parse failed (%s); "
"regex recovery found nothing; falling back to creation prompt",
e,
)
return await self._fallback_generate_wm_creation(
formatted, messages, latest_archive_overview
)
_wm_debug(
f"ops keys={list(ops.keys())[:7]} "
f"ops_summary={[(k, v.get('op') if isinstance(v, dict) else type(v).__name__) for k, v in ops.items()][:7]}"
)
overview = self._merge_wm_sections(latest_archive_overview, ops)
if checkpoint_requests:
return _ArchiveSummaryResult(
overview=overview,
checkpoint_summaries=checkpoint_summaries,
)
return overview
async def _fallback_generate_wm_creation(
self,
formatted_messages: str,
messages: List[Message],
prior_overview: str = "",
) -> str:
"""Re-run WM creation prompt when the update tool_call path fails.
Passes ``prior_overview`` so the creation prompt can incorporate
accumulated context instead of generating from scratch.
"""
_wm_debug(
f"fallback creation prompt: prior_overview={len(prior_overview)}B "
f"messages={len(messages)}"
)
try:
from openviking.prompts import render_prompt
prompt = render_prompt(
"compression.ov_wm_v2",
{
"messages": formatted_messages,
"latest_archive_overview": prior_overview,
"checkpoint_instructions": "",
},
)
return await get_openviking_config().vlm.get_completion_async(prompt)
except Exception as e:
logger.warning(f"WM creation fallback failed: {e}")
turn_count = len([m for m in messages if is_user_query(m)])
return (
f"# Session Summary\n\n**Overview**: {turn_count} turns, {len(messages)} messages"
)
@staticmethod
def _parse_wm_sections(text: str) -> Dict[str, str]:
"""Parse an existing WM markdown into {header_line: body_text}.
Header comparison is case-sensitive on purpose: the update path only
uses this output to look up bodies by our own canonical headers.
"""
sections: Dict[str, str] = {}
current: Optional[str] = None
buf: List[str] = []
for line in (text or "").splitlines():
stripped = line.strip()
if stripped.startswith("## "):
if current is not None:
sections[current] = "\n".join(buf).strip()
current = stripped
buf = []
elif current is not None:
buf.append(line)
if current is not None:
sections[current] = "\n".join(buf).strip()
return sections
_WM_SECTION_BULLET_THRESHOLD = 25
_WM_SECTION_TOKEN_THRESHOLD = 1500
_WM_OVERSIZED_APPEND_CAP = 5
_WM_CONSOLIDATION_SENTINEL = (
"[⚠ CONSOLIDATION REQUIRED: Key Facts exceeds size limit. "
"You MUST use UPDATE to merge and compress existing bullets "
"before adding new facts.]"
)
@staticmethod
def _build_wm_section_reminders(overview: str) -> str:
"""Compute dynamic section-size warnings for the WM update prompt.
Scans the current overview, counts bullets and estimates tokens for
each section. Returns an XML block that the prompt template can
inject verbatim so the LLM knows which sections need consolidation.
"""
if not overview:
return ""
sections = Session._parse_wm_sections(overview)
warnings: List[str] = []
for header, body in sections.items():
name = header.lstrip("#").strip()
if name in Session._WM_APPEND_ONLY_SECTIONS:
continue
items = Session._wm_extract_bullet_items(body)
est_tokens = estimate_text_tokens(body)
if (
len(items) > Session._WM_SECTION_BULLET_THRESHOLD
or est_tokens > Session._WM_SECTION_TOKEN_THRESHOLD
):
warnings.append(
f'WARNING: "{name}" has {len(items)} bullets '
f"(~{est_tokens} tokens).\n"
f"This section MUST be consolidated via UPDATE. Group "
f"related facts by topic into category summaries. "
f"Preserve names, dates, and exact values but merge "
f"repetitive events into patterns.\n"
f"Target: <={Session._WM_SECTION_BULLET_THRESHOLD} "
f"bullets, <={Session._WM_SECTION_TOKEN_THRESHOLD} tokens."
)
if not warnings:
return ""
return "<section_size_warnings>\n" + "\n\n".join(warnings) + "\n</section_size_warnings>"
# Sections where server enforces APPEND-only regardless of what the LLM emits.
_WM_APPEND_ONLY_SECTIONS = frozenset(
{
"Errors & Corrections",
}
)
# Very loose path-like token regex used to detect file paths that existed
# in prior Files & Context and MUST NOT silently disappear after UPDATE.
_WM_PATH_LIKE_RE = re.compile(
r"(?:[\w./\\-]+\.(?:py|ts|tsx|js|jsx|md|yaml|yml|json|sh|ps1|cmd|bat|toml|ini|cfg|rs|go))"
r"|(?:[a-zA-Z_][\w\-]*(?:/[a-zA-Z_][\w\-]*){1,})",
re.IGNORECASE,
)
_WM_TITLE_STOPWORDS = frozenset(
{
"the",
"a",
"an",
"and",
"or",
"of",
"to",
"in",
"on",
"for",
"with",
"by",
"at",
"from",
"session",
"title",
"working",
"memory",
"plan",
"plans",
"notes",
"note",
}
)
@staticmethod
def _wm_recover_ops_from_raw(raw_str: str) -> Dict[str, Any]:
"""Best-effort regex recovery of per-section ops from a malformed
tool_call arguments string.
Used when OV's VLM backend wraps non-JSON tool-call args as
``{"raw": "..."}`` (typical when the LLM emits unescaped characters
inside a string value, uses curly quotes, or emits a truncated JSON).
Scans the raw text for each of the 7 fixed section names and their
``{"op": "KEEP|UPDATE|APPEND", ...}`` markers. Partial UPDATE
content / APPEND items are tolerated; sections that cannot be found
at all are simply omitted (the merge step will then default them to
KEEP and preserve the prior content).
Returns a partial ops dict (possibly fewer than 7 sections).
"""
if not raw_str:
return {}
ops: Dict[str, Any] = {}
names_alt = "|".join(re.escape(n) for n in WM_SEVEN_SECTIONS)
# --- KEEP: "Name": {"op": "KEEP"} ---
keep_re = re.compile(rf'"({names_alt})"\s*:\s*\{{\s*"op"\s*:\s*"KEEP"\s*\}}')
for m in keep_re.finditer(raw_str):
ops.setdefault(m.group(1), {"op": "KEEP"})
# --- UPDATE: "Name": {"op": "UPDATE", "content": "..."} ---
# Capture content non-greedily up to either:
# (a) a closing '"}' that ends the section, or
# (b) the start of the next section key (meaning content string was truncated).
# DOTALL so newlines inside content don't end the match.
update_re = re.compile(
rf'"({names_alt})"\s*:\s*\{{\s*"op"\s*:\s*"UPDATE"\s*,\s*"content"\s*:\s*"'
rf'((?:[^"\\]|\\.)*?)'
rf'(?:"\s*\}}|(?="\s*,\s*"(?:' + names_alt + r')"))',
re.DOTALL,
)
for m in update_re.finditer(raw_str):
header = m.group(1)
if header in ops:
continue
captured = m.group(2)
try:
content = json.loads('"' + captured + '"')
except Exception:
content = captured
ops[header] = {"op": "UPDATE", "content": content}
# --- APPEND: "Name": {"op": "APPEND", "items": [...]} ---
# Tolerate truncated array (no closing ']').
append_re = re.compile(
rf'"({names_alt})"\s*:\s*\{{\s*"op"\s*:\s*"APPEND"\s*,\s*"items"\s*:\s*\['
rf"([\s\S]*?)(?:\]|$)",
)
item_re = re.compile(r'"((?:[^"\\]|\\.)*)"', re.DOTALL)
for m in append_re.finditer(raw_str):
header = m.group(1)
if header in ops:
continue
items_raw = m.group(2)
items: List[str] = []
for im in item_re.finditer(items_raw):
captured = im.group(1)
try:
items.append(json.loads('"' + captured + '"'))
except Exception:
items.append(captured)
ops[header] = {"op": "APPEND", "items": items}
return ops
@staticmethod
def _wm_extract_bullet_items(text: str) -> List[str]:
"""Extract bullet-like items from a markdown section body.
Recognizes ``- ...``, ``* ...``, ``1. ...``, ``2) ...`` lines, as well
as plain non-bullet lines (treated as single items).
"""
items: List[str] = []
for line in (text or "").splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
m = re.match(r"^(?:[-*]|\d+[\.)])\s+(.*)$", stripped)
if m:
item = m.group(1).strip()
else:
item = stripped
if item:
items.append(item)
return items
@staticmethod
def _wm_enforce_append_only(header: str, op: Any, old_content: str) -> Dict[str, Any]:
"""Guard: force KEEP/APPEND semantics on APPEND-only sections.
- KEEP and APPEND pass through.
- UPDATE is demoted: its content is parsed for bullet items; any items
that are not already present in the old body are re-emitted as APPEND
items, so nothing from the LLM's rewrite is lost but nothing from
the old body is dropped either.
- None/unknown op -> KEEP.
"""
if not isinstance(op, dict):
return {"op": "KEEP"}
op_name = (op.get("op") or "").upper()
if op_name in ("KEEP", "APPEND"):
return op
if op_name != "UPDATE":
return {"op": "KEEP"}
new_content = (op.get("content") or "").strip()
new_items = Session._wm_extract_bullet_items(new_content)
old_lower = (old_content or "").lower()
fresh_items = []
for it in new_items:
key = it.strip("_* `").lower()
if key and key not in old_lower:
fresh_items.append(it)
_wm_debug(
f"guard: section {header!r} UPDATE -> forced APPEND "
f"(llm_items={len(new_items)}, fresh_after_dedup={len(fresh_items)})"
)
if not fresh_items:
return {"op": "KEEP"}
return {"op": "APPEND", "items": fresh_items}
_WM_KEY_FACTS_MIN_BULLET_RATIO = 0.15
_WM_KEY_FACTS_MIN_ANCHOR_COVERAGE = 0.70
_WM_ANCHOR_DATE_RE = re.compile(
r"\b\d{4}-\d{2}-\d{2}\b"
r"|\b\d{1,2}\s+(?:January|February|March|April|May|June"
r"|July|August|September|October|November|December)\s+\d{4}\b",
re.IGNORECASE,
)
_WM_ANCHOR_NUMBER_RE = re.compile(
r"\b\d+\s+(?:years?|months?|weeks?|days?|kids?|children"
r"|hours?|miles?|times?|sessions?|rounds?|visits?"
r"|dollars?|euros?|pounds?|bedrooms?|paintings?"
r"|people|persons?)\b"
r"|\$\d[\d,]*"
r"|\b\d+\s+(?:AM|PM)\b",
re.IGNORECASE,
)
_WM_ANCHOR_DECISION_RE = re.compile(
r"\b(?:because|decided|chose|committed|agreed|resolved)\b",
re.IGNORECASE,
)
_WM_ANCHOR_STOPWORDS = frozenset(
{
"the",
"a",
"an",
"and",
"or",
"of",
"to",
"in",
"on",
"for",
"with",
"by",
"at",
"from",
"is",
"are",
"was",
"were",
"has",
"have",
"had",
"been",
"be",
"will",
"would",
"could",
"should",
"may",
"might",
"shall",
"this",
"that",
"these",
"those",
"not",
"no",
"but",
"if",
"then",
"so",
"as",
"it",
"its",
"they",
"their",
"them",
"she",
"her",
"he",
"him",
"his",
"we",
"our",
"us",
"you",
"your",
"who",
"which",
"what",
"when",
"where",
"how",
"why",
"all",
"each",
"every",
"both",
"few",
"more",
"most",
"other",
"some",
"such",
"than",
"too",
"very",
"also",
"just",
"about",
"after",
"before",
"between",
"into",
"through",
"during",
"again",
"further",
"once",
"here",
"there",
"over",
"under",
"out",
"up",
"down",
"off",
"own",
"same",
"only",
"new",
"old",
"key",
"facts",
"decisions",
"session",
"working",
"memory",
}
)
@staticmethod
def _extract_lexical_anchors(text: str) -> set:
"""Extract fact-preserving anchors: dates, numbers, proper nouns,
decision markers."""
anchors: set = set()
for m in Session._WM_ANCHOR_DATE_RE.finditer(text):
anchors.add(m.group().lower().strip())
for m in Session._WM_ANCHOR_NUMBER_RE.finditer(text):
anchors.add(m.group().lower().strip())
for m in Session._WM_ANCHOR_DECISION_RE.finditer(text):
anchors.add(m.group().lower().strip())
for token in re.findall(r"\b[A-Z][a-zA-Z]{2,}\b", text):
if token.lower() not in Session._WM_ANCHOR_STOPWORDS:
anchors.add(token.lower())
return anchors
@staticmethod
def _salvage_new_items_from_rejected_update(
new_content: str, old_content: str
) -> Dict[str, Any]:
"""When a consolidation UPDATE is rejected, salvage genuinely new
items from the update content and APPEND them so we don't lose
facts from the current round."""
new_items = Session._wm_extract_bullet_items(new_content)
old_lower = (old_content or "").lower()
fresh_items = []
for it in new_items:
key = it.strip("_* `").lower()
if key and key not in old_lower:
fresh_items.append(it)
if fresh_items:
_wm_debug(f"guard: salvaged {len(fresh_items)} new items from rejected UPDATE")
return {"op": "APPEND", "items": fresh_items}
return {"op": "KEEP"}
@staticmethod
def _wm_enforce_key_facts_consolidation(op: Any, old_content: str) -> Dict[str, Any]:
"""Guard: allow controlled consolidation for Key Facts & Decisions.
Layer 1 — reject trivially small UPDATEs (< 15% bullet count).
Layer 2 — require >= 70% lexical anchor coverage.
Rejection salvages genuinely new items from the rejected UPDATE
via APPEND, so current-round facts are not silently lost.
Anti-bloat: when Key Facts is already oversized (bullets or tokens
exceed threshold), APPEND is throttled:
- 1x~2x threshold → accept only genuinely new items (deduped),
capped at _WM_OVERSIZED_APPEND_CAP
- >2x threshold (emergency) → reject all normal facts, insert a
single idempotent consolidation sentinel; on subsequent rounds
the sentinel is already present so nothing is added (hard stop)
"""
if not isinstance(op, dict):
return {"op": "KEEP"}
op_name = (op.get("op") or "").upper()
if op_name == "KEEP":
return op
if op_name == "APPEND":
old_items = Session._wm_extract_bullet_items(old_content or "")
est_tokens = estimate_text_tokens(old_content or "")
bullet_over = len(old_items) > Session._WM_SECTION_BULLET_THRESHOLD
token_over = est_tokens > Session._WM_SECTION_TOKEN_THRESHOLD
if not bullet_over and not token_over:
return op
append_items = op.get("items") or []
if not append_items:
raw = (op.get("content") or "").strip()
append_items = Session._wm_extract_bullet_items(raw)
append_items = [str(it) for it in append_items if it]
old_lower = (old_content or "").lower()
fresh = [it for it in append_items if it.strip("_* `").lower() not in old_lower]
emergency = (
len(old_items) > Session._WM_SECTION_BULLET_THRESHOLD * 2
or est_tokens > Session._WM_SECTION_TOKEN_THRESHOLD * 2
)
if emergency:
sentinel = Session._WM_CONSOLIDATION_SENTINEL
if sentinel.lower() in old_lower:
_wm_debug(
f"guard: Key Facts APPEND blocked (emergency, "
f"sentinel already present): "
f"bullets={len(old_items)} est_tok={est_tokens} — "
f"dropped {len(fresh)} new item(s)"
)
return {"op": "KEEP"}
_wm_debug(
f"guard: Key Facts APPEND blocked (emergency, "
f"inserting sentinel): "
f"bullets={len(old_items)} est_tok={est_tokens} — "
f"dropped {len(fresh)} new item(s)"
)
return {"op": "APPEND", "items": [sentinel]}
cap = Session._WM_OVERSIZED_APPEND_CAP
accepted = fresh[:cap]
_wm_debug(
f"guard: Key Facts APPEND throttled (oversized): "
f"bullets={len(old_items)} est_tok={est_tokens} — "
f"input={len(append_items)} deduped={len(fresh)} "
f"accepted={len(accepted)} (cap={cap})"
)
if not accepted:
return {"op": "KEEP"}
return {"op": "APPEND", "items": accepted}
if op_name != "UPDATE":
return {"op": "KEEP"}
new_content = (op.get("content") or "").strip()
old_items = Session._wm_extract_bullet_items(old_content or "")
new_items = Session._wm_extract_bullet_items(new_content)
if not old_items:
return op
est_tokens = estimate_text_tokens(old_content or "")
is_emergency = (
len(old_items) > Session._WM_SECTION_BULLET_THRESHOLD * 2
or est_tokens > Session._WM_SECTION_TOKEN_THRESHOLD * 2
)
# Layer 1: reject trivially small consolidation
ratio = len(new_items) / len(old_items) if old_items else 1.0
if ratio < Session._WM_KEY_FACTS_MIN_BULLET_RATIO:
_wm_debug(
f"guard: Key Facts consolidation REJECTED (layer1): "
f"new={len(new_items)} / old={len(old_items)} = "
f"{ratio:.2%} < {Session._WM_KEY_FACTS_MIN_BULLET_RATIO:.0%}"
)
salvaged = Session._salvage_new_items_from_rejected_update(new_content, old_content)
if is_emergency and salvaged.get("op") == "APPEND":
_wm_debug("guard: suppressing salvage APPEND (emergency level)")
return {"op": "KEEP"}
return salvaged
# Layer 2: lexical anchor coverage
old_anchors = Session._extract_lexical_anchors(old_content or "")
if old_anchors:
new_anchors = Session._extract_lexical_anchors(new_content)
covered = len(old_anchors & new_anchors)
coverage = covered / len(old_anchors)
if coverage < Session._WM_KEY_FACTS_MIN_ANCHOR_COVERAGE:
_wm_debug(
f"guard: Key Facts consolidation REJECTED (layer2): "
f"anchor coverage={coverage:.2%} "
f"({covered}/{len(old_anchors)}) < "
f"{Session._WM_KEY_FACTS_MIN_ANCHOR_COVERAGE:.0%}"
)
salvaged = Session._salvage_new_items_from_rejected_update(new_content, old_content)
if is_emergency and salvaged.get("op") == "APPEND":
_wm_debug("guard: suppressing salvage APPEND (emergency level)")
return {"op": "KEEP"}
return salvaged
_wm_debug(
f"guard: Key Facts consolidation ACCEPTED: "
f"bullets {len(old_items)}->{len(new_items)} "
f"({ratio:.1%}), "
f"anchors={coverage:.1%} ({covered}/{len(old_anchors)})"
)
else:
_wm_debug(
f"guard: Key Facts consolidation ACCEPTED (no old anchors): "
f"bullets {len(old_items)}->{len(new_items)}"
)
return op
@staticmethod
def _wm_enforce_files_no_regression(op: Any, old_content: str) -> Dict[str, Any]:
"""Guard: don't let a 'Files & Context' UPDATE drop file paths.
If the LLM returns UPDATE whose content is missing one or more file
paths that existed in the old content, reject the UPDATE. If the LLM
introduced any new paths, surface them as an APPEND; otherwise KEEP.
"""
if not isinstance(op, dict):
return {"op": "KEEP"}
op_name = (op.get("op") or "").upper()
if op_name != "UPDATE":
return op
new_content = (op.get("content") or "").strip()
old_paths = set(Session._WM_PATH_LIKE_RE.findall(old_content or ""))
new_paths = set(Session._WM_PATH_LIKE_RE.findall(new_content))
missing = {p for p in old_paths if p not in new_paths}
if not missing:
return op
added_paths = new_paths - old_paths
_wm_debug(
f"guard: 'Files & Context' UPDATE drops {len(missing)} paths "
f"{sorted(missing)[:5]}; forcing KEEP (+ APPEND new paths="
f"{len(added_paths)})"
)
if added_paths:
# Preserve the old body as-is, then append the genuinely-new items
# the LLM added (with a short rationale line if we can find one).
new_items: List[str] = []
for path in sorted(added_paths):
# Try to pull the LLM's own phrasing for that path from new_content
for line in new_content.splitlines():
if path in line:
new_items.append(line.strip().lstrip("-*").strip())
break
else:
new_items.append(f"{path} (newly referenced)")
return {"op": "APPEND", "items": new_items}
return {"op": "KEEP"}
@staticmethod
def _wm_enforce_title_stability(op: Any, old_content: str) -> Dict[str, Any]:
"""Guard: reject Session Title UPDATE when it drifts too far.
Heuristic: if the meaningful-word overlap between the old title and
the proposed new title is 0, treat it as drift and fall back to KEEP.
This catches the common failure where the LLM rewrites the title each
round based on the latest delta instead of the overall session scope.
"""
if not isinstance(op, dict):
return {"op": "KEEP"}
op_name = (op.get("op") or "").upper()
if op_name != "UPDATE":
return op
new_content = (op.get("content") or "").strip()
def meaningful_words(text: str) -> set:
tokens = re.findall(r"[A-Za-z][A-Za-z0-9\.]{2,}|[\d\.]+", text or "")
return {t.lower() for t in tokens if t.lower() not in Session._WM_TITLE_STOPWORDS}
old_w = meaningful_words(old_content)
new_w = meaningful_words(new_content)
# If the previous title was empty we have nothing to compare against.
if not old_w:
return op
# If overlap >= 1 meaningful word, accept the rewording.
if len(old_w & new_w) >= 1:
return op
_wm_debug(
f"guard: Session Title drift rejected "
f"(old={old_content[:80]!r}, new={new_content[:80]!r}); KEEP"
)
return {"op": "KEEP"}
@staticmethod
def _wm_enforce_open_issues_resolved(op: Any, old_content: str) -> Dict[str, Any]:
"""Guard: don't let an Open Issues UPDATE silently drop items.
Any bullet from the old body whose first 40 lowercase chars do not
appear anywhere in the new content is considered silently dropped.
We append those items back with a ``[silently dropped, restored]``
marker so the caller can see the LLM's intent but no information is
lost.
"""
if not isinstance(op, dict):
return op
op_name = (op.get("op") or "").upper()
if op_name != "UPDATE":
return op
new_content = (op.get("content") or "").strip()
new_lower = new_content.lower()
old_items = Session._wm_extract_bullet_items(old_content or "")
dropped: List[str] = []
for it in old_items:
if "[silently dropped, restored]" in it:
continue
snippet = it[:40].lower().strip("_* `").strip()
if snippet and snippet not in new_lower:
dropped.append(it)
if not dropped:
return op
_wm_debug(
f"guard: Open Issues UPDATE silently dropped {len(dropped)} "
f"items; restoring once (will not restore again if re-dropped)"
)
restored = "\n".join(f"- [silently dropped, restored] {it}" for it in dropped)
merged = (new_content + ("\n" if new_content else "") + restored).strip()
return {"op": "UPDATE", "content": merged}
@staticmethod
def _merge_wm_sections(old_wm: str, ops: Dict[str, Any]) -> str:
"""Merge LLM per-section ops into a new Working Memory document.
``ops`` is the schema-validated dict shaped like::
{"Session Title": {"op": "KEEP"},
"Current State": {"op": "UPDATE", "content": "..."},
"Open Issues": {"op": "APPEND", "items": ["...", "..."]}}
Per-section server-side guards run BEFORE the op is applied:
- ``Errors & Corrections`` is append-only; UPDATE is demoted to
APPEND of only-new items.
- ``Key Facts & Decisions`` uses a fact-preserving dual-threshold
guard: UPDATE is accepted only if the consolidated content has
>= 15% of old bullet count AND >= 70% lexical anchor coverage.
Rejected UPDATEs fall back to APPEND (salvaging new facts)
or KEEP if no new facts can be extracted.
- ``Files & Context`` UPDATE that loses old file paths is rejected
(KEEP + APPEND newly-added paths instead).
- ``Session Title`` UPDATE with zero meaningful-word overlap against
the prior title is rejected (KEEP instead).
- ``Open Issues`` UPDATE that silently drops old items restores them
with an explicit marker.
Missing sections or unknown ops default to ``KEEP`` (the schema
should prevent this, but we stay defensive so a buggy LLM or
schema-loose backend cannot wipe out the prior WM).
"""
_wm_debug(
f"_merge_wm_sections entry old_wm={len(old_wm or '')}B "
f"sections={list((ops or {}).keys())[:7]}"
)
old_sections = Session._parse_wm_sections(old_wm)
parts: List[str] = ["# Working Memory", ""]
for header in WM_SEVEN_SECTIONS:
full_header = f"## {header}"
op = (ops or {}).get(header)
old_content = old_sections.get(full_header, "").rstrip()
# ---------- per-section guards ----------
if old_content:
if header == "Session Title":
op = Session._wm_enforce_title_stability(op, old_content)
elif header == "Key Facts & Decisions":
op = Session._wm_enforce_key_facts_consolidation(op, old_content)
elif header in Session._WM_APPEND_ONLY_SECTIONS:
op = Session._wm_enforce_append_only(header, op, old_content)
elif header == "Files & Context":
op = Session._wm_enforce_files_no_regression(op, old_content)
elif header == "Open Issues":
op = Session._wm_enforce_open_issues_resolved(op, old_content)
# ----------------------------------------
if op is None:
new_content = old_content
else:
op_name = (op.get("op") or "").upper() if isinstance(op, dict) else ""
if op_name == "KEEP":
new_content = old_content
elif op_name == "UPDATE":
new_content = (op.get("content") or "").strip()
elif op_name == "APPEND":
items = op.get("items") or []
bad_items = [s for s in items if not isinstance(s, str)]
if bad_items:
logger.warning(
"wm_v2: dropped %d non-string APPEND item(s) in section %r: %s",
len(bad_items),
header,
[type(s).__name__ for s in bad_items],
)
appended = "\n".join(
f"- {s.strip()}" for s in items if isinstance(s, str) and s.strip()
)
if old_content and appended:
new_content = f"{old_content}\n{appended}"
else:
new_content = old_content or appended
else:
logger.warning(
"WM update: unknown op %r for section %r; keeping old content",
op,
header,
)
new_content = old_content
parts.append(full_header)
if new_content:
parts.append(new_content)
parts.append("")
return "\n".join(parts).rstrip() + "\n"
async def _write_to_agfs_async(
self,
messages: List[Message],
lease_ref: Optional[Any] = None,
) -> None:
"""Write messages.jsonl to AGFS using an optional held PathLock lease."""
if not self._viking_fs:
return
viking_fs = self._viking_fs
turn_count = len([m for m in messages if is_user_query(m)])
abstract = self._generate_abstract()
overview = self._generate_overview(turn_count)
lines = [m.to_jsonl() for m in messages]
content = "\n".join(lines) + "\n" if lines else ""
await viking_fs.write_file(
uri=f"{self._session_uri}/messages.jsonl",
content=content,
ctx=self.ctx,
lease_ref=lease_ref,
)
await viking_fs.write_file(
uri=f"{self._session_uri}/.abstract.md",
content=abstract,
ctx=self.ctx,
lease_ref=lease_ref,
)
await viking_fs.write_file(
uri=f"{self._session_uri}/.overview.md",
content=overview,
ctx=self.ctx,
lease_ref=lease_ref,
)
def _generate_abstract(self) -> str:
"""Generate one-sentence summary for session."""
if not self._messages:
return ""
first = self._messages[0].content
turn_count = self._stats.total_turns
return f"{turn_count} turns, starting from '{first[:50]}...'"
def _generate_overview(self, turn_count: int) -> str:
"""Generate session directory structure description."""
parts = [
"# Session Directory Structure",
"",
"## File Description",
f"- `messages.jsonl` - Current messages ({turn_count} turns)",
]
if self._compression.compression_index > 0:
parts.append(
f"- `history/` - Historical archives ({self._compression.compression_index} total)"
)
parts.extend(
[
"",
"## Access Methods",
f"- Full conversation: `{self._session_uri}`",
]
)
if self._compression.compression_index > 0:
parts.append(f"- Historical archives: `{self._session_uri}/history/`")
return "\n".join(parts)
# ============= Properties =============
@property
def uri(self) -> str:
"""Session's Viking URI."""
return self._session_uri
@property
def summary(self) -> str:
"""Compression summary."""
return self._compression.summary
@property
def compression(self) -> SessionCompression:
"""Get compression information."""
return self._compression
@property
def usage_records(self) -> List[Usage]:
"""Get usage records."""
return self._usage_records
@property
def stats(self) -> SessionStats:
"""Get session statistics."""
return self._stats
def __repr__(self) -> str:
return f"Session(user={self.user}, id={self.session_id})"