fix(query): recover from OpenAI-compat length truncations; forward non-interactive to headless - #830
Merged
Merged
Conversation
…n-interactive to headless Two model-agnostic fixes ported from the DeepSeek-flash harness work (feat/deepseek-flash-harness), benefiting every provider: 1. finish_reason="length" -> stop_reason "max_tokens" normalization. Every stop_reason consumer in the query loop keys on the internal (Anthropic) "max_tokens" vocabulary — the withheld-content escalation lane, the resume-recovery nudge, _is_withheld_max_output_tokens — and none matched the OpenAI wire's "length". So a truncated response from ANY OpenAI-compatible provider (openai, openrouter, zai, deepseek, …) fell through as a normal end-of-turn: no escalation to 64K, no resume nudge, and a headless run ended with a silently-clipped answer. Anthropic already emits "max_tokens"; this maps the other wire onto the same word. The tag is now applied ONLY to tool-free truncations — a truncation that still carries a surviving tool call must be yielded (headless persistence is the only route into the saved session) rather than withheld while its tools execute; this also closes the same latent leak on the Anthropic wire, where max_tokens + tool_use has always been possible. 2. build_effective_system_prompt now forwards is_non_interactive_session into build_full_system_prompt_blocks, so the headless (-p) path actually receives the "# Non-Interactive Mode" section (it was silently omitted). Interactive callers leave the flag unset and are unchanged. Tests: test_openai_length_stop_reason.py (escalation fires on "length"; tool-carrying truncation is yielded not withheld) and test_headless_non_interactive_section.py. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two model-agnostic fixes extracted from the DeepSeek-flash harness work (
feat/deepseek-flash-harness) — the parts that benefit every provider, with all DeepSeek-gated and eval-specific changes left behind.1. Recover from OpenAI-compat
finish_reason="length"truncationsEvery
stop_reasonconsumer in the query loop keys on the internal (Anthropic) word"max_tokens"— the withheld-content escalation lane (→ 64K retry), the "resume, break into smaller pieces" recovery nudge, and_is_withheld_max_output_tokens. None of them matched the OpenAI wire's"length", so a truncated response from any OpenAI-compatible provider (openai, openrouter, zai, deepseek, …) fell through as a normal end-of-turn: no escalation, no resume nudge, and a headless run ended with a silently-clipped answer._call_model_syncnow mapsfinish_reason == "length"→stop_reason = "max_tokens"(Anthropic already emits"max_tokens", so this only normalizes the other wire).The
_api_error = "max_output_tokens"tag is now applied only to tool-free truncations. A truncation that still carries a surviving tool call must be yielded — headless persistence (on_message) is the only route into the saved session — not withheld while its tools execute anyway. This also closes the same latent leak on the Anthropic wire, wheremax_tokens+tool_usehas always been possible.2. Forward non-interactive mode to the headless prompt
build_effective_system_prompt(the headless/TUI cutover builder) never forwardedoptions.is_non_interactive_sessionintobuild_full_system_prompt_blocks, so the headless (-p) model never received the# Non-Interactive Modesection and behaved as if a human would reply. Now forwarded; interactive callers (TUI bridge, agent-server) leave the flag unset and are unchanged. Model-agnostic.Tests
tests/test_openai_length_stop_reason.py— the 64K escalation fires onfinish_reason="length"; a"stop"turn is untouched; a truncation carrying a surviving tool call is yielded (not withheld).tests/test_headless_non_interactive_section.py— section present when the flag is set, absent otherwise, null-safe whentool_contexthas no.options.Verification
max_tokensescalation tests stay green (they usefinish_reason="max_tokens"+tool_uses=None, so the tool-free guard still fires)."length"is remapped —stop/tool_calls/content_filterpass through unchanged.🤖 Generated with Claude Code