Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cecli/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from packaging import version

__version__ = "1.2.3.dev"
__version__ = "1.3.0.dev"
safe_version = __version__

try:
Expand Down
109 changes: 93 additions & 16 deletions cecli/coders/base_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
from cecli.helpers.conversation import ConversationService, MessageTag
from cecli.helpers.file_system import FileSystemService
from cecli.helpers.io_proxy import IOProxy
from cecli.helpers.loop_detect import LoopDetectedError, LoopDetector
from cecli.helpers.memory_control import trim_memory
from cecli.helpers.observations.service import ObservationService
from cecli.helpers.profiler import TokenProfiler
Expand Down Expand Up @@ -250,6 +251,8 @@ def total_cached_tokens(self, value):
cost_multiplier = 1
stop_on_empty = True
error_code = None
_output_loop_detected = False
_output_loop_message = ""

# Task coordination state variables
input_running = False
Expand Down Expand Up @@ -3649,6 +3652,8 @@ async def send(self, messages, model=None, functions=None, tools=None):
self.got_reasoning_content = False
self.ended_reasoning_content = False
self.empty_response = False
self._output_loop_detected = False
self._output_loop_message = ""

self._streaming_buffer_length = 0
self.io.reset_streaming_response()
Expand Down Expand Up @@ -3857,11 +3862,14 @@ async def show_send_output(self, completion):
async def show_send_output_stream(self, completion):
received_content = False
chunk_index = 0
content_detector = LoopDetector()
tool_detector = LoopDetector()
loop_detected = False

stream = coroutines.interruptible_async_generator(completion, self.interrupt_event)

try:
async for chunk in coroutines.interruptible_async_generator(
completion, self.interrupt_event
):
async for chunk in stream:
if self.args.debug:
with safe_open(".cecli/logs/chunks.log", "a") as f:
print(chunk, file=f)
Expand Down Expand Up @@ -3902,6 +3910,8 @@ async def show_send_output_stream(self, completion):
tool_call_chunk.function.arguments
)

tool_detector.push(tool_call_chunk.function.arguments)

except (AttributeError, IndexError):
# Handle cases where the response structure doesn't match expectations
pass
Expand Down Expand Up @@ -3965,6 +3975,9 @@ async def show_send_output_stream(self, completion):
chunk._hidden_params["created_at"] = chunk_index
self.partial_response_chunks.append(chunk)

if text:
content_detector.push(text)

if self.show_pretty():
# Use simplified streaming - just call the method with full content
content_to_show = self.live_incremental_response(False)
Expand All @@ -3985,6 +3998,30 @@ async def show_send_output_stream(self, completion):
except (asyncio.CancelledError, KeyboardInterrupt):
raise KeyboardInterrupt

except LoopDetectedError as e:
self._output_loop_detected = True
self._output_loop_message = str(e)
loop_detected = True

if loop_detected:
self.io.tool_warning(
f"Output loop detected while streaming: {self._output_loop_message}"
)
# Explicitly close the async generators so the wrapper's interrupt
# task and the underlying provider generator are cleaned up instead
# of being left suspended after we stop consuming them.
if hasattr(stream, "aclose"):
try:
await stream.aclose()
except Exception:
pass
if hasattr(completion, "aclose"):
try:
await completion.aclose()
except Exception:
pass
return

if (
self.show_pretty()
and nested.getter(self.args, "show_thinking")
Expand Down Expand Up @@ -4130,22 +4167,45 @@ def consolidate_chunks(self):
self.tool_reflection = True
self.partial_response_tool_calls = extracted_calls

if self._output_loop_detected:
# A repeating output loop was caught while streaming; turn it into an
# assistant message so the model can adjust and drop any tool calls.
marker = "\n\n[SYSTEM CANCEL: OUTPUT LOOP DETECTED]\n"
self.partial_response_content += marker
self.partial_response_tool_calls = []
self.partial_response_function_call = dict()

# The assistant message stored in the conversation is built from the
# response object (via model_dump()), so the marker has to be written
# there too, otherwise it never reaches the model to react to.
message = response.choices[0].message
message.content = (message.content or "") + marker
message.tool_calls = []
if hasattr(message, "function_call"):
message.function_call = None

self.partial_response_consolidated = (response, func_err, content_err)
return response, func_err, content_err

def _build_tool_calls_from_chunks(self):
"""Rebuild tool calls from the raw streaming chunks, keyed by delta index.

Streaming deltas for parallel tool calls arrive interleaved and may start
at any index (not necessarily 0). Indexing into a dict by the delta's
tool-call ``index`` before converting it back to a list ensures every
parallel call is preserved, correctly ordered, and keeps its
provider-specific fields (e.g. thought signatures) attached.
"""Rebuild tool calls from the raw streaming chunks.

Parallel tool calls arrive interleaved and may start at any index.
Most providers key fragments by a per-call ``index`` (openai / anthropic /
gemini), but some (e.g. deepseek) reuse index0 for every call and only
distinguish them by the ``id`` announced on the first fragment. Keying
by id when present -- and remembering the index -> key mapping so later
id-less fragments resolve to the right call -- preserves every parallel
call instead of collapsing them onto one, keeps them ordered by first
appearance, and retains provider-specific fields (e.g. thought
signatures) attached.
"""
ChatCompletionMessageToolCall = litellm.types.utils.ChatCompletionMessageToolCall
Function = litellm.types.utils.Function

tool_calls_dict = {}
index_lookup = {}
last_key = None

for chunk in self.partial_response_chunks:
try:
Expand All @@ -4160,22 +4220,39 @@ def _build_tool_calls_from_chunks(self):
if nested.getter(tool_call, "function") is None:
continue

tool_id = nested.getter(tool_call, "id") or ""
index = nested.getter(tool_call, "index")
if index is None:
index = len(tool_calls_dict)

if tool_id:
key = ("id", tool_id)

if index is not None:
index_lookup[index] = key

last_key = key
elif index is not None and index in index_lookup:
key = index_lookup[index]
elif index is not None:
key = ("index", index)
index_lookup[index] = key
elif last_key is not None:
key = last_key
else:
key = ("slot", len(tool_calls_dict))

entry = tool_calls_dict.setdefault(
index,
key,
{
"id": None,
"name": None,
"type": "function",
"arguments": [],
"provider_specific_fields": {},
"_order": len(tool_calls_dict),
},
)

entry["id"] = nested.getter(tool_call, "id") or entry["id"]
entry["id"] = tool_id or entry["id"]
entry["type"] = nested.getter(tool_call, "type") or entry["type"]
entry["name"] = nested.getter(tool_call, "function.name") or entry["name"]

Expand All @@ -4192,8 +4269,8 @@ def _build_tool_calls_from_chunks(self):
continue

tool_calls = []
for index in sorted(tool_calls_dict.keys()):
data = tool_calls_dict[index]
for key in sorted(tool_calls_dict.keys(), key=lambda k: tool_calls_dict[k]["_order"]):
data = tool_calls_dict[key]
if not (data["id"] and data["name"]):
continue

Expand Down
97 changes: 56 additions & 41 deletions cecli/commands/help.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,57 +17,74 @@ async def execute(cls, io, coder, args, **kwargs):
await cls._basic_help(io, coder)
return format_command_result(io, "help", "Displayed basic help")

import traceback
from uuid import uuid4 as generate_unique_id

from cecli.coders.base_coder import Coder
from cecli.commands import SwitchCoderSignal
from cecli.help import Help, install_help_extra

# Get the Commands instance from kwargs if available
commands_instance = kwargs.get("commands_instance")

if not commands_instance or not hasattr(commands_instance, "help"):
res = await install_help_extra(io)
if not res:
io.tool_error("Unable to initialize interactive help.")
return format_command_result(io, "help", "Unable to initialize interactive help")

if not commands_instance:
# Create a minimal Commands instance if not provided
from cecli.commands import Commands

commands_instance = Commands(io, coder)
commands_instance.help = Help(coder=coder)

help_instance = commands_instance.help

# Use the editor_model from the main_model if it exists, otherwise use the main_model itself
editor_model = coder.main_model.editor_model or coder.main_model

original_coder = coder

kwargs = dict()
kwargs["io"] = io
kwargs["uuid"] = str(generate_unique_id())
kwargs["from_coder"] = coder
kwargs["edit_format"] = "help"
kwargs["summarize_from_coder"] = False
kwargs["map_tokens"] = 512
kwargs["map_mul_no_files"] = 1
kwargs["main_model"] = editor_model
kwargs["args"] = coder.args
kwargs["suggest_shell_commands"] = False
kwargs["cache_prompts"] = False
kwargs["num_cache_warming_pings"] = 0

help_coder = await Coder.create(**kwargs)
user_msg = help_instance.ask(args)
user_msg += """
# The announcement lines read ``coder.args``, which is None when a coder
# is created without CLI args (e.g. in tests). Skip them in that case.
has_args = bool(getattr(coder, "args", None))

try:
if not commands_instance or not hasattr(commands_instance, "help"):
res = await install_help_extra(io)
if not res:
io.tool_error("Unable to initialize interactive help.")
await cls._basic_help(io, coder)
return format_command_result(
io, "help", "Unable to initialize interactive help"
)

if not commands_instance:
# Create a minimal Commands instance if not provided
from cecli.commands import Commands

commands_instance = Commands(io, coder)
commands_instance.help = Help(coder=coder)

help_instance = commands_instance.help

# Use the editor_model from the main_model if it exists, otherwise use the main_model itself
editor_model = coder.main_model.editor_model or coder.main_model

original_coder = coder

kwargs = dict()
kwargs["io"] = io
kwargs["uuid"] = str(generate_unique_id())
kwargs["from_coder"] = coder
kwargs["edit_format"] = "help"
kwargs["summarize_from_coder"] = False
kwargs["map_tokens"] = 512
kwargs["map_mul_no_files"] = 1
kwargs["main_model"] = editor_model
kwargs["args"] = coder.args
kwargs["suggest_shell_commands"] = False
kwargs["cache_prompts"] = False
kwargs["num_cache_warming_pings"] = 0

help_coder = await Coder.create(**kwargs)
user_msg = help_instance.ask(args)
user_msg += """
# Announcement lines from when this session of cecli was launched:

"""
user_msg += "\n".join(coder.get_announcements()) + "\n"
user_msg += "\n".join(coder.get_announcements() if has_args else []) + "\n"

await help_coder.run(user_msg, preproc=False)
await help_coder.run(user_msg, preproc=False)
except Exception as err:
io.tool_error(f"Interactive help failed to initialize: {err}")
io.tool_error(traceback.format_exc())
await cls._basic_help(io, coder)
return format_command_result(
io, "help", f"Interactive help failed to initialize: {err}"
)

if coder.repo_map:
map_tokens = coder.repo_map.max_map_tokens
Expand All @@ -76,8 +93,6 @@ async def execute(cls, io, coder, args, **kwargs):
map_tokens = 0
map_mul_no_files = 1

from cecli.commands import SwitchCoderSignal

raise SwitchCoderSignal(
edit_format=coder.edit_format,
summarize_from_coder=False,
Expand Down
Loading
Loading