mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
feat(task-agent): carry the provider-native reasoning lane in the sub-harness
A task agent's replayed turns now carry the native reasoning lane the model produced (Anthropic thinking blocks + signatures, OpenAI Responses reasoning items, Gemini thought_signature blocks, vLLM/llama.cpp parsed reasoning text) instead of being rebuilt from content + tool_calls with the reasoning dropped — restoring reasoning continuity across the agent's own multi-turn tool loop on every provider lane. The prerequisite is the id half: replace legalize_tool_call_ids with restore_provider_tool_ids, a lowering pass that maps the session-minted sub-tool ids back to the provider's own ids on the transient wire copy (from the per-run mint map, never by string-splitting). The native tool_use block is replayed verbatim — its id and signature untouched — and the top-level mirror and tool_result agree with it on every request. The minted id stays the sole internal key (registry, DOM, recall, cancel ledger), #820 unchanged. Chat-Completions lane: non-streaming create_completion now surfaces reasoning/reasoning_content as CompletionResult.reasoning (the twin of the streaming reasoning_delta extraction), and the agent seam runs the Phase 5 vLLM reasoning-field replay against the agent's own provider and alias. The native lane is finalized by a shared helper (_finalize_provider_blocks) so the main loop and the sub-harness cannot drift; replay honors the per-model replay_reasoning_to_model flag on every lane, and llama.cpp stays capture-only, matching the main loop.
This commit is contained in:
+25
-6
@@ -18,6 +18,27 @@ Earlier stable lines (`stable/1.6`, `stable/1.5`) are frozen.
|
||||
|
||||
### Added
|
||||
|
||||
- **task_agent keeps its model's reasoning across its own tool loop — on
|
||||
every provider lane.** A task agent's replayed turns now carry the
|
||||
provider-native reasoning lane the model produced — Anthropic thinking
|
||||
blocks with their signatures (commercial or an anthropic-compatible
|
||||
server), OpenAI Responses reasoning items, Gemini `thought_signature`
|
||||
fidelity blocks, and the reasoning text a vLLM `--reasoning-parser` /
|
||||
llama.cpp `reasoning_format` surfaces on the Chat Completions lane —
|
||||
instead of each turn being rebuilt from text + tool calls with the
|
||||
reasoning dropped. On a thinking model this restores reasoning continuity
|
||||
across the agent's own multi-turn tool use. On the wire the agent's
|
||||
session-minted sub-tool ids are mapped back to the provider's own ids
|
||||
(`restore_provider_tool_ids`), so the native block — replayed verbatim,
|
||||
its signature never touched — the `tool_calls` mirror, and each tool
|
||||
result always agree; internally the minted ids still key the live card,
|
||||
recall, and the cancel ledger unchanged. Replay honors the same per-model
|
||||
`replay_reasoning_to_model` flag the main loop uses on every lane: the
|
||||
vLLM Chat-Completions field replay keeps its server-type gate, and
|
||||
llama.cpp stays capture-only, matching main-loop behavior. The native
|
||||
lane is finalized by the same shared builder as the main loop's, so the
|
||||
two harnesses cannot drift.
|
||||
|
||||
- **Background shells: `bash` gains `run_in_background`, plus `bash_output` /
|
||||
`kill_shell`.** Setting `run_in_background=true` starts the command as a
|
||||
detached shell and returns immediately with a `bash_N` handle — "start a dev
|
||||
@@ -43,12 +64,10 @@ Earlier stable lines (`stable/1.6`, `stable/1.5`) are frozen.
|
||||
`{parent}::r{run}s{step}::{id}`, unique within the session (across an
|
||||
agent's turns and across concurrent or sequential runs), and that one id
|
||||
keys the nesting registry, the live rows, recall, and the cancel ledger.
|
||||
As defensive hardening the agent loop also normalizes its self-built wire
|
||||
history — projecting the composite ids to short plain tokens
|
||||
(deterministically, so call/result pairing holds) and legalizing malformed
|
||||
tool-call arguments, the same two validity passes the main loop already runs
|
||||
— so an agent's history stays valid even on a backend stricter than the ones
|
||||
it runs on today.
|
||||
On the wire the agent's self-built history carries the provider's own ids,
|
||||
restored from the mint map (see the reasoning-lane entry under Added), and
|
||||
malformed tool-call arguments are legalized the same way the main loop's
|
||||
wire prep does.
|
||||
|
||||
- **bash tool: never hang on a backgrounded child.** A command that left a
|
||||
long-lived process running (`server &`, a daemon) could wedge the whole
|
||||
|
||||
+51
-39
@@ -12,17 +12,15 @@ this pins the behaviour the old ``_anthropic`` ``pc_tool_ids`` /
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from turnstone.core.lowering import (
|
||||
CANCELLED_TOOL_RESULT,
|
||||
_find_orphaned_tool_calls,
|
||||
legalize_tool_call_ids,
|
||||
repair_wire_messages,
|
||||
restore_provider_tool_ids,
|
||||
sanitize_tool_call_arguments,
|
||||
tool_args_preview,
|
||||
wire_safe_tool_call_id,
|
||||
wire_valid_arguments,
|
||||
)
|
||||
|
||||
@@ -346,65 +344,79 @@ def test_pipeline_every_emitted_arguments_is_a_json_object() -> None:
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# legalize_tool_call_ids — the agent-wire id projection (defensive hardening).
|
||||
# restore_provider_tool_ids — the agent-wire id map (minted → provider-original).
|
||||
#
|
||||
# Sub-agent tool ids are minted "{parent}::r{run}s{step}::{provider_id}" for
|
||||
# session-unique correlation (registry / DOM / recall). These replay fine on
|
||||
# the lenient anthropic-compatible deployment, but the pass projects the long,
|
||||
# "::"-containing ids to a deterministic wire-safe token on the transient wire
|
||||
# copy so an agent's self-built history stays valid on a hypothetically
|
||||
# stricter backend, keeping assistant call / tool result pairing intact.
|
||||
# session-unique correlation (registry / DOM / recall). On the wire the pass
|
||||
# maps them BACK to the provider's own ids from the per-run mint map, so the
|
||||
# provider-native tool_use block (replayed verbatim, id never rewritten), the
|
||||
# top-level tool_calls mirror, and the tool_result all agree on every request.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_wire_safe_tool_call_id_passthrough_when_legal() -> None:
|
||||
# Provider-issued ids (their own echo) and uuid-filled ids are untouched.
|
||||
for tc_id in ("call_9dSVOCr8sPbk3f0oJU8IruBP", "toolu_01ABCdef", "call_" + "a" * 32):
|
||||
assert wire_safe_tool_call_id(tc_id) is tc_id
|
||||
def test_restore_ids_identity_on_empty_map() -> None:
|
||||
msgs = [_assistant_calls(_call("task-1::r1s1::call_0", "{}")), _tool("task-1::r1s1::call_0")]
|
||||
assert restore_provider_tool_ids(msgs, {}) is msgs
|
||||
|
||||
|
||||
def test_wire_safe_tool_call_id_rewrites_illegal_charset_and_length() -> None:
|
||||
minted = "call_0::r1s1::call_0"
|
||||
long_id = "call_" + "a" * 60
|
||||
for bad in (minted, long_id):
|
||||
safe = wire_safe_tool_call_id(bad)
|
||||
assert safe != bad
|
||||
assert re.fullmatch(r"[a-zA-Z0-9_-]{1,40}", safe)
|
||||
# Deterministic (pairing across messages and across requests), distinct inputs distinct.
|
||||
assert wire_safe_tool_call_id(minted) == wire_safe_tool_call_id(minted)
|
||||
assert wire_safe_tool_call_id(minted) != wire_safe_tool_call_id(long_id)
|
||||
|
||||
|
||||
def test_legalize_ids_identity_when_all_legal() -> None:
|
||||
def test_restore_ids_identity_when_nothing_matches() -> None:
|
||||
msgs = [_assistant_calls(_call("call_1", "{}")), _tool("call_1")]
|
||||
assert legalize_tool_call_ids(msgs) is msgs
|
||||
assert restore_provider_tool_ids(msgs, {"task-1::r1s1::call_0": "call_0"}) is msgs
|
||||
|
||||
|
||||
def test_legalize_ids_rewrites_call_and_result_consistently() -> None:
|
||||
minted = "task-1::r1s1::call_0"
|
||||
def test_restore_ids_maps_call_and_result_to_provider_original() -> None:
|
||||
minted = "task-1::r1s1::toolu_01AB"
|
||||
msgs = [_assistant_calls(_call(minted, "{}")), _tool(minted)]
|
||||
out = legalize_tool_call_ids(msgs)
|
||||
wire_id = out[0]["tool_calls"][0]["id"]
|
||||
assert re.fullmatch(r"[a-zA-Z0-9_-]{1,40}", wire_id)
|
||||
assert out[1]["tool_call_id"] == wire_id # pairing survives the projection
|
||||
out = restore_provider_tool_ids(msgs, {minted: "toolu_01AB"})
|
||||
assert out[0]["tool_calls"][0]["id"] == "toolu_01AB"
|
||||
assert out[1]["tool_call_id"] == "toolu_01AB" # pairing restored on both sides
|
||||
# Copy-on-write: the input messages (the canonical-adjacent dicts) are unmutated.
|
||||
assert msgs[0]["tool_calls"][0]["id"] == minted
|
||||
assert msgs[1]["tool_call_id"] == minted
|
||||
|
||||
|
||||
def test_legalize_ids_leaves_legal_siblings_untouched() -> None:
|
||||
def test_restore_ids_recovers_originals_containing_the_mint_delimiter() -> None:
|
||||
# Recovery is by MAP, not by string-splitting the mint suffix: a provider
|
||||
# id that itself contains "::" round-trips exactly.
|
||||
original = "srv::call::0"
|
||||
minted = f"task-1::r1s1::{original}"
|
||||
msgs = [_assistant_calls(_call(minted, "{}")), _tool(minted)]
|
||||
out = restore_provider_tool_ids(msgs, {minted: original})
|
||||
assert out[0]["tool_calls"][0]["id"] == original
|
||||
assert out[1]["tool_call_id"] == original
|
||||
|
||||
|
||||
def test_restore_ids_duplicate_originals_across_turns() -> None:
|
||||
# A local server reissuing "call_0" every turn: two distinct minted ids
|
||||
# both restore to "call_0" — the proven prior wire shape, each round
|
||||
# pairing with its adjacent result.
|
||||
m1, m2 = "task-1::r1s1::call_0", "task-1::r1s2::call_0"
|
||||
msgs = [
|
||||
_assistant_calls(_call(m1, "{}")),
|
||||
_tool(m1),
|
||||
_assistant_calls(_call(m2, "{}")),
|
||||
_tool(m2),
|
||||
]
|
||||
out = restore_provider_tool_ids(msgs, {m1: "call_0", m2: "call_0"})
|
||||
assert out[0]["tool_calls"][0]["id"] == "call_0"
|
||||
assert out[1]["tool_call_id"] == "call_0"
|
||||
assert out[2]["tool_calls"][0]["id"] == "call_0"
|
||||
assert out[3]["tool_call_id"] == "call_0"
|
||||
|
||||
|
||||
def test_restore_ids_leaves_unmapped_siblings_untouched() -> None:
|
||||
minted = "task-1::r1s2::call_1"
|
||||
msgs = [
|
||||
_assistant_calls(_call("call_ok", "{}"), _call(minted, "{}")),
|
||||
_tool("call_ok"),
|
||||
_tool(minted),
|
||||
]
|
||||
out = legalize_tool_call_ids(msgs)
|
||||
out = restore_provider_tool_ids(msgs, {minted: "call_1"})
|
||||
assert out[0]["tool_calls"][0]["id"] == "call_ok"
|
||||
assert out[1]["tool_call_id"] == "call_ok"
|
||||
assert out[0]["tool_calls"][1]["id"] == out[2]["tool_call_id"]
|
||||
assert "::" not in out[0]["tool_calls"][1]["id"]
|
||||
assert out[0]["tool_calls"][1]["id"] == "call_1"
|
||||
assert out[2]["tool_call_id"] == "call_1"
|
||||
|
||||
|
||||
def test_legalize_ids_skips_empty_and_non_string() -> None:
|
||||
def test_restore_ids_skips_empty_and_non_string() -> None:
|
||||
# Empty ids belong to repair_wire_messages' back-fill; non-strings are
|
||||
# someone else's malformation — neither is this pass's to invent.
|
||||
msgs = [
|
||||
@@ -413,6 +425,6 @@ def test_legalize_ids_skips_empty_and_non_string() -> None:
|
||||
),
|
||||
{"role": "tool", "tool_call_id": None, "content": "x"},
|
||||
]
|
||||
out = legalize_tool_call_ids(msgs)
|
||||
out = restore_provider_tool_ids(msgs, {"task-1::r1s1::x": "x"})
|
||||
assert out[0]["tool_calls"][0]["id"] == ""
|
||||
assert out[1]["tool_call_id"] is None
|
||||
|
||||
@@ -277,6 +277,41 @@ class TestConvertMessagesReasoningReplay:
|
||||
types = [it.get("type") for it in items]
|
||||
assert "reasoning" not in types
|
||||
|
||||
def test_agent_shaped_turn_pairs_reasoning_with_restored_call_ids(
|
||||
self, provider: OpenAIResponsesProvider
|
||||
) -> None:
|
||||
# The sub-agent wire shape (native lane carried, minted ids already
|
||||
# restored to the provider originals by the lowering map): the stored
|
||||
# reasoning item rides immediately before the function_call rebuilt
|
||||
# from the SAME original call id, and the function_call_output pairs
|
||||
# to it — the ordering + id agreement the Responses API requires when
|
||||
# replaying reasoning across an agent's own tool loop.
|
||||
messages = [
|
||||
{"role": "user", "content": "go"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [{"id": "call_orig1", "function": {"name": "f", "arguments": "{}"}}],
|
||||
"_provider_content": [
|
||||
{"type": "reasoning", "id": "rs_1", "summary": [], "encrypted_content": "enc"},
|
||||
{
|
||||
"type": "function_call",
|
||||
"call_id": "call_orig1",
|
||||
"name": "f",
|
||||
"arguments": "{}",
|
||||
},
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "call_orig1", "content": "out"},
|
||||
]
|
||||
_, items = provider._convert_messages(messages, replay_reasoning_to_model=True)
|
||||
types = [it.get("type") for it in items]
|
||||
assert types == ["message", "reasoning", "function_call", "function_call_output"]
|
||||
assert items[1]["id"] == "rs_1"
|
||||
assert items[1]["encrypted_content"] == "enc"
|
||||
assert items[2]["call_id"] == "call_orig1"
|
||||
assert items[3]["call_id"] == "call_orig1"
|
||||
|
||||
def test_no_reasoning_items_when_provider_content_lacks_reasoning(
|
||||
self, provider: OpenAIResponsesProvider
|
||||
) -> None:
|
||||
|
||||
@@ -3174,6 +3174,80 @@ class TestAnthropicProviderBlocks:
|
||||
assert assistant_msg["role"] == "assistant"
|
||||
assert assistant_msg["content"] == [{"type": "text", "text": "Hi there"}]
|
||||
|
||||
def test_agent_native_lane_with_restore_map_is_wire_consistent(self) -> None:
|
||||
"""The sub-agent wire shape: an assistant Turn carrying the provider-
|
||||
native lane, its minted tool id restored to the provider original by
|
||||
the lowering map. The native blocks replay verbatim (thinking +
|
||||
signature untouched) and the native tool_use id, the top-level
|
||||
mirror, and the tool_result all agree."""
|
||||
from turnstone.core.lowering import restore_provider_tool_ids
|
||||
from turnstone.core.trajectory import (
|
||||
ProviderNative,
|
||||
ToolCall,
|
||||
Turn,
|
||||
dicts_from_turns,
|
||||
)
|
||||
|
||||
thinking = {"type": "thinking", "thinking": "look first", "signature": "sig_1"}
|
||||
tool_use = {"type": "tool_use", "id": "toolu_01X", "name": "f", "input": {}}
|
||||
minted = "task-1::r1s1::toolu_01X"
|
||||
turns = [
|
||||
Turn.user("go"),
|
||||
Turn.assistant(
|
||||
"using f",
|
||||
tool_calls=(ToolCall(id=minted, name="f", arguments="{}"),),
|
||||
native=ProviderNative(
|
||||
producer="anthropic",
|
||||
blocks=(thinking, {"type": "text", "text": "using f"}, tool_use),
|
||||
),
|
||||
),
|
||||
Turn.tool(minted, "out"),
|
||||
]
|
||||
wire = restore_provider_tool_ids(dicts_from_turns(turns), {minted: "toolu_01X"})
|
||||
_, converted = self.provider._convert_messages(wire, replay_reasoning_to_model=True)
|
||||
assistant = converted[1]
|
||||
assert [b["type"] for b in assistant["content"]] == ["thinking", "text", "tool_use"]
|
||||
assert assistant["content"][0]["signature"] == "sig_1"
|
||||
assert assistant["content"][2]["id"] == "toolu_01X"
|
||||
tool_results = [b for b in converted[2]["content"] if b.get("type") == "tool_result"]
|
||||
assert tool_results and tool_results[0]["tool_use_id"] == "toolu_01X"
|
||||
|
||||
def test_agent_native_lane_without_restore_map_orphans_the_result(self) -> None:
|
||||
"""Documents why the id map is a PREREQUISITE of carrying the native
|
||||
lane, not hygiene: without it the tool_result arrives with the minted
|
||||
id, matches no native tool_use, and the converter drops it as an
|
||||
orphan — leaving an unanswered tool_use on the wire (a provider
|
||||
rejection)."""
|
||||
from turnstone.core.trajectory import (
|
||||
ProviderNative,
|
||||
ToolCall,
|
||||
Turn,
|
||||
dicts_from_turns,
|
||||
)
|
||||
|
||||
tool_use = {"type": "tool_use", "id": "toolu_01X", "name": "f", "input": {}}
|
||||
minted = "task-1::r1s1::toolu_01X"
|
||||
turns = [
|
||||
Turn.user("go"),
|
||||
Turn.assistant(
|
||||
"",
|
||||
tool_calls=(ToolCall(id=minted, name="f", arguments="{}"),),
|
||||
native=ProviderNative(producer="anthropic", blocks=(tool_use,)),
|
||||
),
|
||||
Turn.tool(minted, "out"),
|
||||
]
|
||||
_, converted = self.provider._convert_messages(
|
||||
dicts_from_turns(turns), replay_reasoning_to_model=True
|
||||
)
|
||||
all_results = [
|
||||
b
|
||||
for m in converted
|
||||
if isinstance(m.get("content"), list)
|
||||
for b in m["content"]
|
||||
if isinstance(b, dict) and b.get("type") == "tool_result"
|
||||
]
|
||||
assert all_results == []
|
||||
|
||||
def test_block_to_dict_with_model_dump(self) -> None:
|
||||
"""_block_to_dict uses model_dump(exclude_none=True) when available."""
|
||||
from turnstone.core.providers._anthropic import _block_to_dict
|
||||
@@ -4235,6 +4309,56 @@ class TestOpenAIResponsesProvider:
|
||||
assert caps.supports_tool_search is True
|
||||
|
||||
|
||||
class TestOpenAIChatReasoningCapture:
|
||||
"""Non-streaming ``create_completion`` surfaces the Chat-Completions
|
||||
lane's non-canonical reasoning (vLLM ``--reasoning-parser``, llama.cpp
|
||||
``reasoning_format``) as ``CompletionResult.reasoning`` — the twin of the
|
||||
streaming path's ``reasoning_delta`` extraction, same attribute pair and
|
||||
precedence."""
|
||||
|
||||
@staticmethod
|
||||
def _client(*, reasoning: Any = None, reasoning_content: Any = None) -> MagicMock:
|
||||
msg = MagicMock()
|
||||
msg.content = "ok"
|
||||
msg.tool_calls = None
|
||||
msg.annotations = None
|
||||
msg.reasoning = reasoning
|
||||
msg.reasoning_content = reasoning_content
|
||||
choice = MagicMock()
|
||||
choice.message = msg
|
||||
choice.finish_reason = "stop"
|
||||
resp = MagicMock()
|
||||
resp.choices = [choice]
|
||||
resp.usage = None
|
||||
client = MagicMock()
|
||||
client.chat.completions.create.return_value = resp
|
||||
return client
|
||||
|
||||
def _complete(self, client: MagicMock):
|
||||
provider = OpenAIChatCompletionsProvider()
|
||||
return provider.create_completion(
|
||||
client=client, model="m", messages=[{"role": "user", "content": "hi"}]
|
||||
)
|
||||
|
||||
def test_reasoning_content_captured(self) -> None:
|
||||
result = self._complete(self._client(reasoning_content="thought text"))
|
||||
assert result.reasoning == "thought text"
|
||||
|
||||
def test_reasoning_attribute_takes_precedence(self) -> None:
|
||||
result = self._complete(self._client(reasoning="direct", reasoning_content="parsed"))
|
||||
assert result.reasoning == "direct"
|
||||
|
||||
def test_absent_reasoning_is_empty(self) -> None:
|
||||
result = self._complete(self._client())
|
||||
assert result.reasoning == ""
|
||||
|
||||
def test_non_string_reasoning_collapses_to_empty(self) -> None:
|
||||
# A server surfacing a structured reasoning object (not text) must not
|
||||
# leak a non-str into the result.
|
||||
result = self._complete(self._client(reasoning={"odd": True}))
|
||||
assert result.reasoning == ""
|
||||
|
||||
|
||||
class TestResponsesMessageConversion:
|
||||
"""Tests for _convert_messages — Chat Completions format to Responses API."""
|
||||
|
||||
|
||||
+197
-16
@@ -2567,7 +2567,7 @@ class TestAgentChildRegistration:
|
||||
# _tool_status, the card's own data-call-id row — so two runs with the
|
||||
# same parent id still alias at the card level. Parent ids are
|
||||
# main-loop ids; de-colliding them is the main-loop id-hygiene
|
||||
# follow-up (see legalize_tool_call_ids' docstring), not this change.
|
||||
# follow-up, not this change.
|
||||
from turnstone.core.providers._openai_chat import OpenAIChatCompletionsProvider
|
||||
|
||||
session = _make_session()
|
||||
@@ -2597,17 +2597,16 @@ class TestAgentChildRegistration:
|
||||
assert minted == ["call_0::r1s1::call_0", "call_0::r2s1::call_0"]
|
||||
assert len(set(minted)) == 2
|
||||
|
||||
def test_agent_wire_is_validity_legalized(self):
|
||||
def test_agent_wire_restores_provider_ids_and_sanitizes_args(self):
|
||||
# The agent seam bypasses the main-loop wire prep and builds its own
|
||||
# history, so it runs the same two validity passes itself. Drive one
|
||||
# tool turn whose call carries both a minted "::" id (projected as
|
||||
# defensive hardening) and malformed non-object arguments (a strict
|
||||
# history, so it runs its own validity passes. Drive one tool turn
|
||||
# whose call carries a minted "::" id (mapped back to the provider's
|
||||
# own id on the wire) and malformed non-object arguments (a strict
|
||||
# renderer json.loads and 400s them), then assert the REPLAY request
|
||||
# the second _api_call sends carries the projected id (call/result
|
||||
# pairing intact) and object-shaped arguments. The internal id keeps
|
||||
# the "::" form.
|
||||
# the second _api_call sends carries the PROVIDER-ORIGINAL id on both
|
||||
# the call and its result, and object-shaped arguments. The internal
|
||||
# id keeps the minted "::" form.
|
||||
import json as _json
|
||||
import re as _re
|
||||
|
||||
from turnstone.core.providers._openai_chat import OpenAIChatCompletionsProvider
|
||||
|
||||
@@ -2662,19 +2661,201 @@ class TestAgentChildRegistration:
|
||||
# Internal id (registry) keeps the minted "::" form.
|
||||
internal = session.ui.note_agent_child.call_args.args[0]
|
||||
assert internal == "task-1::r1s1::call_0"
|
||||
# The SECOND request replays the tool turn: wire id is the legal
|
||||
# projection, consistent between the call and its result; arguments
|
||||
# are legalized to a JSON object.
|
||||
# The SECOND request replays the tool turn: the wire carries the
|
||||
# provider's own id, consistent between the call and its result (the
|
||||
# shape the provider-native tool_use block also holds, so a native
|
||||
# replay and a rebuild agree); arguments are legalized to a JSON
|
||||
# object.
|
||||
replay = seen_messages[1]
|
||||
wire_calls = [tc for m in replay if m.get("tool_calls") for tc in m["tool_calls"]]
|
||||
wire_results = [m for m in replay if m.get("role") == "tool"]
|
||||
assert wire_calls and wire_results
|
||||
wire_id = wire_calls[0]["id"]
|
||||
assert _re.fullmatch(r"[a-zA-Z0-9_-]{1,40}", wire_id), wire_id
|
||||
assert wire_results[0]["tool_call_id"] == wire_id
|
||||
assert wire_id != internal
|
||||
assert wire_calls[0]["id"] == "call_0"
|
||||
assert wire_results[0]["tool_call_id"] == "call_0"
|
||||
assert isinstance(_json.loads(wire_calls[0]["function"]["arguments"]), dict)
|
||||
|
||||
def test_agent_carries_native_lane_and_replays_thinking_anthropic(self):
|
||||
# The load-bearing fidelity pin: a thinking-model agent's SECOND
|
||||
# request must carry the prior assistant turn's native lane verbatim
|
||||
# — thinking block and signature untouched — with the provider's own
|
||||
# tool_use id agreeing across the native block, the restored
|
||||
# top-level mirror, and the tool_result. Pre-native-lane, the seam
|
||||
# rebuilt the turn from content + tool_calls and the model re-reasoned
|
||||
# from scratch every tool turn (and commercial Anthropic rejects a
|
||||
# thinking-enabled tool_use turn without its thinking block).
|
||||
from turnstone.core.providers._anthropic import AnthropicProvider
|
||||
|
||||
class _Block:
|
||||
def __init__(self, **d):
|
||||
self._d = d
|
||||
for k, v in d.items():
|
||||
setattr(self, k, v)
|
||||
|
||||
def model_dump(self, **_kw):
|
||||
return dict(self._d)
|
||||
|
||||
session = _make_session()
|
||||
session._provider = AnthropicProvider()
|
||||
session.ui.note_agent_child = MagicMock()
|
||||
|
||||
seen: list[dict] = []
|
||||
call_count = [0]
|
||||
|
||||
def fake_stream(**kwargs):
|
||||
seen.append(kwargs)
|
||||
call_count[0] += 1
|
||||
resp = MagicMock()
|
||||
if call_count[0] == 1:
|
||||
resp.content = [
|
||||
_Block(type="thinking", thinking="check the file first", signature="sig_v1"),
|
||||
_Block(type="text", text="reading"),
|
||||
_Block(type="tool_use", id="toolu_01AB", name="read_file", input={"path": "x"}),
|
||||
]
|
||||
resp.stop_reason = "tool_use"
|
||||
else:
|
||||
resp.content = [_Block(type="text", text="done")]
|
||||
resp.stop_reason = "end_turn"
|
||||
resp.usage = None
|
||||
mgr = MagicMock()
|
||||
mgr.__enter__ = MagicMock(
|
||||
return_value=MagicMock(get_final_message=MagicMock(return_value=resp))
|
||||
)
|
||||
mgr.__exit__ = MagicMock(return_value=False)
|
||||
return mgr
|
||||
|
||||
session.client.messages.stream = fake_stream
|
||||
|
||||
def fake_prepare(tc_dict, **_kwargs):
|
||||
return {
|
||||
"call_id": tc_dict["id"],
|
||||
"func_name": "read_file",
|
||||
"needs_approval": False,
|
||||
"execute": lambda p: (p["call_id"], "contents"),
|
||||
}
|
||||
|
||||
with (
|
||||
patch.object(session, "_prepare_tool", side_effect=fake_prepare),
|
||||
patch.object(session, "_resolve_replay_reasoning_to_model", return_value=True),
|
||||
):
|
||||
session._run_agent(
|
||||
[Turn.user("x")],
|
||||
tools=[{"type": "function", "function": {"name": "read_file", "parameters": {}}}],
|
||||
label="task",
|
||||
parent_call_id="task-1",
|
||||
)
|
||||
|
||||
# Internal key stays minted — the nesting registry saw the "::" id.
|
||||
assert session.ui.note_agent_child.call_args.args[0] == "task-1::r1s1::toolu_01AB"
|
||||
# Second request: the assistant wire turn IS the native lane.
|
||||
replay = seen[1]["messages"]
|
||||
assistant = next(
|
||||
m for m in replay if m["role"] == "assistant" and isinstance(m.get("content"), list)
|
||||
)
|
||||
kinds = [b.get("type") for b in assistant["content"]]
|
||||
assert kinds == ["thinking", "text", "tool_use"]
|
||||
assert assistant["content"][0]["thinking"] == "check the file first"
|
||||
assert assistant["content"][0]["signature"] == "sig_v1" # byte-untouched
|
||||
assert assistant["content"][2]["id"] == "toolu_01AB" # provider-original
|
||||
tool_results = [
|
||||
b
|
||||
for m in replay
|
||||
if m["role"] == "user" and isinstance(m.get("content"), list)
|
||||
for b in m["content"]
|
||||
if isinstance(b, dict) and b.get("type") == "tool_result"
|
||||
]
|
||||
assert tool_results and tool_results[0]["tool_use_id"] == "toolu_01AB"
|
||||
|
||||
def test_agent_synthesizes_reasoning_and_attaches_vllm_replay_field(self):
|
||||
# Chat-Completions lane (vLLM): non-streaming ``reasoning_content`` is
|
||||
# captured into CompletionResult.reasoning, synthesized into the agent
|
||||
# turn's native lane as a ``reasoning_text`` block by the SAME
|
||||
# finalize helper the main loop uses — source-tagged from the AGENT
|
||||
# alias — and replayed on the next request as vLLM's non-standard
|
||||
# ``reasoning`` field (Phase 5 at the agent seam; the internal
|
||||
# ``_provider_content`` key itself never reaches the wire).
|
||||
from types import SimpleNamespace
|
||||
|
||||
from turnstone.core.providers._openai_chat import OpenAIChatCompletionsProvider
|
||||
from turnstone.core.providers._openai_common import OPENAI_COMPAT_DEFAULT
|
||||
|
||||
session = _make_session()
|
||||
session._provider = OpenAIChatCompletionsProvider()
|
||||
session._model_alias = "loc-qwen"
|
||||
session._registry = MagicMock()
|
||||
session._registry.resolve_agent_alias.return_value = None
|
||||
session._registry.resolve_agent_effort.return_value = None
|
||||
session._registry.get_config.return_value = SimpleNamespace(
|
||||
server_compat={"server_type": "vllm"}, replay_reasoning_to_model=True
|
||||
)
|
||||
session.ui.note_agent_child = MagicMock()
|
||||
|
||||
seen_messages: list[list[dict]] = []
|
||||
call_count = [0]
|
||||
|
||||
def fake_create(**kwargs):
|
||||
seen_messages.append(kwargs.get("messages") or [])
|
||||
call_count[0] += 1
|
||||
resp = MagicMock()
|
||||
choice = MagicMock()
|
||||
if call_count[0] == 1:
|
||||
choice.finish_reason = "tool_calls"
|
||||
tc = MagicMock()
|
||||
tc.id = "call_0"
|
||||
tc.function.name = "read_file"
|
||||
tc.function.arguments = '{"path": "x"}'
|
||||
choice.message.tool_calls = [tc]
|
||||
choice.message.content = None
|
||||
choice.message.reasoning = None
|
||||
choice.message.reasoning_content = "scan the repo first"
|
||||
else:
|
||||
choice.finish_reason = "stop"
|
||||
choice.message.tool_calls = None
|
||||
choice.message.content = "done"
|
||||
choice.message.reasoning = None
|
||||
choice.message.reasoning_content = None
|
||||
resp.choices = [choice]
|
||||
resp.usage = MagicMock(prompt_tokens=10, completion_tokens=5)
|
||||
return resp
|
||||
|
||||
session.client.chat.completions.create = fake_create
|
||||
|
||||
def fake_prepare(tc_dict, **_kwargs):
|
||||
return {
|
||||
"call_id": tc_dict["id"],
|
||||
"func_name": "read_file",
|
||||
"needs_approval": False,
|
||||
"execute": lambda p: (p["call_id"], "contents"),
|
||||
}
|
||||
|
||||
turns = [Turn.user("x")]
|
||||
with (
|
||||
patch.object(session, "_prepare_tool", side_effect=fake_prepare),
|
||||
patch.object(session, "_resolve_capabilities", return_value=OPENAI_COMPAT_DEFAULT),
|
||||
patch.object(session, "_provider_extra_params", return_value={}),
|
||||
):
|
||||
session._run_agent(
|
||||
turns,
|
||||
tools=[{"type": "function", "function": {"name": "read_file"}}],
|
||||
label="task",
|
||||
parent_call_id="task-1",
|
||||
)
|
||||
|
||||
# The agent Turn carries the synthesized native lane, source-tagged
|
||||
# via the agent alias (alias threading through the shared helper).
|
||||
assistant_turn = turns[1]
|
||||
assert assistant_turn.native is not None
|
||||
assert assistant_turn.native.producer == "openai-compatible"
|
||||
assert assistant_turn.native.blocks == (
|
||||
{"type": "reasoning_text", "text": "scan the repo first", "source": "vllm"},
|
||||
)
|
||||
# The replay request carries the vLLM ``reasoning`` field on the
|
||||
# assistant turn; the internal ``_provider_content`` key is stripped
|
||||
# by the provider's sanitize before the wire.
|
||||
replay = seen_messages[1]
|
||||
assistant_wire = next(m for m in replay if m.get("role") == "assistant")
|
||||
assert assistant_wire.get("reasoning") == "scan the repo first"
|
||||
assert "_provider_content" not in assistant_wire
|
||||
|
||||
|
||||
class TestRunAgentDenialMessage:
|
||||
"""A denied sub-tool must surface the SPECIFIC denial reason that
|
||||
|
||||
+43
-71
@@ -19,14 +19,14 @@ This module owns the three provider-neutral lowering passes:
|
||||
can't reject the whole request. Mutates the transient wire copy only — the
|
||||
canonical trajectory keeps the raw output. See
|
||||
:func:`sanitize_tool_call_arguments`. The id sibling,
|
||||
:func:`legalize_tool_call_ids`, projects session-minted sub-agent tool ids
|
||||
(``{parent}::r{run}s{step}::{provider_id}`` — long and ``::``-containing) to
|
||||
deterministic safe tokens as DEFENSIVE hardening (no backend turnstone
|
||||
targets is known to reject them; the prior ``::`` id format ran fine); it
|
||||
runs at the AGENT wire seam (``ChatSession._run_agent``'s ``_api_call``)
|
||||
only — main-loop ids are provider-issued or uuid-filled, and main-loop
|
||||
assistant turns
|
||||
carry a provider-native lane whose block ids must stay untouched.
|
||||
:func:`restore_provider_tool_ids`, maps session-minted sub-agent tool ids
|
||||
(``{parent}::r{run}s{step}::{provider_id}``) back to the provider's OWN ids
|
||||
on the wire copy, so the provider-native block lane — whose ``tool_use``
|
||||
blocks carry the provider id verbatim, under a reasoning signature that must
|
||||
never be touched — stays id-consistent with the top-level mirror and the
|
||||
tool results. It runs at the AGENT wire seam (``ChatSession._run_agent``'s
|
||||
``_api_call``) only — main-loop ids are provider-issued or uuid-filled and
|
||||
already consistent with their native lane.
|
||||
* **repair** (validity) — synthesizing cancellation results for orphaned client
|
||||
tool calls. See :func:`repair_wire_messages`.
|
||||
|
||||
@@ -60,7 +60,6 @@ their own.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
@@ -296,61 +295,40 @@ def sanitize_tool_call_arguments(messages: list[dict[str, Any]]) -> list[dict[st
|
||||
return messages if out is None else out
|
||||
|
||||
|
||||
# A conservative wire-legal shape for tool-call ids: alphanumerics, ``_`` and
|
||||
# ``-``, up to 40 chars — the tightest charset/length a provider wire is
|
||||
# plausibly strict about. DEFENSIVE only: no backend turnstone targets is
|
||||
# known to require it — the deployment is lenient anthropic-compatible vLLM,
|
||||
# and the prior ``::``-containing id format replayed fine — so an id already
|
||||
# matching this passes through untouched and the projection is belt-and-braces.
|
||||
_WIRE_TOOL_ID_RE = re.compile(r"[a-zA-Z0-9_-]{1,40}")
|
||||
def restore_provider_tool_ids(
|
||||
messages: list[dict[str, Any]], id_map: dict[str, str]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return *messages* with session-minted sub-agent tool ids mapped back to
|
||||
the provider's own ids — the id half of the legalize pass, applied at the
|
||||
AGENT wire seam.
|
||||
|
||||
*id_map* is the per-``_run_agent`` ``{minted_id: provider_original_id}``
|
||||
record built at the mint site. Rewriting assistant ``tool_calls[*].id``
|
||||
and tool ``tool_call_id`` back to the provider originals makes every wire
|
||||
representation agree: the provider-native ``tool_use`` block (which holds
|
||||
the provider id verbatim and must never be rewritten — its bytes sit under
|
||||
the turn's reasoning signature), the top-level ``tool_calls`` mirror, and
|
||||
the ``tool_result``. A translator that replays the native lane and one
|
||||
that rebuilds from ``tool_calls`` therefore emit the same ids, so the
|
||||
pairing holds on both paths. The minted id stays the internal key
|
||||
(registry / DOM / recall / cancel ledger) untouched — only the transient
|
||||
wire copy is mapped.
|
||||
|
||||
def wire_safe_tool_call_id(tc_id: str) -> str:
|
||||
"""Return *tc_id* unchanged if it already matches the conservative
|
||||
wire-legal shape (:data:`_WIRE_TOOL_ID_RE`), else a deterministic safe
|
||||
token (``tid_`` + 32 hex chars of its SHA-256, 36 chars total).
|
||||
|
||||
Determinism is the contract: the same original id maps to the same token
|
||||
in the assistant ``tool_use`` and its ``tool_result`` (intra-request
|
||||
pairing) and across successive requests that replay the same turns.
|
||||
Provider-issued ids (their own echo) and ``_ensure_tool_call_ids``'s
|
||||
uuid fills already match, so they pass through unchanged; only
|
||||
session-minted composite ids (``{parent}::r{run}s{step}::{provider_id}``)
|
||||
are projected.
|
||||
"""
|
||||
if _WIRE_TOOL_ID_RE.fullmatch(tc_id):
|
||||
return tc_id
|
||||
return "tid_" + hashlib.sha256(tc_id.encode("utf-8", "surrogatepass")).hexdigest()[:32]
|
||||
|
||||
|
||||
def legalize_tool_call_ids(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Return *messages* with every tool-call id made wire-legal — the id
|
||||
half of the legalize pass, applied at the AGENT wire seam.
|
||||
|
||||
Rewrites assistant ``tool_calls[*].id`` and tool ``tool_call_id`` through
|
||||
:func:`wire_safe_tool_call_id`, so a minted ``::`` id is projected to a
|
||||
plain token before the wire (call/result pairing survives via
|
||||
determinism). This is DEFENSIVE hardening — the ids replay fine on the
|
||||
lenient anthropic-compatible deployment today; the projection just keeps
|
||||
an agent's self-built history valid on a hypothetically stricter backend.
|
||||
Empty and non-string ids are left alone — the empty back-fill belongs to
|
||||
:func:`repair_wire_messages`'s domain, and a non-string is someone else's
|
||||
malformation to surface, not silently rename.
|
||||
Replaying the provider's own ids to the producing provider is the proven
|
||||
prior behaviour, including the duplicate-ish ids a local server that
|
||||
reissues per-response ids ("call_0") produces — an agent run is pinned to
|
||||
one provider, so the ids always return to the backend that issued them.
|
||||
Ids not in the map (uuid back-fills, an unparented run that never minted)
|
||||
pass through untouched; recovery is by MAP ONLY, never by string-splitting
|
||||
the mint suffix (provider ids can contain surprising characters,
|
||||
including the mint's own delimiter).
|
||||
|
||||
Copy-on-write + identity-preserving, exactly like
|
||||
:func:`sanitize_tool_call_arguments`: a conversation whose ids are all
|
||||
legal returns the same object. Applied at the AGENT seam only, where the
|
||||
``::`` mint is the sole illegal-id source; NOT wired into the main-loop
|
||||
wire prep, because assistant turns there can carry a provider-native block
|
||||
lane whose ids must stay byte-identical to the mirrored ``tool_calls``,
|
||||
and projecting them would desync the two. If the main loop ever needs the
|
||||
same hygiene (a mid-session ``/model`` switch replays the prior backend's
|
||||
ids — a vLLM ``chatcmpl-tool-`` + 32-hex id is 46 chars, long enough that a
|
||||
backend with a short ``tool_call_id`` cap could reject it), the fix is
|
||||
de-colliding + legalizing at ``_ensure_tool_call_ids`` in a way that also
|
||||
rewrites the native lane — the broader main-loop id-hygiene follow-up, out
|
||||
of scope here.
|
||||
:func:`sanitize_tool_call_arguments`: an empty map or a conversation with
|
||||
no minted id returns the same object.
|
||||
"""
|
||||
if not id_map:
|
||||
return messages
|
||||
out: list[dict[str, Any]] | None = None # copy-on-write: None until first fix
|
||||
for idx, msg in enumerate(messages):
|
||||
role = msg.get("role")
|
||||
@@ -358,29 +336,23 @@ def legalize_tool_call_ids(messages: list[dict[str, Any]]) -> list[dict[str, Any
|
||||
repaired: list[dict[str, Any]] | None = None
|
||||
for ci, tc in enumerate(msg["tool_calls"]):
|
||||
tc_id = tc.get("id")
|
||||
if not isinstance(tc_id, str) or not tc_id:
|
||||
continue
|
||||
safe = wire_safe_tool_call_id(tc_id)
|
||||
if safe is tc_id:
|
||||
original = id_map.get(tc_id) if isinstance(tc_id, str) else None
|
||||
if original is None or original == tc_id:
|
||||
continue
|
||||
if repaired is None:
|
||||
repaired = list(msg["tool_calls"])
|
||||
log.debug("wire.tool_id_legalized", call_id=tc_id, wire_id=safe)
|
||||
repaired[ci] = {**tc, "id": safe}
|
||||
repaired[ci] = {**tc, "id": original}
|
||||
if repaired is not None:
|
||||
if out is None:
|
||||
out = list(messages)
|
||||
out[idx] = {**msg, "tool_calls": repaired}
|
||||
elif role == "tool":
|
||||
tc_id = msg.get("tool_call_id")
|
||||
if not isinstance(tc_id, str) or not tc_id:
|
||||
continue
|
||||
safe = wire_safe_tool_call_id(tc_id)
|
||||
if safe is tc_id:
|
||||
continue
|
||||
original = id_map.get(tc_id) if isinstance(tc_id, str) else None
|
||||
if original is not None and original != tc_id:
|
||||
if out is None:
|
||||
out = list(messages)
|
||||
out[idx] = {**msg, "tool_call_id": safe}
|
||||
out[idx] = {**msg, "tool_call_id": original}
|
||||
return messages if out is None else out
|
||||
|
||||
|
||||
|
||||
@@ -344,6 +344,11 @@ class OpenAIChatCompletionsProvider:
|
||||
if annotations:
|
||||
content = format_citations(content, annotations)
|
||||
|
||||
# Non-canonical reasoning text (vLLM ``--reasoning-parser``, llama.cpp
|
||||
# ``reasoning_format``) — same attribute pair, same precedence, as the
|
||||
# streaming delta extraction above, so the two paths can't drift.
|
||||
reasoning = getattr(msg, "reasoning", None) or getattr(msg, "reasoning_content", None)
|
||||
|
||||
usage = extract_usage(getattr(response, "usage", None))
|
||||
|
||||
result = CompletionResult(
|
||||
@@ -352,6 +357,7 @@ class OpenAIChatCompletionsProvider:
|
||||
finish_reason=choice.finish_reason or "stop",
|
||||
usage=usage,
|
||||
provider_blocks=provider_blocks,
|
||||
reasoning=reasoning if isinstance(reasoning, str) else "",
|
||||
)
|
||||
log.debug(
|
||||
"openai.chat.response",
|
||||
|
||||
@@ -59,6 +59,12 @@ class CompletionResult:
|
||||
finish_reason: str = "stop"
|
||||
usage: UsageInfo | None = None
|
||||
provider_blocks: list[dict[str, Any]] = field(default_factory=list)
|
||||
# Non-canonical reasoning text surfaced by Chat-Completions-lane servers
|
||||
# (vLLM ``--reasoning-parser``, llama.cpp ``reasoning_format``) — the
|
||||
# non-streaming twin of ``StreamChunk.reasoning_delta``. Lanes whose
|
||||
# reasoning rides ``provider_blocks`` natively (Anthropic ``thinking``,
|
||||
# OpenAI Responses ``reasoning`` items) leave it empty.
|
||||
reasoning: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
+105
-39
@@ -69,8 +69,8 @@ from turnstone.core.lowering import (
|
||||
UNOBSERVED_OUTCOME_CLAUSE,
|
||||
drop_empty_user_turns,
|
||||
fold_system_turns,
|
||||
legalize_tool_call_ids,
|
||||
repair_wire_messages,
|
||||
restore_provider_tool_ids,
|
||||
sanitize_tool_call_arguments,
|
||||
tool_args_preview,
|
||||
wire_valid_arguments,
|
||||
@@ -185,6 +185,7 @@ from turnstone.core.tools import (
|
||||
)
|
||||
from turnstone.core.trajectory import (
|
||||
EffectStatus,
|
||||
ProviderNative,
|
||||
Role,
|
||||
TextBlock,
|
||||
ToolCall,
|
||||
@@ -2119,6 +2120,7 @@ class ChatSession:
|
||||
self,
|
||||
provider_blocks: list[dict[str, Any]],
|
||||
reasoning_parts: list[str],
|
||||
alias: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Stamp captured ``reasoning_parts`` as a synthetic ``reasoning_text``
|
||||
block when no reasoning-bearing block already appears in
|
||||
@@ -2165,6 +2167,11 @@ class ChatSession:
|
||||
(:meth:`_maybe_attach_vllm_chat_reasoning`) reads
|
||||
``cfg.server_compat`` directly rather than the synthetic
|
||||
block's tag.
|
||||
|
||||
*alias* names the model whose server produced the reasoning —
|
||||
the sub-agent loop passes its own agent alias so the source tag
|
||||
names the agent's server, not the session primary's. ``None``
|
||||
(the main-loop caller) keeps the primary-alias resolution.
|
||||
"""
|
||||
text = "".join(reasoning_parts)
|
||||
if not text.strip():
|
||||
@@ -2179,13 +2186,42 @@ class ChatSession:
|
||||
"type": "reasoning_text",
|
||||
"text": text,
|
||||
}
|
||||
server_type = self._resolve_server_type()
|
||||
server_type = self._resolve_server_type(alias)
|
||||
if server_type:
|
||||
block["source"] = server_type
|
||||
# Append rather than replace so non-reasoning fidelity blocks
|
||||
# (e.g. Google tool_calls with thought_signature) survive.
|
||||
return [*provider_blocks, block]
|
||||
|
||||
def _finalize_provider_blocks(
|
||||
self,
|
||||
provider_blocks: list[dict[str, Any]],
|
||||
reasoning_parts: list[str],
|
||||
*,
|
||||
has_tool_calls: bool,
|
||||
alias: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Finalize an assistant turn's provider-native block lane: synthesize
|
||||
the path-3 ``reasoning_text`` block when reasoning arrived only as
|
||||
loose text (:meth:`_maybe_synth_reasoning_block`), then enforce the
|
||||
native↔tool_calls mirror in memory — a truncation that cleared
|
||||
``tool_calls`` can leave an orphan client ``tool_use`` in the captured
|
||||
blocks, which a same-provider replay would send with no matching
|
||||
``tool_result`` (see ``storage._utils.normalize_native_for_save``, the
|
||||
save-time chokepoint with the same gate).
|
||||
|
||||
The ONE builder both harnesses share: the main-loop stream accumulator
|
||||
and the sub-agent loop (``_run_agent``) finalize their captured blocks
|
||||
here, so how a native lane is assembled cannot drift between them.
|
||||
Returns a possibly-empty list; callers attach it only when non-empty.
|
||||
"""
|
||||
provider_blocks = self._maybe_synth_reasoning_block(
|
||||
provider_blocks, reasoning_parts, alias=alias
|
||||
)
|
||||
if provider_blocks and not has_tool_calls:
|
||||
provider_blocks = strip_orphan_client_tool_blocks(provider_blocks)
|
||||
return provider_blocks
|
||||
|
||||
def _resolve_replay_reasoning_to_model(
|
||||
self,
|
||||
alias: str | None = None,
|
||||
@@ -7024,18 +7060,12 @@ class ChatSession:
|
||||
|
||||
# Store raw provider content blocks for multi-turn preservation
|
||||
# (e.g. Anthropic web_search_tool_result with encrypted_content).
|
||||
# Phase 3 path-3 capture: when no native blocks were emitted but
|
||||
# ``reasoning_delta`` chunks accumulated text, synthesize a
|
||||
# ``reasoning_text`` block so the captured reasoning survives
|
||||
# past the live stream and surfaces on history reload.
|
||||
provider_blocks = self._maybe_synth_reasoning_block(provider_blocks, reasoning_parts)
|
||||
# Enforce the native↔tool_calls mirror in memory too. A truncation that cleared
|
||||
# tool_calls (finish_reason="length") can leave an orphan tool_use in the captured
|
||||
# blocks; the save-time chokepoint fixes the persisted row, but a same-session
|
||||
# continuation reads this in-memory copy, so strip the orphan here as well.
|
||||
# See storage._utils.normalize_native_for_save.
|
||||
if provider_blocks and not msg.get("tool_calls"):
|
||||
provider_blocks = strip_orphan_client_tool_blocks(provider_blocks)
|
||||
# Finalization (path-3 reasoning synthesis + the in-memory
|
||||
# native↔tool_calls mirror gate) is shared with the sub-agent loop —
|
||||
# see _finalize_provider_blocks.
|
||||
provider_blocks = self._finalize_provider_blocks(
|
||||
provider_blocks, reasoning_parts, has_tool_calls=bool(msg.get("tool_calls"))
|
||||
)
|
||||
if provider_blocks:
|
||||
msg["_provider_content"] = provider_blocks
|
||||
|
||||
@@ -15264,32 +15294,38 @@ class ChatSession:
|
||||
turns: list[Turn],
|
||||
_tools: list[dict[str, Any]] | None = tools,
|
||||
) -> CompletionResult:
|
||||
# NOTE: Phase 5 vLLM ``reasoning`` field replay is intentionally
|
||||
# NOT wired here. Agent assistant messages are built from
|
||||
# ``CompletionResult.content + tool_calls`` only (no
|
||||
# ``_provider_content`` carried), so the helper would no-op
|
||||
# every turn anyway. Task agents are excluded from the
|
||||
# persistence/replay contract — their conversation history
|
||||
# is in-memory and rebuilt per ``_run_agent`` invocation.
|
||||
# Lower the trajectory once, not once per retry attempt — ``turns``
|
||||
# is invariant across attempts (the retry path only sleeps and
|
||||
# re-sends the same messages). Two validity passes, the same two
|
||||
# the main-loop wire prep runs, both applied here because agent
|
||||
# calls bypass that prep and build their own history:
|
||||
# re-sends the same messages). Agent calls bypass the main-loop
|
||||
# wire prep and build their own history, so the seam runs its own
|
||||
# passes:
|
||||
# * ``sanitize_tool_call_arguments`` — a local model can emit an
|
||||
# unterminated / non-object ``arguments`` with a non-``length``
|
||||
# finish reason; a strict renderer (vLLM ``deepseek_v4``) then
|
||||
# ``json.loads`` it and 400s every request that replays it.
|
||||
# (Documented in-tree for the main loop; agents hit the same
|
||||
# backends, so the same guard applies.)
|
||||
# * ``legalize_tool_call_ids`` — project the session-minted
|
||||
# ``::`` sub-tool ids to plain tokens, DEFENSIVE only: they
|
||||
# replay fine on the lenient anthropic-compatible deployment,
|
||||
# this just keeps the history valid on a hypothetically
|
||||
# stricter backend. Deterministic, so call/result pairing
|
||||
# holds within and across requests; the minted id stays the
|
||||
# internal key (registry / DOM / recall) untouched.
|
||||
wire = legalize_tool_call_ids(sanitize_tool_call_arguments(dicts_from_turns(turns)))
|
||||
# * ``restore_provider_tool_ids`` — map the session-minted ``::``
|
||||
# sub-tool ids back to the provider's own ids, so the
|
||||
# provider-native ``tool_use`` block (replayed verbatim below,
|
||||
# under a reasoning signature that must not be touched), the
|
||||
# ``tool_calls`` mirror, and the ``tool_result`` all agree on
|
||||
# the wire. The minted id stays the internal key (registry /
|
||||
# DOM / recall / cancel ledger) untouched.
|
||||
# * ``_maybe_attach_vllm_chat_reasoning`` — Phase 5 replay for
|
||||
# the agent's own turns, live here since agent turns carry
|
||||
# ``_provider_content`` (reasoning included); the helper's
|
||||
# three gates (Chat-Completions provider, server_type vllm,
|
||||
# operator flag) all resolve against the AGENT's provider and
|
||||
# alias, exactly like the main loop's send paths.
|
||||
# Agent trajectories stay excluded from the persistence/replay
|
||||
# contract — history is in-memory, rebuilt per ``_run_agent``
|
||||
# invocation; the native lane carried here serves the WITHIN-RUN
|
||||
# reasoning continuity of the agent's own tool loop.
|
||||
wire = restore_provider_tool_ids(
|
||||
sanitize_tool_call_arguments(dicts_from_turns(turns)), wire_id_map
|
||||
)
|
||||
wire = self._maybe_attach_vllm_chat_reasoning(wire, agent_provider, agent_alias)
|
||||
last_err: Exception | None = None
|
||||
for attempt in range(self._MAX_RETRIES + 1):
|
||||
try:
|
||||
@@ -15332,10 +15368,14 @@ class ChatSession:
|
||||
# for the PARENT task_agent call too. ``sub_step_seq`` is monotonic
|
||||
# across the WHOLE run, so ids stay distinct across turns even when
|
||||
# the provider reuses per-response ids ("call_0") for sub-tools.
|
||||
# ``wire_id_map`` records minted → provider-original for every mint,
|
||||
# read by ``restore_provider_tool_ids`` in ``_api_call`` (the id map
|
||||
# IS the recovery path — never string-split the mint suffix).
|
||||
with self._agent_run_seq_lock:
|
||||
self._agent_run_seq += 1
|
||||
run_seq = self._agent_run_seq
|
||||
sub_step_seq = 0
|
||||
wire_id_map: dict[str, str] = {}
|
||||
while max_tool_turns < 0 or turn < max_tool_turns:
|
||||
self._check_cancelled()
|
||||
try:
|
||||
@@ -15387,15 +15427,19 @@ class ChatSession:
|
||||
# id traceable and is what the frontend's "::" child checks
|
||||
# key off. Every downstream consumer (nesting registry,
|
||||
# error-flags, DOM data-call-id, recall, cancel ledger) keys
|
||||
# on this ONE id; the wire alone sees a deterministic legal
|
||||
# projection instead (``legalize_tool_call_ids`` in
|
||||
# ``_api_call`` — strict providers reject ``::`` ids), which
|
||||
# preserves intra-request call/result pairing. Skipped for a
|
||||
# top-level run (no parent → no nesting).
|
||||
# on this ONE id; the wire alone sees the provider's original
|
||||
# ids restored from ``wire_id_map`` instead
|
||||
# (``restore_provider_tool_ids`` in ``_api_call``), so the
|
||||
# top-level mirror, the ``tool_result``, and the native
|
||||
# ``tool_use`` block — which keeps the provider id verbatim
|
||||
# and is never rewritten — agree on every request. Skipped
|
||||
# for a top-level run (no parent → no nesting).
|
||||
if parent_call_id:
|
||||
for tc in result.tool_calls:
|
||||
sub_step_seq += 1
|
||||
tc["id"] = f"{parent_call_id}::r{run_seq}s{sub_step_seq}::{tc['id']}"
|
||||
original_id = tc["id"]
|
||||
tc["id"] = f"{parent_call_id}::r{run_seq}s{sub_step_seq}::{original_id}"
|
||||
wire_id_map[tc["id"]] = original_id
|
||||
agent_tool_calls = tuple(
|
||||
ToolCall(
|
||||
id=tc["id"],
|
||||
@@ -15404,7 +15448,29 @@ class ChatSession:
|
||||
)
|
||||
for tc in result.tool_calls
|
||||
)
|
||||
agent_turns.append(Turn.assistant(result.content or "", tool_calls=agent_tool_calls))
|
||||
# Carry the provider-native lane (thinking blocks, signatures,
|
||||
# Responses reasoning items, synthesized ``reasoning_text``) so
|
||||
# the agent's own multi-turn tool loop keeps its reasoning
|
||||
# continuity instead of re-reasoning from scratch each turn —
|
||||
# the same fidelity the main loop keeps, finalized by the same
|
||||
# shared builder. ``producer`` is the agent's own provider: a
|
||||
# run is pinned to one provider, so the blocks always replay to
|
||||
# the backend that produced them (and the translators' per-block
|
||||
# shape filters drop anything foreign).
|
||||
native_blocks = self._finalize_provider_blocks(
|
||||
result.provider_blocks,
|
||||
[result.reasoning] if result.reasoning else [],
|
||||
has_tool_calls=bool(result.tool_calls),
|
||||
alias=agent_alias,
|
||||
)
|
||||
native = (
|
||||
ProviderNative(producer=agent_provider.provider_name, blocks=tuple(native_blocks))
|
||||
if native_blocks
|
||||
else None
|
||||
)
|
||||
agent_turns.append(
|
||||
Turn.assistant(result.content or "", tool_calls=agent_tool_calls, native=native)
|
||||
)
|
||||
|
||||
if not result.tool_calls:
|
||||
content = result.content or "(no output)"
|
||||
|
||||
Reference in New Issue
Block a user