Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/en/guides/01-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1364,6 +1364,7 @@ For memory-related settings, add a `memory` section in `ov.conf`:
| `version` | Deprecated and ignored. OpenViking always uses the v3 memory extraction pipeline; existing configs that set this field still load without error. | `"v3"` |
| `custom_templates_dir` | Custom memory templates directory. If set, templates from this directory are loaded in addition to built-in templates. | `""` |
| `extraction_enabled` | Whether session commit runs long-term memory extraction. | `true` |
| `extraction_prompt_max_chars` | Maximum characters in the conversation-history user message sent to one memory-extraction VLM request. Older messages are omitted first while stable range indices are preserved. System/schema and follow-up tool messages are outside this budget. | `60000` |
| `session_skill_extraction_enabled` | Whether session commit also extracts reusable skills into the current user's skill directory. | `false` |
| `link_enabled` | Whether memory extraction writes and resolves memory links. | `false` |

Expand Down
1 change: 1 addition & 0 deletions docs/zh/guides/01-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1331,6 +1331,7 @@ openviking-server --config /path/to/ov.conf
| `version` | 已废弃且会被忽略。OpenViking 始终使用 v3 记忆抽取链路;已有配置中保留该字段仍可正常加载,不会报错。 | `"v3"` |
| `custom_templates_dir` | 自定义 memory templates 目录。设置后会在内置模板之外加载该目录中的模板。 | `""` |
| `extraction_enabled` | session commit 时是否执行长期记忆抽取。 | `true` |
| `extraction_prompt_max_chars` | 单次记忆抽取 VLM 请求中,conversation-history user message 的最大字符数。超限时优先省略较早消息,并保留原始 range 索引;system/schema 与后续工具消息不计入该预算。 | `60000` |
| `session_skill_extraction_enabled` | session commit 时是否同时抽取可复用 skill 到当前用户的 skill 目录。 | `false` |
| `link_enabled` | 记忆抽取是否写入和解析 memory links。 | `false` |

Expand Down
1 change: 1 addition & 0 deletions examples/ov.conf.example
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@
"memory": {
"version": "v2",
"extraction_enabled": true,
"extraction_prompt_max_chars": 60000,
"session_skill_extraction_enabled": false,
},
"ingest": {
Expand Down
95 changes: 82 additions & 13 deletions openviking/session/memory/session_extract_context_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@
_PREFETCH_SEARCH_TEXT_PART_MAX_CHARS = 1000
_PREFETCH_SEARCH_ASSISTANT_TEXT_PART_MAX_CHARS = 500
_PREFETCH_SEARCH_TOOL_FIELD_MAX_CHARS = 500
_DEFAULT_EXTRACTION_PROMPT_MAX_CHARS = 60000
_EXTRACTION_PROMPT_OMISSION_MARKER = (
"...[conversation content omitted to fit memory.extraction_prompt_max_chars]..."
)
_RESOURCE_REASON_LANGUAGE_RE = re.compile(
r"(?im)^\s*(?:User reason|用户说明|用户原因|用户理由)[::]\s*(.+?)\s*$"
)
Expand Down Expand Up @@ -80,6 +84,11 @@ def __init__(
config = get_openviking_config()
self._eager_prefetch = config.memory.eager_prefetch if config.memory else False
self._prefetch_search_topn = config.memory.prefetch_search_topn if config.memory else 5
self._extraction_prompt_max_chars = (
config.memory.extraction_prompt_max_chars
if config.memory
else _DEFAULT_EXTRACTION_PROMPT_MAX_CHARS
)
self._ctx = ctx
self._viking_fs = viking_fs
self._transaction_handle = transaction_handle
Expand Down Expand Up @@ -267,21 +276,72 @@ def _build_conversation_message(self) -> Dict[str, Any]:
else:
time_display = session_time_str

extract_context = self.get_extract_context()
conversation = self._assemble_conversation(extract_context.messages)

return {
"role": "user",
"content": f"""## Conversation History
prefix = f"""## Conversation History
**Session Time:** {time_display} ({day_of_week})
Relative times (e.g., 'last week', 'next month') are based on Session Time, not today.

{conversation}
"""
suffix = """

After exploring, analyze the conversation and output ALL memory write/edit/delete operations in a single response. Do not output operations one at a time - gather all changes first, then return them together."""
extract_context = self.get_extract_context()
conversation_budget = max(
1,
self._extraction_prompt_max_chars - len(prefix) - len(suffix),
)
conversation = self._assemble_conversation(
extract_context.messages,
max_chars=conversation_budget,
)
content = f"{prefix}{conversation}{suffix}"
return {"role": "user", "content": content}

After exploring, analyze the conversation and output ALL memory write/edit/delete operations in a single response. Do not output operations one at a time - gather all changes first, then return them together.""",
}
@staticmethod
def _truncate_formatted_message(message: str, max_chars: int) -> str:
if len(message) <= max_chars:
return message
marker = f"\n{_EXTRACTION_PROMPT_OMISSION_MARKER}\n"
if max_chars <= len(marker):
return message[:max_chars]
retained_chars = max_chars - len(marker)
head_chars = max(retained_chars // 2, min(len(message), message.find(": ") + 2))
head_chars = min(head_chars, retained_chars)
tail_chars = retained_chars - head_chars
tail = message[-tail_chars:] if tail_chars else ""
return f"{message[:head_chars]}{marker}{tail}"

@classmethod
def _bound_formatted_messages(cls, messages: List[str], max_chars: int) -> str:
conversation = "\n".join(messages)
if len(conversation) <= max_chars:
return conversation
if not messages:
return ""

def _assemble_conversation(self, messages: Any) -> str:
# A single oversized latest message still keeps its stable range header and
# both ends of the content. For multi-message histories, prefer complete
# recent messages so any emitted range index still maps to the original
# ExtractContext entry.
if len(messages[-1]) > max_chars:
return cls._truncate_formatted_message(messages[-1], max_chars)

marker = _EXTRACTION_PROMPT_OMISSION_MARKER
available = max_chars - len(marker) - 1
selected: List[str] = []
selected_chars = 0
for message in reversed(messages):
separator_chars = 1 if selected else 0
if selected_chars + separator_chars + len(message) > available:
break
selected.insert(0, message)
selected_chars += separator_chars + len(message)

if not selected:
return cls._truncate_formatted_message(messages[-1], max_chars)
selected_text = "\n".join(selected)
return f"{marker}\n{selected_text}"

def _assemble_conversation(self, messages: Any, *, max_chars: Optional[int] = None) -> str:
"""Assemble conversation string from messages.

Args:
Expand All @@ -303,8 +363,6 @@ def format_message_with_parts(msg: Message) -> str:
user utterances and can leak environment/database state into user
memories. Agent-scope providers enable tool evidence explicitly.
"""
from openviking.message.part import ToolPart

parts = getattr(msg, "parts", [])
formatted_parts: List[str] = []
for part in parts:
Expand Down Expand Up @@ -338,7 +396,18 @@ def format_message_header(msg: Message, idx: int) -> str | None:
for idx, msg in enumerate(messages)
if (formatted := format_message_header(msg, idx)) is not None
]
conversation_sections.append("\n".join(formatted_messages))
if max_chars is not None:
bounded = self._bound_formatted_messages(formatted_messages, max_chars)
if len("\n".join(formatted_messages)) > len(bounded):
logger.warning(
"Memory extraction conversation exceeded the configured prompt budget; "
"keeping bounded recent context (%d -> %d chars)",
len("\n".join(formatted_messages)),
len(bounded),
)
conversation_sections.append(bounded)
else:
conversation_sections.append("\n".join(formatted_messages))

return "\n\n".join(section for section in conversation_sections if section)

Expand Down
10 changes: 10 additions & 0 deletions openviking_cli/utils/config/memory_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@ class MemoryConfig(BaseModel):
"0 means unlimited retries."
),
)
extraction_prompt_max_chars: int = Field(
default=60000,
ge=1000,
description=(
"Maximum characters in the conversation-history user message sent for one "
"memory extraction request. System/schema and tool follow-up messages are not "
"included in this limit. Older messages are omitted first while original range "
"indices are preserved."
),
)
experimental_memory_switch: bool = Field(
default=False,
description=(
Expand Down
41 changes: 38 additions & 3 deletions tests/session/memory/test_memory_react_system_prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@
"""

from openviking.message import ImagePart, Message, TextPart, ToolPart
from openviking.session.memory.session_extract_context_provider import SessionExtractContextProvider
from openviking.session.memory.session_extract_context_provider import (
_EXTRACTION_PROMPT_OMISSION_MARKER,
SessionExtractContextProvider,
)
from openviking.session.memory.vision_message_normalizer import IMAGE_DESCRIPTION_PROMPT


Expand Down Expand Up @@ -87,6 +90,39 @@ def test_instruction_includes_resource_uri_handling_for_user_scoped_resource_uri


class TestSessionConversationToolFiltering:
def test_extraction_prompt_omits_older_messages_within_configured_budget(self):
messages = [
Message(id="m0", role="user", parts=[TextPart("old-" + "a" * 700)]),
Message(id="m1", role="assistant", parts=[TextPart("middle-" + "b" * 700)]),
Message(id="m2", role="user", parts=[TextPart("latest-" + "c" * 700)]),
]
provider = SessionExtractContextProvider(messages=messages)
provider._extraction_prompt_max_chars = 1200

prompt = provider._build_conversation_message()["content"]

assert len(prompt) <= 1200
assert _EXTRACTION_PROMPT_OMISSION_MARKER in prompt
assert "[2][user][user]: latest-" in prompt
assert "[0][user][user]: old-" not in prompt
assert "[1][assistant][assistant]: middle-" not in prompt

def test_extraction_prompt_truncates_one_oversized_message_and_keeps_range_header(self):
message = Message(
id="m0",
role="user",
parts=[TextPart("begin-" + "x" * 4000 + "-end")],
)
provider = SessionExtractContextProvider(messages=[message])
provider._extraction_prompt_max_chars = 1200

prompt = provider._build_conversation_message()["content"]

assert len(prompt) <= 1200
assert "[0][user][user]: begin-" in prompt
assert "-end" in prompt
assert _EXTRACTION_PROMPT_OMISSION_MARKER in prompt

def test_session_conversation_omits_skill_tool_call(self):
messages = [
Message(
Expand Down Expand Up @@ -232,8 +268,6 @@ def test_detect_language_prefers_user_text_over_assistant_text(self):

assert provider._detect_language() == "zh-CN"



async def test_prepare_extraction_messages_replaces_image_part_with_vlm_description(self):
class FakeVisionVLM:
def __init__(self):
Expand Down Expand Up @@ -341,6 +375,7 @@ async def test_prepare_extraction_messages_does_not_replace_caller_message_list(
assert any(isinstance(part, ImagePart) for part in messages[0].parts)
assert provider.messages is not messages


def test_session_provider_empty_messages_still_uses_environment_fallback(monkeypatch):
monkeypatch.setenv("TZ", "Asia/Shanghai")
provider = SessionExtractContextProvider(messages=[])
Expand Down