From b131bb6d0dc13b57179a9c379fa8d1df48034700 Mon Sep 17 00:00:00 2001 From: chenjunwen Date: Wed, 8 Apr 2026 21:13:55 +0800 Subject: [PATCH 1/5] fix(memory): handle string response from VLM when tools disabled - Fix AttributeError when VLM returns string instead of VLMResponse - Fix tuple creation bug with trailing comma causing double-nested tools array Co-Authored-By: Claude Opus 4.6 --- openviking/session/memory/extract_loop.py | 74 +++++++++++++++++++---- 1 file changed, 61 insertions(+), 13 deletions(-) diff --git a/openviking/session/memory/extract_loop.py b/openviking/session/memory/extract_loop.py index 9b35f47eb..4554ef71d 100644 --- a/openviking/session/memory/extract_loop.py +++ b/openviking/session/memory/extract_loop.py @@ -86,6 +86,8 @@ def __init__( self._read_files: Set[str] = set() # Transaction handle for file locking self._transaction_handle = None + # Flag to disable tools in next iteration after unknown tool error + self._disable_tools_for_iteration = False async def run(self) -> Tuple[Optional[Any], List[Dict[str, Any]]]: """ @@ -151,7 +153,7 @@ async def run(self) -> Tuple[Optional[Any], List[Dict[str, Any]]]: "role": "system", "content": f""" ## Output Format -See the complete JSON Schema below: +The final output of the model must strictly follow the JSON Schema format shown below: ```json {schema_str} ``` @@ -169,6 +171,19 @@ async def run(self) -> Tuple[Optional[Any], List[Dict[str, Any]]]: ) messages.extend(tool_call_messages) + # Track prefetched files in _read_files to avoid unnecessary refetch + for msg in tool_call_messages: + if msg.get("role") == "user" and "tool_call_name" in msg.get("content", ""): + import json + try: + content = json.loads(msg.get("content", "{}")) + if content.get("tool_call_name") == "read": + uri = content.get("args", {}).get("uri") + if uri: + self._read_files.add(uri) + except (json.JSONDecodeError, AttributeError): + pass + while iteration < max_iterations: iteration += 1 tracer.info(f"ReAct iteration {iteration}/{max_iterations}") @@ -187,10 +202,17 @@ async def run(self) -> Tuple[Optional[Any], List[Dict[str, Any]]]: # Call LLM with tools - model decides: tool calls OR final operations pretty_print_messages(messages) - tool_calls, operations = await self._call_llm(messages, force_final=is_last_iteration) + + tool_calls, operations = await self._call_llm( + messages + ) if tool_calls: - await self._execute_tool_calls(messages, tool_calls, tools_used) + has_unknown_tool = await self._execute_tool_calls(messages, tool_calls, tools_used) + # If model called an unknown tool, disable tools in next iteration + if has_unknown_tool: + self._disable_tools_for_iteration = True + tracer.info("Unknown tool called, will disable tools in next iteration") # Allow one extra iteration for refetch if iteration >= max_iterations: max_iterations += 1 @@ -236,7 +258,14 @@ async def run(self) -> Tuple[Optional[Any], List[Dict[str, Any]]]: return final_operations, tools_used @tracer("extract_loop.execute_tool_calls") - async def _execute_tool_calls(self, messages, tool_calls, tools_used): + async def _execute_tool_calls(self, messages, tool_calls, tools_used) -> bool: + """ + Execute tool calls in parallel. + + Returns: + True if any tool call returned "Unknown tool" error, indicating + the model should not receive tools in the next iteration. + """ # Execute all tool calls in parallel async def execute_single_tool_call(idx: int, tool_call): """Execute a single tool call.""" @@ -248,8 +277,13 @@ async def execute_single_tool_call(idx: int, tool_call): ] results = await self._execute_in_parallel(action_tasks) + has_unknown_tool = False + # Process results and add to messages for _idx, tool_call, result in results: + # Check for unknown tool error + if isinstance(result, dict) and result.get("error", "").startswith("Unknown tool:"): + has_unknown_tool = True # Skip if arguments is None if tool_call.arguments is None: logger.warning(f"Tool call {tool_call.name} has no arguments, skipping") @@ -265,7 +299,8 @@ async def execute_single_tool_call(idx: int, tool_call): # Track read tool calls for refetch detection if tool_call.name == "read" and tool_call.arguments.get("uri"): - self._read_files.add(tool_call.arguments["uri"]) + uri = tool_call.arguments["uri"] + self._read_files.add(uri) add_tool_call_pair_to_messages( messages, @@ -275,6 +310,8 @@ async def execute_single_tool_call(idx: int, tool_call): result=result, ) + return has_unknown_tool + def _validate_operations(self, operations: Any) -> None: """ Validate that all operations have allowed URIs. @@ -308,8 +345,7 @@ def _validate_operations(self, operations: Any) -> None: async def _call_llm( self, - messages: List[Dict[str, Any]], - force_final: bool = False, + messages: List[Dict[str, Any]] ) -> Tuple[Optional[List], Optional[Any]]: """ Call LLM with tools. Returns either tool calls OR final operations. @@ -325,13 +361,17 @@ async def _call_llm( await self._mark_cache_breakpoint(messages) # Call LLM with tools - use tools from strategy - tool_choice = "none" if force_final else None - + tools = None + tool_choice = None + if not self._disable_tools_for_iteration: + tools = self._tool_schemas + tool_choice = "auto" response = await self.vlm.get_completion_async( messages=messages, - tools=self._tool_schemas, + tools=tools, tool_choice=tool_choice, ) + tracer.info(f"response={response}") # print(f'response={response}') # Log cache hit info if hasattr(response, "usage") and response.usage: @@ -352,16 +392,24 @@ async def _call_llm( f"[KVCache] prompt_tokens={prompt_tokens}, cached_tokens={cached_tokens}" ) + # Case 0: Handle string response (when tools are not provided) or None + if response is None: + content = "" + elif isinstance(response, str): + # When tools=None, VLM returns string instead of VLMResponse + content = response # Case 1: LLM returned tool calls - if response.has_tool_calls: + elif response.has_tool_calls: # Format tool calls nicely for debug logging for tc in response.tool_calls: tracer.info(f"[assistant tool_call] (id={tc.id}, name={tc.name})") tracer.info(f" {json.dumps(tc.arguments, indent=2, ensure_ascii=False)}") return (response.tool_calls, None) + else: + # Case 2: VLMResponse without tool calls - get content from response + content = response.content or "" - # Case 2: Try to parse operations from content with stability - content = response.content or "" + # Parse operations from content if content: try: # print(f'LLM response content: {content}') From d38f5fab1b6a11f3aedab14a2e82837ed81e8793 Mon Sep 17 00:00:00 2001 From: chenjunwen Date: Thu, 9 Apr 2026 16:05:53 +0800 Subject: [PATCH 2/5] update --- openviking/client/local.py | 13 ++----- openviking/message/message.py | 13 +++---- openviking/models/vlm/backends/litellm_vlm.py | 4 +- openviking/models/vlm/backends/openai_vlm.py | 2 +- .../models/vlm/backends/volcengine_vlm.py | 2 +- openviking/server/routers/sessions.py | 11 +----- openviking/session/compressor_v2.py | 5 ++- openviking/session/memory/extract_loop.py | 3 +- openviking/session/memory/memory_updater.py | 12 ++++-- .../session_extract_context_provider.py | 6 ++- openviking/session/memory/tools.py | 2 +- .../session/memory/utils/json_parser.py | 39 ++++++++++++++++++- openviking/session/session.py | 10 ++--- openviking/telemetry/tracer.py | 6 ++- 14 files changed, 83 insertions(+), 45 deletions(-) diff --git a/openviking/client/local.py b/openviking/client/local.py index a994843a6..874e78054 100644 --- a/openviking/client/local.py +++ b/openviking/client/local.py @@ -443,7 +443,7 @@ async def add_message( If both content and parts are provided, parts takes precedence. """ - from datetime import datetime + from datetime import datetime, timezone from openviking.message.part import Part, TextPart, part_from_dict @@ -458,15 +458,8 @@ async def add_message( else: raise ValueError("Either content or parts must be provided") - # 解析 created_at - msg_created_at = None - if created_at: - try: - msg_created_at = datetime.fromisoformat(created_at) - except ValueError: - pass - - session.add_message(role, message_parts, created_at=msg_created_at) + # created_at 直接传递给 session (毫秒时间戳) + session.add_message(role, message_parts, created_at=created_at) return { "session_id": session_id, "message_count": len(session.messages), diff --git a/openviking/message/message.py b/openviking/message/message.py index 3280dd06d..51ba13030 100644 --- a/openviking/message/message.py +++ b/openviking/message/message.py @@ -21,7 +21,7 @@ class Message: id: str role: Literal["user", "assistant"] parts: List[Part] - created_at: datetime = None + created_at: str = None @property def content(self) -> str: @@ -64,13 +64,12 @@ def estimated_tokens(self) -> int: def to_dict(self) -> dict: """Serialize to JSONL.""" - created_at_val = self.created_at or datetime.now(timezone.utc) - created_at_str = format_iso8601(created_at_val) + created_at_val = self.created_at or datetime.now(timezone.utc).isoformat() return { "id": self.id, "role": self.role, "parts": [self._part_to_dict(p) for p in self.parts], - "created_at": created_at_str, + "created_at": created_at_val, } def _part_to_dict(self, part: Part) -> dict: @@ -139,7 +138,7 @@ def from_dict(cls, data: dict) -> "Message": id=data["id"], role=data["role"], parts=parts, - created_at=parse_iso_datetime(data["created_at"]), + created_at=data["created_at"], ) @classmethod @@ -151,7 +150,7 @@ def create_user(cls, content: str, msg_id: str = None) -> "Message": id=msg_id or f"msg_{uuid4().hex}", role="user", parts=[TextPart(text=content)], - created_at=datetime.now(timezone.utc), + created_at=datetime.now(timezone.utc).isoformat(), ) @classmethod @@ -194,7 +193,7 @@ def create_assistant( id=msg_id or f"msg_{uuid4().hex}", role="assistant", parts=parts, - created_at=datetime.now(timezone.utc), + created_at=datetime.now(timezone.utc).isoformat() ) def get_context_parts(self) -> List[ContextPart]: diff --git a/openviking/models/vlm/backends/litellm_vlm.py b/openviking/models/vlm/backends/litellm_vlm.py index 620085709..e6cca8dc9 100644 --- a/openviking/models/vlm/backends/litellm_vlm.py +++ b/openviking/models/vlm/backends/litellm_vlm.py @@ -322,6 +322,7 @@ def _call() -> Union[str, VLMResponse]: response = completion(**kwargs) elapsed = time.perf_counter() - t0 self._update_token_usage_from_response(response, duration_seconds=elapsed) + tracer.info(f'response={response}') if tools: return self._build_vlm_response(response, has_tools=True) return self._clean_response(self._extract_content_from_response(response)) @@ -333,7 +334,7 @@ def _call() -> Union[str, VLMResponse]: operation_name="LiteLLM VLM completion", ) - @tracer("vlm.call", ignore_result=False, ignore_args=["messages"]) + @tracer("litellm.vlm.call", ignore_result=True, ignore_args=["messages"]) async def get_completion_async( self, prompt: str = "", @@ -352,6 +353,7 @@ async def _call() -> Union[str, VLMResponse]: response = await acompletion(**kwargs) elapsed = time.perf_counter() - t0 self._update_token_usage_from_response(response, duration_seconds=elapsed) + tracer.info(f'response={response}') if tools: return self._build_vlm_response(response, has_tools=True) return self._clean_response(self._extract_content_from_response(response)) diff --git a/openviking/models/vlm/backends/openai_vlm.py b/openviking/models/vlm/backends/openai_vlm.py index 966686a5a..8668ecd23 100644 --- a/openviking/models/vlm/backends/openai_vlm.py +++ b/openviking/models/vlm/backends/openai_vlm.py @@ -350,7 +350,7 @@ def _call() -> Union[str, VLMResponse]: operation_name="OpenAI VLM completion", ) - @tracer("vlm.call", ignore_result=True, ignore_args=["messages"]) + @tracer("openai.vlm.call", ignore_result=True, ignore_args=["messages"]) async def get_completion_async( self, prompt: str = "", diff --git a/openviking/models/vlm/backends/volcengine_vlm.py b/openviking/models/vlm/backends/volcengine_vlm.py index f5f5370b2..e0b78470e 100644 --- a/openviking/models/vlm/backends/volcengine_vlm.py +++ b/openviking/models/vlm/backends/volcengine_vlm.py @@ -130,7 +130,7 @@ def get_completion( return result return self._clean_response(str(result)) - @tracer("vlm.call") + @tracer("volcengine.vlm.call", ignore_result=True, ignore_args=False) async def get_completion_async( self, prompt: str = "", diff --git a/openviking/server/routers/sessions.py b/openviking/server/routers/sessions.py index 94684a08c..69a4cb717 100644 --- a/openviking/server/routers/sessions.py +++ b/openviking/server/routers/sessions.py @@ -259,15 +259,8 @@ async def add_message( else: parts = [TextPart(text=request.content or "")] - # 解析 created_at - created_at = None - if request.created_at: - try: - created_at = datetime.fromisoformat(request.created_at) - except ValueError: - logger.warning(f"Invalid created_at format: {request.created_at}") - - session.add_message(request.role, parts, created_at=created_at) + # created_at 直接传递给 session (ISO string) + session.add_message(request.role, parts, created_at=request.created_at) return Response( status="ok", result={ diff --git a/openviking/session/compressor_v2.py b/openviking/session/compressor_v2.py index 7572cbac0..d9276a78e 100644 --- a/openviking/session/compressor_v2.py +++ b/openviking/session/compressor_v2.py @@ -13,6 +13,7 @@ from openviking.message import Message from openviking.server.identity import RequestContext from openviking.session.memory import ExtractLoop, MemoryUpdater +from openviking.session.memory.utils.json_parser import JsonUtils from openviking.storage import VikingDBManager from openviking.storage.viking_fs import get_viking_fs from openviking.telemetry import get_current_telemetry @@ -78,6 +79,7 @@ def _get_or_create_updater(self, registry, transaction_handle=None) -> MemoryUpd registry=registry, vikingdb=self.vikingdb, transaction_handle=transaction_handle ) + @tracer() async def extract_long_term_memories( self, messages: List[Message], @@ -92,6 +94,7 @@ async def extract_long_term_memories( Note: Returns empty List[Context] because v2 directly writes to storage. The list length is used for stats in session.py. """ + if not messages: return [] @@ -100,7 +103,7 @@ async def extract_long_term_memories( return [] tracer.info("Starting v2 memory extraction from conversation") - + tracer.info(f"messages={JsonUtils.dumps(messages)}") # Initialize default memory files (soul.md, identity.md) if not exist from openviking.session.memory.memory_type_registry import create_default_registry diff --git a/openviking/session/memory/extract_loop.py b/openviking/session/memory/extract_loop.py index 4554ef71d..449711bd0 100644 --- a/openviking/session/memory/extract_loop.py +++ b/openviking/session/memory/extract_loop.py @@ -244,7 +244,8 @@ async def run(self) -> Tuple[Optional[Any], List[Dict[str, Any]]]: if is_last_iteration: final_operations = self._operations_model() break - # Otherwise continue and try again + # Otherwise disable_tools and try again + self._disable_tools_for_iteration = True continue if final_operations is None: diff --git a/openviking/session/memory/memory_updater.py b/openviking/session/memory/memory_updater.py index ad95850b9..51776712e 100644 --- a/openviking/session/memory/memory_updater.py +++ b/openviking/session/memory/memory_updater.py @@ -140,15 +140,20 @@ def pretty_print(self) -> str: def _first_message_time(self) -> str | None: """获取第一条消息的时间(内部方法)""" + from datetime import datetime + for elem in self.elements: if isinstance(elem, str): continue if hasattr(elem, "created_at") and elem.created_at: - return elem.created_at.strftime("%Y-%m-%d") + dt = datetime.fromisoformat(elem.created_at) + return dt.strftime("%Y-%m-%d") return None def _first_message_time_with_weekday(self) -> str | None: """获取第一条消息的时间,带周几(内部方法)""" + from datetime import datetime + for elem in self.elements: if isinstance(elem, str): continue @@ -163,8 +168,9 @@ def _first_message_time_with_weekday(self) -> str | None: "Saturday", "Sunday", ] - weekday = weekday_en[elem.created_at.weekday()] - return f"{elem.created_at.strftime('%Y-%m-%d')} ({weekday})" + dt = datetime.fromisoformat(elem.created_at) + weekday = weekday_en[dt.weekday()] + return f"{dt.strftime('%Y-%m-%d')} ({weekday})" return None diff --git a/openviking/session/memory/session_extract_context_provider.py b/openviking/session/memory/session_extract_context_provider.py index 87352500a..90be44c83 100644 --- a/openviking/session/memory/session_extract_context_provider.py +++ b/openviking/session/memory/session_extract_context_provider.py @@ -18,6 +18,7 @@ get_tool, ) from openviking.storage.viking_fs import VikingFS +from openviking.telemetry import tracer from openviking_cli.utils import get_logger from openviking_cli.utils.config import get_openviking_config @@ -104,7 +105,7 @@ def _build_conversation_message(self) -> Dict[str, Any]: last_msg_time = None if first_msg_time: - session_time = first_msg_time + session_time = datetime.fromisoformat(first_msg_time) else: session_time = datetime.now() @@ -113,7 +114,8 @@ def _build_conversation_message(self) -> Dict[str, Any]: # 检查是否需要显示范围 if last_msg_time and last_msg_time != first_msg_time: - time_display = f"{session_time_str} - {last_msg_time.strftime('%Y-%m-%d %H:%M')}" + last_time = datetime.fromisoformat(last_msg_time) + time_display = f"{session_time_str} - {last_time.strftime('%Y-%m-%d %H:%M')}" else: time_display = session_time_str diff --git a/openviking/session/memory/tools.py b/openviking/session/memory/tools.py index 5898dc37c..daf3aa873 100644 --- a/openviking/session/memory/tools.py +++ b/openviking/session/memory/tools.py @@ -163,7 +163,7 @@ def name(self) -> str: @property def description(self) -> str: - return "Read single file, offset is start line number (0-indexed), limit is number of lines to read, -1 means read to end" + return "Read single file" @property def parameters(self) -> Dict[str, Any]: diff --git a/openviking/session/memory/utils/json_parser.py b/openviking/session/memory/utils/json_parser.py index 71de8b222..6a26522a5 100644 --- a/openviking/session/memory/utils/json_parser.py +++ b/openviking/session/memory/utils/json_parser.py @@ -11,6 +11,7 @@ """ import json +from dataclasses import is_dataclass, asdict from types import UnionType from typing import ( Any, @@ -25,7 +26,8 @@ ) import json_repair -from pydantic import TypeAdapter +from pydantic import TypeAdapter, BaseModel, parse_obj_as + from openviking_cli.utils import get_logger @@ -42,9 +44,42 @@ "_get_origin_type", "_get_arg_type", "_any_to_str", + "JsonUtils", ] + + +class PydanticEncoder(json.JSONEncoder): + def default(self, obj): + if isinstance(obj, BaseModel) : + # 保存类名和属性值 + return { + **obj.model_dump(mode='python') + } + elif is_dataclass(obj): + return asdict(obj) + return super().default(obj) + + + +class JsonUtils: + + @staticmethod + def dumps(obj, indent=4, ensure_ascii=False): + if obj is None: + return None + return json.dumps(obj, ensure_ascii=ensure_ascii, indent=indent, cls=PydanticEncoder) + + @staticmethod + def loads(json_str, clazz=None): + if not json_str: + return None + if clazz: + return TypeAdapter.validate_python(clazz, json_repair.loads(json_str)) + return json_repair.loads(json_str) + + def extract_json_content(s: str) -> str: """ Layer 1: Extract JSON content from LLM response, removing both leading and trailing non-JSON content. @@ -440,3 +475,5 @@ def parse_json_with_stability( return model_class.model_validate(tolerant_data), None except Exception as e2: return None, f"Model validation failed even after tolerance: {e} (fallback: {e2})" + + diff --git a/openviking/session/session.py b/openviking/session/session.py index 9e842ea0a..4672064b3 100644 --- a/openviking/session/session.py +++ b/openviking/session/session.py @@ -9,7 +9,7 @@ import json import re from dataclasses import dataclass, field -from datetime import datetime +from datetime import datetime, timezone from typing import TYPE_CHECKING, Any, Dict, List, Optional from uuid import uuid4 @@ -170,7 +170,7 @@ def __init__( 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 str(uuid4()) - self.created_at = datetime.now() + self.created_at = int(datetime.now(timezone.utc).timestamp() * 1000) self._auto_commit_threshold = auto_commit_threshold self._session_uri = f"viking://session/{self.user.user_space_name()}/{self.session_id}" @@ -302,14 +302,14 @@ def add_message( self, role: str, parts: List[Part], - created_at: datetime = None, + created_at: str = None, ) -> Message: """Add a message.""" msg = Message( id=f"msg_{uuid4().hex}", role=role, parts=parts, - created_at=created_at or datetime.now(), + created_at=created_at or datetime.now(timezone.utc).isoformat(), ) self._messages.append(msg) @@ -475,7 +475,7 @@ async def commit_async(self) -> Dict[str, Any]: "trace_id": trace_id, } - @tracer("session_commit_phase2") + async def _run_memory_extraction( self, task_id: str, diff --git a/openviking/telemetry/tracer.py b/openviking/telemetry/tracer.py index 401a10d20..a189fa50d 100644 --- a/openviking/telemetry/tracer.py +++ b/openviking/telemetry/tracer.py @@ -472,8 +472,10 @@ def info(line: str, console: bool = False) -> None: if hasattr(current_span, "end_time") and current_span.end_time: return # span 已结束,不添加 event current_span.add_event(line) - except Exception: - pass + except Exception as e: + + import traceback + traceback.print_stack() @staticmethod def info_span(line: str, console: bool = False) -> None: From 024648d97e59e5644a9deedb5075047d202bbe8a Mon Sep 17 00:00:00 2001 From: chenjunwen Date: Thu, 9 Apr 2026 20:30:07 +0800 Subject: [PATCH 3/5] chore: replace LGBTQ example with book club in entities.yaml Co-Authored-By: Claude Opus 4.6 --- openviking/prompts/templates/memory/entities.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/openviking/prompts/templates/memory/entities.yaml b/openviking/prompts/templates/memory/entities.yaml index 92f75f1e5..cc5499f78 100644 --- a/openviking/prompts/templates/memory/entities.yaml +++ b/openviking/prompts/templates/memory/entities.yaml @@ -2,7 +2,7 @@ memory_type: entities description: | Wikipedia article - manages page using Zettelkasten method. Each page represents an article with relative path links to events. - Cards should be rich and distributed - avoid putting all info in one card. + Entity should be rich and distributed - avoid putting all info in one entity. directory: "viking://user/{{ user_space }}/memories/entities" filename_template: "{{ category }}/{{ name }}.md" enabled: true @@ -25,10 +25,10 @@ fields: description: | - Detailed Zettelkasten card content in markdown format Relative path format: events://{event_name} or ../events/{year}/{month}/{day}/{event_name}.md - - Example: - # LGBTQ+ events Caroline participated in: - - [Pride parade](../events/2023/03/05/Pride parade.md) - - [Caroline's school LGBTQ talk](events://Caroline's school LGBTQ talk) - - [Support group](../events/2024/03/10/Support group.md) + - Example: + # Book club events Caroline participated in: + - [Monthly book discussion](../events/2023/03/05/Monthly book discussion.md) + - [Author meetup](events://Author meetup) + - [Summer reading challenge](../events/2024/03/10/Summer reading challenge.md) merge_op: patch From a43675b9cc9073d65acea2dc8b33a2730ae761b5 Mon Sep 17 00:00:00 2001 From: chenjunwen Date: Thu, 9 Apr 2026 20:49:31 +0800 Subject: [PATCH 4/5] fix(memory): disable tools on extended iteration to prevent infinite loop When max_iterations is extended due to tool calls, disable tools for the additional iteration to ensure the extract loop terminates. Co-Authored-By: Claude Opus 4.6 --- openviking/session/memory/extract_loop.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openviking/session/memory/extract_loop.py b/openviking/session/memory/extract_loop.py index 449711bd0..296c1b9a5 100644 --- a/openviking/session/memory/extract_loop.py +++ b/openviking/session/memory/extract_loop.py @@ -216,6 +216,7 @@ async def run(self) -> Tuple[Optional[Any], List[Dict[str, Any]]]: # Allow one extra iteration for refetch if iteration >= max_iterations: max_iterations += 1 + self._disable_tools_for_iteration = True tracer.info(f"Extended max_iterations to {max_iterations} for tool call") continue From f84c3f16b95f4ce54c13c0c69b9a45d2d68ad0bb Mon Sep 17 00:00:00 2001 From: chenjunwen Date: Thu, 9 Apr 2026 21:15:15 +0800 Subject: [PATCH 5/5] fix(memory): handle out-of-bounds range from LLM extraction Clamp range values to valid message indices instead of skipping, to handle cases where LLM extracts incorrect ranges. Co-Authored-By: Claude Opus 4.6 --- openviking/session/memory/memory_updater.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/openviking/session/memory/memory_updater.py b/openviking/session/memory/memory_updater.py index 51776712e..15b1f4b2b 100644 --- a/openviking/session/memory/memory_updater.py +++ b/openviking/session/memory/memory_updater.py @@ -108,7 +108,12 @@ def read_message_ranges(self, ranges_str: str) -> "MessageRange": # elements 可以是 Message 或 str ("...") elements: List[Message | str] = [] for i, (start, end) in enumerate(ranges): - if start < 0 or end >= len(self.messages): + # 兼容 LLM 提取的 range 越界情况 + if start < 0: + start = 0 + if end >= len(self.messages): + end = len(self.messages) - 1 + if start > end: continue range_msgs = self.messages[start : end + 1]