diff --git a/cecli/__init__.py b/cecli/__init__.py index cf267466b92..f92d0cbc15d 100644 --- a/cecli/__init__.py +++ b/cecli/__init__.py @@ -1,6 +1,6 @@ from packaging import version -__version__ = "1.2.3.dev" +__version__ = "1.3.0.dev" safe_version = __version__ try: diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index 31983d75c6f..c14608dcbf7 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -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 @@ -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 @@ -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() @@ -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) @@ -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 @@ -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) @@ -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") @@ -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: @@ -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"] @@ -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 diff --git a/cecli/commands/help.py b/cecli/commands/help.py index 14c5c29fd39..2683421735d 100644 --- a/cecli/commands/help.py +++ b/cecli/commands/help.py @@ -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 @@ -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, diff --git a/cecli/help.py b/cecli/help.py index f9cab2288d0..107fe85f1ab 100755 --- a/cecli/help.py +++ b/cecli/help.py @@ -1,15 +1,11 @@ -import json import os -import shutil import warnings from pathlib import Path import importlib_resources from cecli import __version__, utils -from cecli.dump import dump # noqa from cecli.help_pats import exclude_website_pats -from cecli.helpers.file_searcher import handle_core_files warnings.simplefilter("ignore", category=FutureWarning) @@ -20,162 +16,148 @@ async def install_help_extra(io): + """Ensure the local chromadb backend is installed for interactive /help. + + The previous dependency chain (``llama_index.embeddings.huggingface`` -> + ``sentence_transformers`` -> ``datasets``) crashed on the WSL2 + OpenSSL 3.5 + + Python 3.14 lazy-init flake and on a ``datasets`` circular import. Chroma's + default ONNX embedding needs neither, so it is what ``Help`` uses. + """ pip_install_cmd = [ "cecli-dev[help]", "--extra-index-url", "https://download.pytorch.org/whl/cpu", ] - res = await utils.check_pip_install_extra( + return await utils.check_pip_install_extra( io, - "llama_index.embeddings.huggingface", + "chromadb", "To use interactive /help you need to install the help extras", pip_install_cmd, ) - return res def get_package_files(): - for path in importlib_resources.files("cecli.website").iterdir(): - if path.is_file(): - yield path - elif path.is_dir(): - for subpath in path.rglob("*.md"): - yield subpath + docs = importlib_resources.files("cecli.website") / "docs" + for path in docs.rglob("*.md"): + yield path def fname_to_url(filepath): + """Map a file path in the website package to its published URL. + + Doc sources live under ``website/docs/`` and docmd renders each ``.md`` + to ``/index.html``, so ``docs//page.md`` becomes + ``https://cecli.dev/docs//page/``. Top-level site files (``index.html``) + publish to the site root. Everything else — build artifacts, ``_includes`` + partials, okf sources — is not a published page and returns ``""``. + """ website = "website" + docs = "docs" index = "index.md" md = ".md" + filepath = filepath.replace("\\", "/") - path = Path(filepath) - parts = path.parts + parts = Path(filepath).parts + try: website_index = [p.lower() for p in parts].index(website.lower()) except ValueError: return "" + relevant_parts = parts[website_index + 1 :] - if relevant_parts and relevant_parts[0].lower() == "_includes": + if not relevant_parts: return "" - url_path = "/".join(relevant_parts) - # docmd renders each .md source to /index.html, so the published - # URLs are directory-style (e.g. /docs/usage/) rather than .html files. - is_doc = False - if url_path.lower().endswith(index.lower()): - url_path = url_path[: -len(index)] - is_doc = True - elif url_path.lower().endswith(md.lower()): - url_path = url_path[: -len(md)] - is_doc = True + # Doc pages live under website/docs/ and publish to /docs//. + if relevant_parts[0].lower() == docs: + url_path = _strip_doc_suffix("/".join(relevant_parts[1:]), index, md) + return _format_docs_url(url_path) - url_path = url_path.strip("/") - if not url_path: - return "https://cecli.dev/" - if is_doc: - return f"https://cecli.dev/{url_path}/" - return f"https://cecli.dev/{url_path}" + # Only top-level site files publish to the site root; anything nested + # (_includes, _site, .docmd-*, share, etc.) is not a page. + if len(relevant_parts) == 1: + return f"https://cecli.dev/{relevant_parts[0].lstrip('/')}" + + return "" def get_index(coder=None): - from llama_index.core import ( - Document, - StorageContext, - VectorStoreIndex, - load_index_from_storage, + """Build a local chromadb vector index over the bundled help docs. + + Chroma's default ONNX embedding needs no ``sentence_transformers`` or + ``datasets``, so it avoids the WSL2 + OpenSSL 3.5 + Python 3.14 lazy-init + flake and the ``datasets`` circular import that broke ``/help ``. + Returns the chromadb collection ready for text queries. + """ + import chromadb + from chromadb.utils.embedding_functions import DefaultEmbeddingFunction + + dname = Path.home() / ".cecli" / "caches" / ("help." + __version__) + dname.parent.mkdir(parents=True, exist_ok=True) + + client = chromadb.PersistentClient(path=str(dname)) + collection = client.get_or_create_collection( + "help", embedding_function=DefaultEmbeddingFunction() ) - from llama_index.core.node_parser import MarkdownNodeParser - dname = handle_core_files(Path.home() / ".cecli" / "caches" / ("help." + __version__)) - index = None - try: - if dname.exists(): - storage_context = StorageContext.from_defaults(persist_dir=dname) - index = load_index_from_storage(storage_context) - except (OSError, json.JSONDecodeError): - shutil.rmtree(dname) - if index is None: - io = getattr(coder, "io", None) if coder is not None else None - in_tui = io is not None and _in_tui(coder) - - # Inside the Textual TUI, stdout/stderr are redirected to streams whose - # fileno() == -1, so tqdm's lazily-created multiprocessing.RLock crashes - # with "bad value(s) in fds_to_keep". Use coder.io spinner states instead - # of tqdm there; keep the tqdm progress bar everywhere else. - if in_tui: - io.start_spinner("Parsing help docs...") - try: - parser = MarkdownNodeParser() - nodes = [] - for fname in get_package_files(): - fname = Path(fname) - if any(fname.match(pat) for pat in exclude_website_pats): - continue - doc = Document( - text=importlib_resources.files("cecli.website") - .joinpath(fname) - .read_text(encoding="utf-8"), - metadata=dict( - filename=fname.name, extension=fname.suffix, url=fname_to_url(str(fname)) - ), - ) - nodes += parser.get_nodes_from_documents([doc]) - - if in_tui: - io.update_spinner("Embedding help docs...") - index = VectorStoreIndex(nodes, show_progress=not in_tui) - dname.parent.mkdir(parents=True, exist_ok=True) - index.storage_context.persist(dname) - finally: - if in_tui: - io.stop_spinner() - return index + if collection.count() == 0: + documents = [] + ids = [] + metadatas = [] + for fname in get_package_files(): + fname = Path(fname) + if any(fname.match(pat) for pat in exclude_website_pats): + continue + documents.append(fname.read_text(encoding="utf-8")) + ids.append(str(fname)) + metadatas.append(dict(filename=fname.name, url=fname_to_url(str(fname)))) + if documents: + collection.add(ids=ids, documents=documents, metadatas=metadatas) -class Help: - def __init__(self, coder=None): - from huggingface_hub.utils import disable_progress_bars - from llama_index.core import Settings - from llama_index.embeddings.huggingface import HuggingFaceEmbedding - from transformers import logging + return collection - disable_progress_bars() - logging.set_verbosity_error() - Settings.embed_model = HuggingFaceEmbedding(model_name="BAAI/bge-small-en-v1.5") - index = get_index(coder=coder) - self.retriever = index.as_retriever(similarity_top_k=20) +class Help: + """Vector retriever over the bundled help docs using a local chromadb store.""" + + def __init__(self, coder=None): + self.collection = get_index(coder=coder) def ask(self, question): - nodes = self.retriever.retrieve(question) + results = self.collection.query( + query_texts=[question], + n_results=20, + include=["documents", "metadatas"], + ) + documents = results.get("documents", [[]])[0] + metadatas = results.get("metadatas", [[]])[0] + context = f"# Question: {question}\n\n# Relevant docs:\n\n" - for node in nodes: - url = node.metadata.get("url", "") + for doc, meta in zip(documents, metadatas): + url = (meta or {}).get("url", "") if url: url = f' from_url="{url}"' context += f"\n" - context += node.text + context += doc context += "\n\n\n" return context -def _in_tui(coder): - """Return True if coder is attached to a live TUI app. +def _strip_doc_suffix(url_path, index, md): + if url_path.lower().endswith(index.lower()): + return url_path[: -len(index)] - The TUI stores itself on coders as a weakref (``coder.tui = weakref.ref(app)``), - so we dereference it (``tui()``) to confirm the app is alive — mirroring the - ``if coder.tui and coder.tui():`` idiom used across the codebase. - """ - try: - import weakref + if url_path.lower().endswith(md.lower()): + return url_path[: -len(md)] + + return url_path - tui_ref = getattr(coder, "tui", None) - if tui_ref is None: - return False - if isinstance(tui_ref, weakref.ref): - return tui_ref() is not None +def _format_docs_url(url_path): + url_path = url_path.strip("/") + + if not url_path: + return "https://cecli.dev/docs/" - return bool(tui_ref) - except Exception: - return False + return f"https://cecli.dev/docs/{url_path}/" diff --git a/cecli/helpers/agents/defaults/memorizer.md b/cecli/helpers/agents/defaults/memorizer.md index bd4e1e1d84b..9c9618c5ee9 100644 --- a/cecli/helpers/agents/defaults/memorizer.md +++ b/cecli/helpers/agents/defaults/memorizer.md @@ -21,7 +21,7 @@ agent-config: - directory_structure - environment_info - git_status - - symbol_outli", + - symbol_outline - todo_list - sub_agents - skills @@ -76,4 +76,4 @@ Before this number hits at most 10, update what you can and yield. Do not delibe Important facts will be easy to search for and extract from the given context. Always prefer **concrete, reusable** facts over vague prose. -Focus on extracting clear results and aids for navigating, modifying, and extending the project in the future. +Focus on extracting aids for navigating, modifying, and extending the project in the future. diff --git a/cecli/helpers/hashline.py b/cecli/helpers/hashline.py index 5b9918f6ba9..da66a70a7c8 100644 --- a/cecli/helpers/hashline.py +++ b/cecli/helpers/hashline.py @@ -1341,28 +1341,43 @@ def _honor_cancellations(resolved_ops): def _deduplicate_ranges(resolved_ops): """ - Deduplicate operations that start on the same line. - If multiple operations start on the same line, keep only the latest one. - This handles cases where a model might generate multiple operations for the same line while "thinking" + Deduplicate operations that target the same line and operation type. + + Inserts are anchored after their target line, so an insert and a replace + sharing a start index are independent operations and must both be retained. + Multiple operations of the same type still keep only the latest one. + + Returns: + tuple: (deduplicated_ops, dropped_ops) where dropped_ops describes the + operations that were removed so callers can surface them as failures. """ deduplicated_ops = [] - # Group operations by start_idx - start_idx_to_ops = {} - # Loop to group operations by their start index + dropped = [] + ops_by_key = {} + for op in resolved_ops: - start_idx = op["start_idx"] - if start_idx not in start_idx_to_ops: - start_idx_to_ops[start_idx] = [] - start_idx_to_ops[start_idx].append(op) - - # For each start_idx, keep only the operation with the highest original index (latest in the list) - # Loop to select only the latest operation per start index - for start_idx, ops in start_idx_to_ops.items(): - # Sort by original index descending and take the first one + key = (op["start_idx"], op["op"]["operation"]) + ops_by_key.setdefault(key, []).append(op) + + for ops in ops_by_key.values(): ops.sort(key=lambda x: x["index"], reverse=True) - deduplicated_ops.append(ops[0]) + kept = ops[0] + deduplicated_ops.append(kept) + + for dropped_op in ops[1:]: + dropped.append( + { + "index": dropped_op["index"], + "error": ( + f"Edit {dropped_op['index'] + 1} targets the same start line and " + f"operation type as Edit {kept['index'] + 1} and was not applied" + ), + "operation": dropped_op["op"], + "failure_type": "not_applied", + } + ) - return deduplicated_ops + return deduplicated_ops, dropped def _honor_special_markers(resolved_ops): @@ -1376,9 +1391,21 @@ def _honor_special_markers(resolved_ops): starting between beginning of file and that end hash. 3. If an operation has a normal start hash and "000@" as end hash, remove any operations ending between that start hash and end of file. + + Returns: + tuple: (remaining_ops, dropped_ops) where dropped_ops describes the + operations removed by the special markers so callers can surface them + as failures instead of silently discarding them. """ if not resolved_ops: - return resolved_ops + return resolved_ops, [] + + def _drop(op, reason): + return { + "index": op["index"], + "error": f"Edit {op['index'] + 1} {reason}", + "operation": op["op"], + } # Check for full file replacement (@000 to 000@) for op in resolved_ops: @@ -1388,7 +1415,12 @@ def _honor_special_markers(resolved_ops): if start_hash == "@000" and end_hash == "000@": # This operation replaces the entire file, keep only this one - return [op] + dropped = [ + _drop(other, "was superseded by a full-file replacement (@000..000@)") + for other in resolved_ops + if other is not op + ] + return [op], dropped # Track which operations have special markers has_special_marker = [False] * len(resolved_ops) @@ -1399,8 +1431,8 @@ def _honor_special_markers(resolved_ops): if start_hash == "@000" or end_hash == "000@": has_special_marker[i] = True - # Mark operations for removal - ops_to_remove = set() + # Mark operations for removal, along with the reason they were removed + ops_to_remove = {} for i, op in enumerate(resolved_ops): original_op = op["op"] @@ -1416,7 +1448,10 @@ def _honor_special_markers(resolved_ops): if j != i and not has_special_marker[j]: other_start_idx = other_op["start_idx"] if other_start_idx <= end_idx: - ops_to_remove.add(j) + ops_to_remove.setdefault( + j, + "was superseded by an operation starting at the top of the file (@000)", + ) elif end_hash == "000@": # Operation ends at end of file # Remove any operations ending at or after this operation's start_idx @@ -1426,26 +1461,39 @@ def _honor_special_markers(resolved_ops): if j != i and not has_special_marker[j]: other_end_idx = other_op["end_idx"] if other_end_idx >= start_idx: - ops_to_remove.add(j) + ops_to_remove.setdefault( + j, + "was superseded by an operation ending at the bottom of the file (000@)", + ) # Filter out operations marked for removal result = [] + dropped = [] for i, op in enumerate(resolved_ops): if i not in ops_to_remove: result.append(op) + else: + dropped.append(_drop(op, ops_to_remove[i])) - return result + return result, dropped def _merged_contained_ranges(resolved_ops): """ Discard inner ranges that are completely contained within outer ranges. This prevents redundant operations and potential errors. + + Returns: + tuple: (optimized_ops, dropped_ops) where dropped_ops describes the + contained operations that were removed so callers can surface them + as failures instead of silently discarding them. """ optimized_ops = [] + dropped = [] # Loop to remove operations that are completely contained within other operations for i, op_a in enumerate(resolved_ops): keep_op = True + container = None # Check if this operation is contained within any other operation for j, op_b in enumerate(resolved_ops): @@ -1466,13 +1514,25 @@ def _merged_contained_ranges(resolved_ops): # Keep both operations if they have different types continue # op_a is inside op_b, discard op_a + container = op_b keep_op = False break if keep_op: optimized_ops.append(op_a) + else: + dropped.append( + { + "index": op_a["index"], + "error": ( + f"Edit {op_a['index'] + 1} range is contained within " + f"Edit {container['index'] + 1} and was not applied" + ), + "operation": op_a["op"], + } + ) - return optimized_ops + return optimized_ops, dropped def sort_ranges(op): @@ -2002,13 +2062,6 @@ def apply_hashline_operations( if not normalized_operations: return original_content, [], failed_ops - # Convert insert operations without @000 marker to inclusive replace operations - for op in normalized_operations: - if op["operation"] == "insert": - start_hash_fragment, _, _ = parse_hashline(op["start_line_hash"]) - if start_hash_fragment != "@000" and start_hash_fragment != "000@": - op["operation"] = "replace" - op["end_line_hash"] = op["start_line_hash"] # Apply hashline to original content once # This converts content to hashed lines for line tracking @@ -2029,6 +2082,9 @@ def apply_hashline_operations( # Genesis anchor - if empty, insert at 0. If not empty, insert at -1 # so that hashed_lines.insert(found_start + 1, text) inserts at 0. found_start = 0 if not hashed_lines else -1 + elif start_hash_fragment == "000@": + # Bottom-of-file anchor - insert after the last line + found_start = len(hashed_lines) - 1 if hashed_lines else -1 else: # Try exact match first for insert operations found_start = find_hashline_by_exact_match( @@ -2100,14 +2156,23 @@ def apply_hashline_operations( # Honor cancellations: remove operations that are cancelled by later cancel operations resolved_ops = _honor_cancellations(resolved_ops) - # Deduplicate: if multiple operations start on the same line, keep only the latest one - # This handles cases where a model might generate multiple operations for the same line while "thinking" - resolved_ops = _deduplicate_ranges(resolved_ops) + # Deduplicate: if multiple operations start on the same line, keep only the latest one. + # This handles cases where a model might generate multiple operations for the same line while "thinking". + # Dropped operations are surfaced as failures instead of being silently discarded. + resolved_ops, dropped = _deduplicate_ranges(resolved_ops) + for drop in dropped: + failed_ops.append(drop) + # Honor special markers: handle @000 and 000@ special markers for whole-file or partial-file operations - resolved_ops = _honor_special_markers(resolved_ops) - # Optimize: discard inner ranges that are completely contained within outer ranges - # This prevents redundant operations and potential errors - resolved_ops = _merged_contained_ranges(resolved_ops) + resolved_ops, dropped = _honor_special_markers(resolved_ops) + for drop in dropped: + failed_ops.append(drop) + + # Optimize: discard inner ranges that are completely contained within outer ranges. + # This prevents redundant operations and potential errors. + resolved_ops, dropped = _merged_contained_ranges(resolved_ops) + for drop in dropped: + failed_ops.append(drop) # Merge contiguous replace operations resolved_ops = _merge_replace_operations(resolved_ops) @@ -2140,19 +2205,22 @@ def apply_hashline_operations( op = resolved["op"] start_idx = resolved["start_idx"] end_idx = resolved["end_idx"] + changed = True if op["operation"] == "insert": text = op["text"] - if text and not text.endswith("\n"): - text += "\n" - # Special handling for empty hashed_lines (genesis anchor case) - if hashed_lines: - if not hashed_lines[start_idx].endswith("\n"): - hashed_lines[start_idx] += "\n" - hashed_lines.insert(start_idx + 1, text) + if not text: + changed = False else: - # Empty content with genesis anchor - just add the text - hashed_lines.append(text) + if not text.endswith("\n"): + text += "\n" + # Special handling for empty hashed_lines (genesis anchor case) + if hashed_lines: + if not hashed_lines[start_idx].endswith("\n"): + hashed_lines[start_idx] += "\n" + hashed_lines.insert(start_idx + 1, text) + else: + hashed_lines.append(text) elif op["operation"] == "delete": del hashed_lines[start_idx : end_idx + 1] elif op["operation"] == "replace": @@ -2161,6 +2229,13 @@ def apply_hashline_operations( end_idx = len(hashed_lines) - 1 text = op["text"] + # Snapshot the pre-edit range content so no-op replacements are + # not counted as successful edits. Normalize line endings because + # the apply path enforces a trailing newline on replacement lines. + before_range = [ + strip_hashline(line).rstrip("\r\n") + for line in hashed_lines[start_idx : end_idx + 1] + ] if text: # Split text into lines, preserving trailing newline behavior # If text doesn't end with newline, we add one to ensure proper line separation @@ -2207,12 +2282,31 @@ def apply_hashline_operations( else: # Empty text - replace with nothing (delete) + replacement_lines = [] hashed_lines[start_idx : end_idx + 1] = [] - if "merged_indices" in resolved: - successful_ops.extend(resolved["merged_indices"]) + replacement_content = [line.rstrip("\r\n") for line in replacement_lines] + if before_range == replacement_content: + changed = False + + if changed: + if "merged_indices" in resolved: + successful_ops.extend(resolved["merged_indices"]) + else: + successful_ops.append(resolved["index"]) else: - successful_ops.append(resolved["index"]) + # No-op edit: report it as failed so the caller's success count + # reflects edits that actually changed the file + indices = resolved.get("merged_indices") or [resolved["index"]] + for idx in indices: + failed_ops.append( + { + "index": idx, + "error": "Edit resulted in no changes to the file content", + "operation": op, + "failure_type": "no_change", + } + ) except Exception as e: failed_ops.append( {"index": resolved["index"], "error": str(e), "operation": resolved["op"]} diff --git a/cecli/helpers/hashpos/hashpos.py b/cecli/helpers/hashpos/hashpos.py index 6c0d2cb1664..0190c8df418 100644 --- a/cecli/helpers/hashpos/hashpos.py +++ b/cecli/helpers/hashpos/hashpos.py @@ -76,7 +76,7 @@ class HashPos: # Loose prefix for robust stripping: Matches a tilde-wrapped 4-char string containing non-ASCII _LOOSE_PREFIX_RE = re.compile( - rf"^{HASH_DELIMITER}(?=.{{0,3}}[^\x00-\x7f]).{{4}}{HASH_DELIMITER}" + rf"^[{HASH_DELIMITER}]?(?=.{{0,3}}[^\x00-\x7f]).{{4}}{HASH_DELIMITER}" ) def __init__(self, source_text: str = ""): diff --git a/cecli/helpers/llms/domains/chat.py b/cecli/helpers/llms/domains/chat.py index 7aa4a29aca8..0f9f37d49c4 100644 --- a/cecli/helpers/llms/domains/chat.py +++ b/cecli/helpers/llms/domains/chat.py @@ -274,14 +274,18 @@ def parse_chat_chunk(data: Dict[str, Any]) -> Optional[CompletionChunk]: # Tool-call deltas arrive as fragments keyed by provider ``index``: the # first fragment carries id+name, later fragments only argument deltas. - # Preserve that contract -- consumers (base_coder / stream_chunk_builder) - # merge fragments by index and concatenate the ``_fragment`` JSON. + # Preserve the raw index AND the id -- consumers (base_coder / + # stream_chunk_builder) key by id when present and fall back to the index, + # so parallel calls that reuse index0 (e.g. deepseek) are not collapsed. for tc in delta.get("tool_calls") or []: fn = tc.get("function") or {} args_raw = fn.get("arguments") or "" tool_calls.append( ToolCall( - id=tc.get("id", ""), name=fn.get("name", ""), arguments={"_fragment": args_raw} + id=tc.get("id", ""), + name=fn.get("name", ""), + index=tc.get("index"), + arguments={"_fragment": args_raw}, ) ) diff --git a/cecli/helpers/llms/litellm_compat.py b/cecli/helpers/llms/litellm_compat.py index e9744d6c82c..91042c07387 100644 --- a/cecli/helpers/llms/litellm_compat.py +++ b/cecli/helpers/llms/litellm_compat.py @@ -220,16 +220,20 @@ def _choice_to_dict(choice: Choices) -> Dict[str, Any]: def _message_to_dict(message: Message) -> Dict[str, Any]: - return { + res = { "role": message.role, "content": message.content, "tool_calls": [_tool_call_to_dict(tc) for tc in message.tool_calls] or None, "function_call": _function_to_dict(message.function_call), "reasoning_content": message.reasoning_content, - "reasoning_redacted": message.reasoning_redacted, "provider_specific_fields": message.provider_specific_fields, } + if getattr(message, "reasoning_redacted", None): + res["reasoning_redacted"] = message.reasoning_redacted + + return res + def _tool_call_to_dict(tc: ChatCompletionMessageToolCall) -> Dict[str, Any]: function = tc.function @@ -627,27 +631,60 @@ def _chunk_shim(chunk: Any, model: Optional[str] = None) -> StreamChunk: def _accumulate_tool_call( - tool_calls_dict: Dict[int, Dict[str, Any]], tc: Optional[ChatCompletionMessageToolCall] + tool_calls_dict: Dict[Any, Dict[str, Any]], + tc: Optional[ChatCompletionMessageToolCall], + state: Optional[Dict[str, Any]] = None, ) -> None: - """Merge one delta tool-call into an index-keyed accumulation dict.""" + """Merge one delta tool-call into a keyed accumulation dict. + + Parallel tool calls are keyed by their ``id`` when the provider supplies one + (OpenAI / Responses style), falling back to the delta ``index`` for providers + that only increment a per-event index (anthropic / gemini). Some providers + (e.g. deepseek) reuse index0 across parallel calls and only distinguish them + by the id announced on the first fragment, so the index -> key mapping in + ``state`` lets later id-less fragments resolve to the right bucket instead of + collapsing onto one call. + """ if tc is None: return + function = tc.function if function is None: return + if state is None: + state = {} + + index_lookup = state.setdefault("index_lookup", {}) + last_key = state.get("last_key") index = tc.index - if index is None: - index = len(tool_calls_dict) + + if tc.id: + key = ("id", tc.id) + + if index is not None: + index_lookup[index] = key + + state["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"] = tc.id or entry["id"] @@ -664,12 +701,16 @@ def _accumulate_tool_call( def _finalize_tool_calls( - tool_calls_dict: Dict[int, Dict[str, Any]], + tool_calls_dict: Dict[Any, Dict[str, Any]], ) -> List[ChatCompletionMessageToolCall]: - """Build final tool-call shims from an index-keyed accumulation dict.""" + """Build final tool-call shims from a keyed accumulation dict. + + Entries are ordered by first appearance (``_order``) so parallel calls keep + the stream order even when their keys are ids rather than indices. + """ tool_calls: List[ChatCompletionMessageToolCall] = [] - 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 function = Function(arguments="".join(data["arguments"]) or "{}", name=data["name"]) @@ -839,12 +880,13 @@ def stream_chunk_builder( """Reassemble streaming chunks into a single litellm-shaped response. Mirrors ``litellm.stream_chunk_builder``: text/reasoning are joined, - tool calls are accumulated per delta index, and finish_reason/usage + tool calls are accumulated per call id (falling back to delta index), and finish_reason/usage come from the last chunk that carried them. """ content_parts: List[str] = [] reasoning_parts: List[str] = [] - tool_calls_dict: Dict[int, Dict[str, Any]] = {} + tool_calls_dict: Dict[Any, Dict[str, Any]] = {} + tool_state: Dict[str, Any] = {} finish_reason: Optional[str] = None usage: Optional[Usage] = None response_id: Optional[str] = None @@ -883,7 +925,7 @@ def stream_chunk_builder( if reasoning_msg: reasoning_parts.append(reasoning_msg) for tc in getattr(message, "tool_calls", None) or []: - _accumulate_tool_call(tool_calls_dict, tc) + _accumulate_tool_call(tool_calls_dict, tc, tool_state) continue if getattr(delta, "content", None): @@ -894,7 +936,7 @@ def stream_chunk_builder( if reasoning_delta: reasoning_parts.append(reasoning_delta) for tc in getattr(delta, "tool_calls", None) or []: - _accumulate_tool_call(tool_calls_dict, tc) + _accumulate_tool_call(tool_calls_dict, tc, tool_state) delta_psf = getattr(delta, "provider_specific_fields", None) if delta_psf: diff --git a/cecli/helpers/loop_detect.py b/cecli/helpers/loop_detect.py new file mode 100644 index 00000000000..d456ade1d94 --- /dev/null +++ b/cecli/helpers/loop_detect.py @@ -0,0 +1,121 @@ +from collections import OrderedDict + + +class LoopDetectedError(Exception): + """Raised when a repeating output loop is detected while streaming.""" + + +class LoopDetector: + def __init__(self, char_limit=100, word_limit=25, sentence_limit=5, max_sentences=10): + self.char_limit = char_limit + self.word_limit = word_limit + self.sentence_limit = sentence_limit + self.max_sentences = max_sentences + + # Level 1: Character State + self.last_char = "" + self.char_count = 0 + + # Level 2: Word State + self.current_word = [] + self.last_word = "" + self.word_count = 0 + + # Level 3: Sentence State (Bounded LRU Cache) + self.current_sentence = [] + self.sentence_counts = OrderedDict() + + def push(self, chunk: str) -> None: + for char in chunk: + # ------------------------- + # 1. Character Level Check + # ------------------------- + if not char.isspace(): + if char == self.last_char: + self.char_count += 1 + if self.char_count >= self.char_limit: + raise LoopDetectedError( + f"Char loop: '{char}' repeated {self.char_count} times." + ) + else: + self.char_count = 1 + self.last_char = char + + # ------------------------- + # 2. Word Level Check + # ------------------------- + if char.isalnum() or char in ["_", "-"]: + self.current_word.append(char) + elif self.current_word: + word_str = "".join(self.current_word).lower() + + if word_str == self.last_word: + self.word_count += 1 + if self.word_count >= self.word_limit: + raise LoopDetectedError( + f"Word loop: '{word_str}' repeated {self.word_count} times." + ) + else: + self.word_count = 1 + + self.last_word = word_str + self.current_word = [] + + # ------------------------- + # 3. Sentence Level Check + # ------------------------- + self.current_sentence.append(char) + + if char in [".", "!", "?", "\n"]: + sentence_str = "".join(self.current_sentence).strip() + + if len(sentence_str) > 10 and is_sentence(sentence_str): + normalized = " ".join(sentence_str.lower().split()) + + # Update count and move to the "most recent" position + if normalized in self.sentence_counts: + self.sentence_counts[normalized] += 1 + self.sentence_counts.move_to_end(normalized) + else: + self.sentence_counts[normalized] = 1 + + # Check threshold + if self.sentence_counts[normalized] >= self.sentence_limit: + raise LoopDetectedError( + f"Sentence loop: '{normalized}' occurred {self.sentence_counts[normalized]} times." + ) + + # Enforce the 10-sentence memory bound + if len(self.sentence_counts) > self.max_sentences: + # popitem(last=False) removes the oldest, least recently used item + self.sentence_counts.popitem(last=False) + + self.current_sentence = [] + + return None + + +def is_sentence(text): + """Return True if *text* looks like a latin-language sentence, not a code block. + + A sentence must be non-empty, start with a capital letter, end with a + sentence terminator, and contain a space (i.e. multiple words). + """ + text = text.strip() + + if not text: + return False + + # Check if it starts with a capital letter + if not text[0].isupper(): + return False + + # Check if it ends with a punctuation mark + if text[-1] not in [".", "!", "?"]: + return False + + # Check if it contains at least one space (implying multiple words) + if " " not in text: + return False + + return True diff --git a/cecli/helpers/model_config/pipeline.py b/cecli/helpers/model_config/pipeline.py index 2ff83805c34..720dd0e273d 100644 --- a/cecli/helpers/model_config/pipeline.py +++ b/cecli/helpers/model_config/pipeline.py @@ -4,14 +4,17 @@ ``get_default_config`` feeds a small context dict through a chain of step functions, each of which transforms the context and returns it. -Like ``cecli/models.py``, large metadata files are scanned as raw JSON strings -(one entry at a time) instead of being ``json.loads``-ed wholesale, so a model -lookup never materializes the full metadata dict in memory. +Like ``cecli/models.py``, user-supplied metadata files are scanned as raw +JSON strings (one entry at a time) instead of being ``json.loads``-ed +wholesale, so a lookup never materializes a large metadata dict in memory. +The small bundled ``model-metadata.json`` default is parsed once and cached +so lookups against it are O(1) rather than re-scanning the raw text per key. """ from __future__ import annotations import importlib.resources as importlib_resources +import json import re from pathlib import Path from typing import Any, Dict, List, Optional, Union @@ -37,6 +40,10 @@ #: Lazily loaded raw text of the bundled metadata file (never json.loads-ed). _BUNDLED_RAW_CACHE: Optional[str] = None +#: Parsed bundled metadata dict, cached once so bundled lookups are O(1) +#: instead of re-scanning the raw JSON text for each candidate key. +_BUNDLED_JSON_CACHE: Optional[Dict[str, Any]] = None + MetadataSource = Union[str, Path, Dict[str, Any]] @@ -102,7 +109,7 @@ def _load_metadata(context): files = context["metadata_files"] if files is None: - context["sources"] = [{"kind": "raw", "text": _bundled_metadata_raw()}] + context["sources"] = [{"kind": "dict", "data": _bundled_metadata()}] return context context["sources"] = [_normalize_source(source) for source in _as_list(files)] @@ -172,6 +179,26 @@ def _bundled_metadata_raw() -> str: return _BUNDLED_RAW_CACHE +def _bundled_metadata() -> Dict[str, Any]: + """Return the parsed bundled metadata dict, cached after the first load. + + The raw text is kept for callers that only need a single record; the + parsed dict makes lookups against the bundled default O(1) instead of + re-scanning the raw JSON string once per candidate key. + """ + global _BUNDLED_JSON_CACHE + + if _BUNDLED_JSON_CACHE is None: + try: + parsed = json.loads(_bundled_metadata_raw()) + _BUNDLED_JSON_CACHE = parsed if isinstance(parsed, dict) else {} + + except (ValueError, TypeError): + _BUNDLED_JSON_CACHE = {} + + return _BUNDLED_JSON_CACHE + + def _as_list(value): """Normalize a single source or a list of sources into a list.""" if isinstance(value, (list, tuple)): @@ -191,7 +218,7 @@ def _normalize_source(source): if _path_exists(path): try: if path.name == RESOURCE_FILE: - return {"kind": "raw", "text": _bundled_metadata_raw()} + return {"kind": "dict", "data": _bundled_metadata()} return {"kind": "raw", "text": path.read_text()} diff --git a/cecli/main.py b/cecli/main.py index e653582dce8..262f7ab1646 100644 --- a/cecli/main.py +++ b/cecli/main.py @@ -1262,7 +1262,11 @@ def get_io(pretty): mcp_servers = load_mcp_servers( args.mcp_servers, args.mcp_servers_files, io, args.verbose, args.mcp_transport ) - mcp_manager = await McpServerManager.from_servers(mcp_servers, io, args.verbose) + # Create the manager without connecting. Connections are established + # later on the coder's event loop (connect_all below for CLI mode, or + # CoderWorker._async_run for TUI mode) so loop-bound MCP state stays + # on the loop the coder actually runs on. + mcp_manager = McpServerManager(mcp_servers, io=io, verbose=args.verbose) if from_coder: from_coder.tui = None @@ -1328,6 +1332,14 @@ def get_io(pretty): f" {', '.join(loaded_hooks)}" ) + # Connect MCP servers on the coder's event loop so loop-bound MCP + # state (sessions, locks, keepalive tasks) is created where the coder + # runs. In TUI mode the coder runs on the worker thread's loop and + # CoderWorker connects there; connecting here on the main loop would + # migrate the connections across loops on first use. + if not args.tui: + await mcp_manager.connect_all() + if args.show_model_warnings and not suppress_pre_init: problem = await models.sanity_check_models(pre_init_io, main_model) if problem: diff --git a/cecli/mcp/manager.py b/cecli/mcp/manager.py index 0e361289516..af3e2eaa0a0 100644 --- a/cecli/mcp/manager.py +++ b/cecli/mcp/manager.py @@ -32,6 +32,9 @@ def __init__( self._server_tools: dict[str, list] = {} # Maps server name to its tools self._connected_servers: set[McpServer] = set() + # Event loop that created the MCP connections (set by connect_all). + # Loop-bound MCP state must only be torn down on this loop. + self._connection_loop: asyncio.AbstractEventLoop | None = None def _log_verbose(self, message: str) -> None: """Log a verbose message if verbose mode is enabled and IO is available.""" @@ -85,8 +88,11 @@ def servers(self) -> list["McpServer"]: @property def is_connected(self) -> bool: - """Check if any servers are connected.""" - return len(self._connected_servers) > 0 + """Check if any servers have a live session (including not-yet-registered ones).""" + if self._connected_servers: + return True + + return any(server.is_connected for server in self._servers) def get_server(self, name: str) -> McpServer | None: """ @@ -104,8 +110,23 @@ def get_server(self, name: str) -> McpServer | None: return None async def disconnect_all(self) -> None: - """Disconnect from all MCP servers.""" - if not self._connected_servers: + """ + Disconnect from all MCP servers. + + Connections are loop-bound: they must only be torn down on the loop that + created them (see connect_all). Callers on any other loop (e.g. the TUI + main loop during a reload) skip, because the owning loop's teardown owns + the cleanup and tearing down cross-loop would raise or leak transports. + """ + current_loop = asyncio.get_running_loop() + if self._connection_loop is not None and self._connection_loop is not current_loop: + self._log_verbose("Skipping disconnect_all: MCP connections live on another event loop") + return + + # Include servers with a live session that never made it into + # _connected_servers (e.g. connect_all was cancelled between + # server.connect() and registering the server). + if not self._connected_servers and not any(server.is_connected for server in self._servers): self._log_verbose("MCP servers already disconnected") return @@ -127,8 +148,11 @@ async def disconnect_server(server: McpServer) -> tuple[McpServer, bool]: self._log_warning(f"Error disconnected from MCP server: {server.name}") return (server, False) - # Create a copy to avoid modifying during iteration - servers_to_disconnect = list(self._connected_servers) + servers_to_disconnect = [ + server + for server in self._servers + if server in self._connected_servers or server.is_connected + ] tasks = [disconnect_server(server) for server in servers_to_disconnect] try: @@ -141,7 +165,7 @@ async def disconnect_server(server: McpServer) -> tuple[McpServer, bool]: for server, success in results: if success: - self._connected_servers.remove(server) + self._connected_servers.discard(server) async def connect_server(self, name: str) -> bool: """ @@ -209,6 +233,10 @@ async def connect_server(self, name: str) -> bool: f"Failed to connect to MCP server {name} " f"after {max_retries} attempts: {e}" ) + if server.is_connected: + # Session was established but tool listing failed; tear + # it down so the transport/subprocess doesn't leak. + await server.disconnect() return False async def disconnect_server(self, name: str) -> bool: @@ -310,45 +338,54 @@ async def from_servers( Create an MCP Server Manager from a list of servers it should manage. Automatically connects if the server is set to auto connect (by default it is) """ - mcp_manager = cls(servers=[], io=io, verbose=verbose) - - async def add_server_with_retry( - server: McpServer, connect: bool = True, max_retries: int = 3 - ) -> tuple[McpServer, bool]: - """Try to add and connect to a server with retries.""" - if not connect: - success = await mcp_manager.add_server(server, connect=False) - return (server, success) - - # connect_server now has built-in retry logic, so we only need - # a single call here — no separate retry loop needed. - success = await mcp_manager.add_server(server, connect=True) - return (server, success) - - tasks = [] - for server in servers: - auto_connect = server.config.get("enabled", True) - tasks.append(add_server_with_retry(server, connect=auto_connect)) - - results = await asyncio.gather(*tasks) - for server, did_connect in results: - if not did_connect and server.name.lower() not in ["unnamed-server", "local"]: - io.tool_warning( + mcp_manager = cls(servers=servers, io=io, verbose=verbose) + await mcp_manager.connect_all() + + return mcp_manager + + async def connect_all(self) -> None: + """ + Connect all configured servers that are enabled (default) and populate their tools. + + Runs on whatever event loop calls it. In TUI mode the coder runs on the worker + thread's event loop (CoderWorker), so callers should invoke this from the coder's + loop — otherwise loop-bound MCP state (sessions, asyncio locks, keepalive tasks) + is created on one loop and migrated to another on first use, which can raise or + hang. + """ + self._connection_loop = asyncio.get_running_loop() + + async def _connect(server: McpServer) -> tuple[McpServer, bool, bool]: + if not server.config.get("enabled", True): + # Disabled servers are registered but intentionally not connected. + return (server, False, False) + + success = await self.connect_server(server.name) + + return (server, success, True) + + results = await asyncio.gather(*(_connect(server) for server in self._servers)) + + for server, did_connect, attempted in results: + if ( + attempted + and not did_connect + and server.name.lower() not in ["unnamed-server", "local"] + ): + self._log_warning( f"MCP tool initialization failed after multiple retries: {server.name}" ) - if verbose: - io.tool_output("MCP servers configured:") + if self.verbose and self.io: + self.io.tool_output("MCP servers configured:") - for server, _ in results: - io.tool_output(f" - {server.name}") + for server, _did_connect, _attempted in results: + self.io.tool_output(f" - {server.name}") - for tool in mcp_manager.get_server_tools(server.name): + for tool in self.get_server_tools(server.name): tool_name = tool.get("function", {}).get("name", "unknown") tool_desc = tool.get("function", {}).get("description", "").split("\n")[0] - io.tool_output(f" - {tool_name}: {tool_desc}") - - return mcp_manager + self.io.tool_output(f" - {tool_name}: {tool_desc}") def get_local_tool_schemas(): diff --git a/cecli/mcp/server.py b/cecli/mcp/server.py index 88faa585ceb..108b8ea73c3 100644 --- a/cecli/mcp/server.py +++ b/cecli/mcp/server.py @@ -2,6 +2,7 @@ import logging import os import random +import threading import webbrowser from contextlib import AsyncExitStack from enum import Enum, auto @@ -59,7 +60,14 @@ def __init__(self, server_config, io=None, verbose=False): self.verbose = verbose self.session = None self._connection_loop: asyncio.AbstractEventLoop | None = None - self._cleanup_lock: asyncio.Lock = asyncio.Lock() + # threading.Lock (not asyncio.Lock): disconnect can be reached from a + # different event loop than the one that created the connection (TUI + # worker thread, ReloadProgramSignal), and asyncio.Lock is loop-bound + # and raises RuntimeError on a different loop. The lock only guards the + # short _disconnecting check-and-set below (no awaits while held), so a + # concurrent disconnect on the same loop never blocks the loop thread. + self._cleanup_lock: threading.Lock = threading.Lock() + self._disconnecting = False self.exit_stack = AsyncExitStack() @property @@ -121,18 +129,30 @@ async def connect(self): raise async def disconnect(self): - """Disconnect from the MCP server and clean up resources.""" - async with self._cleanup_lock: - try: - await self.exit_stack.aclose() - except (asyncio.CancelledError, RuntimeError, GeneratorExit): - # Expected during shutdown - anyio cancel scopes don't play - # well with asyncio teardown. Resources are still cleaned up. - pass - except Exception as e: - logging.error(f"Error during cleanup of server {self.name}: {e}") - finally: + """ + Disconnect from the MCP server and clean up resources. + + Idempotent and safe to call from any task or thread: only one caller + performs the teardown; concurrent callers (possibly on another event + loop) return immediately once the session is marked gone. + """ + with self._cleanup_lock: + if self._disconnecting: self.session = None + return + self._disconnecting = True + + try: + await self.exit_stack.aclose() + except (asyncio.CancelledError, RuntimeError, GeneratorExit): + # Expected during shutdown - anyio cancel scopes don't play + # well with asyncio teardown. Resources are still cleaned up. + pass + except Exception as e: + logging.error(f"Error during cleanup of server {self.name}: {e}") + finally: + self.session = None + self._disconnecting = False async def reconnect(self): """Disconnect and reconnect, establishing a fresh session. @@ -440,28 +460,40 @@ async def reconnect(self): ) async def disconnect(self, cancel_keepalive: bool = True): - """Disconnect from the MCP server and clean up resources.""" - async with self._cleanup_lock: - try: - if cancel_keepalive and self._keepalive_task: - self._keepalive_task.cancel() - try: - await asyncio.wait_for(self._keepalive_task, timeout=15) - except asyncio.CancelledError: - pass - logger.info(f"Keepalive task stopped for {self.name}") - if hasattr(self, "_oauth_shutdown"): - self._oauth_shutdown() - await self.exit_stack.aclose() - except (asyncio.CancelledError, RuntimeError, GeneratorExit): - # Expected during shutdown - anyio cancel scopes don't play - # well with asyncio teardown. Resources are still cleaned up. - pass - except Exception as e: - logging.error(f"Error during cleanup of server {self.name}: {e}") - finally: + """ + Disconnect from the MCP server and clean up resources. + + Idempotent and safe to call from any task or thread: only one caller + performs the teardown; concurrent callers (possibly on another event + loop) return immediately once the session is marked gone. + """ + with self._cleanup_lock: + if self._disconnecting: self.session = None - self._http_client = None + return + self._disconnecting = True + + try: + if cancel_keepalive and self._keepalive_task: + self._keepalive_task.cancel() + try: + await asyncio.wait_for(self._keepalive_task, timeout=15) + except asyncio.CancelledError: + pass + logger.info(f"Keepalive task stopped for {self.name}") + if hasattr(self, "_oauth_shutdown"): + self._oauth_shutdown() + await self.exit_stack.aclose() + except (asyncio.CancelledError, RuntimeError, GeneratorExit): + # Expected during shutdown - anyio cancel scopes don't play + # well with asyncio teardown. Resources are still cleaned up. + pass + except Exception as e: + logging.error(f"Error during cleanup of server {self.name}: {e}") + finally: + self.session = None + self._http_client = None + self._disconnecting = False class HttpStreamingServer(HttpBasedMcpServer): diff --git a/cecli/tests/test_queue_commands.py b/cecli/tests/test_queue_commands.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/cecli/tools/command.py b/cecli/tools/command.py index 4f581db0d64..4359003e57a 100644 --- a/cecli/tools/command.py +++ b/cecli/tools/command.py @@ -84,14 +84,15 @@ class Tool(BaseTool): "When True, runs the command interactively using a " "pseudo-terminal (PTY), allowing the user to provide " "inputs like passwords or navigate terminal interfaces. " - "Handles TUI suspension automatically." ), "default": False, }, "timeout": { "type": "integer", "description": ( - "Timeout in seconds for command execution. " "Default is 30 seconds." + "Timeout in seconds for command execution. " + "Default is 30 seconds. Maximum 300 seconds. " + "If the command exceeds this time, it will continue in the background." ), "default": 30, }, diff --git a/cecli/tools/edit_file.py b/cecli/tools/edit_file.py index c3497dac3e2..edd7f24f560 100644 --- a/cecli/tools/edit_file.py +++ b/cecli/tools/edit_file.py @@ -1,3 +1,4 @@ +from cecli.helpers import nested from cecli.helpers.hashline import ( HASH_DELIMITER, UNIQUE_HASH_DELIMITER, @@ -29,6 +30,7 @@ USER_EDIT_CATEGORIES = { "no_changes": "No Changes", + "not_applied": "Edit Not Applied", "syntax_errors": "Syntax Errors", "boundary_errors": "Boundary Resolution Error", } @@ -189,6 +191,7 @@ def execute( # 3. Process each file all_results = [] all_failed_edits = [] + skipped_file_failures = [] total_successful_edits = 0 files_processed = 0 @@ -226,18 +229,18 @@ def execute( ) edit_file_raw = edit.get("text") - edit_start_line = edit.get("start_line") - edit_end_line = edit.get("end_line") + edit_start_line = nested.getter( + edit, ["start_line", "range_start", "line_start", "start"] + ) + edit_end_line = nested.getter( + edit, ["end_line", "range_end", "line_end", "end"] + ) # --------------------------------------------------------- # DEFENSIVE FALLBACKS # --------------------------------------------------------- - # 1. Handle missing text parameter by defaulting to empty string - if edit_file_raw is None: - edit_file_raw = "" - - # 2. Programmatically enforce @000 for empty files + # 1. Programmatically enforce @000 for empty files if not original_content or not original_content.strip(): edit_start_line = "@000" edit_end_line = "@000" @@ -271,12 +274,12 @@ def execute( edit_file = edit_file_raw # Validate required fields based on operation type - # (Note: The check for 'edit_file is None' will now be safely - # bypassed because we defaulted it to "" above) + # Missing text must not silently degrade into a delete + # of the targeted range. if operation in ("replace", "insert"): - if edit_file is None: + if edit_file_raw is None or edit_file_raw == "": raise ToolError( - f"Edit {edit_index + 1}: 'text' parameter is required for " + f"Edit {edit_index + 1}: non-empty 'text' parameter is required for " f"'{operation}' operation" ) if operation in ("replace", "delete"): @@ -337,13 +340,24 @@ def execute( if new_content != original_content: file_successful_edits += len(successful_ops) else: - # Be specific about why content didn't change - if failed_ops: + # Be specific about why content didn't change. + # If no operation reached the apply stage, every edit + # already failed validation and the per-edit failures + # explain why; skip the generic no-change message. + if operations and failed_ops: + no_change_failures = all( + op.get("failure_type") == "no_change" for op in failed_ops + ) + if no_change_failures: + raise ToolError( + "Invalid Edit - The requested edit matched the existing content; " + "no changes were applied. Adjust the replacement text or targeted range." + ) error_details = "; ".join(op["error"] for op in failed_ops) raise ToolError( f"Invalid Edit - Review content ID bounds: {error_details}" ) - else: + elif operations: raise ToolError( "Invalid Edit - Review content ID bounds - " "All edits resulted in unchanged content" @@ -365,6 +379,8 @@ def execute( # Check if any changes were made for this file if original_content == new_content or file_successful_edits == 0: + if file_failed_edits: + skipped_file_failures.append((file_path_key, file_failed_edits)) continue # Handle dry run @@ -413,21 +429,30 @@ def execute( } ) total_successful_edits += file_successful_edits - all_failed_edits.extend(file_failed_edits) files_processed += 1 except Exception as e: # Record all edits for this file as failed + file_errors = [] + for edit_index, _ in file_edits: - all_failed_edits.append( - f"Edit {edit_index + 1} - {cls._categorize_edit_error(str(e))}" - ) + error_msg = f"Edit {edit_index + 1} - {cls._categorize_edit_error(str(e))}" + file_errors.append(error_msg) + all_failed_edits.append(error_msg) + + if file_errors: + skipped_file_failures.append((file_path_key, file_errors)) continue # If dry run, return all results if dry_run: dry_run_messages = "\n".join(r.get("dry_run_message", "") for r in all_results) response.append_result(dry_run_messages or "Dry run: No changes would be made") + + for file_path_key, failures in skipped_file_failures: + response.append_error( + f"Edits to {file_path_key} were not applied:\n" + "\n".join(failures) + ) return response # 4. Check if any edits succeeded overall @@ -480,6 +505,13 @@ def execute( }, ) + # Surface failures from files whose edits were not applied at all, + # even when other files in the batch succeeded. + for file_path_key, failures in skipped_file_failures: + response.append_error( + f"Edits to {file_path_key} were not applied:\n" + "\n".join(failures) + ) + return response except ToolError as e: @@ -534,8 +566,10 @@ def format_output(cls, coder, mcp_server, tool_response): coder.io.tool_output(f"{color_start}{OPERATION_NOUNS[operation]}:{color_end}") text = strip_hashline(edit.get("text", "")) - start_line = edit.get("start_line") - end_line = edit.get("end_line") + start_line = nested.getter( + edit, ["start_line", "range_start", "line_start", "start"] + ) + end_line = nested.getter(edit, ["end_line", "range_end", "line_end", "end"]) # Show output based on operation type if operation in ("replace", "delete"): # Show diff for replace operations @@ -624,6 +658,13 @@ def _categorize_edit_error(cls, error_msg: str) -> str: if "syntax error" in error_lower or "introduces new syntax" in error_lower: return USER_EDIT_CATEGORIES["syntax_errors"] + elif ( + "not applied" in error_lower + or "superseded" in error_lower + or "contained within" in error_lower + ): + return f"{USER_EDIT_CATEGORIES['not_applied']}: {error_msg}" + elif "hash" in error_lower or "content id" in error_lower or "not found" in error_lower: # Append the actual error string so the LLM can self-correct its specific mistake return f"{USER_EDIT_CATEGORIES['boundary_errors']}: {error_msg}" diff --git a/cecli/tools/read_file.py b/cecli/tools/read_file.py index 93721d266d2..c3c10b2cba9 100644 --- a/cecli/tools/read_file.py +++ b/cecli/tools/read_file.py @@ -2,6 +2,7 @@ import os from typing import Dict, List +from cecli.helpers import nested from cecli.helpers.hashline import hashline_formatted, strip_hashline from cecli.helpers.hashpos.transformations import ( apply_contextual_marker, @@ -138,8 +139,10 @@ def execute(cls, coder, read, **kwargs): for read_index, read_op in enumerate(read): # Extract parameters for this read operation file_path = read_op.get("file_path") - range_start = read_op.get("range_start") - range_end = read_op.get("range_end") + range_start = nested.getter( + read_op, ["range_start", "start_line", "line_start", "start"] + ) + range_end = nested.getter(read_op, ["range_end", "end_line", "line_end", "end"]) padding = 0 if file_path is None: @@ -888,8 +891,12 @@ def format_output(cls, coder, mcp_server, tool_response): coder.io.tool_output("") for i, read_op in enumerate(read_ops): file_path = read_op.get("file_path", "") - range_start = strip_hashline(read_op.get("range_start", "")).strip() - range_end = strip_hashline(read_op.get("range_end", "")).strip() + range_start = strip_hashline( + nested.getter(read_op, ["range_start", "start_line", "line_start", "start"]) + ).strip() + range_end = strip_hashline( + nested.getter(read_op, ["range_end", "end_line", "line_end", "end"]) + ).strip() # Format as "read: • file_path • range_start • range_end • padding" formatted_query = ( diff --git a/cecli/tui/worker.py b/cecli/tui/worker.py index babe0b14396..e9471b4df50 100644 --- a/cecli/tui/worker.py +++ b/cecli/tui/worker.py @@ -72,6 +72,18 @@ def _cleanup_loop(self): return try: + # Disconnect MCP servers on this loop (connections live here) so + # main-loop disconnect_all() becomes a no-op instead of racing + # loop-bound cleanup across threads. + mcp_manager = getattr(self.coder, "mcp_manager", None) + if mcp_manager is not None and mcp_manager.is_connected and not self.loop.is_closed(): + try: + self.loop.run_until_complete( + asyncio.wait_for(mcp_manager.disconnect_all(), timeout=10) + ) + except Exception: + pass # Ignore cleanup errors + # Cancel pending tasks if loop is still running if not self.loop.is_closed(): pending = asyncio.all_tasks(self.loop) @@ -97,6 +109,16 @@ def _cleanup_loop(self): async def _async_run(self): """Async entry point - runs coder loop.""" + # MCP servers connect lazily on the coder's event loop (see main.py) + # so loop-bound MCP state (sessions, locks, keepalive tasks) stays on + # the same loop the coder runs on instead of migrating across loops. + mcp_manager = getattr(self.coder, "mcp_manager", None) + if mcp_manager is not None: + try: + await mcp_manager.connect_all() + except Exception as e: + logger.error("Failed to connect MCP servers in worker: %s", e, exc_info=True) + while self.running: try: await self.coder.run() diff --git a/cecli/utils.py b/cecli/utils.py index b1bcc0a6aca..f4341e03e7f 100644 --- a/cecli/utils.py +++ b/cecli/utils.py @@ -4,6 +4,7 @@ import platform import shlex import shutil +import ssl import subprocess import sys import tempfile @@ -387,11 +388,20 @@ def touch_file(fname): async def check_pip_install_extra(io, module, prompt, pip_install_cmd, self_update=False): if module: - try: - __import__(module) - return True - except (ImportError, ModuleNotFoundError, RuntimeError): - pass + for _attempt in range(2): + try: + __import__(module) + return True + except ssl.SSLError: + # OpenSSL CONF module lazy-init flake (WSL2 + OpenSSL 3.5 + Py3.14): + # the very first SSL context creation can fail with + # ``[CONF: MODULE_INITIALIZATION_ERROR]`` / "unknown error (0x0)", + # and a second attempt succeeds. Mirrors llms.runtime.make_client. + if _attempt == 0: + continue + raise + except (ImportError, ModuleNotFoundError, RuntimeError): + break cmd = get_pip_install(pip_install_cmd) diff --git a/requirements.txt b/requirements.txt index 6cca5ff1bdd..434705d7ce4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -320,6 +320,10 @@ tomlkit==0.14.0 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in +tqdm==4.67.1 + # via + # -c requirements/common-constraints.txt + # -r requirements/requirements.in # via # -c requirements/common-constraints.txt # -r requirements/requirements.in diff --git a/requirements/common-constraints.txt b/requirements/common-constraints.txt index 62c222fe470..4fd11b636ba 100644 --- a/requirements/common-constraints.txt +++ b/requirements/common-constraints.txt @@ -2,14 +2,10 @@ # uv pip compile --no-strip-extras --output-file=requirements/common-constraints.txt requirements/requirements.in requirements/requirements-dev.in requirements/requirements-help.in requirements/requirements-playwright.in aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.13.2 - # via - # huggingface-hub - # llama-index-core +aiohttp==3.14.3 + # via kubernetes aiosignal==1.4.0 # via aiohttp -aiosqlite==0.21.0 - # via llama-index-core annotated-types==0.7.0 # via pydantic anyio==4.11.0 @@ -24,18 +20,21 @@ attrs==25.4.0 # aiohttp # jsonschema # referencing -banks==2.2.0 - # via llama-index-core +bcrypt==5.0.0 + # via chromadb beautifulsoup4==4.14.2 # via -r requirements/requirements.in blinker==1.9.0 # via -r requirements/requirements.in build==1.3.0 - # via pip-tools + # via + # chromadb + # pip-tools certifi==2025.11.12 # via # httpcore # httpx + # kubernetes # requests cffi==2.0.0 # via @@ -48,9 +47,10 @@ charset-normalizer==3.4.9 # via # -r requirements/requirements.in # requests +chromadb==1.5.9 + # via -r requirements/requirements-help.in click==8.3.1 # via - # nltk # pip-tools # typer # uvicorn @@ -58,8 +58,6 @@ codespell==2.4.1 # via -r requirements/requirements-dev.in cogapp==3.6.0 # via -r requirements/requirements-dev.in -colorama==0.4.6 - # via griffe configargparse==1.7.1 # via -r requirements/requirements.in contourpy==1.3.3 @@ -70,31 +68,22 @@ cryptography==46.0.3 # pyjwt cycler==0.12.1 # via matplotlib -dataclasses-json==0.6.7 - # via llama-index-core -deprecated==1.3.1 - # via - # banks - # llama-index-core - # llama-index-instrumentation diff-match-patch==20241021 # via -r requirements/requirements.in -dirtyjson==1.0.8 - # via llama-index-core diskcache==5.6.3 # via -r requirements/requirements.in distlib==0.4.0 # via virtualenv +durationpy==0.10 + # via kubernetes filelock==3.20.0 # via # huggingface-hub - # torch - # transformers # virtualenv -filetype==1.2.0 - # via llama-index-core flake8==7.3.0 # via -r requirements/requirements-dev.in +flatbuffers==25.12.19 + # via onnxruntime fonttools==4.60.1 # via matplotlib frozenlist==1.8.0 @@ -102,20 +91,19 @@ frozenlist==1.8.0 # aiohttp # aiosignal fsspec==2025.10.0 - # via - # huggingface-hub - # llama-index-core - # torch + # via huggingface-hub gitdb==4.0.12 # via gitpython gitpython==3.1.45 # via -r requirements/requirements.in +googleapis-common-protos==1.75.1 + # via opentelemetry-exporter-otlp-proto-grpc greenlet==3.2.4 + # via playwright +grpcio==1.83.0 # via - # playwright - # sqlalchemy -griffe==1.15.0 - # via banks + # chromadb + # opentelemetry-exporter-otlp-proto-grpc guppy3==3.1.6 # via -r requirements/requirements-dev.in h11==0.16.0 @@ -126,18 +114,16 @@ hf-xet==1.2.0 # via huggingface-hub httpcore==1.0.9 # via httpx +httptools==0.8.0 + # via uvicorn httpx==0.28.1 # via - # llama-index-core + # chromadb # mcp httpx-sse==0.4.3 # via mcp -huggingface-hub[inference]==0.36.0 - # via - # llama-index-embeddings-huggingface - # sentence-transformers - # tokenizers - # transformers +huggingface-hub==0.36.0 + # via tokenizers identify==2.6.15 # via pre-commit idna==3.11 @@ -151,38 +137,28 @@ imgcat==0.6.0 importlib-metadata==8.7.0 # via -r requirements/requirements.in importlib-resources==6.5.2 - # via -r requirements/requirements.in + # via + # -r requirements/requirements.in + # chromadb iniconfig==2.3.0 # via pytest jinja2==3.1.6 - # via - # banks - # memray - # torch -joblib==1.5.2 - # via - # nltk - # scikit-learn + # via memray json-repair==0.60.1 # via -r requirements/requirements.in jsonschema==4.25.1 # via # -r requirements/requirements.in + # chromadb # mcp jsonschema-specifications==2025.9.1 # via jsonschema kiwisolver==1.4.9 # via matplotlib +kubernetes==36.0.3 + # via chromadb linkify-it-py==2.0.3 # via markdown-it-py -llama-index-core==0.14.8 - # via llama-index-embeddings-huggingface -llama-index-embeddings-huggingface==0.6.1 - # via -r requirements/requirements-help.in -llama-index-instrumentation==0.4.2 - # via llama-index-workflows -llama-index-workflows==2.11.5 - # via llama-index-core lox==1.0.0 # via -r requirements/requirements-dev.in marisa-trie==1.4.1 @@ -194,8 +170,6 @@ markdown-it-py[linkify]==4.0.0 # textual markupsafe==3.0.3 # via jinja2 -marshmallow==3.26.1 - # via dataclasses-json matplotlib==3.10.7 # via -r requirements/requirements-dev.in mccabe==0.7.0 @@ -208,94 +182,70 @@ mdurl==0.1.2 # via markdown-it-py memray==1.19.2 # via -r requirements/requirements-dev.in -mpmath==1.3.0 - # via sympy +mmh3==5.2.1 + # via chromadb mslex==1.3.0 # via oslex multidict==6.7.0 # via # aiohttp # yarl -mypy-extensions==1.1.0 - # via typing-inspect -nest-asyncio==1.6.0 - # via llama-index-core -networkx==3.6 - # via - # llama-index-core - # torch ngram==4.0.3 # via -r requirements/requirements.in -nltk==3.9.2 - # via llama-index-core nodeenv==1.9.1 # via pre-commit numpy==2.3.5 # via # -r requirements/requirements-help.in + # chromadb # contourpy - # llama-index-core # matplotlib + # onnxruntime # pandas # rustworkx - # scikit-learn - # scipy # soundfile - # transformers -nvidia-cublas-cu12==12.8.4.1 - # via - # nvidia-cudnn-cu12 - # nvidia-cusolver-cu12 - # torch -nvidia-cuda-cupti-cu12==12.8.90 - # via torch -nvidia-cuda-nvrtc-cu12==12.8.93 - # via torch -nvidia-cuda-runtime-cu12==12.8.90 - # via torch -nvidia-cudnn-cu12==9.10.2.21 - # via torch -nvidia-cufft-cu12==11.3.3.83 - # via torch -nvidia-cufile-cu12==1.13.1.3 - # via torch -nvidia-curand-cu12==10.3.9.90 - # via torch -nvidia-cusolver-cu12==11.7.3.90 - # via torch -nvidia-cusparse-cu12==12.5.8.93 - # via - # nvidia-cusolver-cu12 - # torch -nvidia-cusparselt-cu12==0.7.1 - # via torch -nvidia-nccl-cu12==2.27.5 - # via torch -nvidia-nvjitlink-cu12==12.8.93 - # via - # nvidia-cufft-cu12 - # nvidia-cusolver-cu12 - # nvidia-cusparse-cu12 - # torch -nvidia-nvshmem-cu12==3.3.20 - # via torch -nvidia-nvtx-cu12==12.8.90 - # via torch +oauthlib==3.3.1 + # via requests-oauthlib objgraph==3.6.2 # via -r requirements/requirements-dev.in +onnxruntime==1.29.0 + # via chromadb +opentelemetry-api==1.44.0 + # via + # chromadb + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-sdk + # opentelemetry-semantic-conventions +opentelemetry-exporter-otlp-proto-common==1.44.0 + # via opentelemetry-exporter-otlp-proto-grpc +opentelemetry-exporter-otlp-proto-grpc==1.44.0 + # via chromadb +opentelemetry-proto==1.44.0 + # via + # opentelemetry-exporter-otlp-proto-common + # opentelemetry-exporter-otlp-proto-grpc +opentelemetry-sdk==1.44.0 + # via + # chromadb + # opentelemetry-exporter-otlp-proto-grpc +opentelemetry-semantic-conventions==0.65b0 + # via opentelemetry-sdk orjson==3.11.9 - # via -r requirements/requirements.in + # via + # -r requirements/requirements.in + # chromadb oslex==0.1.3 # via -r requirements/requirements.in +overrides==7.7.0 + # via chromadb packaging==25.0 # via # -r requirements/requirements.in # build # huggingface-hub - # marshmallow # matplotlib + # onnxruntime # pytest - # transformers pandas==2.3.3 # via -r requirements/requirements-dev.in pathspec==0.12.1 @@ -305,17 +255,13 @@ pexpect==4.9.0 pillow==12.0.0 # via # -r requirements/requirements.in - # llama-index-core # matplotlib - # sentence-transformers pip==25.3 # via pip-tools pip-tools==7.5.2 # via -r requirements/requirements-dev.in platformdirs==4.5.0 # via - # banks - # llama-index-core # textual # virtualenv playwright==1.56.0 @@ -330,6 +276,11 @@ propcache==0.4.1 # via # aiohttp # yarl +protobuf==7.36.0 + # via + # googleapis-common-protos + # onnxruntime + # opentelemetry-proto psutil==7.1.3 # via # -r requirements/requirements-dev.in @@ -338,22 +289,23 @@ ptyprocess==0.7.0 # via pexpect py-cymbal==0.2.1 # via -r requirements/requirements.in +pybase64==1.5.0 + # via chromadb pycodestyle==2.14.0 # via flake8 pycparser==2.23 # via cffi pydantic==2.12.4 # via - # banks - # llama-index-core - # llama-index-instrumentation - # llama-index-workflows + # chromadb # mcp # pydantic-settings pydantic-core==2.41.5 # via pydantic pydantic-settings==2.12.0 - # via mcp + # via + # chromadb + # mcp pydub==0.25.1 # via -r requirements/requirements.in pyee==13.0.0 @@ -375,6 +327,8 @@ pyparsing==3.2.5 # via matplotlib pyperclip==1.11.0 # via -r requirements/requirements.in +pypika==0.51.1 + # via chromadb pyproject-hooks==1.2.0 # via # build @@ -393,6 +347,7 @@ pytest-mock==3.15.1 # via -r requirements/requirements-dev.in python-dateutil==2.9.0.post0 # via + # kubernetes # matplotlib # pandas python-dotenv==1.2.2 @@ -400,6 +355,7 @@ python-dotenv==1.2.2 # -r requirements/requirements.in # pydantic-settings # pytest-env + # uvicorn python-multipart==0.0.20 # via mcp pytz==2025.2 @@ -407,31 +363,29 @@ pytz==2025.2 pyyaml==6.0.3 # via # -r requirements/requirements.in + # chromadb # huggingface-hub - # llama-index-core + # kubernetes # pre-commit - # transformers + # uvicorn rapidfuzz==3.14.5 # via -r requirements/requirements.in referencing==0.37.0 # via # jsonschema # jsonschema-specifications -regex==2025.11.3 - # via - # nltk - # tiktoken - # transformers requests==2.32.5 # via # -r requirements/requirements.in # huggingface-hub - # llama-index-core - # tiktoken - # transformers + # kubernetes + # requests-oauthlib +requests-oauthlib==2.0.0 + # via kubernetes rich==14.2.0 # via # -r requirements/requirements.in + # chromadb # memray # textual # typer @@ -441,29 +395,18 @@ rpds-py==0.29.0 # referencing rustworkx==0.17.1 # via -r requirements/requirements.in -safetensors==0.7.0 - # via transformers -scikit-learn==1.7.2 - # via sentence-transformers -scipy==1.16.3 - # via - # scikit-learn - # sentence-transformers semver==3.0.4 # via -r requirements/requirements-dev.in -sentence-transformers==5.1.2 - # via llama-index-embeddings-huggingface setuptools==80.9.0 - # via - # llama-index-core - # pip-tools - # torch + # via pip-tools shellingham==1.5.4 # via typer shtab==1.8.0 # via -r requirements/requirements.in six==1.17.0 - # via python-dateutil + # via + # kubernetes + # python-dateutil smmap==5.0.2 # via gitdb sniffio==1.3.1 @@ -476,39 +419,25 @@ soundfile==0.13.1 # via -r requirements/requirements.in soupsieve==2.8 # via beautifulsoup4 -sqlalchemy[asyncio]==2.0.44 - # via llama-index-core sse-starlette==3.0.3 # via mcp starlette==0.50.0 # via mcp -sympy==1.14.0 - # via torch tenacity==9.1.2 - # via llama-index-core + # via chromadb textual==8.2.8 # via # -r requirements/requirements.in # memray -threadpoolctl==3.6.0 - # via scikit-learn -tiktoken==0.13.0 - # via llama-index-core tokenizers==0.22.1 - # via transformers + # via chromadb tomlkit==0.14.0 # via -r requirements/requirements.in -torch==2.9.1 - # via sentence-transformers tqdm==4.67.1 # via + # -r requirements/requirements.in + # chromadb # huggingface-hub - # llama-index-core - # nltk - # sentence-transformers - # transformers -transformers==4.57.2 - # via sentence-transformers tree-sitter==0.25.2 # via # -r requirements/requirements.in @@ -524,39 +453,35 @@ tree-sitter-languages==1.10.2 # via -r requirements/requirements.in tree-sitter-yaml==0.7.2 # via tree-sitter-language-pack -triton==3.5.1 - # via torch truststore==0.10.4 # via -r requirements/requirements.in typer==0.20.0 - # via -r requirements/requirements-dev.in + # via + # -r requirements/requirements-dev.in + # chromadb typing-extensions==4.15.0 # via + # aiohttp # aiosignal - # aiosqlite # anyio # beautifulsoup4 + # chromadb + # grpcio # huggingface-hub - # llama-index-core - # llama-index-workflows # mcp + # opentelemetry-api + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-sdk + # opentelemetry-semantic-conventions # pydantic # pydantic-core # pyee # pytest-asyncio # referencing - # sentence-transformers - # sqlalchemy # starlette # textual - # torch # typer - # typing-inspect # typing-inspection -typing-inspect==0.9.0 - # via - # dataclasses-json - # llama-index-core typing-inspection==0.4.2 # via # mcp @@ -567,25 +492,33 @@ tzdata==2025.2 uc-micro-py==1.0.3 # via linkify-it-py urllib3==2.5.0 - # via requests + # via + # kubernetes + # requests uv==0.9.11 # via -r requirements/requirements-dev.in -uvicorn==0.38.0 - # via mcp +uvicorn[standard]==0.38.0 + # via + # chromadb + # mcp +uvloop==0.22.1 + # via uvicorn virtualenv==20.35.4 # via pre-commit watchfiles==1.1.1 - # via -r requirements/requirements.in + # via + # -r requirements/requirements.in + # uvicorn wcwidth==0.2.14 # via prompt-toolkit +websocket-client==1.9.0 + # via kubernetes websockets==16.1.1 - # via -r requirements/requirements.in + # via + # -r requirements/requirements.in + # uvicorn wheel==0.45.1 # via pip-tools -wrapt==2.0.1 - # via - # deprecated - # llama-index-core xxhash==3.6.0 # via -r requirements/requirements.in yarl==1.22.0 diff --git a/requirements/requirements-dev.txt b/requirements/requirements-dev.txt index 8606fcef39c..7ecd3776847 100644 --- a/requirements/requirements-dev.txt +++ b/requirements/requirements-dev.txt @@ -23,7 +23,6 @@ cogapp==3.6.0 # -r requirements/requirements-dev.in colorama==0.4.6 ; os_name == 'nt' or sys_platform == 'win32' # via - # -c requirements/common-constraints.txt # build # click # pytest diff --git a/requirements/requirements-help.in b/requirements/requirements-help.in index b82009ef174..1bbbacbc7d0 100644 --- a/requirements/requirements-help.in +++ b/requirements/requirements-help.in @@ -1,11 +1,4 @@ -llama-index-embeddings-huggingface +chromadb -# Because sentence-transformers doesn't like >=2 -numpy>=1.26.4 - -# Mac x86 only supports 2.2.2 -# https://discuss.pytorch.org/t/why-no-macosx-x86-64-build-after-torch-2-2-2-cp39-none-macosx-10-9-x86-64-whl/204546/2 -# torch==2.2.2 - -# Later versions break test_help in GitHub Actions on Windows and Ubuntu -# llama-index-core==0.12.26 \ No newline at end of file +# numpy is pulled in by chromadb's onnxruntime embedding stack +numpy>=1.26.4 \ No newline at end of file diff --git a/requirements/requirements-help.txt b/requirements/requirements-help.txt index 16b5f2c6373..5d25ac7ec34 100644 --- a/requirements/requirements-help.txt +++ b/requirements/requirements-help.txt @@ -4,19 +4,14 @@ aiohappyeyeballs==2.6.1 # via # -c requirements/common-constraints.txt # aiohttp -aiohttp==3.13.2 +aiohttp==3.14.3 # via # -c requirements/common-constraints.txt - # huggingface-hub - # llama-index-core + # kubernetes aiosignal==1.4.0 # via # -c requirements/common-constraints.txt # aiohttp -aiosqlite==0.21.0 - # via - # -c requirements/common-constraints.txt - # llama-index-core annotated-types==0.7.0 # via # -c requirements/common-constraints.txt @@ -25,58 +20,59 @@ anyio==4.11.0 # via # -c requirements/common-constraints.txt # httpx + # watchfiles attrs==25.4.0 # via # -c requirements/common-constraints.txt # aiohttp -banks==2.2.0 + # jsonschema + # referencing +bcrypt==5.0.0 + # via + # -c requirements/common-constraints.txt + # chromadb +build==1.3.0 # via # -c requirements/common-constraints.txt - # llama-index-core + # chromadb certifi==2025.11.12 # via # -c requirements/common-constraints.txt # httpcore # httpx + # kubernetes # requests charset-normalizer==3.4.9 # via # -c requirements/common-constraints.txt # requests -click==8.3.1 +chromadb==1.5.9 # via # -c requirements/common-constraints.txt - # nltk -colorama==0.4.6 + # -r requirements/requirements-help.in +click==8.3.1 # via # -c requirements/common-constraints.txt + # typer + # uvicorn +colorama==0.4.6 ; os_name == 'nt' or sys_platform == 'win32' + # via + # build # click - # griffe # tqdm -dataclasses-json==0.6.7 + # uvicorn +durationpy==0.10 # via # -c requirements/common-constraints.txt - # llama-index-core -deprecated==1.3.1 - # via - # -c requirements/common-constraints.txt - # banks - # llama-index-core - # llama-index-instrumentation -dirtyjson==1.0.8 - # via - # -c requirements/common-constraints.txt - # llama-index-core + # kubernetes filelock==3.20.0 # via # -c requirements/common-constraints.txt # huggingface-hub - # torch - # transformers -filetype==1.2.0 +flatbuffers==25.12.19 # via # -c requirements/common-constraints.txt - # llama-index-core + # onnxruntime frozenlist==1.8.0 # via # -c requirements/common-constraints.txt @@ -86,20 +82,20 @@ fsspec==2025.10.0 # via # -c requirements/common-constraints.txt # huggingface-hub - # llama-index-core - # torch -greenlet==3.2.4 +googleapis-common-protos==1.75.1 # via # -c requirements/common-constraints.txt - # sqlalchemy -griffe==1.15.0 + # opentelemetry-exporter-otlp-proto-grpc +grpcio==1.83.0 # via # -c requirements/common-constraints.txt - # banks + # chromadb + # opentelemetry-exporter-otlp-proto-grpc h11==0.16.0 # via # -c requirements/common-constraints.txt # httpcore + # uvicorn hf-xet==1.2.0 ; platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64' # via # -c requirements/common-constraints.txt @@ -108,17 +104,18 @@ httpcore==1.0.9 # via # -c requirements/common-constraints.txt # httpx +httptools==0.8.0 + # via + # -c requirements/common-constraints.txt + # uvicorn httpx==0.28.1 # via # -c requirements/common-constraints.txt - # llama-index-core -huggingface-hub[inference]==0.36.0 + # chromadb +huggingface-hub==0.36.0 # via # -c requirements/common-constraints.txt - # llama-index-embeddings-huggingface - # sentence-transformers # tokenizers - # transformers idna==3.11 # via # -c requirements/common-constraints.txt @@ -126,295 +123,255 @@ idna==3.11 # httpx # requests # yarl -jinja2==3.1.6 - # via - # -c requirements/common-constraints.txt - # banks - # torch -joblib==1.5.2 +importlib-resources==6.5.2 # via # -c requirements/common-constraints.txt - # nltk - # scikit-learn -llama-index-core==0.14.8 + # chromadb +jsonschema==4.25.1 # via # -c requirements/common-constraints.txt - # llama-index-embeddings-huggingface -llama-index-embeddings-huggingface==0.6.1 + # chromadb +jsonschema-specifications==2025.9.1 # via # -c requirements/common-constraints.txt - # -r requirements/requirements-help.in -llama-index-instrumentation==0.4.2 + # jsonschema +kubernetes==36.0.3 # via # -c requirements/common-constraints.txt - # llama-index-workflows -llama-index-workflows==2.11.5 + # chromadb +markdown-it-py==4.0.0 # via # -c requirements/common-constraints.txt - # llama-index-core -markupsafe==3.0.3 + # rich +mdurl==0.1.2 # via # -c requirements/common-constraints.txt - # jinja2 -marshmallow==3.26.1 + # markdown-it-py +mmh3==5.2.1 # via # -c requirements/common-constraints.txt - # dataclasses-json -mpmath==1.3.0 - # via - # -c requirements/common-constraints.txt - # sympy + # chromadb multidict==6.7.0 # via # -c requirements/common-constraints.txt # aiohttp # yarl -mypy-extensions==1.1.0 - # via - # -c requirements/common-constraints.txt - # typing-inspect -nest-asyncio==1.6.0 - # via - # -c requirements/common-constraints.txt - # llama-index-core -networkx==3.6 - # via - # -c requirements/common-constraints.txt - # llama-index-core - # torch -nltk==3.9.2 - # via - # -c requirements/common-constraints.txt - # llama-index-core numpy==2.3.5 # via # -c requirements/common-constraints.txt # -r requirements/requirements-help.in - # llama-index-core - # scikit-learn - # scipy - # transformers -nvidia-cublas-cu12==12.8.4.1 ; platform_machine == 'x86_64' and sys_platform == 'linux' + # chromadb + # onnxruntime +oauthlib==3.3.1 # via # -c requirements/common-constraints.txt - # nvidia-cudnn-cu12 - # nvidia-cusolver-cu12 - # torch -nvidia-cuda-cupti-cu12==12.8.90 ; platform_machine == 'x86_64' and sys_platform == 'linux' + # requests-oauthlib +onnxruntime==1.29.0 # via # -c requirements/common-constraints.txt - # torch -nvidia-cuda-nvrtc-cu12==12.8.93 ; platform_machine == 'x86_64' and sys_platform == 'linux' + # chromadb +opentelemetry-api==1.44.0 # via # -c requirements/common-constraints.txt - # torch -nvidia-cuda-runtime-cu12==12.8.90 ; platform_machine == 'x86_64' and sys_platform == 'linux' + # chromadb + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-sdk + # opentelemetry-semantic-conventions +opentelemetry-exporter-otlp-proto-common==1.44.0 # via # -c requirements/common-constraints.txt - # torch -nvidia-cudnn-cu12==9.10.2.21 ; platform_machine == 'x86_64' and sys_platform == 'linux' + # opentelemetry-exporter-otlp-proto-grpc +opentelemetry-exporter-otlp-proto-grpc==1.44.0 # via # -c requirements/common-constraints.txt - # torch -nvidia-cufft-cu12==11.3.3.83 ; platform_machine == 'x86_64' and sys_platform == 'linux' + # chromadb +opentelemetry-proto==1.44.0 # via # -c requirements/common-constraints.txt - # torch -nvidia-cufile-cu12==1.13.1.3 ; platform_machine == 'x86_64' and sys_platform == 'linux' + # opentelemetry-exporter-otlp-proto-common + # opentelemetry-exporter-otlp-proto-grpc +opentelemetry-sdk==1.44.0 # via # -c requirements/common-constraints.txt - # torch -nvidia-curand-cu12==10.3.9.90 ; platform_machine == 'x86_64' and sys_platform == 'linux' + # chromadb + # opentelemetry-exporter-otlp-proto-grpc +opentelemetry-semantic-conventions==0.65b0 # via # -c requirements/common-constraints.txt - # torch -nvidia-cusolver-cu12==11.7.3.90 ; platform_machine == 'x86_64' and sys_platform == 'linux' + # opentelemetry-sdk +orjson==3.11.9 # via # -c requirements/common-constraints.txt - # torch -nvidia-cusparse-cu12==12.5.8.93 ; platform_machine == 'x86_64' and sys_platform == 'linux' + # chromadb +overrides==7.7.0 # via # -c requirements/common-constraints.txt - # nvidia-cusolver-cu12 - # torch -nvidia-cusparselt-cu12==0.7.1 ; platform_machine == 'x86_64' and sys_platform == 'linux' + # chromadb +packaging==25.0 # via # -c requirements/common-constraints.txt - # torch -nvidia-nccl-cu12==2.27.5 ; platform_machine == 'x86_64' and sys_platform == 'linux' + # build + # huggingface-hub + # onnxruntime +propcache==0.4.1 # via # -c requirements/common-constraints.txt - # torch -nvidia-nvjitlink-cu12==12.8.93 ; platform_machine == 'x86_64' and sys_platform == 'linux' + # aiohttp + # yarl +protobuf==7.36.0 # via # -c requirements/common-constraints.txt - # nvidia-cufft-cu12 - # nvidia-cusolver-cu12 - # nvidia-cusparse-cu12 - # torch -nvidia-nvshmem-cu12==3.3.20 ; platform_machine == 'x86_64' and sys_platform == 'linux' + # googleapis-common-protos + # onnxruntime + # opentelemetry-proto +pybase64==1.5.0 # via # -c requirements/common-constraints.txt - # torch -nvidia-nvtx-cu12==12.8.90 ; platform_machine == 'x86_64' and sys_platform == 'linux' + # chromadb +pydantic==2.12.4 # via # -c requirements/common-constraints.txt - # torch -packaging==25.0 + # chromadb + # pydantic-settings +pydantic-core==2.41.5 # via # -c requirements/common-constraints.txt - # huggingface-hub - # marshmallow - # transformers -pillow==12.0.0 + # pydantic +pydantic-settings==2.12.0 # via # -c requirements/common-constraints.txt - # llama-index-core - # sentence-transformers -platformdirs==4.5.0 + # chromadb +pygments==2.19.2 # via # -c requirements/common-constraints.txt - # banks - # llama-index-core -propcache==0.4.1 + # rich +pypika==0.51.1 # via # -c requirements/common-constraints.txt - # aiohttp - # yarl -pydantic==2.12.4 + # chromadb +pyproject-hooks==1.2.0 # via # -c requirements/common-constraints.txt - # banks - # llama-index-core - # llama-index-instrumentation - # llama-index-workflows -pydantic-core==2.41.5 + # build +python-dateutil==2.9.0.post0 # via # -c requirements/common-constraints.txt - # pydantic + # kubernetes +python-dotenv==1.2.2 + # via + # -c requirements/common-constraints.txt + # pydantic-settings + # uvicorn pyyaml==6.0.3 # via # -c requirements/common-constraints.txt + # chromadb # huggingface-hub - # llama-index-core - # transformers -regex==2025.11.3 + # kubernetes + # uvicorn +referencing==0.37.0 # via # -c requirements/common-constraints.txt - # nltk - # tiktoken - # transformers + # jsonschema + # jsonschema-specifications requests==2.32.5 # via # -c requirements/common-constraints.txt # huggingface-hub - # llama-index-core - # tiktoken - # transformers -safetensors==0.7.0 + # kubernetes + # requests-oauthlib +requests-oauthlib==2.0.0 # via # -c requirements/common-constraints.txt - # transformers -scikit-learn==1.7.2 + # kubernetes +rich==14.2.0 # via # -c requirements/common-constraints.txt - # sentence-transformers -scipy==1.16.3 + # chromadb + # typer +rpds-py==0.29.0 # via # -c requirements/common-constraints.txt - # scikit-learn - # sentence-transformers -sentence-transformers==5.1.2 + # jsonschema + # referencing +shellingham==1.5.4 # via # -c requirements/common-constraints.txt - # llama-index-embeddings-huggingface -setuptools==80.9.0 + # typer +six==1.17.0 # via # -c requirements/common-constraints.txt - # llama-index-core - # torch + # kubernetes + # python-dateutil sniffio==1.3.1 # via # -c requirements/common-constraints.txt # anyio -sqlalchemy[asyncio]==2.0.44 - # via - # -c requirements/common-constraints.txt - # llama-index-core -sympy==1.14.0 - # via - # -c requirements/common-constraints.txt - # torch tenacity==9.1.2 # via # -c requirements/common-constraints.txt - # llama-index-core -threadpoolctl==3.6.0 - # via - # -c requirements/common-constraints.txt - # scikit-learn -tiktoken==0.13.0 - # via - # -c requirements/common-constraints.txt - # llama-index-core + # chromadb tokenizers==0.22.1 # via # -c requirements/common-constraints.txt - # transformers -torch==2.9.1 - # via - # -c requirements/common-constraints.txt - # sentence-transformers + # chromadb tqdm==4.67.1 # via # -c requirements/common-constraints.txt + # chromadb # huggingface-hub - # llama-index-core - # nltk - # sentence-transformers - # transformers -transformers==4.57.2 +typer==0.20.0 # via # -c requirements/common-constraints.txt - # sentence-transformers -triton==3.5.1 ; platform_machine == 'x86_64' and sys_platform == 'linux' - # via - # -c requirements/common-constraints.txt - # torch + # chromadb typing-extensions==4.15.0 # via # -c requirements/common-constraints.txt + # aiohttp # aiosignal - # aiosqlite # anyio + # chromadb + # grpcio # huggingface-hub - # llama-index-core - # llama-index-workflows + # opentelemetry-api + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-sdk + # opentelemetry-semantic-conventions # pydantic # pydantic-core - # sentence-transformers - # sqlalchemy - # torch - # typing-inspect + # referencing + # typer # typing-inspection -typing-inspect==0.9.0 - # via - # -c requirements/common-constraints.txt - # dataclasses-json - # llama-index-core typing-inspection==0.4.2 # via # -c requirements/common-constraints.txt # pydantic + # pydantic-settings urllib3==2.5.0 # via # -c requirements/common-constraints.txt + # kubernetes # requests -wrapt==2.0.1 +uvicorn[standard]==0.38.0 + # via + # -c requirements/common-constraints.txt + # chromadb +uvloop==0.22.1 ; platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32' + # via + # -c requirements/common-constraints.txt + # uvicorn +watchfiles==1.1.1 + # via + # -c requirements/common-constraints.txt + # uvicorn +websocket-client==1.9.0 + # via + # -c requirements/common-constraints.txt + # kubernetes +websockets==16.1.1 # via # -c requirements/common-constraints.txt - # deprecated - # llama-index-core + # uvicorn yarl==1.22.0 # via # -c requirements/common-constraints.txt diff --git a/requirements/requirements.in b/requirements/requirements.in index 284f3914e7d..0e3ba766d00 100644 --- a/requirements/requirements.in +++ b/requirements/requirements.in @@ -9,6 +9,7 @@ pexpect>=4.9.0 psutil>=7.0.0 watchfiles>=1.1.0 charset-normalizer>=3.4.7 + # git GitPython>=3.1.45 pathspec>=0.12.1 @@ -43,6 +44,7 @@ tomlkit>=0.14.0 # search & replace in legacy modes diff-match-patch>=20241021 +tqdm>3.0.0 # copying and pasting pyperclip>=1.9.0 diff --git a/tests/basic/test_hashline.py b/tests/basic/test_hashline.py index 774e09b6b74..f2eefaa8b08 100644 --- a/tests/basic/test_hashline.py +++ b/tests/basic/test_hashline.py @@ -264,3 +264,98 @@ def test_resolve_stripped_ambiguous_line_stays_unchanged_in_both_paths(): assert successful == [] assert len(failed) == 1 assert new_content == original_content + + +def test_noop_replace_last_line_without_trailing_newline_is_failed(): + """An identical replacement of a final unterminated line is a no-op.""" + original_content = "first\nlast" + _, last_line_id = resolve_content_to_hashline_ids(original_content, "last", "last") + + new_content, successful, failed = apply_hashline_operations( + original_content, + [ + { + "start_line_hash": last_line_id, + "end_line_hash": last_line_id, + "operation": "replace", + "text": "last", + } + ], + ) + + assert new_content == original_content + assert successful == [] + assert failed[0]["failure_type"] == "no_change" + + +def test_mixed_noop_and_real_replace_reports_only_real_success(): + """A no-op in a batch must not be counted as a successful operation.""" + original_content = "first\nmiddle\nlast" + first_id, _ = resolve_content_to_hashline_ids(original_content, "first", "first") + _, last_id = resolve_content_to_hashline_ids(original_content, "last", "last") + lines = [first_id, last_id] + + new_content, successful, failed = apply_hashline_operations( + original_content, + [ + { + "start_line_hash": lines[0], + "end_line_hash": lines[0], + "operation": "replace", + "text": "changed", + }, + { + "start_line_hash": lines[1], + "end_line_hash": lines[1], + "operation": "replace", + "text": "last", + }, + ], + ) + + assert new_content == "changed\nmiddle\nlast" + assert successful == [0] + assert [op["index"] for op in failed] == [1] + assert failed[0]["failure_type"] == "no_change" + + +def test_insert_and_replace_same_anchor_are_both_applied(): + """Insert and replace operations sharing an anchor remain independent.""" + original_content = "anchor\nend\n" + anchor_id, _ = resolve_content_to_hashline_ids(original_content, "anchor", "anchor") + + new_content, successful, failed = apply_hashline_operations( + original_content, + [ + { + "start_line_hash": anchor_id, + "end_line_hash": anchor_id, + "operation": "replace", + "text": "replaced", + }, + { + "start_line_hash": anchor_id, + "operation": "insert", + "text": "inserted", + }, + ], + ) + + assert new_content == "replaced\ninserted\nend\n" + assert sorted(successful) == [0, 1] + assert failed == [] + + +def test_empty_insert_is_a_noop(): + """An empty insert must not mutate content or anchor formatting.""" + original_content = "anchor\n" + anchor_id, _ = resolve_content_to_hashline_ids(original_content, "anchor", "anchor") + + new_content, successful, failed = apply_hashline_operations( + original_content, + [{"start_line_hash": anchor_id, "operation": "insert", "text": ""}], + ) + + assert new_content == original_content + assert successful == [] + assert failed[0]["failure_type"] == "no_change" diff --git a/tests/coders/test_tool_call_consolidation.py b/tests/coders/test_tool_call_consolidation.py index ab5fd8f19b4..246b78cfd8a 100644 --- a/tests/coders/test_tool_call_consolidation.py +++ b/tests/coders/test_tool_call_consolidation.py @@ -195,3 +195,345 @@ def test_build_tool_calls_from_chunks_handles_missing_index(): built = coder._build_tool_calls_from_chunks() assert [t.function.name for t in built] == ["Local--A"] assert built[0].function.arguments == '{"a":1}' + + +def test_parallel_tool_calls_same_index_distinct_ids(): + """Bug 4 (deepseek): parallel calls reuse index0; only the ids differ. + + Without id-based keying both calls collapse onto one bucket: the second id + overwrites the first and the argument fragments concatenate into invalid + JSON (``{"tasks":...}{"path":...}``), silently dropping one call. + """ + chunks = [ + mk_chunk( + { + "role": "assistant", + "content": None, + "tool_calls": [tc(0, "call_00", "local--UpdateTodoList", "")], + } + ), + mk_chunk( + { + "role": None, + "content": None, + "tool_calls": [tc(0, None, None, '{"tasks": [{"task": "Explore')], + } + ), + mk_chunk( + { + "role": None, + "content": None, + "tool_calls": [tc(0, None, None, ' the code", "done": false}')], + } + ), + mk_chunk( + {"role": None, "content": None, "tool_calls": [tc(0, "call_01", "local--ls", "")]} + ), + mk_chunk( + {"role": None, "content": None, "tool_calls": [tc(0, None, None, '{"path": "."}')]} + ), + mk_chunk({}, finish_reason="tool_calls"), + ] + coder = make_coder(chunks) + response, func_err, content_err = coder.consolidate_chunks() + + assert func_err is None + calls = coder.partial_response_tool_calls + assert [t.id for t in calls] == ["call_00", "call_01"] + assert [t.function.name for t in calls] == ["local--UpdateTodoList", "local--ls"] + assert calls[0].function.arguments == '{"tasks": [{"task": "Explore the code", "done": false}' + assert calls[1].function.arguments == '{"path": "."}' + + msg_tool_calls = response.choices[0].message.tool_calls + assert [t.id for t in msg_tool_calls] == ["call_00", "call_01"] + + +def test_stream_chunk_builder_keeps_same_index_distinct_ids(): + """The facade stream_chunk_builder keeps parallel calls that share index0.""" + chunks = [ + mk_chunk( + {"role": "assistant", "content": None, "tool_calls": [tc(0, "call_a", "f_alpha", "")]} + ), + mk_chunk({"role": None, "content": None, "tool_calls": [tc(0, None, None, '{"a"')]}), + mk_chunk({"role": None, "content": None, "tool_calls": [tc(0, None, None, ":1}")]}), + mk_chunk({"role": None, "content": None, "tool_calls": [tc(0, "call_b", "f_beta", "")]}), + mk_chunk({"role": None, "content": None, "tool_calls": [tc(0, None, None, '{"b"')]}), + mk_chunk({"role": None, "content": None, "tool_calls": [tc(0, None, None, ":2}")]}), + mk_chunk({}, finish_reason="tool_calls"), + ] + resp = litellm.stream_chunk_builder(chunks) + calls = resp.choices[0].message.tool_calls + + assert [t.id for t in calls] == ["call_a", "call_b"] + assert [t.function.name for t in calls] == ["f_alpha", "f_beta"] + assert calls[0].function.arguments == '{"a":1}' + assert calls[1].function.arguments == '{"b":2}' + + +def _domain_pipeline(parse_fn, events, reset_fn=None): + """Run domain SSE events through parse -> shim -> both accumulators.""" + from cecli.helpers.llms.litellm_compat import _chunk_shim + + if reset_fn: + reset_fn() + + shims = [] + for evt in events: + chunk = parse_fn(evt) + if chunk is not None: + shims.append(_chunk_shim(chunk, "test-model")) + + resp = litellm.stream_chunk_builder(shims) + coder = make_coder(shims) + return resp.choices[0].message.tool_calls, coder._build_tool_calls_from_chunks() + + +def test_parallel_tool_calls_across_domains(): + """responses / anthropic / gemini / chat all keep parallel tool calls. + + Each domain streams parallel calls differently (item_id-keyed deltas, + per-block indices, complete parts with per-call indices, reused index0 with + distinct ids); every path must survive both accumulators. + """ + from cecli.helpers.llms.domains.chat import parse_chat_chunk + from cecli.helpers.llms.domains.gemini import _stream_state as gem_state + from cecli.helpers.llms.domains.gemini import parse_gemini_chunk + from cecli.helpers.llms.domains.messages import parse_anthropic_chunk + from cecli.helpers.llms.domains.responses import ( + _reset_stream_state, + parse_responses_chunk, + ) + + cases = [ + ( + "responses", + parse_responses_chunk, + _reset_stream_state, + [ + { + "type": "response.output_item.added", + "output_index": 0, + "item": { + "id": "fc_1", + "type": "function_call", + "call_id": "call_1", + "name": "f_alpha", + "arguments": "", + }, + }, + { + "type": "response.output_item.added", + "output_index": 1, + "item": { + "id": "fc_2", + "type": "function_call", + "call_id": "call_2", + "name": "f_beta", + "arguments": "", + }, + }, + { + "type": "response.function_call_arguments.delta", + "item_id": "fc_1", + "output_index": 0, + "delta": '{"a"', + }, + { + "type": "response.function_call_arguments.delta", + "item_id": "fc_2", + "output_index": 1, + "delta": '{"b"', + }, + { + "type": "response.function_call_arguments.delta", + "item_id": "fc_1", + "output_index": 0, + "delta": ":1}", + }, + { + "type": "response.function_call_arguments.delta", + "item_id": "fc_2", + "output_index": 1, + "delta": ":2}", + }, + {"type": "response.completed", "response": {"status": "completed", "usage": {}}}, + ], + ["call_1", "call_2"], + ["f_alpha", "f_beta"], + ['{"a":1}', '{"b":2}'], + ), + ( + "anthropic", + parse_anthropic_chunk, + None, + [ + { + "type": "content_block_start", + "index": 0, + "content_block": { + "type": "tool_use", + "id": "toolu_1", + "name": "f_alpha", + "input": {}, + }, + }, + { + "type": "content_block_start", + "index": 1, + "content_block": { + "type": "tool_use", + "id": "toolu_2", + "name": "f_beta", + "input": {}, + }, + }, + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "input_json_delta", "partial_json": '{"a"'}, + }, + { + "type": "content_block_delta", + "index": 1, + "delta": {"type": "input_json_delta", "partial_json": '{"b"'}, + }, + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "input_json_delta", "partial_json": ":1}"}, + }, + { + "type": "content_block_delta", + "index": 1, + "delta": {"type": "input_json_delta", "partial_json": ":2}"}, + }, + {"type": "message_delta", "delta": {"stop_reason": "tool_use"}, "usage": {}}, + ], + ["toolu_1", "toolu_2"], + ["f_alpha", "f_beta"], + ['{"a":1}', '{"b":2}'], + ), + ( + "gemini", + parse_gemini_chunk, + lambda: gem_state.__setitem__("tool_indices", {}), + [ + { + "candidates": [ + { + "content": { + "parts": [ + { + "functionCall": { + "name": "f_alpha", + "args": {"a": 1}, + "id": "call_1", + } + } + ] + } + } + ] + }, + { + "candidates": [ + { + "content": { + "parts": [ + { + "functionCall": { + "name": "f_beta", + "args": {"b": 2}, + "id": "call_2", + } + } + ] + } + } + ] + }, + {"candidates": [{"content": {"parts": []}, "finishReason": "STOP"}]}, + ], + ["call_1", "call_2"], + ["f_alpha", "f_beta"], + ['{"a": 1}', '{"b": 2}'], + ), + ( + "chat/deepseek (index0 reused)", + parse_chat_chunk, + None, + [ + { + "choices": [ + { + "delta": { + "role": "assistant", + "tool_calls": [ + { + "index": 0, + "id": "call_00", + "type": "function", + "function": {"name": "todo", "arguments": ""}, + } + ], + } + } + ] + }, + { + "choices": [ + { + "delta": { + "tool_calls": [ + {"index": 0, "function": {"arguments": '{"tasks":[{"task":"A"'}} + ] + } + } + ] + }, + { + "choices": [ + {"delta": {"tool_calls": [{"index": 0, "function": {"arguments": "}]}"}}]}} + ] + }, + { + "choices": [ + { + "delta": { + "tool_calls": [ + { + "index": 0, + "id": "call_01", + "type": "function", + "function": {"name": "ls", "arguments": ""}, + } + ] + } + } + ] + }, + { + "choices": [ + { + "delta": { + "tool_calls": [ + {"index": 0, "function": {"arguments": '{"path":"."}'}} + ] + } + } + ] + }, + {"choices": [{"delta": {}, "finish_reason": "tool_calls"}]}, + ], + ["call_00", "call_01"], + ["todo", "ls"], + ['{"tasks":[{"task":"A"}]}', '{"path":"."}'], + ), + ] + + for label, parse_fn, reset_fn, events, ids, names, args in cases: + facade, built = _domain_pipeline(parse_fn, events, reset_fn) + for calls in (facade, built): + assert [c.id for c in calls] == ids, f"{label}: ids {[c.id for c in calls]}" + assert [c.function.name for c in calls] == names, f"{label}: names mismatch" + assert [c.function.arguments for c in calls] == args, f"{label}: args mismatch" diff --git a/tests/help/test_help.py b/tests/help/test_help.py index b7ac2ceb4d5..5b5b63b4f0e 100644 --- a/tests/help/test_help.py +++ b/tests/help/test_help.py @@ -171,3 +171,105 @@ def test_fname_to_url_edge_cases(self): # Test path with 'website' in the wrong place assert fname_to_url("/home/user/website_project/docs/index.md") == "" + + def test_fname_to_url_ignores_non_doc_sources(self): + # Build artifacts, partials and non-doc sources are not pages. + base = "/home/user/project/website" + assert fname_to_url(f"{base}/_site/docs/okf/index.md") == "" + assert fname_to_url(f"{base}/.docmd-test/docs/okf/concepts/config-subagents.md") == "" + assert fname_to_url(f"{base}/.docmd-site/okf/concepts/config-subagents.md") == "" + assert fname_to_url(f"{base}/_includes/help.md") == "" + assert fname_to_url(f"{base}/share/index.md") == "" + + # Real docs map under /docs/. + assert ( + fname_to_url(f"{base}/docs/config/subagents.md") + == "https://cecli.dev/docs/config/subagents/" + ) + assert fname_to_url(f"{base}/docs/config/index.md") == "https://cecli.dev/docs/config/" + + def test_help_falls_back_when_extras_check_fails(self): + # A broken/circular-import dependency (e.g. ``datasets``) used to leak out + # of the help setup as a raw "Unable to complete help" error. It must now + # be caught and fall back to basic help instead of crashing. + from types import SimpleNamespace + from unittest.mock import AsyncMock, patch + + from cecli.commands.help import HelpCommand + + io = InputOutput(pretty=False, yes=True) + coder = SimpleNamespace(args=None) + + with patch.object(HelpCommand, "_basic_help", new=AsyncMock()) as basic_help: + with patch( + "cecli.help.install_help_extra", + side_effect=AttributeError("partially initialized module 'datasets'"), + ): + result = asyncio.run(HelpCommand.execute(io, coder, "how do I configure subagents")) + + basic_help.assert_awaited_once() + assert result is not None + + def test_check_pip_install_extra_retries_on_ssl_flake(self): + # The OpenSSL CONF module's lazy init can make the first SSL context + # creation fail (WSL2 + OpenSSL 3.5 + Py3.14). check_pip_install_extra + # must retry the import once and succeed, mirroring llms.runtime.make_client. + import builtins + import ssl + import types + from unittest.mock import patch + + from cecli.utils import check_pip_install_extra + + class FakeIO: + def tool_warning(self, *a, **k): + pass + + def tool_error(self, *a, **k): + pass + + async def confirm_ask(self, *a, **k): + return False + + calls = {"n": 0} + sentinel = types.ModuleType("sentinel") + + def flaky_import(name, *a, **k): + calls["n"] += 1 + if calls["n"] == 1: + raise ssl.SSLError("unknown error (0x0) (_ssl.c:3187)") + return sentinel + + with patch.object(builtins, "__import__", side_effect=flaky_import): + result = asyncio.run( + check_pip_install_extra( + FakeIO(), + "llama_index.embeddings.huggingface", + None, + ["pip", "install", "x"], + ) + ) + + assert result is True + assert calls["n"] == 2 + + def test_help_falls_back_when_index_build_fails(self): + # A failure while building the help index (e.g. an SSL error downloading + # the embedding model) must fall back to basic help instead of crashing. + from types import SimpleNamespace + from unittest.mock import AsyncMock, patch + + from cecli.commands.help import HelpCommand + + io = InputOutput(pretty=False, yes=True) + coder = SimpleNamespace(args=None) + + with patch.object(HelpCommand, "_basic_help", new=AsyncMock()) as basic_help: + with patch( + "cecli.help.Help", + side_effect=OSError("unknown error (0x0) (_ssl.c:3187)"), + ): + result = asyncio.run(HelpCommand.execute(io, coder, "how do I configure subagents")) + + basic_help.assert_awaited_once() + assert result is not None diff --git a/tests/helpers/test_loop_detect.py b/tests/helpers/test_loop_detect.py new file mode 100644 index 00000000000..3cd64ec16ca --- /dev/null +++ b/tests/helpers/test_loop_detect.py @@ -0,0 +1,430 @@ +"""Tests for the streaming loop detector and its integration into the coder. + +The ``LoopDetector`` watches a stream of text as it is generated and raises a +``LoopDetectedError`` when the model starts repeating itself (stuck on a single +character, a single word, or a whole sentence). These tests cover: + +* what the detector can and cannot catch from LLM / small-language-model output, +* the bounded-LRU behaviour of the sentence cache, +* the ``show_send_output_stream`` integration that stops streaming early, +* the ``send``-level handling that marks the turn as ``[SYSTEM CANCEL: OUTPUT LOOP DETECTED]`` + and drops any pending tool calls so they are never executed. +""" + +import hashlib +from types import SimpleNamespace + +import pytest + +from cecli.coders.base_coder import Coder +from cecli.helpers.loop_detect import LoopDetectedError, LoopDetector, is_sentence +from cecli.helpers.threading import ThreadSafeEvent +from cecli.llm import litellm + + +# --------------------------------------------------------------------------- # +# LoopDetector unit tests +# --------------------------------------------------------------------------- # +def test_detects_char_loop(): + detector = LoopDetector(char_limit=5) + with pytest.raises(LoopDetectedError) as exc: + detector.push("aaaaaa") + + assert "Char loop: 'a' repeated 5 times." == str(exc.value) + + +def test_char_loop_does_not_trigger_below_limit(): + detector = LoopDetector(char_limit=5) + assert detector.push("aaaa") is None # 4 < limit, no raise + + +def test_detects_word_loop(): + detector = LoopDetector(word_limit=3) + with pytest.raises(LoopDetectedError) as exc: + detector.push("foo foo foo foo") + + assert "Word loop: 'foo' repeated 3 times." == str(exc.value) + + +def test_word_loop_does_not_trigger_below_limit(): + detector = LoopDetector(word_limit=10) + assert detector.push("the the the the") is None # 4 < 10 + + +def test_detects_sentence_loop(): + detector = LoopDetector(sentence_limit=3) + sentence = "This is a repeated sentence. " + with pytest.raises(LoopDetectedError) as exc: + for _ in range(3): + detector.push(sentence) + + assert "Sentence loop: 'this is a repeated sentence.'" in str(exc.value) + + +def test_sentence_loop_does_not_trigger_below_limit(): + detector = LoopDetector(sentence_limit=3) + sentence = "This is a repeated sentence. " + # Two occurrences are below the trigger of three. + assert detector.push(sentence) is None + assert detector.push(sentence) is None + + +def test_sentence_cache_is_bounded_lru(): + """Oldest sentences are evicted so a repeated-in-the-past sentence is fresh.""" + detector = LoopDetector(sentence_limit=2, max_sentences=2) + + detector.push("This is sentence number one.") + detector.push("This is sentence number two.") + detector.push("This is sentence number three.") # evicts number one + detector.push("This is sentence number one.") # fresh again, count == 1 + + assert detector.sentence_counts.get("this is sentence number one.") == 1 + + +def test_normal_prose_is_not_flagged(): + detector = LoopDetector() + text = ( + "The quick brown fox jumps over the lazy dog. It was a sunny " + "afternoon and the meadow was full of wildflowers." + ) + assert detector.push(text) is None + + +def test_small_model_repetitive_but_short_output_is_not_flagged(): + """Tiny models often echo a word or two; that must not be called a loop.""" + detector = LoopDetector() + for _ in range(4): + assert detector.push("ok") is None + + +def test_custom_limits_respected(): + detector = LoopDetector(char_limit=3, word_limit=2, sentence_limit=2) + with pytest.raises(LoopDetectedError): + detector.push("aaaa") + assert detector.char_count == 3 + + +def test_error_can_be_caught_as_exception(): + detector = LoopDetector(char_limit=2) + try: + detector.push("aaaa") + except Exception as exc: # noqa: BLE001 - deliberately broad + assert isinstance(exc, LoopDetectedError) + assert "Char loop" in str(exc) + else: + pytest.fail("expected LoopDetectedError") + + +def test_is_sentence_matches_latin_prose(): + assert is_sentence("The quick brown fox jumps over the lazy dog.") is True + assert is_sentence("Hello world!") is True + assert is_sentence("Are you sure?") is True + + +def test_is_sentence_rejects_fragments_and_code(): + assert is_sentence("Not a sentence") is False # no terminator + assert is_sentence("not a sentence.") is False # lowercase start + assert is_sentence("Uppercase.") is False # single word (no space) + assert is_sentence("") is False + assert is_sentence(" ") is False + assert is_sentence("def foo():") is False # code, no terminator + assert is_sentence("print('x')") is False # code + + +def test_code_like_output_does_not_trigger_sentence_loop(): + """Lowercase code/prose fragments must not be counted as sentence loops.""" + detector = LoopDetector(sentence_limit=5) + fragment = "some code like line that is long.\n" + + for _ in range(6): + detector.push(fragment) + + assert detector.sentence_counts == {} + + +# --------------------------------------------------------------------------- # +# Integration helpers +# --------------------------------------------------------------------------- # +class _AlwaysSetEvent: + def is_set(self): + return True + + +class _FakeIO: + def __init__(self): + self.confirmation_in_progress_event = _AlwaysSetEvent() + self.warnings = [] + + def tool_error(self, *a, **k): + pass + + def tool_warning(self, *a, **k): + self.warnings.append(a) + + def update_spinner_suffix(self, *a, **k): + pass + + def reset_streaming_response(self): + pass + + def stream_output(self, *a, **k): + pass + + def ai_output(self, *a, **k): + pass + + def tool_output(self, *a, **k): + pass + + def rule(self, *a, **k): + pass + + def update_spinner(self, *a, **k): + pass + + def start_spinner(self, *a, **k): + pass + + def stop_spinner(self, *a, **k): + pass + + def assistant_output(self, *a, **k): + pass + + def llm_started(self): + pass + + def ring_bell(self): + pass + + +class _FakeTokenProfiler: + def start(self): + pass + + def on_token(self): + pass + + def on_error(self): + pass + + def add_to_usage_report(self, *a, **k): + return a[0] if a else "" + + +def _make_coder(): + coder = Coder.__new__(Coder) + coder.stream = True + coder.args = SimpleNamespace(debug=False, show_thinking=False) + coder.io = _FakeIO() + coder.interrupt_event = ThreadSafeEvent() + coder.pretty = False + coder.reasoning_tag_name = "THINKING" + coder.got_reasoning_content = False + coder.ended_reasoning_content = False + coder.empty_response = False + coder.tool_reflection = False + coder.partial_response_content = "" + coder.partial_response_reasoning_content = "" + coder.partial_response_chunks = [] + coder.partial_response_tool_calls = [] + coder.partial_response_function_call = dict() + coder.partial_response_consolidated = None + coder._streaming_buffer_length = 0 + coder.token_profiler = _FakeTokenProfiler() + coder._output_loop_detected = False + coder._output_loop_message = "" + coder._has_empty_reflected = False + coder.edit_format = "code" + coder.max_compaction_retries = 3 + coder.enable_context_compaction = False + coder.model_kwargs = {} + coder.chat_completion_call_hashes = [] + coder.last_user_message = "" + coder.error_code = None + return coder + + +def _content_chunk(text): + delta = litellm.Delta(role="assistant", content=text) + choice = litellm.StreamChoice(finish_reason=None, index=0, delta=delta) + return litellm.StreamChunk( + id="cmpl-test", created=1000, model="gpt-test", choices=[choice], usage=None + ) + + +def _tc(index, call_id, name, arguments): + return litellm.ChatCompletionMessageToolCall( + id=call_id, + function=litellm.Function(arguments=arguments or "", name=name), + type="function", + index=index, + ) + + +def _tool_chunk(tool_calls): + delta = litellm.Delta(role="assistant", content=None, tool_calls=tool_calls) + choice = litellm.StreamChoice(finish_reason=None, index=0, delta=delta) + return litellm.StreamChunk( + id="cmpl-test", created=1000, model="gpt-test", choices=[choice], usage=None + ) + + +def _agen(chunks): + async def gen(): + for chunk in chunks: + yield chunk + + return gen() + + +class _FakeModel: + def __init__(self, chunks): + self.chunks = chunks + + async def send_completion(self, *args, **kwargs): + return hashlib.sha1(b"test"), _agen(self.chunks) + + +# --------------------------------------------------------------------------- # +# show_send_output_stream integration +# --------------------------------------------------------------------------- # +async def test_show_stream_detects_content_loop_and_stops(): + coder = _make_coder() + chunks = [_content_chunk("a" * 50), _content_chunk("a" * 51)] + + async for _ in coder.show_send_output_stream(_agen(chunks)): + pass + + assert coder._output_loop_detected is True + assert "Char loop" in coder._output_loop_message + + +async def test_show_stream_detects_tool_args_loop_and_stops(): + coder = _make_coder() + # A single tool-call fragment that is itself a repeated-character loop. + chunks = [_tool_chunk([_tc(0, "call_1", "Local--ls", "a" * 101)])] + + async for _ in coder.show_send_output_stream(_agen(chunks)): + pass + + assert coder._output_loop_detected is True + assert "Char loop" in coder._output_loop_message + + +async def test_show_stream_does_not_flag_normal_stream(): + coder = _make_coder() + # Real consolidation is expensive here, so stub it; we only assert that a + # healthy stream never trips the detector flag. + coder.consolidate_chunks = lambda: (None, None, None) + chunks = [ + _content_chunk("The quick brown fox jumps over the lazy dog."), + _content_chunk(" It was a sunny afternoon."), + ] + + async for _ in coder.show_send_output_stream(_agen(chunks)): + pass + + assert coder._output_loop_detected is False + + +def test_consolidate_chunks_applies_marker_and_clears_tool_calls(): + coder = _make_coder() + coder.partial_response_chunks = [_tool_chunk([_tc(0, "call_1", "Local--ls", "{}")])] + coder._output_loop_detected = True + + response, func_err, content_err = coder.consolidate_chunks() + + assert func_err is None + assert "[SYSTEM CANCEL: OUTPUT LOOP DETECTED]" in coder.partial_response_content + assert coder.partial_response_tool_calls == [] + assert coder.partial_response_function_call == dict() + + # The marker must also be written into the response message so it actually + # reaches the assistant message / conversation (which is built via model_dump). + msg = response.choices[0].message + assert "[SYSTEM CANCEL: OUTPUT LOOP DETECTED]" in (msg.content or "") + assert msg.tool_calls == [] + + dumped = response.model_dump()["choices"][0]["message"] + assert "[SYSTEM CANCEL: OUTPUT LOOP DETECTED]" in (dumped.get("content") or "") + + +def test_consolidate_chunks_propagates_marker_onto_existing_content(): + """A content-only loop keeps prior text and appends the marker to message.content.""" + coder = _make_coder() + coder.partial_response_chunks = [ + _content_chunk("The quick brown fox jumps over the lazy dog. ") + ] + coder._output_loop_detected = True + + response, func_err, content_err = coder.consolidate_chunks() + + assert func_err is None + msg = response.choices[0].message + assert "[SYSTEM CANCEL: OUTPUT LOOP DETECTED]" in msg.content + assert "The quick brown fox jumps over the lazy dog." in msg.content + + +def test_consolidate_chunks_leaves_normal_stream_untouched(): + coder = _make_coder() + coder.partial_response_chunks = [_content_chunk("The quick brown fox jumps over the lazy dog.")] + + response, func_err, content_err = coder.consolidate_chunks() + + assert func_err is None + assert "[SYSTEM CANCEL: OUTPUT LOOP DETECTED]" not in coder.partial_response_content + assert coder.partial_response_tool_calls == [] + + +async def test_show_stream_closes_provider_on_loop(): + """Loop-detection must aclose() the provider async generator, not leak it.""" + coder = _make_coder() + closed = {"closed": False} + + async def provider(): + try: + yield _content_chunk("a" * 50) + yield _content_chunk("a" * 51) + finally: + closed["closed"] = True + + stream = provider() + + async for _ in coder.show_send_output_stream(stream): + pass + + assert coder._output_loop_detected is True + assert closed["closed"] is True + + +# --------------------------------------------------------------------------- # +# send() integration +# --------------------------------------------------------------------------- # +async def test_send_appends_marker_and_clears_tool_calls_on_content_loop(): + coder = _make_coder() + coder.calculate_and_show_tokens_and_cost = lambda *a, **k: None + chunks = [_content_chunk("a" * 50), _content_chunk("a" * 51)] + model = _FakeModel(chunks) + + async for _ in coder.send([], model=model): + pass + + assert coder._output_loop_detected is True + assert "[SYSTEM CANCEL: OUTPUT LOOP DETECTED]" in coder.partial_response_content + assert coder.partial_response_tool_calls == [] + + +async def test_send_appends_marker_and_clears_tool_calls_on_tool_loop(): + coder = _make_coder() + coder.calculate_and_show_tokens_and_cost = lambda *a, **k: None + chunks = [_tool_chunk([_tc(0, "call_1", "Local--ls", "a" * 101)])] + model = _FakeModel(chunks) + + async for _ in coder.send([], model=model): + pass + + assert coder._output_loop_detected is True + assert "[SYSTEM CANCEL: OUTPUT LOOP DETECTED]" in coder.partial_response_content + assert coder.partial_response_tool_calls == [] + assert coder.partial_response_function_call == dict() diff --git a/tests/tools/test_insert_block.py b/tests/tools/test_insert_block.py index 856fbaed6c0..5c8a692e0ca 100644 --- a/tests/tools/test_insert_block.py +++ b/tests/tools/test_insert_block.py @@ -100,9 +100,10 @@ def test_position_top_succeeds_with_no_patterns(coder_with_file): assert "Applied" in result.to_dict()["result"][0]["content"] lines = file_path.read_text().splitlines() - # Inserted line replaces first line (inclusive bounds) assert lines[1] == "second line" - # Original second line shifts up - assert lines[0] == "inserted line" + # Insert places the new line after the anchor line + assert lines[0] == "first line" + assert lines[1] == "inserted line" + assert lines[2] == "second line" coder.io.tool_error.assert_not_called() @@ -224,7 +225,7 @@ def test_line_number_beyond_file_length_appends(coder_with_file): assert "Applied" in result.to_dict()["result"][0]["content"] content = file_path.read_text() - assert content == "first line\nappended line\n" + assert content == "first line\nsecond line\nappended line\n" coder.io.tool_error.assert_not_called() @@ -255,7 +256,6 @@ def test_line_number_beyond_file_length_appends_no_trailing_newline(coder_with_f ], ) content = file_path.read_text() - # Current implementation joins with \n, but respects original trailing newline - # Original doesn't have trailing newline, so result won't have one either - assert content == "first line\nappended line" + # Insert respects the original trailing newline: no trailing newline in the result + assert content == "first line\nsecond line\nappended line" coder.io.tool_error.assert_not_called()