mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
70165807c7
Some serving setups emit model reasoning inline with no think tags and no
reasoning_content at all — nothing any parser can segregate (measured live
on the dev vLLM: 20/20 sampled completions, streamed and not, proxied and
direct). The drain seam correctly passes unmarked prose through, so it
became the artifact on every bounded-artifact lane: workstream titles
("Thinking Process:"), compaction summaries that were ~90% chain-of-
thought, and the web-fetch tool results #940 reports — which then ride
every following turn as context.
Three coordinated changes:
* Utility lanes ask for no reasoning. _utility_completion (title,
compaction, web-fetch extraction) pins the alias's declared thinking
toggle off and withholds every reasoning-effort channel — the relayed
session knob, the lane rung, the definition default, and the graded
template key — via lane_without_thinking / lane_thinking_suppressed,
the same suppression omni transcription already used (now shared as
thinking_off_template_kwargs). Measured end-to-end: the extraction
that returned 3.7k chars of reasoning returns a 258-char answer.
* server_parses_reasoning capability. A backend that segregates
reasoning into its own channel declares it, and the inline tag scan
turns off on every lane: the drain seam, the interactive splitter
(which now reads the ACTIVE stream's capabilities via the creation-
time handoff register, never the primary alias's), and the title
lane's cosmetic peel — so prose that merely quotes a tag can no
longer be misrouted, and the utility suppression stands down where
reasoning costs the artifact nothing. The built-in commercial
capability tables declare it wholesale (known models and table-miss
defaults); local compat lanes keep the passthrough default the scan
exists for. Bool-typed capability overrides coerce string spellings
instead of truthiness-flipping on hand-edited JSON.
* Title selection follows the prompt's contract, not line position:
the last line within the word cap that ends in a word character —
rejecting explanation sentences, sign-offs, parentheticals, and
reasoning headings in any script (terminal punctuation carries
unspaced scripts where whitespace word counts are meaningless) —
else the last non-empty line. 20/20 captured live responses title
correctly (9/20 before, unchanged since well before the seam
unification: the old and new pipelines scored identically on every
sample, so the regression source was the backend's output shape,
not #965).
Also folded in from the review round: a think tag split across a
reasoning-delta boundary reassembles in the drain (partial-tag tail
carry; tool boundaries still flush), Turn.text joins text blocks with a
newline so multi-block answers stop fusing words in notification bodies
and every flattened read, the notify hook reads final_assistant_text
directly instead of through a one-line shim, web-fetch extraction uses
the shared _non_blank_or fallback, and the judge/output-guard suites use
real ModelCapabilities instead of truthy mock attributes.
Closes #940.
83 lines
2.8 KiB
Python
83 lines
2.8 KiB
Python
"""Unit tests for the canonical ``Turn`` model (turnstone.core.trajectory)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from turnstone.core.trajectory import (
|
|
AttachmentRef,
|
|
ProviderNative,
|
|
Role,
|
|
TextBlock,
|
|
ToolCall,
|
|
Turn,
|
|
TurnMeta,
|
|
)
|
|
|
|
|
|
def test_role_values() -> None:
|
|
assert {r.value for r in Role} == {"user", "assistant", "tool", "system"}
|
|
# Constructible from the wire string (used by the row→Turn adapter).
|
|
assert Role("assistant") is Role.ASSISTANT
|
|
|
|
|
|
def test_text_joins_only_textblocks() -> None:
|
|
turn = Turn(
|
|
Role.USER,
|
|
(TextBlock("look at "), AttachmentRef("sha-1", "image"), TextBlock("this")),
|
|
)
|
|
# Attachment blocks contribute nothing to the FTS/text projection, and
|
|
# adjacent text blocks join with a NEWLINE — they are distinct spans,
|
|
# and a bare concatenation fused words across block boundaries in
|
|
# every flattened read (notification bodies, final_assistant_text).
|
|
assert turn.text == "look at \nthis"
|
|
|
|
|
|
def test_user_helper() -> None:
|
|
turn = Turn.user("hello")
|
|
assert turn.role is Role.USER
|
|
assert turn.content == (TextBlock("hello"),)
|
|
assert turn.text == "hello"
|
|
|
|
|
|
def test_tool_helper_carries_error_flag() -> None:
|
|
ok = Turn.tool("call_1", "result")
|
|
err = Turn.tool("call_2", "boom", is_error=True)
|
|
assert ok.role is Role.TOOL and ok.tool_call_id == "call_1" and ok.is_error is False
|
|
assert err.is_error is True and err.text == "boom"
|
|
|
|
|
|
def test_assistant_with_tool_calls_and_native() -> None:
|
|
tc = ToolCall(id="call_1", name="get_weather", arguments='{"city": "Paris"}')
|
|
native = ProviderNative(producer="anthropic", blocks=({"type": "thinking"},))
|
|
turn = Turn.assistant("on it", tool_calls=(tc,), native=native)
|
|
assert turn.role is Role.ASSISTANT
|
|
assert turn.tool_calls == (tc,)
|
|
assert turn.native is native
|
|
assert turn.text == "on it"
|
|
|
|
|
|
def test_assistant_empty_text_has_no_content_block() -> None:
|
|
# A tool-only assistant turn carries no TextBlock.
|
|
turn = Turn.assistant("", tool_calls=(ToolCall("call_1", "x", "{}"),))
|
|
assert turn.content == ()
|
|
assert turn.text == ""
|
|
|
|
|
|
def test_system_turn_source_marks_operator_context() -> None:
|
|
base = Turn.system("base prompt")
|
|
op = Turn.system("output-guard flagged this", source="output_guard")
|
|
assert base.source is None # base prompt
|
|
assert op.source == "output_guard" # operator-context turn
|
|
|
|
|
|
def test_turnmeta_defaults_are_independent() -> None:
|
|
# default_factory must not share a single dict across instances.
|
|
a = Turn.user("a")
|
|
b = Turn.user("b")
|
|
a.meta.extra["k"] = "v"
|
|
assert b.meta.extra == {}
|
|
assert isinstance(a.meta, TurnMeta)
|
|
|
|
|
|
def test_provider_native_defaults_to_empty_blocks() -> None:
|
|
assert ProviderNative(producer="google").blocks == ()
|