Repository navigation
fix(session): respect configured thinking in patch merge and repair budget-exhausted extraction - #6104
Conversation
…udget-exhausted extraction PatchMergePolicyOptimizer forced per-call thinking=True for experiences/ trajectories merges, overriding a deployment's configured thinking=False (volcengine backend per-call value takes precedence over its config). The single format retry reused the same thinking mode and output cap without showing the model its truncated program, so a reasoning-heavy merge that consumed the completion budget failed terminally as parse_error after a ~2h session commit (volcengine#6096). - policy_optimizer: add thinking field (bool | None, default None); None defers to the VLM backend's configured thinking policy, explicit True/False still override. - extract_loop: accept thinking: Optional[bool] = None and pass it through verbatim so the backend default applies when unset; callers passing True/False are unchanged. - extract_loop: classify a response as budget_exhausted when its finish_reason is 'length' or its completion tokens reach the resolved output cap; the single repair now appends the failed assistant program, renders budget-specific guidance via the output protocol (render_budget_retry), and raises the cap once, bounded to 2x the effective cap and a 65536-token ceiling. Ordinary format mistakes keep the previous repair behavior; malformed programs are still rejected. Fixes volcengine#6096
cocolord
left a comment
There was a problem hiding this comment.
Thanks for picking this up. The thinking part looks right to me: keeping None / False / True and letting None inherit the VLM config is exactly what the issue asks for.
The truncation half doesn't hold on the actual incident path, though. I checked it against ad1bbae:
lengthnever reaches_response_hit_output_budgeton patch merge.PatchMergeContextProvider.get_tools()returns[], so_call_llmsendstools=None. The Volcengine and LiteLLM backends then return a plainstrand dropfinish_reasonand usage._response_hit_output_budgetreturnsFalseforstr. With the realVolcEngineVLMreturningfinish_reason="length"andcompletion_tokens=32768, the failure is still classified asparse_error. The new tests pass becauseScriptedVLMreturns aVLMResponsewhatevertoolsis.- A truncated program that still parses is accepted. The budget check only runs after a parse failure. A program cut off at a statement boundary is valid Python, so
finish_reason="length"with a parseable body is returned as successful operations. The trailing writes and deletes are silently lost. completion_tokens >= capis treated as truncation. Afinish_reason="stop"response that lands exactly on the cap is classified asbudget_exhausted. The issue explicitly avoids inferringfinish_reasonfrom token counters.
Repro (all three fail on ad1bbae)
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from openviking.models.vlm.backends.volcengine_vlm import VolcEngineVLM
from openviking.models.vlm.base import VLMResponse
from openviking.session.memory.extract_loop import ExtractLoop
def _loop(vlm, parse_result):
loop = ExtractLoop(
vlm=vlm, viking_fs=MagicMock(), context_provider=MagicMock(), max_output_tokens=32768
)
loop._tool_schemas = [] # PatchMergeContextProvider.get_tools() == []
loop._output_protocol = MagicMock()
loop._output_protocol.parse.return_value = parse_result
loop._output_context = MagicMock()
return loop
def _fake_vlm(response):
vlm = MagicMock(model="test-model")
async def get_completion_async(**_kwargs):
return response
vlm.get_completion_async = get_completion_async
return vlm
@pytest.mark.asyncio
async def test_length_is_detected_through_real_backend_on_patch_merge_path():
raw = SimpleNamespace(
choices=[
SimpleNamespace(
message=SimpleNamespace(content='sdk.write("a", """unterminated', tool_calls=None),
finish_reason="length",
)
],
usage=SimpleNamespace(
prompt_tokens=20214,
completion_tokens=32768,
total_tokens=52982,
prompt_tokens_details=None,
completion_tokens_details=None,
),
)
async def create(**_kwargs):
return raw
vlm = VolcEngineVLM({"provider": "volcengine", "model": "test-model"})
vlm.get_async_client = lambda: SimpleNamespace(
chat=SimpleNamespace(completions=SimpleNamespace(create=create))
)
loop = _loop(vlm, (None, "unterminated triple-quoted string literal"))
await loop._call_llm([])
assert loop._last_llm_failure_kind == "budget_exhausted" # actual: parse_error
@pytest.mark.asyncio
async def test_length_response_that_still_parses_is_not_accepted():
operations = object()
loop = _loop(
_fake_vlm(VLMResponse(content="sdk.write('a', 'x')", finish_reason="length")),
(operations, None),
)
_tool_calls, result = await loop._call_llm([])
assert result is not operations # actual: truncated program accepted
@pytest.mark.asyncio
async def test_stop_at_cap_is_not_truncation():
loop = _loop(
_fake_vlm(
VLMResponse(content="bad", finish_reason="stop", usage={"completion_tokens": 32768})
),
(None, "invalid syntax"),
)
await loop._call_llm([])
assert loop._last_llm_failure_kind != "budget_exhausted" # actual: budget_exhaustedSuggested direction:
- Have
_call_llmpasstools=[]instead ofNonewhen tools are disabled. Align Volcengine and LiteLLM with the OpenAI backend's existingtools is not Noneconvention from #3535, so a no-tool call still returns aVLMResponsewithfinish_reasonand usage. Whentoolsis empty, those backends must still run_clean_responseon the content. Otherwise inline<think>...</think>output from OpenAI-compatible or LiteLLM models stops parsing. - Check for an explicit
finish_reason == "length"before parsing, and fail with a dedicated, actionable error (cap, usage, preview). Don't route it through the format retry. Don't usecompletion_tokensas a truncation signal on its own. - I'd drop the cap doubling and the replay of the truncated output from this PR. The issue doesn't assume a larger cap or a repeated prompt will help. Replaying ~32k tokens of truncated output also grows the prompt. Once thinking is no longer forced, failing fast is enough here, and any bounded recovery can be designed separately.
The main things worth looking at: the real-backend test for the tools=None / str path, and the parseable-but-truncated case.
|
Thanks for the thorough review and the repro — these are valid findings, and I agree the current truncation handling doesn't hold on the real patch-merge path. I'll rework it along your suggested direction: pass |
… only Review rework for PR volcengine#6104 (cocolord): the truncation handling did not hold on the real patch-merge path, where PatchMergeContextProvider.get_tools() returns []. - extract_loop: pass tools=[] instead of 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. - volcengine/litellm backends: switch the return path to the 'tools is not None' convention; when tools is an empty list, keep the VLMResponse but still run _clean_response on the content so inline <think>...</think> output from OpenAI-compatible or LiteLLM models keeps parsing. - extract_loop: check an explicit finish_reason == 'length' BEFORE parsing and fail with a dedicated actionable error (cap, usage, preview). A truncated program that still parses is no longer accepted. Budget exhaustion is terminal in run(): no format retry, no cap doubling (_raise_output_budget_for_retry and the ceiling removed), no replay of the truncated output (render_budget_retry removed from the output protocols), and completion_tokens is no longer used as a truncation signal. - tests: rewrite the budget-recovery tests for the new behavior, including the real VolcEngineVLM backend repro for the tools=[] path; _run_loop now awaits inside the patch context so the config patches are actually active.
|
Pushed 4f18d6c reworking the truncation handling along your suggested direction. Point by point: 1. 2. Explicit 3. Cap doubling and truncated-output replay dropped. Removed Test notes: the rewritten |
J-StrawHat
left a comment
There was a problem hiding this comment.
Thanks for the update.
I reviewed 4f18d6c8 and found two remaining issues:
-
Passing
tools=[]bypasses content cleaning inOpenAIVLM.get_completion_async, leaving inline<think>...</think>content intact. An otherwise valid SDK program can therefore fail withparse_error. -
Responses containing tool calls return before the
finish_reason == "length"check. A truncated response can still execute a tool and trigger another model call instead of failing immediately.
I've addressed both in a small follow-up commit: 20ce905. It restores content cleaning for the empty-tools async text-completion path and moves the existing truncation check ahead of tool-call handling.
Description
PatchMergePolicyOptimizerhard-codes a per-callthinking=Truefor experiences/trajectories merges (openviking/session/train/components/policy_optimizer.py:186on main), and the volcengine backend lets an explicit per-call value override its configured thinking policy (openviking/models/vlm/backends/volcengine_vlm.py:246:effective_thinking = self.thinking if thinking is None else thinking; config defaultFalseatopenviking/models/vlm/base.py:79). A deployment withthinking=Falsetherefore still had reasoning forced on during patch merge.The reasoning consumed the entire completion budget: the output was truncated mid-string (
finish_reason=length), but the extract loop's single format retry classified it as an ordinaryparse_error— it did not look atfinish_reason/completion_tokens, did not append the failed assistant program, and reused the samemax_tokens. The retry reproduced the identical truncation and the session commit failed terminally after ~2h.Root cause
thinking=TrueinPatchMergePolicyOptimizeroverrides a deployment's configuredthinking=False(per-call value wins over config in the volcengine backend).finish_reason == "length"orcompletion_tokens >= max_tokens) is treated the same as a syntax mistake, the truncated assistant program is not shown to the model, and the output cap is not adjusted — so a budget-exhausted generation can never be repaired.Fix
policy_optimizer: newthinking: bool | None = Nonefield. The default (None) defers to the VLM backend's configured thinking policy; explicitTrue/Falsestill force the mode for callers that want it.extract_loop: signature changed tothinking: Optional[bool] = Noneand passed through verbatim (soNoneis not blocked); existing callers passing explicit booleans are unchanged.budget_exhaustedwhenfinish_reason == "length"orcompletion_tokens >=the effective output cap. In that case the single retry: appends the failed assistant program so the model repairs its own truncated output, renders protocol-level budget guidance via the newrender_budget_retry(default inextraction_output_protocol/base.py, with a python-protocol SDK-specific override), and raises the cap once, bounded tomin(2x, 65536)and never lowered. Ordinary format mistakes keep the previous repair behavior; malformed Python programs are still rejected.Human Involvement
Related Issue
Fixes #6096
Type of Change
Changes Made
openviking/session/train/components/policy_optimizer.py: addthinkingfield (bool | None, defaultNone) and pass it to the extract loop instead of forcingthinking=self.memory_type in {"trajectories", "experiences"}.openviking/session/memory/extract_loop.py: acceptOptional[bool]thinking and forward it verbatim; classify budget-truncated responses asbudget_exhausted; on budget exhaustion make the single retry append the failed assistant program, renderrender_budget_retryguidance, and raise the output cap once (boundedmin(2x, 65536)).openviking/session/memory/extraction_output_protocol/base.py: add defaultrender_budget_retry.openviking/session/memory/extraction_output_protocol/python_protocol.py: python-protocol-specificrender_budget_retryoverride.tests/session/memory/test_extract_loop_budget_recovery.py: new (7 tests) — budget classification and single-retry recovery behavior.tests/session/train/test_train_components.py: new wiring tests (3) — optimizerthinkingdefaults to config passthrough; explicit values are forwarded.Testing
All commands below ran with
OPENVIKING_CONFIG_FILE=/tmp/ovtest/ov.conf uv run --no-sync python -m pytest … --override-ini addopts="".Red (before the fix, re-verified on a clean
89fbcb9worktree with the new test files applied): 7 of the 10 new tests fail —tests/session/memory/test_extract_loop_budget_recovery.py4 failed (3 passed: they pin the ordinary parse-error behavior that already works on main);tests/session/train/test_train_components.py -k thinking3 failed.Green (after the fix, rebased onto current
origin/main=9baf83f, this push's HEADad1bbae):pytest tests/session/memory/test_extract_loop_budget_recovery.py→ 7 passedpytest tests/session/train/test_train_components.py -k thinking→ 3 passed (23 deselected)test_memory_react.py test_extraction_output_protocol.py test_streaming_memory_updater.py test_consolidation_compile.py test_train_components.py test_extract_loop_budget_recovery.py) → 284 passed, 2 failed; the 2 failures aretest_skill_policy_creation_preserves_lock_ownership[standalone|outer-tree-lock], the ragfs_python environment noise already documented on the issue.pytest tests/session/ -q: cleanorigin/main(9baf83f) baseline → 1244 passed, 36 failed, 5 skipped, 118 errors; with this fix → 1254 passed, 36 failed, 5 skipped, 118 errors. The FAILED/ERROR lists (154 lines each) are byte-identical after diff, i.e. zero new failures; the +10 passed are exactly the new tests.Checklist
Additional Notes
tests/session/are pre-existing on cleanorigin/mainand untouched by this PR (identical failure lists; includes the 2lock_ownershipfailures caused by the ragfs_python environment, as noted on the issue).