fix(providers): segregate inline reasoning at the drain seam

Passthrough servers (parserless vLLM/llama.cpp, LM Studio, bare
gateways) emit reasoning as literal <think>/<reasoning> blocks inside
content, and only three of nine drained lanes stripped them: web_fetch
tool results persisted raw think blocks into every following turn
(#940), judge verdicts parsed through tag noise, and a draft verdict
inside a think block could shadow the real one at the output guard.

One rule at the seam now. drain_stream accumulates content in RUNS
bounded by interleaving signals (provider-parsed reasoning deltas,
tool-call deltas) with the interactive consumer's within-chunk ordering
— reasoning, then content, then the tool-call close — and splits each
run through split_inline_reasoning, the one-shot form of the
interactive lane's ThinkTagSplitter: a pure raw split, exactly
equivalent to the streaming form on every catalog case. One trim policy
exists and the drain owns it: blank edge lines are trimmed once over
the joined runs when a tag was consumed, so tag residue dies at the
edges while genuine inter-run paragraph separators survive. Extracted
text is appended to result.reasoning after any server-parsed reasoning
with a blank-line boundary and rides the native lane as the
reasoning_text synth block. Orphan CLOSE tags deliberately pass through
byte-identical: a close whose open never arrived is indistinguishable
from prose QUOTING the tag, and drained lanes routinely quote
third-party text — reclassifying would let a malicious page containing
the literal tag destroy the extraction that cites it. The title lane
keeps a local rfind peel as display-string formatting. The citations
footer folds only onto non-blank content — sourcing for an answer that
does not exist is dropped rather than handed to emptiness checks as a
footer-only "answer".

Every private strip is deleted: the title lane's strip, the summarizer
strip, _strip_reasoning itself, and the optimizer's five regexes
(_strip_markdown_fence is now the one fence rule, applied to normalized
model output only, never to or-fallback values). Think-only and
whitespace-only responses drain to blank content, and every lane's
no-answer fallback gates on blankness: web_fetch returns an honest
extraction-error card, the intent judge takes the empty-retry ladder,
the task-agent synthesis reports "(no output)", and the optimizer keeps
the current observer system and prompt verbatim on no-answer passes.
Final-say reads (optimizer analyst, eval final_content, the notify
hook) use trajectory.final_assistant_text — the last assistant turn
only, never an earlier narration presented as the conclusion — while
last_assistant_text is the salvage walk (task_agent partial-work
recovery), skipping tool-call-only, all-reasoning, and whitespace-only
turns. Perception memoizes every completed description immediately,
including an empty one — one perceive per key, ever — under a
commit-lock guard so an empty result never overwrites a concurrently
memoized real description; an all-reasoning perception model pins the
placeholder until restart, and the remediation is server-side (a
reasoning parser or the template thinking toggle on the perception
alias). A true double-reasoning shape (inline-extracted text alongside
a native reasoning block) logs chars-only at the drain, where it is
distinguishable from the routine reasoning_delta mirror.

The dialect's semantics are pinned as one table
(tests/_reasoning_dialect.py) driven through shared fixtures
(think_tag_stream, seam_provider): one-shot conformance, the exact
one-shot/streaming equivalence property, the drain seam rules including
quoted-tag safety, run-boundary and separator-preservation pins,
per-lane pins for all nine lanes, and the empty-content assistant wire
shape.

Closes #965. Closes #940.
This commit is contained in:
Patrick Buckley
2026-08-04 08:06:04 -07:00
parent 1d7db73305
commit bc3fa60011
25 changed files with 1363 additions and 173 deletions
+52
View File
@@ -19,6 +19,8 @@ from turnstone.core.trajectory import (
TextBlock,
ToolCall,
Turn,
final_assistant_text,
last_assistant_text,
materialize_attachments,
resolve_attachment_parts,
turn_from_dict,
@@ -287,3 +289,53 @@ def test_materialize_attachments_noop_without_resolver_or_placeholders() -> None
messages = [{"role": "user", "content": "hi"}]
assert materialize_attachments(messages, None) is messages
assert materialize_attachments(messages, lambda ids: {}) is messages
def test_last_assistant_text_picks_most_recent_substantive() -> None:
turns = [
Turn.user("q"),
Turn.assistant("early answer"),
Turn.assistant("", tool_calls=(ToolCall(id="c1", name="bash", arguments="{}"),)),
Turn.tool("c1", "out"),
Turn.assistant("final answer"),
]
assert last_assistant_text(turns) == "final answer"
def test_last_assistant_text_skips_textless_finals() -> None:
# Tool-call-only and all-reasoning (empty-text) finals are skipped —
# the walk falls back to the last SUBSTANTIVE assistant text.
turns = [
Turn.assistant("substantive"),
Turn.assistant("", tool_calls=(ToolCall(id="c2", name="bash", arguments="{}"),)),
Turn.assistant(""),
]
assert last_assistant_text(turns) == "substantive"
def test_last_assistant_text_none_when_no_assistant_text() -> None:
assert last_assistant_text([]) is None
assert last_assistant_text([Turn.user("q"), Turn.assistant("")]) is None
def test_last_assistant_text_skips_whitespace_only_turns() -> None:
# "Substantive" means non-blank, not merely truthy: a whitespace-only
# final turn must not be salvaged as partial work.
turns = [Turn.assistant("real work"), Turn.assistant(" \n")]
assert last_assistant_text(turns) == "real work"
assert last_assistant_text([Turn.assistant(" \n")]) is None
def test_final_assistant_text_reads_last_turn_only() -> None:
# The final-say read: no walk-back — an empty final say reports empty,
# never an earlier turn's narration.
substantive_then_empty = [Turn.assistant("mid-loop narration"), Turn.assistant("")]
assert final_assistant_text(substantive_then_empty) == ""
tool_final = [
Turn.assistant("narration"),
Turn.assistant("", tool_calls=(ToolCall(id="c1", name="bash", arguments="{}"),)),
]
assert final_assistant_text(tool_final) == ""
assert final_assistant_text([Turn.assistant(" the answer\n")]) == "the answer"
assert final_assistant_text([Turn.user("q")]) == ""
assert final_assistant_text([]) == ""