diff --git a/docs/en/guides/01-configuration.md b/docs/en/guides/01-configuration.md index 971b4b4d3..c3dbc3cac 100644 --- a/docs/en/guides/01-configuration.md +++ b/docs/en/guides/01-configuration.md @@ -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` | diff --git a/docs/zh/guides/01-configuration.md b/docs/zh/guides/01-configuration.md index 77a7c4523..1bdda1d70 100644 --- a/docs/zh/guides/01-configuration.md +++ b/docs/zh/guides/01-configuration.md @@ -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` | diff --git a/examples/ov.conf.example b/examples/ov.conf.example index 1e9b1365e..c54dbac37 100644 --- a/examples/ov.conf.example +++ b/examples/ov.conf.example @@ -160,6 +160,7 @@ "memory": { "version": "v2", "extraction_enabled": true, + "extraction_prompt_max_chars": 60000, "session_skill_extraction_enabled": false, }, "ingest": { diff --git a/openviking/session/memory/session_extract_context_provider.py b/openviking/session/memory/session_extract_context_provider.py index 0a142f44f..e1c90b90d 100644 --- a/openviking/session/memory/session_extract_context_provider.py +++ b/openviking/session/memory/session_extract_context_provider.py @@ -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*$" ) @@ -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 @@ -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: @@ -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: @@ -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) diff --git a/openviking_cli/utils/config/memory_config.py b/openviking_cli/utils/config/memory_config.py index c1caca226..55f91a808 100644 --- a/openviking_cli/utils/config/memory_config.py +++ b/openviking_cli/utils/config/memory_config.py @@ -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=( diff --git a/tests/session/memory/test_memory_react_system_prompt.py b/tests/session/memory/test_memory_react_system_prompt.py index a37bdc02e..430ae97c4 100644 --- a/tests/session/memory/test_memory_react_system_prompt.py +++ b/tests/session/memory/test_memory_react_system_prompt.py @@ -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 @@ -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( @@ -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): @@ -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=[])