Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
40 changes: 32 additions & 8 deletions openviking/models/vlm/backends/litellm_vlm.py
Original file line number Diff line number Diff line change
Expand Up @@ -471,8 +471,14 @@ def _call() -> Union[str, VLMResponse]:
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)
if tools is not None:
result = self._build_vlm_response(response, has_tools=True)
if tools:
return result
# tools=[] (tools disabled): keep the VLMResponse so finish_reason
# and usage survive, but still strip inline thinking from content.
result.content = self._clean_response(result.content or "")
return result
return self._clean_response(self._extract_content_from_response(response))

return _call()
Expand Down Expand Up @@ -502,8 +508,14 @@ async def _call() -> Union[str, VLMResponse]:
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)
if tools is not None:
result = self._build_vlm_response(response, has_tools=True)
if tools:
return result
# tools=[] (tools disabled): keep the VLMResponse so finish_reason
# and usage survive, but still strip inline thinking from content.
result.content = self._clean_response(result.content or "")
return result
return self._clean_response(self._extract_content_from_response(response))

return await _call()
Expand All @@ -525,8 +537,14 @@ def _call() -> Union[str, VLMResponse]:
response = completion(**kwargs)
elapsed = time.perf_counter() - t0
self._update_token_usage_from_response(response, duration_seconds=elapsed)
if tools:
return self._build_vlm_response(response, has_tools=True)
if tools is not None:
result = self._build_vlm_response(response, has_tools=True)
if tools:
return result
# tools=[] (tools disabled): keep the VLMResponse so finish_reason
# and usage survive, but still strip inline thinking from content.
result.content = self._clean_response(result.content or "")
return result
return self._clean_response(self._extract_content_from_response(response))

return _call()
Expand All @@ -548,8 +566,14 @@ 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)
if tools:
return self._build_vlm_response(response, has_tools=True)
if tools is not None:
result = self._build_vlm_response(response, has_tools=True)
if tools:
return result
# tools=[] (tools disabled): keep the VLMResponse so finish_reason
# and usage survive, but still strip inline thinking from content.
result.content = self._clean_response(result.content or "")
return result
return self._clean_response(self._extract_content_from_response(response))

return await _call()
Expand Down
5 changes: 4 additions & 1 deletion openviking/models/vlm/backends/openai_vlm.py
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,10 @@ async def _call() -> Union[str, VLMResponse]:
elapsed = time.perf_counter() - t0
if tools is not None:
self._update_token_usage_from_response(response, duration_seconds=elapsed)
return self._build_vlm_response(response, has_tools=True)
result = self._build_vlm_response(response, has_tools=True)
if not tools:
result.content = self._clean_response(result.content or "")
return result
return await self._extract_completion_content_async(response, elapsed)

# 用 tracer.info 打印请求(人类可读格式)
Expand Down
28 changes: 24 additions & 4 deletions openviking/models/vlm/backends/volcengine_vlm.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,9 +227,14 @@ def get_completion(
raise
elapsed = time.perf_counter() - t0
self._update_token_usage_from_response(response, duration_seconds=elapsed)
result = self._build_vlm_response(response, has_tools=bool(tools))
result = self._build_vlm_response(response, has_tools=tools is not None)
if tools:
return result
if isinstance(result, VLMResponse):
# tools=[] (tools disabled): keep the VLMResponse so finish_reason and
# usage survive, but still strip inline thinking from the content.
result.content = self._clean_response(result.content or "")
return result
return self._clean_response(str(result))

async def get_completion_async(
Expand Down Expand Up @@ -285,9 +290,14 @@ async def _call() -> Union[str, VLMResponse]:
raise
elapsed = time.perf_counter() - t0
self._update_token_usage_from_response(response, duration_seconds=elapsed)
result = self._build_vlm_response(response, has_tools=bool(tools))
result = self._build_vlm_response(response, has_tools=tools is not None)
if tools:
return result
if isinstance(result, VLMResponse):
# tools=[] (tools disabled): keep the VLMResponse so finish_reason
# and usage survive, but still strip inline thinking from content.
result.content = self._clean_response(result.content or "")
return result
content = self._clean_response(str(result))
if content:
tracer.info(f"message.content={content}")
Expand Down Expand Up @@ -454,9 +464,14 @@ def get_vision_completion(
raise
elapsed = time.perf_counter() - t0
self._update_token_usage_from_response(response, duration_seconds=elapsed)
result = self._build_vlm_response(response, has_tools=bool(tools))
result = self._build_vlm_response(response, has_tools=tools is not None)
if tools:
return result
if isinstance(result, VLMResponse):
# tools=[] (tools disabled): keep the VLMResponse so finish_reason and
# usage survive, but still strip inline thinking from the content.
result.content = self._clean_response(result.content or "")
return result
return self._clean_response(str(result))

async def get_vision_completion_async(
Expand Down Expand Up @@ -504,7 +519,12 @@ async def get_vision_completion_async(
raise
elapsed = time.perf_counter() - t0
self._update_token_usage_from_response(response, duration_seconds=elapsed)
result = self._build_vlm_response(response, has_tools=bool(tools))
result = self._build_vlm_response(response, has_tools=tools is not None)
if tools:
return result
if isinstance(result, VLMResponse):
# tools=[] (tools disabled): keep the VLMResponse so finish_reason and
# usage survive, but still strip inline thinking from the content.
result.content = self._clean_response(result.content or "")
return result
return self._clean_response(str(result))
62 changes: 56 additions & 6 deletions openviking/session/memory/extract_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ def __init__(
ctx: Optional[RequestContext] = None,
context_provider: Optional[Any] = None, # ExtractContextProvider
isolation_handler: MemoryIsolationHandler = None,
thinking: bool = False,
thinking: Optional[bool] = None,
max_output_tokens: Optional[int] = None,
):
"""
Expand All @@ -147,7 +147,9 @@ def __init__(
max_iterations: Maximum number of ReAct iterations (default: 5)
ctx: Request context
context_provider: ExtractContextProvider - 必须提供(由 provider 加载 schema)
thinking: Whether to explicitly enable model thinking for this extraction loop.
thinking: Per-call thinking override for this extraction loop. True/False
force the mode for every call; None (default) defers to the VLM
backend's configured thinking policy.
max_output_tokens: Per-call output cap for the extraction LLM. When None, the
configured vlm.max_tokens is used, falling back to a value large enough for
full memory rewrites so providers do not apply a small server-side default
Expand All @@ -159,7 +161,7 @@ def __init__(
self.max_iterations = max_iterations
self.ctx = ctx
self.context_provider = context_provider
self.thinking = bool(thinking)
self.thinking = thinking
self.max_output_tokens = max_output_tokens
# Resolved in run() via _resolve_effective_max_output_tokens(); default to
# the extraction floor so a direct _call_llm() before run() stays safe.
Expand Down Expand Up @@ -404,10 +406,21 @@ async def run(self) -> Tuple[Optional[Any], List[Dict[str, Any]]]:
parse_error = self._last_parse_error
# Add format error message if parse failed (max 1 retry). This may raise
# max_iterations, granting one more attempt after this failure.
if failure_kind == "budget_exhausted":
# Truncation is not an ordinary format mistake: a same-cap format
# retry would only hit the budget again, and replaying the
# truncated output just grows the prompt. Fail fast with the
# dedicated error recorded by _call_llm().
raise RuntimeError(
f"Memory extraction aborted: {parse_error or failure_kind}"
)

if self._format_retry_count == 0:
self._format_retry_count += 1
max_iterations += 1
retry_reason = "refusal_text" if failure_kind == "refusal_text" else "format_retry"
retry_reason = (
"refusal_text" if failure_kind == "refusal_text" else "format_retry"
)
tracer.info(f"Extended max_iterations to {max_iterations} for {retry_reason}")
self._add_format_error_message(messages)

Expand Down Expand Up @@ -481,6 +494,25 @@ def _resolve_effective_max_output_tokens(self) -> None:
configured if configured is not None else _DEFAULT_EXTRACTION_MAX_OUTPUT_TOKENS
)

def _budget_exhausted_error(self, response: Any, content: str) -> str:
"""Build the dedicated, actionable error for a truncated response.

Truncation is detected only from the backend's explicit
``finish_reason == "length"``; token counters are never used to infer it.
"""
usage = getattr(response, "usage", None)
completion_tokens = usage.get("completion_tokens") if isinstance(usage, dict) else None
details = (
f"finish_reason='length', output cap={self._effective_max_output_tokens} tokens"
)
if completion_tokens:
details += f", completion_tokens={completion_tokens}"
return (
"Response was cut off by the output-token budget "
f"({details}), response_preview={_preview_text(content)!r}. "
"Raise vlm.max_tokens in ov.conf or reduce the memory context before retrying."
)

def _retryable_resolution_issues(self, operations: ResolvedOperations) -> List[Dict[str, Any]]:
issues: List[Dict[str, Any]] = []
for operation in operations.upsert_operations:
Expand Down Expand Up @@ -1175,8 +1207,11 @@ async def _call_llm(
Returns:
Tuple of (tool_calls, operations) - one will be None, the other set
"""
# Call LLM with tools - use tools from strategy
tools = None
# Call LLM with tools - use tools from strategy. Pass tools=[] (not None)
# when tools are disabled so backends following the OpenAI backend's
# `tools is not None` convention still return a VLMResponse carrying
# finish_reason and usage instead of a bare string.
tools: Optional[List[Dict[str, Any]]] = []
tool_choice = None
if not self._disable_tools_for_iteration and self._tool_schemas:
tools = self._tool_schemas
Expand Down Expand Up @@ -1221,6 +1256,21 @@ async def _call_llm(
f"[KVCache] prompt_tokens={prompt_tokens}, cached_tokens={cached_tokens}"
)

# Reject explicit truncation before handling tool calls or parsing:
# neither a partial tool call nor a parseable program is safe to use.
# run() treats budget_exhausted as terminal, without a format retry.
if getattr(response, "finish_reason", None) == "length":
content = getattr(response, "content", None) or ""
self._last_llm_failure_kind = "budget_exhausted"
self._last_llm_failure_content = content
self._last_parse_error = self._budget_exhausted_error(response, content)
tracer.set("memory.extraction.parse_error", self._last_parse_error)
logger.debug(
"Response truncated by output-token budget "
f"failure_kind=budget_exhausted error={self._last_parse_error}"
)
return (None, None)

# Case 0: Handle string response (when tools are not provided) or None
if response is None:
content = ""
Expand Down
11 changes: 10 additions & 1 deletion openviking/session/train/components/policy_optimizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@ class PatchMergePolicyOptimizer:
memory_type: str = "experiences"
memory_registry: MemoryTypeRegistry | None = None
vlm_resolver: VLMResolver | None = None
thinking: bool | None = None
"""Per-call thinking override for the merge extract loop.

None (default) defers to the VLM backend's configured ``thinking`` policy.
True/False force the mode for every merge call, overriding the backend
configuration. The optimizer never forces thinking on by itself: forcing
``thinking=True`` for experiences/trajectories merges made reasoning consume
the whole completion budget and truncate the emitted program mid-string.
"""

@tracer(
"train.policy_optimizer.patch_merge.plan",
Expand Down Expand Up @@ -183,7 +192,7 @@ async def _run_merge_extract_loop(
context_provider=provider,
isolation_handler=isolation_handler,
max_iterations=1,
thinking=self.memory_type in {"trajectories", "experiences"},
thinking=self.thinking,
)
operations, _ = await orchestrator.run()
return operations
Expand Down
Loading
Loading