Skip to content

Commit dbc59bc

Browse files
authored
fix(core): correlate streamed tool calls safely (#205)
* docs(architecture): design provider stream handling * docs(agent): remove unavailable guard requirement * fix(core): correlate streamed tool-call fragments * fix(core): preserve streamed tool-call identity * fix(core): preserve responses terminal reason precedence * fix(core): defer tools until stream validation * fix(soul): avoid replaying published stream failures * docs(changelog): note safe streamed tool calls * fix(core): address stream review findings * fix(core): stop responses streams on error
1 parent bdbe55b commit dbc59bc

23 files changed

Lines changed: 2779 additions & 120 deletions

AGENTS.md

Lines changed: 0 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -143,33 +143,6 @@ validated, never trusted.
143143
abstraction, custom logic where native features or existing helpers suffice, and changes a junior
144144
maintainer would struggle to follow.
145145

146-
## Guardrails: pythinker-guard Skill
147-
148-
**REQUIRED BACKGROUND:** Before committing any changes to Pythinker code, **use the
149-
`pythinker-guard` skill** to enforce the non-negotiable rules above against time pressure and
150-
sunk-cost rationalization.
151-
152-
**When to use:** Invoke `pythinker-guard` BEFORE:
153-
- Committing changes to Pythinker codebase
154-
- Opening a PR
155-
- Declaring a feature complete
156-
157-
**What it prevents:** The skill enforces:
158-
- Surgical changes (no drive-by refactors, reformatting, or cleanup)
159-
- Explicit error contracts (no bare `except`, no silent failures)
160-
- Type safety (all new functions typed; `make check` passes)
161-
- Test-driven development (tests written first, gate passed locally)
162-
- Fail-closed behavior (errors distinguished and logged, never swallowed)
163-
164-
The skill specifically guards against 5 pressure vectors that trigger violations:
165-
1. Time scarcity → shortcuts in testing, typing, error handling
166-
2. Sunk-cost fallacy → skipping types/tests because "we've already built most of it"
167-
3. Confidence illusion → "it's obvious this works" → silent errors
168-
4. Proximity heuristic → "we're in the file anyway" → unrelated cleanup
169-
5. Inversion of priorities → "tests slow us down" → untestable design
170-
171-
See the skill itself for verification checkpoints, hard stops, and escalation triggers.
172-
173146
## Quick commands
174147

175148
Use these first; they encode the supported local workflow.

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ GitHub Releases page; `0.8.0` is the new starting line.
1515

1616
## Unreleased
1717

18+
- **Parallel streamed tool calls are now correlated safely.** Interleaved argument chunks stay attached to their indexed calls, malformed or truncated call streams stop before tool execution, and failed attempts are not retried after output has already been shown.
19+
1820
## 0.58.0 (2026-07-11)
1921

2022
- **Agent-spec loading is more defensive and truthful.** Subagent `path`, `extend`, and

docs/en/release-notes/changelog.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ GitHub Releases page; `0.8.0` is the new starting line.
1717

1818
## Unreleased
1919

20+
- **Parallel streamed tool calls are now correlated safely.** Interleaved argument chunks stay attached to their indexed calls, malformed or truncated call streams stop before tool execution, and failed attempts are not retried after output has already been shown.
21+
2022
## 0.58.0 (2026-07-11)
2123

2224
- **Agent-spec loading is more defensive and truthful.** Subagent `path`, `extend`, and

docs/superpowers/specs/2026-07-14-provider-stream-and-tool-execution-design.md

Lines changed: 535 additions & 0 deletions
Large diffs are not rendered by default.

packages/pythinker-core/src/pythinker_core/__init__.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
from pythinker_core._generate import GenerateResult, generate
1616
from pythinker_core.chat_provider import (
1717
ChatProvider,
18-
ChatProviderError,
1918
StreamedMessagePart,
2019
TokenUsage,
2120
)
@@ -74,16 +73,19 @@ async def step(
7473

7574
tool_calls: list[ToolCall] = []
7675
tool_result_futures: dict[str, ToolResultFuture] = {}
76+
tool_callbacks_active = True
7777

78-
def future_done_callback(future: ToolResultFuture):
78+
def future_done_callback(future: ToolResultFuture) -> None:
79+
if not tool_callbacks_active:
80+
return
7981
if on_tool_result:
8082
try:
8183
result = future.result()
8284
on_tool_result(result)
8385
except asyncio.CancelledError:
8486
return
8587

86-
async def on_tool_call(tool_call: ToolCall):
88+
async def on_tool_call(tool_call: ToolCall) -> None:
8789
tool_calls.append(tool_call)
8890
result = toolset.handle(tool_call)
8991

@@ -105,12 +107,16 @@ async def on_tool_call(tool_call: ToolCall):
105107
on_message_part=on_message_part,
106108
on_tool_call=on_tool_call,
107109
)
108-
except (ChatProviderError, asyncio.CancelledError):
109-
# cancel all the futures to avoid hanging tasks
110-
for future in tool_result_futures.values():
110+
except BaseException:
111+
# A later terminal dispatch can fail after earlier work was accepted. Deactivate
112+
# publication before touching callbacks/tasks: already-queued callbacks cannot be
113+
# retracted by remove_done_callback().
114+
tool_callbacks_active = False
115+
futures = list(tool_result_futures.values())
116+
for future in futures:
111117
future.remove_done_callback(future_done_callback)
112118
future.cancel()
113-
await asyncio.gather(*tool_result_futures.values(), return_exceptions=True)
119+
await asyncio.gather(*futures, return_exceptions=True)
114120
raise
115121

116122
return StepResult(

packages/pythinker-core/src/pythinker_core/_generate.py

Lines changed: 34 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@
55

66
from pythinker_core.chat_provider import (
77
APIEmptyResponseError,
8+
APIStreamProtocolError,
89
ChatProvider,
910
StreamedMessagePart,
1011
TokenUsage,
1112
)
12-
from pythinker_core.message import ContentPart, Message, TextPart, ThinkPart, ToolCall
13+
from pythinker_core.message import Message, TextPart, ThinkPart, ToolCall
14+
from pythinker_core.stream_message_assembler import StreamMessageAssembler
1315
from pythinker_core.tooling import Tool
1416
from pythinker_core.utils.aio import Callback, callback
1517

@@ -46,30 +48,43 @@ async def generate(
4648
APIEmptyResponseError: If the API returns an empty response.
4749
ChatProviderError: If any other recognized chat provider error occurs.
4850
"""
49-
message = Message(role="assistant", content=[])
50-
pending_part: StreamedMessagePart | None = None # message part that is currently incomplete
51+
assembler = StreamMessageAssembler()
52+
output_published = False
5153

52-
logger.trace("Generating with history: {history}", history=history)
54+
logger.trace(
55+
"Generating with {history_count} history messages and {tool_count} tools",
56+
history_count=len(history),
57+
tool_count=len(tools),
58+
)
5359
stream = await chat_provider.generate(system_prompt, tools, history)
5460
async for part in stream:
55-
logger.trace("Received part: {part}", part=part)
61+
logger.trace("Received stream part: {part_type}", part_type=type(part).__name__)
5662
if on_message_part:
5763
await callback(on_message_part, part.model_copy(deep=True))
64+
output_published = True
65+
66+
try:
67+
assembler.add(part)
68+
except APIStreamProtocolError as error:
69+
error.output_published = output_published
70+
if error.response_id is None:
71+
error.response_id = stream.id
72+
raise
73+
74+
try:
75+
message = assembler.finish(
76+
response_id=stream.id,
77+
finish_reason=stream.finish_reason,
78+
)
79+
except APIStreamProtocolError as error:
80+
error.output_published = output_published
81+
if error.response_id is None:
82+
error.response_id = stream.id
83+
raise
5884

59-
if pending_part is None:
60-
pending_part = part
61-
elif not pending_part.merge_in_place(part): # try merge into the pending part
62-
# unmergeable part must push the pending part to the buffer
63-
_message_append(message, pending_part)
64-
if isinstance(pending_part, ToolCall) and on_tool_call:
65-
await callback(on_tool_call, pending_part)
66-
pending_part = part
67-
68-
# end of message
69-
if pending_part is not None:
70-
_message_append(message, pending_part)
71-
if isinstance(pending_part, ToolCall) and on_tool_call:
72-
await callback(on_tool_call, pending_part)
85+
for tool_call in message.tool_calls or []:
86+
if on_tool_call:
87+
await callback(on_tool_call, tool_call)
7388

7489
if not message.content and not message.tool_calls:
7590
raise APIEmptyResponseError("The API returned an empty response.")
@@ -112,16 +127,3 @@ class GenerateResult:
112127
"""The token usage of the generated message."""
113128
truncated: bool = False
114129
"""True when the response was cut off by the output-token limit (finish_reason 'length')."""
115-
116-
117-
def _message_append(message: Message, part: StreamedMessagePart) -> None:
118-
match part:
119-
case ContentPart():
120-
message.content.append(part)
121-
case ToolCall():
122-
if message.tool_calls is None:
123-
message.tool_calls = []
124-
message.tool_calls.append(part)
125-
case _:
126-
# may be an orphaned `ToolCallPart`
127-
return

packages/pythinker-core/src/pythinker_core/chat_provider/__init__.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,44 @@ def __init__(self, message: str):
157157
super().__init__(message)
158158

159159

160+
type StreamProtocolErrorCategory = Literal[
161+
"ambiguous_fragment",
162+
"conflicting_identity",
163+
"conflicting_name",
164+
"missing_name",
165+
"mixed_correlation",
166+
"orphan_fragment",
167+
"terminal_failure",
168+
"truncated_tool_call",
169+
]
170+
171+
172+
class APIStreamProtocolError(ChatProviderError):
173+
"""A provider-neutral streamed-message correlation failure."""
174+
175+
category: StreamProtocolErrorCategory
176+
response_id: str | None
177+
stream_index: int | None
178+
call_id: str | None
179+
output_published: bool
180+
181+
def __init__(
182+
self,
183+
category: StreamProtocolErrorCategory,
184+
*,
185+
response_id: str | None = None,
186+
stream_index: int | None = None,
187+
call_id: str | None = None,
188+
output_published: bool = False,
189+
) -> None:
190+
super().__init__(f"Stream protocol error: {category}")
191+
self.category = category
192+
self.response_id = response_id
193+
self.stream_index = stream_index
194+
self.call_id = call_id
195+
self.output_published = output_published
196+
197+
160198
class APIConnectionError(ChatProviderError):
161199
"""The error raised when the API connection fails."""
162200

packages/pythinker-core/src/pythinker_core/chat_provider/pythinker.py

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,7 @@ async def _convert_stream_response(
457457
self,
458458
response: AsyncIterator[ChatCompletionChunk],
459459
) -> AsyncIterator[StreamedMessagePart]:
460+
started_tool_call_indices: set[int] = set()
460461
try:
461462
async for chunk in response:
462463
if chunk.id:
@@ -483,24 +484,36 @@ async def _convert_stream_response(
483484

484485
# convert tool calls
485486
for tool_call in delta.tool_calls or []:
486-
if not tool_call.function:
487+
function = tool_call.function
488+
if function is None:
489+
if tool_call.id is not None:
490+
yield ToolCallPart(
491+
stream_index=tool_call.index,
492+
stream_call_id=tool_call.id,
493+
)
487494
continue
488495

489-
if tool_call.function.name:
496+
if tool_call.index not in started_tool_call_indices:
497+
started_tool_call_indices.add(tool_call.index)
490498
yield ToolCall(
491-
id=tool_call.id or str(uuid.uuid4()),
499+
id=tool_call.id or "",
492500
function=ToolCall.FunctionBody(
493-
name=tool_call.function.name,
494-
arguments=tool_call.function.arguments,
501+
name=function.name or "",
502+
arguments=function.arguments,
495503
),
504+
stream_index=tool_call.index,
496505
)
497-
elif tool_call.function.arguments:
506+
elif (
507+
tool_call.id is not None
508+
or function.name is not None
509+
or function.arguments is not None
510+
):
498511
yield ToolCallPart(
499-
arguments_part=tool_call.function.arguments,
512+
arguments_part=function.arguments,
513+
name_part=function.name,
514+
stream_index=tool_call.index,
515+
stream_call_id=tool_call.id,
500516
)
501-
else:
502-
# skip empty tool calls
503-
pass
504517
except (OpenAIError, httpx.HTTPError) as e:
505518
raise convert_error(e) from e
506519

packages/pythinker-core/src/pythinker_core/contrib/chat_provider/anthropic.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,7 @@ async def _convert_stream_response(
630630
yield ToolCall(
631631
id=block.id,
632632
function=ToolCall.FunctionBody(name=block.name, arguments=""),
633+
stream_index=event.index,
633634
)
634635
case "server_tool_use" | "web_search_tool_result":
635636
# ignore
@@ -650,7 +651,10 @@ async def _convert_stream_response(
650651
case "thinking_delta":
651652
yield ThinkPart(think=delta.thinking)
652653
case "input_json_delta":
653-
yield ToolCallPart(arguments_part=delta.partial_json)
654+
yield ToolCallPart(
655+
arguments_part=delta.partial_json,
656+
stream_index=event.index,
657+
)
654658
case "signature_delta":
655659
yield ThinkPart(think="", encrypted=delta.signature)
656660
case "citations_delta":

packages/pythinker-core/src/pythinker_core/contrib/chat_provider/openai_legacy.py

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,7 @@ async def _convert_stream_response(
305305
self,
306306
response: AsyncIterator[ChatCompletionChunk],
307307
) -> AsyncIterator[StreamedMessagePart]:
308+
started_tool_call_indices: set[int] = set()
308309
try:
309310
async for chunk in response:
310311
if chunk.id:
@@ -332,24 +333,36 @@ async def _convert_stream_response(
332333

333334
# convert tool calls
334335
for tool_call in delta.tool_calls or []:
335-
if not tool_call.function:
336+
function = tool_call.function
337+
if function is None:
338+
if tool_call.id is not None:
339+
yield ToolCallPart(
340+
stream_index=tool_call.index,
341+
stream_call_id=tool_call.id,
342+
)
336343
continue
337344

338-
if tool_call.function.name:
345+
if tool_call.index not in started_tool_call_indices:
346+
started_tool_call_indices.add(tool_call.index)
339347
yield ToolCall(
340-
id=tool_call.id or str(uuid.uuid4()),
348+
id=tool_call.id or "",
341349
function=ToolCall.FunctionBody(
342-
name=tool_call.function.name,
343-
arguments=tool_call.function.arguments,
350+
name=function.name or "",
351+
arguments=function.arguments,
344352
),
353+
stream_index=tool_call.index,
345354
)
346-
elif tool_call.function.arguments:
355+
elif (
356+
tool_call.id is not None
357+
or function.name is not None
358+
or function.arguments is not None
359+
):
347360
yield ToolCallPart(
348-
arguments_part=tool_call.function.arguments,
361+
arguments_part=function.arguments,
362+
name_part=function.name,
363+
stream_index=tool_call.index,
364+
stream_call_id=tool_call.id,
349365
)
350-
else:
351-
# skip empty tool calls
352-
pass
353366
except (OpenAIError, httpx.HTTPError) as e:
354367
raise convert_error(e) from e
355368

0 commit comments

Comments
 (0)