Skip to content

Commit 7fe6049

Browse files
author
Your Name
committed
Fix parallel tool calling index iteration and TUI output
1 parent 5816b16 commit 7fe6049

7 files changed

Lines changed: 365 additions & 77 deletions

File tree

cecli/coders/base_coder.py

Lines changed: 129 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -3440,11 +3440,17 @@ async def add_assistant_reply_to_cur_messages(self):
34403440
to be `None` when `tool_calls` are present.
34413441
"""
34423442
msg = dict(role="assistant")
3443-
response = (
3444-
self.partial_response_chunks[0]
3445-
if not self.stream
3446-
else litellm.stream_chunk_builder(self.partial_response_chunks)
3447-
)
3443+
3444+
# Prefer the response already produced by consolidate_chunks(): it carries
3445+
# the provider-specific fields (e.g. reasoning_items) that we preserved
3446+
# across all chunks, which a fresh litellm.stream_chunk_builder() pass
3447+
# alone would drop or truncate.
3448+
if self.partial_response_consolidated:
3449+
response = self.partial_response_consolidated[0]
3450+
elif not self.stream:
3451+
response = self.partial_response_chunks[0]
3452+
else:
3453+
response = litellm.stream_chunk_builder(self.partial_response_chunks)
34483454

34493455
try:
34503456
# Use response_dict as a regular dictionary
@@ -3963,63 +3969,51 @@ def consolidate_chunks(self):
39633969
if getattr(last_chunk, "usage", None):
39643970
response.usage = last_chunk.usage
39653971

3966-
# Collect provider-specific fields from chunks to preserve them
3967-
# We need to track both by ID (primary) and index (fallback) since
3968-
# early chunks might not have IDs established yet
3969-
provider_specific_fields_by_id = {}
3970-
provider_specific_fields_by_index = {}
3971-
3972+
# Collect message-level provider-specific fields (e.g. `reasoning_items`
3973+
# for reasoning models) from ALL chunks. litellm's stream_chunk_builder()
3974+
# merges these with last-wins semantics for list fields, silently dropping
3975+
# every reasoning item except the final one. Reasoning models depend on the
3976+
# full ordered item list being present in the assistant message so that
3977+
# exact-prefix prompt caching keeps working across turns, so we collect the
3978+
# fields ourselves and concatenate list-valued entries.
3979+
message_provider_specific_fields = {}
39723980
for chunk in self.partial_response_chunks:
39733981
try:
3974-
if chunk.choices and chunk.choices[0].delta and chunk.choices[0].delta.tool_calls:
3975-
for tool_call in chunk.choices[0].delta.tool_calls:
3976-
if (
3977-
hasattr(tool_call, "provider_specific_fields")
3978-
and tool_call.provider_specific_fields
3979-
):
3980-
# Ensure provider_specific_fields is a dictionary
3981-
psf = tool_call.provider_specific_fields
3982-
if not isinstance(psf, dict):
3983-
continue
3984-
3985-
# Try to use ID first
3986-
if hasattr(tool_call, "id") and tool_call.id:
3987-
tool_id = tool_call.id
3988-
if tool_id not in provider_specific_fields_by_id:
3989-
provider_specific_fields_by_id[tool_id] = {}
3990-
# Merge provider-specific fields for this tool ID
3991-
provider_specific_fields_by_id[tool_id].update(psf)
3992-
# Also track by index as fallback
3993-
elif hasattr(tool_call, "index"):
3994-
tool_index = tool_call.index
3995-
if tool_index not in provider_specific_fields_by_index:
3996-
provider_specific_fields_by_index[tool_index] = {}
3997-
provider_specific_fields_by_index[tool_index].update(psf)
3982+
if chunk.choices and chunk.choices[0].delta:
3983+
psf = getattr(chunk.choices[0].delta, "provider_specific_fields", None)
3984+
if psf and isinstance(psf, dict):
3985+
for key, value in psf.items():
3986+
if isinstance(value, list):
3987+
message_provider_specific_fields.setdefault(key, []).extend(value)
3988+
elif value is not None:
3989+
message_provider_specific_fields[key] = value
39983990
except (AttributeError, IndexError):
39993991
continue
40003992

3993+
if message_provider_specific_fields:
3994+
message_psf = getattr(response.choices[0].message, "provider_specific_fields", None)
3995+
if not isinstance(message_psf, dict):
3996+
message_psf = {}
3997+
message_psf.update(message_provider_specific_fields)
3998+
response.choices[0].message.provider_specific_fields = message_psf
3999+
40014000
try:
4002-
if response.choices[0].message.tool_calls:
4003-
for i, tool_call in enumerate(response.choices[0].message.tool_calls):
4004-
# Add provider-specific fields if we collected any for this tool
4005-
tool_id = tool_call.id
4006-
4007-
# Try ID first
4008-
if tool_id in provider_specific_fields_by_id:
4009-
# Add provider-specific fields directly to the tool call object
4010-
tool_call.provider_specific_fields = provider_specific_fields_by_id[tool_id]
4011-
# Fall back to index
4012-
elif i in provider_specific_fields_by_index:
4013-
# Add provider-specific fields directly to the tool call object
4014-
tool_call.provider_specific_fields = provider_specific_fields_by_index[i]
4015-
4016-
# Only append to partial_response_tool_calls if it's empty
4017-
if len(self.partial_response_tool_calls) == 0:
4018-
self.partial_response_tool_calls.append(tool_call)
4019-
4020-
self.partial_response_function_call = (
4021-
response.choices[0].message.tool_calls[0].function
4022-
)
4001+
message_tool_calls = response.choices[0].message.tool_calls
4002+
if message_tool_calls and len(message_tool_calls):
4003+
if self.stream:
4004+
built_tool_calls = self._build_tool_calls_from_chunks()
4005+
if built_tool_calls:
4006+
response.choices[0].message.tool_calls = built_tool_calls
4007+
self.partial_response_tool_calls = built_tool_calls
4008+
else:
4009+
# Fall back to litellm's merged list, keeping every call
4010+
self.partial_response_tool_calls = list(message_tool_calls)
4011+
else:
4012+
# Non-streaming: the single response chunk already carries the
4013+
# full tool_calls list
4014+
self.partial_response_tool_calls = list(message_tool_calls)
4015+
4016+
self.partial_response_function_call = self.partial_response_tool_calls[0].function
40234017
except AttributeError as e:
40244018
func_err = e
40254019

@@ -4075,6 +4069,85 @@ def consolidate_chunks(self):
40754069
self.partial_response_consolidated = (response, func_err, content_err)
40764070
return response, func_err, content_err
40774071

4072+
def _build_tool_calls_from_chunks(self):
4073+
"""Rebuild tool calls from the raw streaming chunks, keyed by delta index.
4074+
4075+
Streaming deltas for parallel tool calls arrive interleaved and may start
4076+
at any index (not necessarily 0). Indexing into a dict by the delta's
4077+
tool-call ``index`` before converting it back to a list ensures every
4078+
parallel call is preserved, correctly ordered, and keeps its
4079+
provider-specific fields (e.g. thought signatures) attached.
4080+
"""
4081+
from litellm.types.utils import ChatCompletionMessageToolCall, Function
4082+
4083+
tool_calls_dict = {}
4084+
4085+
for chunk in self.partial_response_chunks:
4086+
try:
4087+
if not (chunk.choices and chunk.choices[0].delta):
4088+
continue
4089+
4090+
delta = chunk.choices[0].delta
4091+
for tool_call in delta.tool_calls or []:
4092+
if tool_call is None:
4093+
continue
4094+
4095+
if nested.getter(tool_call, "function") is None:
4096+
continue
4097+
4098+
index = nested.getter(tool_call, "index")
4099+
if index is None:
4100+
index = len(tool_calls_dict)
4101+
4102+
entry = tool_calls_dict.setdefault(
4103+
index,
4104+
{
4105+
"id": None,
4106+
"name": None,
4107+
"type": "function",
4108+
"arguments": [],
4109+
"provider_specific_fields": {},
4110+
},
4111+
)
4112+
4113+
entry["id"] = nested.getter(tool_call, "id") or entry["id"]
4114+
entry["type"] = nested.getter(tool_call, "type") or entry["type"]
4115+
entry["name"] = nested.getter(tool_call, "function.name") or entry["name"]
4116+
4117+
arguments = nested.getter(tool_call, "function.arguments")
4118+
if arguments:
4119+
entry["arguments"].append(arguments)
4120+
4121+
psf = nested.getter(tool_call, "provider_specific_fields")
4122+
if not psf:
4123+
psf = nested.getter(tool_call, "function.provider_specific_fields")
4124+
if psf and isinstance(psf, dict):
4125+
entry["provider_specific_fields"].update(psf)
4126+
except (AttributeError, IndexError):
4127+
continue
4128+
4129+
tool_calls = []
4130+
for index in sorted(tool_calls_dict.keys()):
4131+
data = tool_calls_dict[index]
4132+
if not (data["id"] and data["name"]):
4133+
continue
4134+
4135+
function = Function(
4136+
arguments="".join(data["arguments"]) or "{}",
4137+
name=data["name"],
4138+
)
4139+
params = {
4140+
"id": data["id"],
4141+
"function": function,
4142+
"type": data["type"] or "function",
4143+
}
4144+
if data["provider_specific_fields"]:
4145+
params["provider_specific_fields"] = data["provider_specific_fields"]
4146+
4147+
tool_calls.append(ChatCompletionMessageToolCall(**params))
4148+
4149+
return tool_calls
4150+
40784151
def stream_wrapper(self, content, final):
40794152
if not hasattr(self, "_streaming_buffer_length"):
40804153
self._streaming_buffer_length = 0

cecli/helpers/io_proxy.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ def __init__(self, target: T, coder: Any) -> None:
5151
super().__setattr__("_coder", weakref.ref(coder))
5252
# Per-coder task storage: {coder_uuid: {attr_name: asyncio.Task}}
5353
super().__setattr__("_per_coder", {coder_uuid: {}})
54+
# Last tool `type` emitted via tool_output — lives on the proxy,
55+
# never on the shared target (like coder_uuid)
56+
super().__setattr__("_last_type", None)
5457

5558
# Register a per-coder input queue (TUI mode only)
5659
# Allows the TUI to push input directly to this coder's queue,
@@ -76,6 +79,7 @@ def tool_output(self, *messages: Any, **kwargs: Any) -> Any:
7679
"""Forward tool_output with coder_uuid injected."""
7780
if "coder_uuid" not in kwargs:
7881
kwargs["coder_uuid"] = self._coder_uuid
82+
self._last_type = kwargs.get("type")
7983
return self._target.tool_output(*messages, **kwargs)
8084

8185
def tool_error(self, message: str = "", strip: bool = True, **kwargs: Any) -> Any:
@@ -265,7 +269,7 @@ def __getattr__(self, name: str) -> Any:
265269

266270
def __setattr__(self, name: str, value: Any) -> None:
267271
# Proxy-internal attributes — store on proxy instance only
268-
if name in ("_target", "_coder_uuid", "_coder", "_per_coder"):
272+
if name in ("_target", "_coder_uuid", "_coder", "_per_coder", "_last_type"):
269273
super().__setattr__(name, value)
270274
# Per-coder task attributes — isolate per-coder so coders don't
271275
# compete for the same promise on the shared InputOutput instance

cecli/tools/ls.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ def execute(cls, coder, path=None, **kwargs):
8282

8383
if contents:
8484
coder.io.tool_output(
85-
f"🗐 Listed {len(contents)} file(s) in '{dir_path}'", type="tool-result"
85+
f"🗐 Listed {len(contents)} file(s) in '{dir_path}'", type="tool-result"
8686
)
8787
sorted_contents = sorted(contents)
8888
if len(sorted_contents) > 500:
@@ -98,7 +98,7 @@ def execute(cls, coder, path=None, **kwargs):
9898
)
9999
return response
100100
else:
101-
coder.io.tool_output(f"🗐 No files found in '{dir_path}'", type="tool-result")
101+
coder.io.tool_output(f"🗐 No files found in '{dir_path}'", type="tool-result")
102102
response.append_result("No files found in directory")
103103
return response
104104
except Exception as e:

cecli/tools/utils/output.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,10 @@ def tool_header(coder, mcp_server, tool_response, params=None):
2828
tool_response: a tool_response dictionary
2929
"""
3030
color_start, color_end = color_markers(coder)
31+
nl = "\n" if coder.io._last_type == "tool-footer" else ""
3132

3233
coder.io.tool_output(
33-
f"{color_start}Tool Call:{color_end} {mcp_server.name}{tool_response.function.name}",
34+
f"{nl}{color_start}Tool Call:{color_end} {mcp_server.name}{tool_response.function.name}",
3435
type="Tool Call",
3536
)
3637

cecli/tui/io.py

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,11 @@ def __init__(self, output_queue, input_queue, **kwargs):
5353
("Removing", "file_op"),
5454
]
5555

56-
# Tool call buffering for styled panel rendering
57-
self._tool_call_buffer = []
58-
self._in_tool_call = False
59-
self._expect_tool_result = False
56+
# Tool call buffering for styled panel rendering — per-coder tracking
57+
# Dicts keyed by coder_uuid to support simultaneous multi-coder streaming
58+
self._tool_call_buffers: dict[str, list] = {}
59+
self._in_tool_call: dict[str, bool] = {}
60+
self._expect_tool_result: dict[str, bool] = {}
6061

6162
def rule(self):
6263
pass
@@ -283,44 +284,45 @@ def tool_output(self, *messages, **kwargs):
283284
def _reroute_output(self, text, msg_type, **kwargs):
284285
# Handle tool call buffering for styled panel rendering
285286
coder_uuid = kwargs.get("coder_uuid", None)
287+
key = coder_uuid if coder_uuid else "default"
286288

287289
if msg_type == "Tool Call":
288290
# Start buffering a new tool call
289-
self._in_tool_call = True
290-
self._tool_call_buffer = [text]
291+
self._in_tool_call[key] = True
292+
self._tool_call_buffers[key] = [text]
291293
# Log to history
292294
self.append_chat_history(text, linebreak=True, blockquote=True)
293295
return True
294296
elif msg_type == "tool-footer":
295297
# End of tool call - flush buffer as styled panel
296-
if self._in_tool_call and self._tool_call_buffer:
298+
if self._in_tool_call.get(key, False) and self._tool_call_buffers.get(key):
297299
msg = {
298300
"type": "tool_call",
299-
"lines": self._tool_call_buffer,
301+
"lines": self._tool_call_buffers[key],
300302
}
301303
if coder_uuid:
302304
msg["coder_uuid"] = coder_uuid
303305
self.output_queue.put(msg)
304306
server_signals.send_tool_call(
305-
self, lines=self._tool_call_buffer, coder_uuid=coder_uuid
307+
self, lines=self._tool_call_buffers[key], coder_uuid=coder_uuid
306308
)
307309
# Expect a tool result next
308-
self._expect_tool_result = True
309-
self._in_tool_call = False
310-
self._tool_call_buffer = []
310+
self._expect_tool_result[key] = True
311+
self._in_tool_call[key] = False
312+
self._tool_call_buffers[key] = []
311313
return True
312-
elif self._in_tool_call:
314+
elif self._in_tool_call.get(key, False):
313315
# Add to tool call buffer
314316
if text.strip():
315-
self._tool_call_buffer.append(text)
317+
self._tool_call_buffers[key].append(text)
316318
# Log to history
317319
self.append_chat_history(text, linebreak=True, blockquote=True)
318320
return True
319321

320322
# Check if this is a tool result (comes right after tool call)
321-
if self._expect_tool_result and text.strip():
323+
if self._expect_tool_result.get(key, False) and text.strip():
322324
if msg_type != "tool-result":
323-
self._expect_tool_result = False
325+
self._expect_tool_result[key] = False
324326
msg = {
325327
"type": "tool_result",
326328
"text": text,

0 commit comments

Comments
 (0)