mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
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:
@@ -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([]) == ""
|
||||
|
||||
Reference in New Issue
Block a user