From c2d1377c84ad523fa7fec7bb6128fb66c2ec2469 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 6 Jun 2026 13:22:43 -0700 Subject: [PATCH 01/47] feat: Add retry on empty LLM response Co-authored-by: cecli (openai/gemini_cli_local/gemini-2.5-pro) --- cecli/args.py | 6 ++++++ cecli/coders/base_coder.py | 33 +++++++++++++++++++++++++++++++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/cecli/args.py b/cecli/args.py index d51421d9012..b02bf95141d 100644 --- a/cecli/args.py +++ b/cecli/args.py @@ -278,6 +278,12 @@ def get_parser(default_config_files, git_root): help="Specify LLM retry configuration as a JSON string", default=None, ) + group.add_argument( + "--retry-on-empty", + action=argparse.BooleanOptionalAction, + default=False, + help="Enable/disable retrying on empty LLM responses (default: False)", + ) ####### group = parser.add_argument_group("Customization Settings") diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index c28dc866cc6..698b7e0c235 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -91,6 +91,10 @@ class FinishReasonLength(Exception): pass +class EmptyResponseError(Exception): + pass + + def wrap_fence(name): return f"<{name}>", f"" @@ -2399,9 +2403,29 @@ async def format_in_executor(): try: while True: try: + self.empty_response = False async for chunk in self.send(messages, tools=self.get_tool_list()): yield chunk break + except EmptyResponseError: + self.io.tool_warning(self.empty_llm_tool_warning()) + if not (self.args and self.args.retry_on_empty): + break + + retry_delay *= 2 + if retry_delay > RETRY_TIMEOUT: + self.io.tool_error("Retry timeout exceeded on empty response.") + break + + self.io.tool_output(f"Retrying in {retry_delay:.1f} seconds...") + + _res, interrupted_sleep = await coroutines.interruptible( + asyncio.sleep(retry_delay), self.interrupt_event + ) + if interrupted_sleep: + interrupted = True + break + continue except litellm_ex.exceptions_tuple() as err: ex_info = litellm_ex.get_ex_info(err) @@ -3302,6 +3326,9 @@ async def send(self, messages, model=None, functions=None, tools=None): else: await self.show_send_output(completion) + if self.empty_response: + raise EmptyResponseError + response, func_err, content_err = self.consolidate_chunks() if response: @@ -3382,7 +3409,8 @@ async def show_send_output(self, completion): and not len(self.partial_response_tool_calls) and not len(self.partial_response_reasoning_content) ): - self.io.tool_warning(self.empty_llm_tool_warning()) + self.empty_response = True + return self.io.assistant_output(show_resp, pretty=self.show_pretty()) @@ -3539,7 +3567,8 @@ async def show_send_output_stream(self, completion): return if not received_content and len(self.partial_response_tool_calls) == 0: - self.io.tool_warning(self.empty_llm_tool_warning()) + self.empty_response = True + return def consolidate_chunks(self): if self.partial_response_consolidated: From 32b619b5b5b0c1bcead79996026f69b94f8e0750 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 7 Jun 2026 08:26:37 -0400 Subject: [PATCH 02/47] Update tool validation to handle nested paths --- cecli/tools/validations/validations.py | 114 ++++++++++++-- tests/tools/validations.py | 206 +++++++++++++++++++++++++ 2 files changed, 309 insertions(+), 11 deletions(-) diff --git a/cecli/tools/validations/validations.py b/cecli/tools/validations/validations.py index 531d295d59b..15fc22ab460 100644 --- a/cecli/tools/validations/validations.py +++ b/cecli/tools/validations/validations.py @@ -67,22 +67,114 @@ def validate_params(cls, params: dict, validations: dict, schema: dict | None = return params for raw_key, method_names in validations.items(): - # Determine whether the key targets list items (trailing "[]") - iterate_over_list = raw_key.endswith("[]") - clean_key = raw_key.rstrip("[]") + segments = cls._parse_validation_key(raw_key) + if not segments: + continue + cls._apply_along_segments(params, segments, method_names) + return params + + @staticmethod + def _parse_validation_key(raw_key: str) -> list[tuple[str, bool]]: + """ + Parse a validation path into a list of (key, iterate) tuples. - # Split on dots to get the navigation path into params - path = clean_key.split(".") if clean_key else [] + Supports the following path shapes: - if not path: - continue + "segment" -> [("segment", False)] + "segment.nested" -> [("segment", False), ("nested", False)] + "segment[]" -> [("segment", True)] + "segment[].nested" -> [("segment", True), ("nested", False)] + "segment.nested[]" -> [("segment", False), ("nested", True)] + "segment[].nested[].n2" -> [("segment", True), ("nested", True), ("n2", False)] - if iterate_over_list: - cls._apply_validations_to_list_items(params, path, method_names) + Any trailing ``[]`` on a path segment marks it for iteration — the + validation will be applied to each item in the list found at that key. + + Returns: + A list of (key, should_iterate) tuples. Returns an empty list + if the key is empty or contains only separators. + """ + if not raw_key: + return [] + + parts = raw_key.split(".") + segments: list[tuple[str, bool]] = [] + for part in parts: + if not part: + continue + if part.endswith("[]"): + segments.append((part[:-2], True)) else: - cls._apply_validations_to_value(params, path, method_names) + segments.append((part, False)) - return params + return segments + + @classmethod + def _apply_along_segments( + cls, params: dict, segments: list[tuple[str, bool]], method_names: list[str] + ) -> None: + """ + Recursively apply *method_names* along the parsed *segments* path. + + Each segment is a ``(key, iterate)`` tuple. When *iterate* is ``True`` + the method expects ``params[key]`` to be a list and either applies the + validations to each item (if this is the last segment) or recurses + into each item's dict (if there are further segments). When *iterate* + is ``False`` the method either applies validations to ``params[key]`` + (last segment) or recurses into the nested dict. + + ``params`` is mutated in place. + """ + if not segments: + return + + key, iterate = segments[0] + remaining = segments[1:] + + if not isinstance(params, dict) or key not in params: + return + + if iterate: + items = params[key] + if not isinstance(items, list): + return + + if not remaining: + # Apply validation methods to each item in the list + new_items: list = [] + for item in items: + for method_name in method_names: + method = getattr(cls, method_name, None) + if method is None: + raise ToolError(f"Unknown validation method: {method_name}") + item = method(item) + if item is None: + break + if item is not None: + new_items.append(item) + params[key] = new_items + else: + # Recurse into each item, applying remaining segments + for item in items: + if isinstance(item, dict): + cls._apply_along_segments(item, remaining, method_names) + else: + if not remaining: + # Apply validation methods to the value at this key + value = params[key] + for method_name in method_names: + method = getattr(cls, method_name, None) + if method is None: + raise ToolError(f"Unknown validation method: {method_name}") + value = method(value) + if value is None: + break + params[key] = value + else: + # Navigate deeper + nested = params[key] + if isinstance(nested, dict): + cls._apply_along_segments(nested, remaining, method_names) @classmethod def _basic_validations(cls, params: object, schema: dict | None = None) -> dict: diff --git a/tests/tools/validations.py b/tests/tools/validations.py index 95f58bc183b..9f651e75586 100644 --- a/tests/tools/validations.py +++ b/tests/tools/validations.py @@ -461,3 +461,209 @@ def test_list_iteration_on_non_list_does_nothing(self): {"items[]": ["coerce_dict"]}, ) assert result == {"items": "not a list"} + + +# ========================================================================= +# path parsing tests +# ========================================================================= + + +class TestPathParsing: + """Path resolution: segment, segment.nested, segment[], segment[].nested, segment.nested[], and complex.""" + + # ---- "segment" - single path ---- + + def test_single_path_segment(self): + """A simple key should resolve to a top-level param value.""" + params = {"delegations": '[{"name": "a1"}]'} + result = ToolValidations.validate_params( + params, + {"delegations": ["coerce_list"]}, + ) + assert result == {"delegations": [{"name": "a1"}]} + + # ---- "segment.nested" - nested path ---- + + def test_nested_path_segment(self): + """A dot-separated key should resolve to a nested param value.""" + params = {"outer": {"inner": '[{"name": "a1"}]'}} + result = ToolValidations.validate_params( + params, + {"outer.inner": ["coerce_list"]}, + ) + assert result == {"outer": {"inner": [{"name": "a1"}]}} + + def test_nested_path_deep(self): + """Deeply nested dot-separated key should resolve correctly.""" + params = {"a": {"b": {"c": '{"x": 1}'}}} + result = ToolValidations.validate_params( + params, + {"a.b.c": ["coerce_dict"]}, + ) + assert result == {"a": {"b": {"c": {"x": 1}}}} + + # ---- "segment[]" - iterate over list items at segment ---- + + def test_segment_bracket_iterates_list_items(self): + """A key with trailing [] should apply validation to each list item.""" + params = { + "items": [ + '{"name": "a1", "prompt": "do x"}', + '{"name": "a2", "prompt": "do y"}', + ] + } + result = ToolValidations.validate_params( + params, + {"items[]": ["coerce_dict"]}, + ) + assert result == { + "items": [ + {"name": "a1", "prompt": "do x"}, + {"name": "a2", "prompt": "do y"}, + ] + } + + # ---- "segment[].nested" - iterate then access sub-key ---- + + def test_segment_bracket_nested_key(self): + """segment[].nested: iterate over segment items, apply validation to each item's .nested.""" + params = { + "items": [ + {"nested": '{"a": 1}'}, + {"nested": '{"b": 2}'}, + ] + } + result = ToolValidations.validate_params( + params, + {"items[].nested": ["coerce_dict"]}, + ) + assert result == { + "items": [ + {"nested": {"a": 1}}, + {"nested": {"b": 2}}, + ] + } + + def test_segment_bracket_nested_skips_missing_keys(self): + """segment[].nested: items missing the nested key should be left alone.""" + params = { + "items": [ + {"nested": '{"a": 1}'}, + {"other": "value"}, + ] + } + result = ToolValidations.validate_params( + params, + {"items[].nested": ["coerce_dict"]}, + ) + # The item without 'nested' should remain unchanged + assert result == { + "items": [ + {"nested": {"a": 1}}, + {"other": "value"}, + ] + } + + def test_segment_bracket_nested_not_a_list(self): + """segment[].nested: if segment is not a list, params should be left unchanged.""" + params = {"items": "not a list"} + result = ToolValidations.validate_params( + params, + {"items[].nested": ["coerce_dict"]}, + ) + assert result == {"items": "not a list"} + + # ---- "segment.nested[]" - navigate then iterate ---- + + def test_nested_dot_bracket_iterates_list(self): + """segment.nested[]: navigate to segment.nested, then iterate over list items.""" + params = { + "group": { + "items": [ + '{"name": "a1"}', + '{"name": "a2"}', + ] + } + } + result = ToolValidations.validate_params( + params, + {"group.items[]": ["coerce_dict"]}, + ) + assert result == { + "group": { + "items": [ + {"name": "a1"}, + {"name": "a2"}, + ] + } + } + + # ---- "segment[].nested[].nested2" - complex ---- + + def test_complex_nested_iteration(self): + """segment[].nested[].nested2: iterate, descend, iterate, access sub-key.""" + params = { + "items": [ + { + "nested": [ + {"nested2": '{"a": 1}'}, + {"nested2": '{"b": 2}'}, + ] + }, + { + "nested": [ + {"nested2": '{"c": 3}'}, + ] + }, + ] + } + result = ToolValidations.validate_params( + params, + {"items[].nested[].nested2": ["coerce_dict"]}, + ) + assert result == { + "items": [ + { + "nested": [ + {"nested2": {"a": 1}}, + {"nested2": {"b": 2}}, + ] + }, + { + "nested": [ + {"nested2": {"c": 3}}, + ] + }, + ] + } + + # ---- edge cases ---- + + def test_complex_missing_intermediate_key(self): + """Complex path: missing intermediate key should leave params unchanged.""" + params = {"items": [{"nested": [{"nested2": "value"}]}]} + result = ToolValidations.validate_params( + params, + {"items[].missing[].nested2": ["coerce_dict"]}, + ) + # "missing" doesn't exist, so nothing happens + assert result == {"items": [{"nested": [{"nested2": "value"}]}]} + + def test_complex_middle_not_a_list(self): + """Complex path: if an intermediate [] target is not a list, params left unchanged.""" + params = {"items": [{"nested": "not a list"}]} + result = ToolValidations.validate_params( + params, + {"items[].nested[].nested2": ["coerce_dict"]}, + ) + # "nested" is not a list, so the second [] iteration can't happen + assert result == {"items": [{"nested": "not a list"}]} + + def test_complex_empty_inner_list(self): + """Complex path: an empty inner list should remain empty.""" + params = {"items": [{"nested": []}]} + result = ToolValidations.validate_params( + params, + {"items[].nested[].nested2": ["coerce_dict"]}, + ) + assert result == {"items": [{"nested": []}]} From f22459327c1e3ef1702fd32ebc8aec3abae6fa47 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 7 Jun 2026 08:30:16 -0400 Subject: [PATCH 03/47] #561: ReadRange string coercion --- cecli/tools/read_range.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cecli/tools/read_range.py b/cecli/tools/read_range.py index 413a111b61f..3f25b8accca 100644 --- a/cecli/tools/read_range.py +++ b/cecli/tools/read_range.py @@ -19,6 +19,8 @@ class Tool(BaseTool): VALIDATIONS = { "show": ["coerce_list"], "show[]": ["coerce_dict"], + "show[].start_text": ["coerce_str"], + "show[].end_text": ["coerce_str"], } SCHEMA = { "type": "function", From 86a5d061913538073f68c4c33abfde25b95d42c6 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 7 Jun 2026 11:40:47 -0400 Subject: [PATCH 04/47] Update ReadRange to handle mixed input types --- cecli/tools/read_range.py | 226 ++++++---- tests/tools/test_get_lines.py | 20 +- tests/tools/test_read_range_execute.py | 544 +++++++++++++++++++++++++ 3 files changed, 695 insertions(+), 95 deletions(-) create mode 100644 tests/tools/test_read_range_execute.py diff --git a/cecli/tools/read_range.py b/cecli/tools/read_range.py index 3f25b8accca..5b9f19c9345 100644 --- a/cecli/tools/read_range.py +++ b/cecli/tools/read_range.py @@ -19,26 +19,31 @@ class Tool(BaseTool): VALIDATIONS = { "show": ["coerce_list"], "show[]": ["coerce_dict"], - "show[].start_text": ["coerce_str"], - "show[].end_text": ["coerce_str"], + "show[].start_marker": ["coerce_str"], + "show[].end_marker": ["coerce_str"], } SCHEMA = { "type": "function", "function": { "name": "ReadRange", "description": ( - "Get content hash prefixes of content between start and end patterns in files." - " Accepts an array of `show` objects, each with file_path, start_text, end_text." - " These values must be lines of content in the file. They can contain up to 3" - " lines but newlines should generally be avoided. Avoid using generic keywords and" + "Get content hash prefixes of content between start and end markers in files." + " This is useful for files you are attempting to edit and for understanding their structure." + " Accepts an array of `show` objects, each with file_path, start_marker, end_marker." + " These values should be lines of content in the file. They can contain up to 3" + " lines of content but newlines should generally be avoided. Avoid using generic keywords and" " symbols. Special markers @000 and 000@ represent the file boundaries and can be" - " used for start_text and end_text for the first and last lines of the file" - " respectively. Avoid using both of the special markers together on non-empty" - " files. Line numbers may be used as values but they are discouraged as" - " they shift between edits. Never use content hashes as the start_text and end_text values." - " Do not use the same pattern for the start_text and end_text. It is best to use function" - " names, variable declarations and other meaningful identifiers as start_text and" - " end_text values." + " used for start_marker and end_marker for the first and last lines of the file" + " respectively. Line numbers may also be used for range lookups." + " It is best to use function names, variable declarations and other meaningful identifiers" + " as start_marker and end_marker values." + " Do not use both of the special markers together on non-empty file." + " Do not use content hashes as the start_marker and end_marker values." + " Do not use the same pattern for the start_marker and end_marker." + " Do not use empty strings for the start_marker and end_marker." + " Prefer using this tool to cli tools for reading files." + " Calling this tool sequentially on increasingly finer grained searchers " + " will help with getting outlines of important structural features" ), "parameters": { "type": "object", @@ -52,14 +57,14 @@ class Tool(BaseTool): "type": "string", "description": "File path to search in.", }, - "start_text": { + "start_marker": { "type": "string", "description": ( "The text marking the beginning of the range." " Use '@000' for the first line on empty files." ), }, - "end_text": { + "end_marker": { "type": "string", "description": ( "The text marking the end of the range." @@ -67,7 +72,7 @@ class Tool(BaseTool): ), }, }, - "required": ["file_path", "start_text", "end_text"], + "required": ["file_path", "start_marker", "end_marker"], }, "description": "Array of show operations to perform.", }, @@ -111,8 +116,8 @@ def execute(cls, coder, show, **kwargs): for show_index, show_op in enumerate(show): # Extract parameters for this show operation file_path = show_op.get("file_path") - start_text = show_op.get("start_text") - end_text = show_op.get("end_text") + start_marker = show_op.get("start_marker") + end_marker = show_op.get("end_marker") padding = 5 if file_path is None: @@ -129,37 +134,37 @@ def execute(cls, coder, show, **kwargs): continue # Validate arguments for this operation - if not is_provided(start_text) or not is_provided(end_text): + if not is_provided(start_marker) or not is_provided(end_marker): error_outputs.append( cls.format_error( coder, ( - f"Show operation {show_index + 1}: Provide both 'start_text' and" - " 'end_text'." + f"Show operation {show_index + 1}: Provide both 'start_marker' and" + " 'end_marker'." ), file_path, - start_text, - end_text, + start_marker, + end_marker, show_index, ) ) continue - if start_text.count("\n") > 4 or end_text.count("\n") > 4: + if start_marker.count("\n") > 4 or end_marker.count("\n") > 4: error_outputs.append( cls.format_error( coder, "Patterns must not contain more than 5 lines.", file_path, - start_text, - end_text, + start_marker, + end_marker, show_index, ) ) continue - start_text = strip_hashline(start_text).strip() - end_text = strip_hashline(end_text).strip() + start_marker = strip_hashline(start_marker).strip() + end_marker = strip_hashline(end_marker).strip() # 2. Resolve path abs_path, rel_path = resolve_paths(coder, file_path) @@ -170,8 +175,8 @@ def execute(cls, coder, show, **kwargs): coder, f"File not found: {file_path}", file_path, - start_text, - end_text, + start_marker, + end_marker, show_index, ) ) @@ -185,8 +190,8 @@ def execute(cls, coder, show, **kwargs): coder, f"Could not read file: {file_path}", file_path, - start_text, - end_text, + start_marker, + end_marker, show_index, ) ) @@ -214,25 +219,79 @@ def execute(cls, coder, show, **kwargs): # 4. Determine line range start_line_idx = -1 end_line_idx = -1 + both_structured = False # found_by = "" - if start_text is not None and end_text is not None: - if start_text.isdigit() and end_text.isdigit(): - # Treat both as 1-based line numbers - start_line_num = int(start_text) - end_line_num = int(end_text) - # Clamp to valid range [1, num_lines] - start_line_num = max(1, min(start_line_num, num_lines)) - end_line_num = max(1, min(end_line_num, num_lines)) - if start_line_num > end_line_num: - # Swap so start <= end - start_line_num, end_line_num = end_line_num, start_line_num - start_indices = [start_line_num - 1] - end_indices = [end_line_num - 1] - elif start_text == "@000" or start_text == "000@": - start_indices = [0] + if start_marker is not None and end_marker is not None: + + def _is_valid_int(s): + try: + int(s) + return True + except ValueError: + return False + + start_is_digit = _is_valid_int(start_marker) + end_is_digit = _is_valid_int(end_marker) + start_is_special = start_marker in ("@000", "000@") + end_is_special = end_marker in ("@000", "000@") + both_structured = (start_is_digit or start_is_special) and ( + end_is_digit or end_is_special + ) + start_is_text = not start_is_digit and not start_is_special + end_is_text = not end_is_digit and not end_is_special + mixed_special_search = (start_is_special and end_is_text) or ( + end_is_special and start_is_text + ) + start_indices = [] + end_indices = [] + + if both_structured: + if start_is_digit: + start_line_num = int(start_marker) + start_line_num = max(1, min(start_line_num, num_lines)) + start_indices = [start_line_num - 1] + else: + start_indices = [0] + + if end_is_digit: + end_line_num = int(end_marker) + end_line_num = max(1, min(end_line_num, num_lines)) + end_indices = [end_line_num - 1] + else: + end_indices = [num_lines - 1] + elif mixed_special_search: + if start_is_special: + # Start is special marker, end is text pattern + if start_marker == "@000": + start_indices = [0] + else: # 000@ + start_indices = [num_lines - 1] + # Search for end pattern as text + end_pattern_lines = end_marker.split("\n") + end_indices = [] + for i in range(len(lines) - len(end_pattern_lines) + 1): + if all( + p_line in lines[i + j] + for j, p_line in enumerate(end_pattern_lines) + ): + end_indices.append(i + len(end_pattern_lines) - 1) + else: + # Start is text pattern, end is special marker + start_pattern_lines = start_marker.split("\n") + start_indices = [] + for i in range(len(lines) - len(start_pattern_lines) + 1): + if all( + p_line in lines[i + j] + for j, p_line in enumerate(start_pattern_lines) + ): + start_indices.append(i) + if end_marker == "@000": + end_indices = [0] + else: # 000@ + end_indices = [num_lines - 1] else: - start_pattern_lines = start_text.split("\n") + start_pattern_lines = start_marker.split("\n") start_indices = [] for i in range(len(lines) - len(start_pattern_lines) + 1): if all( @@ -241,10 +300,7 @@ def execute(cls, coder, show, **kwargs): ): start_indices.append(i) - if end_text == "000@" or end_text == "@000": - end_indices = [num_lines - 1] - else: - end_pattern_lines = end_text.split("\n") + end_pattern_lines = end_marker.split("\n") end_indices = [] for i in range(len(lines) - len(end_pattern_lines) + 1): if all( @@ -261,12 +317,12 @@ def execute(cls, coder, show, **kwargs): cls.format_error( coder, ( - f"Start pattern '{start_text}' too broad." + f"Start pattern '{start_marker}' too broad." " Refine your search. Be more specific." ), file_path, - start_text, - end_text, + start_marker, + end_marker, show_index, ) ) @@ -313,12 +369,12 @@ def execute(cls, coder, show, **kwargs): cls.format_error( coder, ( - f"Start pattern '{start_text}' not found in {file_path}." + f"Start pattern '{start_marker}' not found in {file_path}." " Refine your search." ), file_path, - start_text, - end_text, + start_marker, + end_marker, show_index, ) ) @@ -329,12 +385,12 @@ def execute(cls, coder, show, **kwargs): cls.format_error( coder, ( - f"End pattern '{end_text}' not found in {file_path}." + f"End pattern '{end_marker}' not found in {file_path}." " Refine your search." ), file_path, - start_text, - end_text, + start_marker, + end_marker, show_index, ) ) @@ -345,12 +401,12 @@ def execute(cls, coder, show, **kwargs): cls.format_error( coder, ( - f"End pattern '{end_text}' not found after start pattern in" + f"End pattern '{end_marker}' not found after start pattern in" f" {file_path}." ), file_path, - start_text, - end_text, + start_marker, + end_marker, show_index, ) ) @@ -358,10 +414,10 @@ def execute(cls, coder, show, **kwargs): s_idx, e_idx = best_pair - # Validate range width when special markers are used - # If too large, use _get_range_preview which tries get_file_stub - # first, falling back to 20 equally-spaced lines for non-code files - if (start_text == "@000" or end_text == "000@") and (e_idx - s_idx > 200): + # For structured searches (line numbers, special markers) or mixed searches + # (one special marker, one text pattern), cap large ranges with preview + # Text pattern searches are not subject to capping + if (both_structured or mixed_special_search) and (e_idx - s_idx > 200): preview = cls._get_range_preview( abs_path, coder.io, start_idx=s_idx, end_idx=e_idx, line_numbers=True ) @@ -374,7 +430,7 @@ def execute(cls, coder, show, **kwargs): # Store the found indices for future disambiguation cls._last_invocation[abs_path] = {"start_idx": s_idx, "end_idx": e_idx} - # found_by = f"range '{start_text}' to '{end_text}'" + # found_by = f"range '{start_marker}' to '{end_marker}'" try: padding_int = int(padding) @@ -392,8 +448,8 @@ def execute(cls, coder, show, **kwargs): coder, "Internal error: Could not determine line range.", file_path, - start_text, - end_text, + start_marker, + end_marker, show_index, ) ) @@ -629,13 +685,13 @@ def format_output(cls, coder, mcp_server, tool_response): coder.io.tool_output("") for i, show_op in enumerate(show_ops): file_path = show_op.get("file_path", "") - start_text = strip_hashline(show_op.get("start_text", "")).strip() - end_text = strip_hashline(show_op.get("end_text", "")).strip() + start_marker = strip_hashline(show_op.get("start_marker", "")).strip() + end_marker = strip_hashline(show_op.get("end_marker", "")).strip() - # Format as "show: • file_path • start_text • end_text • padding" + # Format as "show: • file_path • start_marker • end_marker • padding" formatted_query = ( - f"{color_start}range_{i + 1}:{color_end} {file_path} • {start_text} •" - f" {end_text}" + f"{color_start}range_{i + 1}:{color_end} {file_path} • {start_marker} •" + f" {end_marker}" ) coder.io.tool_output(formatted_query) coder.io.tool_output("") @@ -643,24 +699,24 @@ def format_output(cls, coder, mcp_server, tool_response): tool_footer(coder=coder, tool_response=tool_response, params=params) @classmethod - def format_error(cls, coder, error_text, file_path, start_text, end_text, operation_index): + def format_error(cls, coder, error_text, file_path, start_marker, end_marker, operation_index): """Format error output for the ReadRange tool.""" - # Truncate start_text to first line with ellipsis if multiline - start_line = (start_text or "N/A").split("\n")[0] - if start_text and start_text.count("\n") > 0: + # Truncate start_marker to first line with ellipsis if multiline + start_line = (start_marker or "N/A").split("\n")[0] + if start_marker and start_marker.count("\n") > 0: start_line = start_line + " ..." - # Truncate end_text to first line with ellipsis if multiline - end_line = (end_text or "N/A").split("\n")[0] - if end_text and end_text.count("\n") > 0: + # Truncate end_marker to first line with ellipsis if multiline + end_line = (end_marker or "N/A").split("\n")[0] + if end_marker and end_marker.count("\n") > 0: end_line = end_line + " ..." output = [ f"[Operation {operation_index + 1}]", f"file_path: {file_path or 'N/A'}", - f"start_text: {start_line}", - f"end_text: {end_line}", + f"start_marker: {start_line}", + f"end_marker: {end_line}", "", error_text, ] diff --git a/tests/tools/test_get_lines.py b/tests/tools/test_get_lines.py index 1bbeb0d3b6c..59c5e15f2ed 100644 --- a/tests/tools/test_get_lines.py +++ b/tests/tools/test_get_lines.py @@ -57,8 +57,8 @@ def test_pattern_with_zero_line_number_is_allowed(coder_with_file): show=[ { "file_path": "example.txt", - "start_text": "beta", - "end_text": "beta", + "start_marker": "beta", + "end_marker": "beta", "padding": 0, } ], @@ -77,8 +77,8 @@ def test_empty_pattern_uses_line_number(coder_with_file): show=[ { "file_path": "example.txt", - "start_text": "beta", - "end_text": "beta", + "start_marker": "beta", + "end_marker": "beta", "padding": 0, } ], @@ -98,13 +98,13 @@ def test_conflicting_pattern_and_line_number_raise(coder_with_file): show=[ { "file_path": "example.txt", - "end_text": "beta", + "end_marker": "beta", "padding": 0, } ], ) - assert "Provide both 'start_text' and 'end_text'" in result + assert "Provide both 'start_marker' and 'end_marker'" in result coder.io.tool_error.assert_called() @@ -133,8 +133,8 @@ def test_multiline_pattern_search(coder_with_file): show=[ { "file_path": "example.txt", - "start_text": "alpha\nbeta", - "end_text": "beta\ngamma", + "start_marker": "alpha\nbeta", + "end_marker": "beta\ngamma", "padding": 0, } ], @@ -160,8 +160,8 @@ def test_empty_file_includes_edit_hint(tmp_path): show=[ { "file_path": "pubspec.yaml", - "start_text": "@000", - "end_text": "@000", + "start_marker": "@000", + "end_marker": "@000", } ], ) diff --git a/tests/tools/test_read_range_execute.py b/tests/tools/test_read_range_execute.py new file mode 100644 index 00000000000..ee885130605 --- /dev/null +++ b/tests/tools/test_read_range_execute.py @@ -0,0 +1,544 @@ +""" +Tests for the execute method of read_range.py. + +Focuses on the parsing logic for line numbers, special markers (@000, 000@), +and text strings. Tests cover all combinations of these marker types. +""" + +import os +import sys +import tempfile +from unittest.mock import MagicMock, patch + +import pytest + +# Add project root to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../..")) + + +# ============================================================================= +# Fixtures +# ============================================================================= + + +@pytest.fixture +def mock_coder(): + """Create a mock coder object with all necessary attributes.""" + coder = MagicMock() + coder.turn_count = 5 + coder.abs_root_path.side_effect = lambda p: os.path.abspath(p) + coder.get_rel_fname.side_effect = lambda p: os.path.relpath(p, os.getcwd()) + coder.io.tool_output = MagicMock() + coder.io.tool_error = MagicMock() + coder.io.tool_warning = MagicMock() + return coder + + +@pytest.fixture +def mock_file_context(): + """Mock the ConversationService file context operations.""" + file_context = MagicMock() + file_context.get_file_context.return_value = None + file_context.update_file_context.return_value = (1, 10) + file_context.clear_ranges = MagicMock() + file_context.push_range = MagicMock() + return file_context + + +@pytest.fixture +def mock_chunks(): + """Mock the ConversationService chunks operations.""" + chunks = MagicMock() + chunks.add_file_context_messages = MagicMock() + return chunks + + +@pytest.fixture +def mock_manager(): + """Mock the ConversationService manager operations.""" + manager = MagicMock() + manager.get_tag_messages.return_value = [] + return manager + + +def create_test_file(content): + """Create a temporary file with the given content and return the path.""" + tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) + tmp.write(content) + tmp.close() + return tmp.name + + +# ============================================================================= +# Test Class +# ============================================================================= + + +class TestReadRangeExecute: + """Tests for Tool.execute() parsing logic.""" + + # Class-level patches that apply to all tests + @pytest.fixture(autouse=True) + def setup_patches(self): + self.patches = [] + yield + for p in self.patches: + p.stop() + + def _setup(self, mock_coder, mock_file_context, mock_chunks, mock_manager, file_content=""): + """Set up mocks and create a test file with given content.""" + self.coder = mock_coder + self.test_file = create_test_file(file_content) + self.coder.io.read_text.return_value = file_content + + # Patch ConversationService - it's imported locally in execute(), + # so we patch at the source module + mock_cs = MagicMock() + mock_cs.get_files.return_value = mock_file_context + mock_cs.get_chunks.return_value = mock_chunks + mock_cs.get_manager.return_value = mock_manager + cs_patch = patch("cecli.helpers.conversation.ConversationService", mock_cs) + cs_patch.start() + self.patches.append(cs_patch) + + # Patch strip_hashline to be identity + sh_patch = patch("cecli.tools.read_range.strip_hashline", side_effect=lambda x: x) + sh_patch.start() + self.patches.append(sh_patch) + + # Patch hashline to be identity + hl_patch = patch("cecli.tools.read_range.hashline", side_effect=lambda x: x) + hl_patch.start() + self.patches.append(hl_patch) + + # Patch resolve_paths + rp_patch = patch( + "cecli.tools.read_range.resolve_paths", + return_value=(self.test_file, os.path.relpath(self.test_file)), + ) + rp_patch.start() + self.patches.append(rp_patch) + + # Patch is_provided + ip_patch = patch( + "cecli.tools.read_range.is_provided", + side_effect=lambda v, **kw: v is not None and v != "", + ) + ip_patch.start() + self.patches.append(ip_patch) + + # Reset class-level state on Tool + from cecli.tools.read_range import Tool + + self.Tool = Tool + Tool._last_invocation = {} + Tool._last_read_turn = {} + + def _teardown(self): + """Clean up temporary file.""" + if hasattr(self, "test_file") and os.path.exists(self.test_file): + os.unlink(self.test_file) + + # ========================================================================= + # Line Number Parsing (both_structured, both digits) + # ========================================================================= + + def test_both_digits_valid_range( + self, mock_coder, mock_file_context, mock_chunks, mock_manager + ): + """Test: start_marker='5', end_marker='10' -> lines 4-9 (0-based).""" + content = "\n".join(f"line{i}" for i in range(1, 11)) + self._setup(mock_coder, mock_file_context, mock_chunks, mock_manager, content) + try: + show = [{"file_path": self.test_file, "start_marker": "5", "end_marker": "10"}] + result = self.Tool.execute(self.coder, show) + assert "Snapshot" in result + assert "line5" in result + assert "line10" in result + finally: + self._teardown() + + def test_both_digits_same_line(self, mock_coder, mock_file_context, mock_chunks, mock_manager): + """Test: start_marker='1', end_marker='1' -> just line 0.""" + content = "\n".join(f"line{i}" for i in range(1, 11)) + self._setup(mock_coder, mock_file_context, mock_chunks, mock_manager, content) + try: + show = [{"file_path": self.test_file, "start_marker": "1", "end_marker": "1"}] + result = self.Tool.execute(self.coder, show) + assert "line1" in result + finally: + self._teardown() + + def test_both_digits_out_of_bounds( + self, mock_coder, mock_file_context, mock_chunks, mock_manager + ): + """Test: start_marker='1', end_marker='100' -> clamp to valid range.""" + content = "\n".join(f"line{i}" for i in range(1, 11)) + self._setup(mock_coder, mock_file_context, mock_chunks, mock_manager, content) + try: + show = [{"file_path": self.test_file, "start_marker": "1", "end_marker": "100"}] + result = self.Tool.execute(self.coder, show) + assert "line1" in result + assert "line10" in result + finally: + self._teardown() + + def test_both_digits_inverted_order( + self, mock_coder, mock_file_context, mock_chunks, mock_manager + ): + """Test: start_marker='10', end_marker='5': inverted matching swaps.""" + content = "\n".join(f"line{i}" for i in range(1, 11)) + self._setup(mock_coder, mock_file_context, mock_chunks, mock_manager, content) + try: + show = [{"file_path": self.test_file, "start_marker": "10", "end_marker": "5"}] + result = self.Tool.execute(self.coder, show) + # Inverted: start=[9], end=[4], only one each -> swap to (4, 9) + assert result is not None + finally: + self._teardown() + + # ========================================================================= + # Special Marker Parsing (both_structured, both special) + # ========================================================================= + + def test_special_start_end(self, mock_coder, mock_file_context, mock_chunks, mock_manager): + """Test: @000 to 000@ -> first to last line.""" + content = "\n".join([f"line{i}" for i in range(1, 6)]) + self._setup(mock_coder, mock_file_context, mock_chunks, mock_manager, content) + try: + show = [{"file_path": self.test_file, "start_marker": "@000", "end_marker": "000@"}] + result = self.Tool.execute(self.coder, show) + assert "line1" in result + assert "line5" in result + finally: + self._teardown() + + def test_special_start_at_000(self, mock_coder, mock_file_context, mock_chunks, mock_manager): + """Test: @000 to @000 -> first line only.""" + content = "\n".join([f"line{i}" for i in range(1, 6)]) + self._setup(mock_coder, mock_file_context, mock_chunks, mock_manager, content) + try: + show = [{"file_path": self.test_file, "start_marker": "@000", "end_marker": "@000"}] + result = self.Tool.execute(self.coder, show) + assert "line1" in result + finally: + self._teardown() + + def test_special_end_at_000(self, mock_coder, mock_file_context, mock_chunks, mock_manager): + """Test: 000@ to 000@ -> last line only.""" + content = "\n".join([f"line{i}" for i in range(1, 6)]) + self._setup(mock_coder, mock_file_context, mock_chunks, mock_manager, content) + try: + show = [{"file_path": self.test_file, "start_marker": "000@", "end_marker": "000@"}] + result = self.Tool.execute(self.coder, show) + assert "line5" in result + finally: + self._teardown() + + # ========================================================================= + # Mixed Digit + Special (both_structured) + # ========================================================================= + + def test_special_start_digit_end( + self, mock_coder, mock_file_context, mock_chunks, mock_manager + ): + """Test: @000 to '3' -> first to line 2 (0-based).""" + content = "line1\nline2\nline3\nline4\nline5" + self._setup(mock_coder, mock_file_context, mock_chunks, mock_manager, content) + try: + show = [{"file_path": self.test_file, "start_marker": "@000", "end_marker": "3"}] + result = self.Tool.execute(self.coder, show) + assert "line1" in result + assert "line3" in result + finally: + self._teardown() + + def test_digit_start_special_end( + self, mock_coder, mock_file_context, mock_chunks, mock_manager + ): + """Test: '2' to 000@ -> line 1 to last.""" + content = "line1\nline2\nline3\nline4\nline5" + self._setup(mock_coder, mock_file_context, mock_chunks, mock_manager, content) + try: + show = [{"file_path": self.test_file, "start_marker": "2", "end_marker": "000@"}] + result = self.Tool.execute(self.coder, show) + assert "line2" in result + assert "line5" in result + finally: + self._teardown() + + # ========================================================================= + # Text Pattern Parsing + # ========================================================================= + + def test_both_text_patterns(self, mock_coder, mock_file_context, mock_chunks, mock_manager): + """Test text patterns that exist in the file.""" + content = ( + "def foo():\n return 1\n\ndef bar():\n return 2\n\ndef baz():\n return 3\n" + ) + self._setup(mock_coder, mock_file_context, mock_chunks, mock_manager, content) + try: + show = [ + { + "file_path": self.test_file, + "start_marker": "def foo():", + "end_marker": "def bar():", + } + ] + result = self.Tool.execute(self.coder, show) + assert "Snapshot" in result + assert "def foo()" in result + assert "def bar()" in result + finally: + self._teardown() + + def test_text_pattern_not_found(self, mock_coder, mock_file_context, mock_chunks, mock_manager): + """Test text pattern that doesn't exist -> error.""" + content = "line1\nline2\nline3" + self._setup(mock_coder, mock_file_context, mock_chunks, mock_manager, content) + try: + show = [ + { + "file_path": self.test_file, + "start_marker": "nonexistent_pattern", + "end_marker": "also_nonexistent", + } + ] + result = self.Tool.execute(self.coder, show) + assert "Errors" in result or "not found" in result + finally: + self._teardown() + + def test_text_pattern_multiline(self, mock_coder, mock_file_context, mock_chunks, mock_manager): + """Test multiline text patterns.""" + content = "def foo():\n return 1\n\ndef bar():\n return 2\n" + self._setup(mock_coder, mock_file_context, mock_chunks, mock_manager, content) + try: + show = [ + {"file_path": self.test_file, "start_marker": "def foo", "end_marker": "def bar"} + ] + result = self.Tool.execute(self.coder, show) + assert "Snapshot" in result + finally: + self._teardown() + + # ========================================================================= + # Mixed Special + Text (mixed_special_search) + # ========================================================================= + + def test_special_start_text_end(self, mock_coder, mock_file_context, mock_chunks, mock_manager): + """Test: @000 to text 'debug_mode'. + + NOTE: This may expose a bug in mixed_special_search where indices + get overwritten after the if/else block. + """ + content = "header\nconfig_value = 42\ndebug_mode = True\nfooter" + self._setup(mock_coder, mock_file_context, mock_chunks, mock_manager, content) + try: + show = [ + {"file_path": self.test_file, "start_marker": "@000", "end_marker": "debug_mode"} + ] + result = self.Tool.execute(self.coder, show) + # Should find '@000' at start and 'debug_mode' as text + print(f"\n[special_start_text_end] result: {result[:300]}") + assert result is not None + finally: + self._teardown() + + def test_text_start_special_end(self, mock_coder, mock_file_context, mock_chunks, mock_manager): + """Test: text 'config_value' to 000@. + + NOTE: This may expose a bug in mixed_special_search where indices + get overwritten after the if/else block. + """ + content = "header\nconfig_value = 42\ndebug_mode = True\nfooter" + self._setup(mock_coder, mock_file_context, mock_chunks, mock_manager, content) + try: + show = [ + {"file_path": self.test_file, "start_marker": "config_value", "end_marker": "000@"} + ] + result = self.Tool.execute(self.coder, show) + print(f"\n[text_start_special_end] result: {result[:300]}") + assert result is not None + finally: + self._teardown() + + # ========================================================================= + # Edge Cases + # ========================================================================= + + def test_empty_file(self, mock_coder, mock_file_context, mock_chunks, mock_manager): + """Test with an empty file.""" + self._setup(mock_coder, mock_file_context, mock_chunks, mock_manager, "") + try: + show = [{"file_path": self.test_file, "start_marker": "@000", "end_marker": "000@"}] + result = self.Tool.execute(self.coder, show) + assert "empty" in result.lower() + finally: + self._teardown() + + def test_single_line_file(self, mock_coder, mock_file_context, mock_chunks, mock_manager): + """Test with a single line file.""" + self._setup(mock_coder, mock_file_context, mock_chunks, mock_manager, "only_line") + try: + show = [{"file_path": self.test_file, "start_marker": "1", "end_marker": "1"}] + result = self.Tool.execute(self.coder, show) + assert "only_line" in result + finally: + self._teardown() + + def test_file_not_found(self, mock_coder, mock_file_context, mock_chunks, mock_manager): + """Test with a non-existent file.""" + mock_coder.io.read_text.return_value = None + # We need abs_path to pass os.path.exists but read_text to return None + abs_path = "/nonexistent/path.py" + mock_coder.abs_root_path.return_value = abs_path + + rp_patch = patch( + "cecli.tools.read_range.resolve_paths", return_value=(abs_path, "nonexistent/path.py") + ) + rp_patch.start() + self.patches.append(rp_patch) + + from cecli.tools.read_range import Tool + + show = [{"file_path": "nonexistent/path.py", "start_marker": "1", "end_marker": "10"}] + result = Tool.execute(mock_coder, show) + assert "not found" in result or "Errors" in result + + def test_missing_parameters(self, mock_coder, mock_file_context, mock_chunks, mock_manager): + """Test with missing start_marker and end_marker (empty strings).""" + from cecli.tools.read_range import Tool + + show = [{"file_path": "some_file.py", "start_marker": "", "end_marker": ""}] + result = Tool.execute(mock_coder, show) + assert "Provide both" in result or "Errors" in result + + def test_multiple_show_operations( + self, mock_coder, mock_file_context, mock_chunks, mock_manager + ): + """Test multiple show operations in one call.""" + content1 = "line1_1\nline1_2\nline1_3\nline1_4\nline1_5" + content2 = "line2_1\nline2_2\nline2_3\nline2_4\nline2_5" + test_file1 = create_test_file(content1) + test_file2 = create_test_file(content2) + + def resolve_side_effect(coder, file_path): + if "file1" in file_path: + return (test_file1, "file1.py") + return (test_file2, "file2.py") + + rp_patch = patch("cecli.tools.read_range.resolve_paths", side_effect=resolve_side_effect) + rp_patch.start() + + sh_patch = patch("cecli.tools.read_range.strip_hashline", side_effect=lambda x: x) + sh_patch.start() + + hl_patch = patch("cecli.tools.read_range.hashline", side_effect=lambda x: x) + hl_patch.start() + + ip_patch = patch( + "cecli.tools.read_range.is_provided", + side_effect=lambda v, **kw: v is not None and v != "", + ) + ip_patch.start() + + mock_cs = MagicMock() + mock_cs.get_files.return_value = mock_file_context + mock_cs.get_chunks.return_value = mock_chunks + mock_cs.get_manager.return_value = mock_manager + cs_patch = patch("cecli.helpers.conversation.ConversationService", mock_cs) + cs_patch.start() + + mock_coder.io.read_text.side_effect = [content1, content2] + + try: + from cecli.tools.read_range import Tool + + Tool._last_invocation = {} + Tool._last_read_turn = {} + + show = [ + {"file_path": "file1.py", "start_marker": "1", "end_marker": "3"}, + {"file_path": "file2.py", "start_marker": "2", "end_marker": "4"}, + ] + result = Tool.execute(mock_coder, show) + assert "line1_1" in result + assert "line2_2" in result + finally: + for p in [cs_patch, sh_patch, hl_patch, rp_patch, ip_patch]: + p.stop() + os.unlink(test_file1) + os.unlink(test_file2) + + # ========================================================================= + # Multiple Matches / Disambiguation + # ========================================================================= + + def test_few_matches(self, mock_coder, mock_file_context, mock_chunks, mock_manager): + """Test with ≤5 matches where each pattern appears once.""" + content = """def func_a(): + pass + +def func_b(): + pass + +def func_c(): + pass + +def func_d(): + pass + +def func_e(): + pass + +def func_f(): + pass +""" + self._setup(mock_coder, mock_file_context, mock_chunks, mock_manager, content) + try: + show = [ + { + "file_path": self.test_file, + "start_marker": "def func_a", + "end_marker": "def func_c", + } + ] + result = self.Tool.execute(self.coder, show) + assert "Snapshot" in result + finally: + self._teardown() + + def test_too_many_matches_without_history( + self, mock_coder, mock_file_context, mock_chunks, mock_manager + ): + """Test with >5 matches without history -> should report 'too broad'.""" + content = """def func_a(): + pass + +def func_b(): + pass + +def func_c(): + pass + +def func_d(): + pass + +def func_e(): + pass + +def func_f(): + pass +""" + self._setup(mock_coder, mock_file_context, mock_chunks, mock_manager, content) + try: + show = [{"file_path": self.test_file, "start_marker": "def", "end_marker": "def"}] + result = self.Tool.execute(self.coder, show) + assert "too broad" in result.lower() + finally: + self._teardown() + + +if __name__ == "__main__": + pytest.main([__file__, "-v", "--tb=short", "-s"]) From cf6fadc4d721a97359cb52a47c362c33258e2644 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 7 Jun 2026 11:41:06 -0400 Subject: [PATCH 05/47] Simplify grep tool, change defaults --- cecli/tools/grep.py | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/cecli/tools/grep.py b/cecli/tools/grep.py index a925bffc606..cf709995b6c 100644 --- a/cecli/tools/grep.py +++ b/cecli/tools/grep.py @@ -46,24 +46,14 @@ class Tool(BaseTool): }, "use_regex": { "type": "boolean", - "default": False, + "default": True, "description": "Whether to use regex.", }, "case_insensitive": { "type": "boolean", - "default": False, + "default": True, "description": "Whether to perform a case-insensitive search.", }, - "context_before": { - "type": "integer", - "default": 5, - "description": "Number of lines to show before a match.", - }, - "context_after": { - "type": "integer", - "default": 5, - "description": "Number of lines to show after a match.", - }, }, "required": ["pattern"], }, @@ -117,8 +107,8 @@ def execute( pattern = strip_hashline(search_op.get("pattern")) file_pattern = search_op.get("file_pattern", "*") directory = search_op.get("directory", search_op.get("path", ".")) - use_regex = search_op.get("use_regex", False) - case_insensitive = search_op.get("case_insensitive", False) + use_regex = search_op.get("use_regex", True) + case_insensitive = search_op.get("case_insensitive", True) context_before = search_op.get("context_before", 5) context_after = search_op.get("context_after", 5) From d7f18345b4de24854b1bfd465e2884b77af32475 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 7 Jun 2026 12:17:22 -0400 Subject: [PATCH 06/47] Updates to hashpos system: - Remove closure safe gaurd because it is unreliable - change bit mixing scheme to not interleave so un-affected lines has more consistent output hashes - Standardize on "content ID" nomenclature instead of "content hash" --- cecli/coders/agent_coder.py | 2 +- cecli/helpers/hashline.py | 135 ++++++------------------------- cecli/helpers/hashpos/hashpos.py | 10 +-- cecli/prompts/agent.yml | 4 +- cecli/prompts/hashline.yml | 2 +- cecli/tools/edit_text.py | 14 ++-- tests/tools/test_insert_block.py | 2 +- 7 files changed, 42 insertions(+), 127 deletions(-) diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index d57cfad9a9d..33a5d148c82 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -1115,7 +1115,7 @@ def _generate_tool_context(self, repetitive_tools): context_parts.append("## File Editing Tools Disabled") context_parts.append( "File editing tools are currently disabled. Use `ReadRange` to determine the" - " current content hash prefixes needed to perform an edit and activate them when" + " current content ID prefixes needed to perform an edit and activate them when" " you are ready to edit a file." ) diff --git a/cecli/helpers/hashline.py b/cecli/helpers/hashline.py index 110c8ebab25..cba3057787a 100644 --- a/cecli/helpers/hashline.py +++ b/cecli/helpers/hashline.py @@ -650,6 +650,20 @@ def _apply_start_stitching( # The replacement line matches the line being replaced # Don't stitch to a line in lines_before_range continue + + # Require 2 consecutive matching lines to avoid false positives + # (single boilerplate lines like "import sys" or "def foo():" + # are too likely to be coincidental) + if line_idx + 1 < len(replacement_lines) and match_index + 1 < len( + lines_before_range_normalized + ): + next_repl = replacement_lines[line_idx + 1] + next_repl_stripped = strip_hashline(next_repl) + if not next_repl_stripped.endswith("\n"): + next_repl_stripped += "\n" + if next_repl_stripped != lines_before_range_normalized[match_index + 1]: + continue # Only 1 line matches — likely coincidental + # Found a line that already exists before the range! # This is a non-contiguous match - we need to "stitch" the replacement # at this exact content match to prevent duplicate code structures @@ -694,9 +708,9 @@ def _apply_start_stitching( start_idx = new_start_idx replacement_lines = new_replacement_lines else: - # Can't extend backward due to overlap, but we can still truncate - # the replacement text to avoid duplication - replacement_lines = new_replacement_lines + # Can't extend backward due to overlap with another operation + # Don't truncate without extending — that would silently lose content + continue # Try next line instead # We've found our stitching point, break out of the loop break @@ -772,6 +786,15 @@ def _apply_end_stitching( # Check if this line exists anywhere in lines_after_range_normalized try: match_index = lines_after_range_normalized.index(replacement_line_stripped) + + # Require 2 consecutive matching lines to reduce false positives + if line_idx - 1 >= 0 and match_index - 1 >= 0: + prev_repl = replacement_lines[line_idx - 1] + prev_repl_stripped = strip_hashline(prev_repl) + if not prev_repl_stripped.endswith("\n"): + prev_repl_stripped += "\n" + if prev_repl_stripped != lines_after_range_normalized[match_index - 1]: + continue # Only 1 line matches — likely coincidental # Found a line that already exists after the range! # This is a non-contiguous match - we need to "stitch" the replacement # at this exact content match to prevent duplicate code structures @@ -900,109 +923,6 @@ def _apply_range_shifting(hashed_lines, resolved_ops): return resolved_ops -# Regex configuration -RE_CODE_NOISE = r'(#.*|//.*|/\*[\s\S]*?\*/|"(?:\\.|[^"\\])*"|\'(?:\\.|[^\'\\])*\')' - - -def get_brace_balance(lines_to_check: list[str]) -> int: - """ - Calculates the net curly brace debt of a list of lines. - Automatically strips hashlines, comments, and string literals. - """ - text = "".join(lines_to_check) - clean_code = strip_hashline(text) - clean_code = re.sub(RE_CODE_NOISE, "", clean_code) - return clean_code.count("{") - clean_code.count("}") - - -def _apply_closure_safeguard(hashed_lines, resolved_ops): - """ - Enhanced closure safeguard with dynamic bidirectional search. - """ - # Tune these to adjust how far the 'healing' logic searches - MAX_LOOK_DOWN = 5 - # Note: We'll calculate the actual MAX_LOOK_UP per operation - # to ensure we don't scan past the start_idx. - - for i, resolved in enumerate(resolved_ops): - op = resolved["op"] - if op["operation"] not in {"replace", "delete"}: - continue - - replacement_text = op.get("text", "") or "" - replacement_lines = replacement_text.splitlines(keepends=True) - - # --- PHASE 1: BIDIRECTIONAL STRUCTURAL HEALING --- - if get_brace_balance([replacement_text]) == 0: - start_idx = resolved["start_idx"] - orig_end_idx = resolved["end_idx"] - - if get_brace_balance(hashed_lines[start_idx : orig_end_idx + 1]) != 0: - # Dynamic Search List Generation - # We limit look-up so we don't scan before the start_idx - actual_max_up = orig_end_idx - start_idx - actual_max_down = max(MAX_LOOK_DOWN, orig_end_idx - start_idx) - search_offsets = [] - - # Generate alternating offsets: [1, -1, 2, -2, ... N] - for dist in range(1, max(actual_max_down, actual_max_up) + 1): - if dist <= actual_max_down: - search_offsets.append(dist) - if dist <= actual_max_up: - search_offsets.append(-dist) - - for offset in search_offsets: - candidate_end = orig_end_idx + offset - - # Safety: check bounds and avoid overlapping other ops - if candidate_end < start_idx or candidate_end >= len(hashed_lines): - continue - - if any( - j != i and (other["start_idx"] <= candidate_end <= other["end_idx"]) - for j, other in enumerate(resolved_ops) - ): - continue - - if get_brace_balance(hashed_lines[start_idx : candidate_end + 1]) == 0: - resolved["end_idx"] = candidate_end - break - - # --- PHASE 2: CONTRACTION (Indentation Guard) --- - # Prevents replacing an outer-scope brace if the replacement text already - # includes its own correctly indented closer. - if not replacement_lines: - continue - - last_repl_line = strip_hashline(replacement_lines[-1]) - last_repl_stripped = last_repl_line.strip().rstrip(";,") - - if last_repl_stripped and last_repl_stripped[-1] in "})]": - # Calculate replacement indent - repl_indent = len(last_repl_line) - len(last_repl_line.lstrip(" \t")) - - if resolved["end_idx"] < len(hashed_lines): - end_line = strip_hashline(hashed_lines[resolved["end_idx"]]) - check_end = end_line.strip().rstrip(";,") - - if check_end and check_end[-1] in "})]": - # Calculate indent of the existing brace in the file - file_indent = len(end_line) - len(end_line.lstrip(" \t")) - - # If the file's brace is less indented, it belongs to an outer scope - if file_indent < repl_indent and resolved["end_idx"] > resolved["start_idx"]: - new_end_idx = resolved["end_idx"] - 1 - - # Safety: don't contract into another operation's territory - if not any( - j != i and (other["start_idx"] <= new_end_idx <= other["end_idx"]) - for j, other in enumerate(resolved_ops) - ): - resolved["end_idx"] = new_end_idx - - return resolved_ops - - def _merge_replace_operations(resolved_ops): """ Merge contiguous or overlapping replace operations. @@ -1411,9 +1331,6 @@ def apply_hashline_operations( resolved_ops = _merge_replace_operations(resolved_ops) # Apply content-aware range expansion/shifting for replace operations # resolved_ops = _apply_range_shifting(hashed_lines, resolved_ops) - # Apply closure safeguard for braces/brackets - resolved_ops = _apply_closure_safeguard(hashed_lines, resolved_ops) - # Sort by start_idx descending to apply from bottom to top # When operations have same start_idx, apply in order: insert, replace, delete # This ensures correct behavior when multiple operations target the same line diff --git a/cecli/helpers/hashpos/hashpos.py b/cecli/helpers/hashpos/hashpos.py index dc26801ce26..1c80dccbdb9 100644 --- a/cecli/helpers/hashpos/hashpos.py +++ b/cecli/helpers/hashpos/hashpos.py @@ -52,7 +52,7 @@ def generate_private_id(self, text: str) -> str: def generate_public_id(self, text: str, line_idx: int) -> str: """ Generates a 4-char Base64 ID combining modulo buckets and context hash. - Layout: [2-bit b1] [10-bit Hash A] [2-bit b2] [10-bit Hash B] + Layout: [2-bit b1] [2-bit b2] [10-bit Hash A] [10-bit Hash B] """ b1, b2 = self._get_region_bits(line_idx) neighborhood_hash = self._get_neighborhood_hash(line_idx) @@ -62,8 +62,7 @@ def generate_public_id(self, text: str, line_idx: int) -> str: hash_b = neighborhood_hash & 0x3FF # Construct the mixed 24-bit integer - packed = (b1 << 22) | (hash_a << 12) | (b2 << 10) | hash_b - + packed = (b1 << 22) | (b2 << 20) | (hash_a << 10) | hash_b res = "" for _ in range(4): res += self.B64[packed % 64] @@ -79,10 +78,9 @@ def unpack_public_id(self, public_id: str) -> tuple[int, int]: packed |= self.B64.index(char) << (6 * i) b1 = (packed >> 22) & 3 - hash_a = (packed >> 12) & 0x3FF - b2 = (packed >> 10) & 3 + b2 = (packed >> 20) & 3 + hash_a = (packed >> 10) & 0x3FF hash_b = packed & 0x3FF - mod_val = (b1 << 2) | b2 neighborhood_hash = (hash_a << 10) | hash_b diff --git a/cecli/prompts/agent.yml b/cecli/prompts/agent.yml index 6ed6e1566b7..71a3477377e 100644 --- a/cecli/prompts/agent.yml +++ b/cecli/prompts/agent.yml @@ -29,8 +29,8 @@ main_system: | ### 1. FILE FORMAT - File contents will be prefixed with identifiers. Each line starts with a case-sensitive content hash followed by `::`. These are used to target where editing tools will perform edits. - They are algorithmically generated, maintained, and subject to change. Do not search for these content hashes. Focus on the lines they identify. + File contents will be prefixed with identifiers. Each line starts with a case-sensitive content ID followed by `::`. These are used to target where editing tools will perform edits. + They are algorithmically generated, maintained, and subject to change. Do not search for these content IDs. Focus on the lines they identify. **Example File Format :** il9n::#!/usr/bin/env python3 diff --git a/cecli/prompts/hashline.yml b/cecli/prompts/hashline.yml index fd300b1acf5..a5a23fa8594 100644 --- a/cecli/prompts/hashline.yml +++ b/cecli/prompts/hashline.yml @@ -6,7 +6,7 @@ main_system: | Act as an expert software developer. Plan carefully, explain your logic briefly, and execute via LOCATE/CONTENTS blocks. ### 1. FILE FORMAT - Files are provided in "hashline" format. Each line starts with a case-sensitive content hash followed by `::`. + Files are provided in "hashline" format. Each line starts with a case-sensitive content ID followed by `::`. These hashes are used as identifiers for lines when editing. **Example File Format :** diff --git a/cecli/tools/edit_text.py b/cecli/tools/edit_text.py index a8eeabca75f..874c3ca85c2 100644 --- a/cecli/tools/edit_text.py +++ b/cecli/tools/edit_text.py @@ -35,13 +35,13 @@ class Tool(BaseTool): "function": { "name": "EditText", "description": ( - "Edit text in one or more files using content hash markers. " + "Edit text in one or more files using content ID markers. " "Supports replace, delete, and insert operations in a single call. " "Can handle an array of up to 10 edits across multiple files. " "Each edit must include its own file_path and operation type. " - "Use content hash ranges with the start_line and end_line parameters with format " + "Use content ID ranges with the start_line and end_line parameters with format " "`{4 char hash}` (without the braces). For empty files, use `@000` as the " - "content hash references." + "content ID references." ), "parameters": { "type": "object", @@ -74,14 +74,14 @@ class Tool(BaseTool): "start_line": { "type": "string", "description": ( - "Content hash for start line: `{4 char hash}` (without " + "content ID for start line: `{4 char hash}` (without " "the braces)" ), }, "end_line": { "type": "string", "description": ( - "Content hash for end line: `{4 char hash}` (without the" + "content ID for end line: `{4 char hash}` (without the" " braces)" ), }, @@ -248,7 +248,7 @@ def execute( if new_content != original_content: file_successful_edits += len(successful_ops) else: - raise ToolError("Invalid Edit - Update content hash bounds") + raise ToolError("Invalid Edit - Update content ID bounds") if len(failed_ops): for failed_op in failed_ops: @@ -446,7 +446,7 @@ def format_output(cls, coder, mcp_server, tool_response): text=strip_hashline(text), ) except ContentHashError as e: - diff_output = f"Content hash verification failed: {str(e)}" + diff_output = f"content ID verification failed: {str(e)}" except Exception: pass diff --git a/tests/tools/test_insert_block.py b/tests/tools/test_insert_block.py index 9e5ae2b855e..9171a6cfee4 100644 --- a/tests/tools/test_insert_block.py +++ b/tests/tools/test_insert_block.py @@ -121,7 +121,7 @@ def test_mutually_exclusive_parameters_raise(coder_with_file): ) assert result.startswith("Error in EditText:") - assert "Invalid Edit - Update content hash bounds" in result + assert "Invalid Edit - Update content ID bounds" in result assert file_path.read_text().startswith("first line") coder.io.tool_error.assert_called() From d52e51e0f3b5bf850632767420a51b17fb32776f Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 7 Jun 2026 12:40:44 -0400 Subject: [PATCH 07/47] Update file diff messaging to remind the LLM to attend to the diff --- cecli/helpers/conversation/files.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/cecli/helpers/conversation/files.py b/cecli/helpers/conversation/files.py index 50a7a3de239..3acfdee9c52 100644 --- a/cecli/helpers/conversation/files.py +++ b/cecli/helpers/conversation/files.py @@ -281,14 +281,17 @@ def update_file_diff(self, fname: str) -> Optional[str]: diff_message = { "role": "user", "content": ( - f"{rel_fname} has been updated. Here is a git diff of the changes to" - f" review:\n\n{diff}" + f"{rel_fname} has been updated. Review this git diff of the changes to" + f" ensure the modifications are intended:\n\n{diff}" ), } assistant_msg = { "role": "assistant", - "content": f"Thank you for sharing this diff of the updates to {rel_fname}.", + "content": ( + f"Thank you for sharing this diff of the updates to {rel_fname}." + " I will review their contents next turn." + ), } ConversationService.get_manager(coder).add_message( From 993caff6812d39b5b85fa21ed5aa819927bf0729 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 7 Jun 2026 12:58:27 -0400 Subject: [PATCH 08/47] Fix tests to account for CI/CD pipeline drives --- tests/tools/test_read_range_execute.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tests/tools/test_read_range_execute.py b/tests/tools/test_read_range_execute.py index ee885130605..d08e74126c3 100644 --- a/tests/tools/test_read_range_execute.py +++ b/tests/tools/test_read_range_execute.py @@ -16,6 +16,16 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../..")) +def _safe_relpath(path): + """Wrapper around os.path.relpath that handles cross-drive scenarios on Windows.""" + try: + return os.path.relpath(path) + except ValueError: + # On Windows, os.path.relpath fails when path and cwd are on different drives. + # Fall back to basename which is sufficient for test patches. + return os.path.basename(path) + + # ============================================================================= # Fixtures # ============================================================================= @@ -27,7 +37,7 @@ def mock_coder(): coder = MagicMock() coder.turn_count = 5 coder.abs_root_path.side_effect = lambda p: os.path.abspath(p) - coder.get_rel_fname.side_effect = lambda p: os.path.relpath(p, os.getcwd()) + coder.get_rel_fname.side_effect = lambda p: _safe_relpath(p) coder.io.tool_output = MagicMock() coder.io.tool_error = MagicMock() coder.io.tool_warning = MagicMock() @@ -114,7 +124,7 @@ def _setup(self, mock_coder, mock_file_context, mock_chunks, mock_manager, file_ # Patch resolve_paths rp_patch = patch( "cecli.tools.read_range.resolve_paths", - return_value=(self.test_file, os.path.relpath(self.test_file)), + return_value=(self.test_file, _safe_relpath(self.test_file)), ) rp_patch.start() self.patches.append(rp_patch) From 05bb5337dc48ff2d1aec0cbf51db61eda3484fd8 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 7 Jun 2026 14:14:36 -0700 Subject: [PATCH 09/47] refactor: Move --retry-on-empty to retries config block Co-authored-by: cecli (openai/gemini_cli_local/gemini-2.5-pro) --- cecli/args.py | 11 ++++------- cecli/args_formatter.py | 10 ++++++++++ cecli/coders/base_coder.py | 14 +++++++++++++- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/cecli/args.py b/cecli/args.py index 7dbb06f9439..4b368f21b1c 100644 --- a/cecli/args.py +++ b/cecli/args.py @@ -276,15 +276,12 @@ def get_parser(default_config_files, git_root): group.add_argument( "--retries", metavar="RETRIES_JSON", - help="Specify LLM retry configuration as a JSON string", + help=( + "Specify LLM retry configuration as a JSON/YAML string (e.g., '{\"retry_on_empty\": " + "true}')" + ), default=None, ) - group.add_argument( - "--retry-on-empty", - action=argparse.BooleanOptionalAction, - default=False, - help="Enable/disable retrying on empty LLM responses (default: False)", - ) ####### group = parser.add_argument_group("Customization Settings") diff --git a/cecli/args_formatter.py b/cecli/args_formatter.py index 01b9bc94094..aaa9463c3b3 100644 --- a/cecli/args_formatter.py +++ b/cecli/args_formatter.py @@ -132,6 +132,16 @@ def _format_action(self, action): break switch = switch.lstrip("-") + if switch == "retries": + parts.append(f"## {action.help}") + parts.append("#retries:") + parts.append("# retry-timeout: 60") + parts.append("# retry-backoff-factor: 2.0") + parts.append("# retry-on-unavailable: true") + parts.append("# retry-on-empty: false") + parts.append("") + return "\n".join(parts) + if isinstance(action, argparse._StoreTrueAction): default = False elif isinstance(action, argparse._StoreConstAction): diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index 698b7e0c235..bc07ec16b99 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -2409,7 +2409,19 @@ async def format_in_executor(): break except EmptyResponseError: self.io.tool_warning(self.empty_llm_tool_warning()) - if not (self.args and self.args.retry_on_empty): + + retry_on_empty = False + retries_config = self.get_active_model().retries + if isinstance(retries_config, str): + try: + retries_config = json.loads(retries_config) + except json.JSONDecodeError: + self.io.tool_warning(f"Could not parse retries config: {retries_config}") + retries_config = {} + if isinstance(retries_config, dict): + retry_on_empty = retries_config.get("retry_on_empty", False) + + if not retry_on_empty: break retry_delay *= 2 From 8655aa2529d842383cd1a6b1c26f50db60877aad Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 7 Jun 2026 14:15:42 -0700 Subject: [PATCH 10/47] chore: Fix linter warnings Co-authored-by: cecli (openai/gemini_cli_local/gemini-2.5-pro) --- cecli/args.py | 2 +- cecli/coders/base_coder.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/cecli/args.py b/cecli/args.py index 4b368f21b1c..83286782492 100644 --- a/cecli/args.py +++ b/cecli/args.py @@ -277,7 +277,7 @@ def get_parser(default_config_files, git_root): "--retries", metavar="RETRIES_JSON", help=( - "Specify LLM retry configuration as a JSON/YAML string (e.g., '{\"retry_on_empty\": " + 'Specify LLM retry configuration as a JSON/YAML string (e.g., \'{"retry_on_empty": ' "true}')" ), default=None, diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index bc07ec16b99..7372cca1277 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -2416,7 +2416,9 @@ async def format_in_executor(): try: retries_config = json.loads(retries_config) except json.JSONDecodeError: - self.io.tool_warning(f"Could not parse retries config: {retries_config}") + self.io.tool_warning( + f"Could not parse retries config: {retries_config}" + ) retries_config = {} if isinstance(retries_config, dict): retry_on_empty = retries_config.get("retry_on_empty", False) From e76385059700c1a97e00820d33c677270867e224 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 8 Jun 2026 00:41:12 -0400 Subject: [PATCH 11/47] #564: Introduce global thread safe Event construct, ensure mcp servers run on event loop of workers and not the main one --- cecli/coders/base_coder.py | 4 +++- cecli/commands/core.py | 4 ++-- cecli/helpers/coroutines.py | 4 +++- cecli/helpers/threading.py | 46 +++++++++++++++++++++++++++++++++++++ cecli/io.py | 3 ++- cecli/linter.py | 3 ++- cecli/mcp/server.py | 32 +++++++++++++++++++++----- cecli/tools/_yield.py | 3 ++- 8 files changed, 86 insertions(+), 13 deletions(-) create mode 100644 cecli/helpers/threading.py diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index c28dc866cc6..a259b220f3d 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -46,6 +46,7 @@ from cecli.helpers.io_proxy import IOProxy from cecli.helpers.observations.service import ObservationService from cecli.helpers.profiler import TokenProfiler +from cecli.helpers.threading import ThreadSafeEvent from cecli.history import ChatSummary from cecli.hooks import HookIntegration from cecli.io import ConfirmGroup, InputOutput @@ -420,7 +421,7 @@ def __init__( # Each contains "included" and "excluded" sets that filter from the global singletons self.registered_tools = {"included": set(), "excluded": set()} self.registered_servers = {"included": set(), "excluded": set()} - self.interrupt_event = asyncio.Event() + self.interrupt_event = ThreadSafeEvent() self.uuid = str(generate_unique_id()) if uuid: @@ -1643,6 +1644,7 @@ async def output_task(self, preproc): async def generate(self, user_message, preproc): await asyncio.sleep(0.1) + self.interrupt_event.clear() try: if self.enable_context_compaction: diff --git a/cecli/commands/core.py b/cecli/commands/core.py index 2ad884fabd3..5242b73397a 100644 --- a/cecli/commands/core.py +++ b/cecli/commands/core.py @@ -1,4 +1,3 @@ -import asyncio import json import re import sys @@ -7,6 +6,7 @@ from cecli.commands.utils.registry import CommandRegistry from cecli.helpers import nested, plugin_manager from cecli.helpers.file_searcher import handle_core_files +from cecli.helpers.threading import ThreadSafeEvent from cecli.repo import ANY_GIT_ERROR @@ -94,7 +94,7 @@ def __init__( self.custom_commands = nested.getter(customizations, "command-paths", []) self._load_custom_commands(self.custom_commands) - self.cmd_running_event = asyncio.Event() + self.cmd_running_event = ThreadSafeEvent() self.cmd_running_event.set() self.last_command_show_notification = True diff --git a/cecli/helpers/coroutines.py b/cecli/helpers/coroutines.py index 3bab125348f..07e27314a5e 100644 --- a/cecli/helpers/coroutines.py +++ b/cecli/helpers/coroutines.py @@ -1,5 +1,7 @@ import asyncio +from cecli.helpers.threading import ThreadSafeEvent + async def interruptible_async_generator(async_generator, interrupt_event): """ @@ -57,7 +59,7 @@ async def interruptible(coroutine, interrupt_event): - If interrupted: (None, True) """ if interrupt_event is None: - interrupt_event = asyncio.Event() + interrupt_event = ThreadSafeEvent() main_task = asyncio.create_task(coroutine) interrupt_task = asyncio.create_task(interrupt_event.wait()) diff --git a/cecli/helpers/threading.py b/cecli/helpers/threading.py new file mode 100644 index 00000000000..8cd4a70a7d4 --- /dev/null +++ b/cecli/helpers/threading.py @@ -0,0 +1,46 @@ +import asyncio +import threading + + +class ThreadSafeEvent: + def __init__(self): + self._async_event = asyncio.Event() + self._thread_event = threading.Event() + + @staticmethod + def _get_loop(): + """Dynamically resolve the running event loop (not cached).""" + try: + return asyncio.get_running_loop() + except RuntimeError: + return None + + def set(self): + """Can be called from ANY thread or coroutine safely.""" + # Unblock threads + self._thread_event.set() + # Unblock async loop + if loop := self._get_loop(): + loop.call_soon_threadsafe(self._async_event.set) + else: + self._async_event.set() + + def clear(self): + """Can be called from ANY thread or coroutine safely.""" + self._thread_event.clear() + if loop := self._get_loop(): + loop.call_soon_threadsafe(self._async_event.clear) + else: + self._async_event.clear() + + def is_set(self): + """Thread-safe check.""" + return self._thread_event.is_set() + + def thread_wait(self, timeout=None): + """Call this from your background OS Thread.""" + return self._thread_event.wait(timeout=timeout) + + async def wait(self): + """Call this (with await) from your Async Coroutines.""" + await self._async_event.wait() diff --git a/cecli/io.py b/cecli/io.py index 47cbee2eccd..8bf7a3c657e 100644 --- a/cecli/io.py +++ b/cecli/io.py @@ -43,6 +43,7 @@ from cecli.commands import SwitchCoderSignal from cecli.helpers import coroutines +from cecli.helpers.threading import ThreadSafeEvent from cecli.report import update_error_prefix from .dump import dump # noqa: F401 @@ -395,7 +396,7 @@ def __init__( self.linear = False # State tracking for confirmation input - self.confirmation_in_progress_event = asyncio.Event() + self.confirmation_in_progress_event = ThreadSafeEvent() self.confirmation_in_progress_event.set() # Initially set, meaning no confirmation in progress self.confirmation_acknowledgement = False self.confirmation_input_active = False diff --git a/cecli/linter.py b/cecli/linter.py index 9e91d826fd8..434724e2bdf 100644 --- a/cecli/linter.py +++ b/cecli/linter.py @@ -12,6 +12,7 @@ from cecli.dump import dump # noqa: F401 from cecli.helpers.grep_ast import TreeContext, filename_to_lang from cecli.helpers.grep_ast.tsl import get_parser # noqa: E402 +from cecli.helpers.threading import ThreadSafeEvent from cecli.run_cmd import run_cmd_async, run_cmd_subprocess # noqa: F401 # tree_sitter is throwing a FutureWarning @@ -22,7 +23,7 @@ class Linter: def __init__(self, encoding="utf-8", root=None, interrupt_event=None): self.encoding = encoding self.root = root - self.interrupt_event = interrupt_event or asyncio.Event() + self.interrupt_event = interrupt_event or ThreadSafeEvent() self.languages = dict( python=self.py_lint, diff --git a/cecli/mcp/server.py b/cecli/mcp/server.py index fa1fb46ba8d..f148e47bd87 100644 --- a/cecli/mcp/server.py +++ b/cecli/mcp/server.py @@ -42,6 +42,7 @@ def __init__(self, server_config, io=None, verbose=False): self.io = io self.verbose = verbose self.session = None + self._connection_loop: asyncio.AbstractEventLoop | None = None self._cleanup_lock: asyncio.Lock = asyncio.Lock() self.exit_stack = AsyncExitStack() @@ -59,10 +60,17 @@ async def connect(self): Returns: ClientSession: The active session if mcp is not disabled """ + current_loop = asyncio.get_running_loop() if self.session is not None: + # Event loop affinity check: streams from stdio_client() are bound + # to the loop that created them. Reconnect if the loop changed. + if self._connection_loop is current_loop: + if self.verbose and self.io: + self.io.tool_output(f"Using existing session for MCP server: {self.name}") + return self.session if self.verbose and self.io: - self.io.tool_output(f"Using existing session for MCP server: {self.name}") - return self.session + self.io.tool_output(f"Reconnecting MCP server {self.name} (event loop changed)") + await self.disconnect() if self.verbose and self.io: self.io.tool_output(f"Establishing new connection to MCP server: {self.name}") @@ -87,6 +95,7 @@ async def connect(self): session = await self.exit_stack.enter_async_context(ClientSession(read, write)) await session.initialize() self.session = session + self._connection_loop = current_loop return session except Exception as e: logging.error(f"Error initializing server {self.name}: {e}") @@ -193,10 +202,15 @@ def _create_transport(self, url, http_client): raise NotImplementedError("Subclasses must implement _create_transport") async def connect(self): + current_loop = asyncio.get_running_loop() if self.session is not None: + if self._connection_loop is current_loop: + if self.verbose and self.io: + self.io.tool_output(f"Using existing session for {self.name}") + return self.session if self.verbose and self.io: - self.io.tool_output(f"Using existing session for {self.name}") - return self.session + self.io.tool_output(f"Reconnecting {self.name} (event loop changed)") + await self.disconnect() if self.verbose and self.io: self.io.tool_output(f"Establishing new connection to {self.name}") @@ -224,6 +238,7 @@ async def connect(self): session = await self.exit_stack.enter_async_context(ClientSession(read, write)) await session.initialize() self.session = session + self._connection_loop = current_loop if oauth_provider.context.oauth_metadata: token_endpoint = oauth_provider._get_token_endpoint() @@ -270,9 +285,13 @@ class SseServer(McpServer): """SSE (Server-Sent Events) MCP server using mcp.client.sse_client.""" async def connect(self): + current_loop = asyncio.get_running_loop() if self.session is not None: - logging.info(f"Using existing session for SSE MCP server: {self.name}") - return self.session + if self._connection_loop is current_loop: + logging.info(f"Using existing session for SSE MCP server: {self.name}") + return self.session + logging.info(f"Reconnecting SSE MCP server {self.name} (event loop changed)") + await self.disconnect() logging.info(f"Establishing new connection to SSE MCP server: {self.name}") try: @@ -285,6 +304,7 @@ async def connect(self): session = await self.exit_stack.enter_async_context(ClientSession(read, write)) await session.initialize() self.session = session + self._connection_loop = current_loop return session except Exception as e: logging.error(f"Error initializing SSE server {self.name}: {e}") diff --git a/cecli/tools/_yield.py b/cecli/tools/_yield.py index b575cfa9efd..4697ab96561 100644 --- a/cecli/tools/_yield.py +++ b/cecli/tools/_yield.py @@ -1,6 +1,7 @@ import asyncio import logging +from cecli.helpers.threading import ThreadSafeEvent from cecli.tools.utils.base_tool import BaseTool from cecli.tools.utils.helpers import ToolError from cecli.tools.utils.output import color_markers, tool_footer, tool_header @@ -65,7 +66,7 @@ async def execute(cls, coder, **kwargs): # the interrupt event, avoiding nested asyncio.wait() calls. interrupt_event = coder.interrupt_event if interrupt_event is None: - interrupt_event = asyncio.Event() + interrupt_event = ThreadSafeEvent() interrupt_task = asyncio.create_task(interrupt_event.wait()) pending = set(active_tasks) | {interrupt_task} From 2be4b1f66def88f98b98bbe7af0f0b5a79f82c02 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 8 Jun 2026 01:08:36 -0400 Subject: [PATCH 12/47] Set empty response default in send() not send_message() --- cecli/coders/base_coder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index 79b763e9eb1..4d5b26e97df 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -2405,7 +2405,6 @@ async def format_in_executor(): try: while True: try: - self.empty_response = False async for chunk in self.send(messages, tools=self.get_tool_list()): yield chunk break @@ -3292,6 +3291,7 @@ async def send(self, messages, model=None, functions=None, tools=None): self.interrupt_event.clear() self.got_reasoning_content = False self.ended_reasoning_content = False + self.empty_response = False self._streaming_buffer_length = 0 self.io.reset_streaming_response() From 014a5298c4232901fc5ed63ea0025bb304f2bde8 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 9 Jun 2026 02:18:22 -0400 Subject: [PATCH 13/47] Update reading and error messages --- cecli/helpers/hashpos/hashpos.py | 4 +- cecli/tools/read_range.py | 370 +++++++++++++++++++++---------- 2 files changed, 261 insertions(+), 113 deletions(-) diff --git a/cecli/helpers/hashpos/hashpos.py b/cecli/helpers/hashpos/hashpos.py index 1c80dccbdb9..516052012c9 100644 --- a/cecli/helpers/hashpos/hashpos.py +++ b/cecli/helpers/hashpos/hashpos.py @@ -221,6 +221,6 @@ def normalize(hashpos_str: str) -> str: # If no pattern matches, raise error raise ValueError( f"Invalid HashPos format '{hashpos_str}'. " - r"Expected \"{hash_prefix}\" " - r"where hash_prefix is exactly 4 characters from the set [0-9a-zA-Z\~_@]." + r"Expected \"{content ID}\" " + r"where content ID is exactly 4 characters from the set [0-9a-zA-Z\~_@]." ) diff --git a/cecli/tools/read_range.py b/cecli/tools/read_range.py index 5b9f19c9345..9bc4f75a1e7 100644 --- a/cecli/tools/read_range.py +++ b/cecli/tools/read_range.py @@ -17,30 +17,31 @@ class Tool(BaseTool): NORM_NAME = "readrange" TRACK_INVOCATIONS = False VALIDATIONS = { - "show": ["coerce_list"], - "show[]": ["coerce_dict"], - "show[].start_marker": ["coerce_str"], - "show[].end_marker": ["coerce_str"], + "read": ["coerce_list"], + "read[]": ["coerce_dict"], + "read[].range_start": ["coerce_str"], + "read[].range_end": ["coerce_str"], } SCHEMA = { "type": "function", "function": { "name": "ReadRange", "description": ( - "Get content hash prefixes of content between start and end markers in files." + "Get content ID prefixes of content between start and end markers in files." " This is useful for files you are attempting to edit and for understanding their structure." - " Accepts an array of `show` objects, each with file_path, start_marker, end_marker." + " Accepts an array of `read` objects, each with file_path, range_start, range_end." " These values should be lines of content in the file. They can contain up to 3" " lines of content but newlines should generally be avoided. Avoid using generic keywords and" " symbols. Special markers @000 and 000@ represent the file boundaries and can be" - " used for start_marker and end_marker for the first and last lines of the file" + " used for range_start and range_end for the first and last lines of the file" " respectively. Line numbers may also be used for range lookups." " It is best to use function names, variable declarations and other meaningful identifiers" - " as start_marker and end_marker values." + " as range_start and range_end values." " Do not use both of the special markers together on non-empty file." - " Do not use content hashes as the start_marker and end_marker values." - " Do not use the same pattern for the start_marker and end_marker." - " Do not use empty strings for the start_marker and end_marker." + " Do not use content IDs cannot be used as the range_start and range_end values." + " These lookups will fail." + " Do not use the same pattern for the range_start and range_end." + " Do not use empty strings for the range_start and range_end." " Prefer using this tool to cli tools for reading files." " Calling this tool sequentially on increasingly finer grained searchers " " will help with getting outlines of important structural features" @@ -48,7 +49,7 @@ class Tool(BaseTool): "parameters": { "type": "object", "properties": { - "show": { + "read": { "type": "array", "items": { "type": "object", @@ -57,14 +58,14 @@ class Tool(BaseTool): "type": "string", "description": "File path to search in.", }, - "start_marker": { + "range_start": { "type": "string", "description": ( "The text marking the beginning of the range." " Use '@000' for the first line on empty files." ), }, - "end_marker": { + "range_end": { "type": "string", "description": ( "The text marking the end of the range." @@ -72,12 +73,12 @@ class Tool(BaseTool): ), }, }, - "required": ["file_path", "start_marker", "end_marker"], + "required": ["file_path", "range_start", "range_end"], }, - "description": "Array of show operations to perform.", + "description": "Array of read operations to perform.", }, }, - "required": ["show"], + "required": ["read"], }, }, } @@ -86,11 +87,11 @@ class Tool(BaseTool): _last_read_turn: Dict[str, int] = {} # abs_path -> turn_count when last read @classmethod - def execute(cls, coder, show, **kwargs): + def execute(cls, coder, read, **kwargs): """ Displays numbered lines from multiple files centered around target locations (patterns or line_numbers), without adding files to context. - Accepts an array of show operations to perform. + Accepts an array of read operations to perform. Uses utility functions for path resolution and error handling. """ from cecli.helpers.conversation import ConversationService @@ -101,70 +102,70 @@ def execute(cls, coder, show, **kwargs): error_outputs = [] try: - # 1. Validate show parameter - if not isinstance(show, list): - show = [show] if isinstance(show, dict) else show + # 1. Validate read parameter + if not isinstance(read, list): + read = [read] if isinstance(read, dict) else read - if len(show) == 0: - raise ToolError("show array cannot be empty") + if len(read) == 0: + raise ToolError("read array cannot be empty") all_outputs = [] already_up_to_details = [] new_context_details = [] ranges = {} - for show_index, show_op in enumerate(show): - # Extract parameters for this show operation - file_path = show_op.get("file_path") - start_marker = show_op.get("start_marker") - end_marker = show_op.get("end_marker") + 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") padding = 5 if file_path is None: error_outputs.append( cls.format_error( coder, - f"Show operation {show_index + 1} missing required file_path parameter", + f"read operation {read_index + 1} missing required file_path parameter", None, None, None, - show_index, + read_index, ) ) continue # Validate arguments for this operation - if not is_provided(start_marker) or not is_provided(end_marker): + if not is_provided(range_start) or not is_provided(range_end): error_outputs.append( cls.format_error( coder, ( - f"Show operation {show_index + 1}: Provide both 'start_marker' and" - " 'end_marker'." + f"read operation {read_index + 1}: Provide both 'range_start' and" + " 'range_end'." ), file_path, - start_marker, - end_marker, - show_index, + range_start, + range_end, + read_index, ) ) continue - if start_marker.count("\n") > 4 or end_marker.count("\n") > 4: + if range_start.count("\n") > 4 or range_end.count("\n") > 4: error_outputs.append( cls.format_error( coder, "Patterns must not contain more than 5 lines.", file_path, - start_marker, - end_marker, - show_index, + range_start, + range_end, + read_index, ) ) continue - start_marker = strip_hashline(start_marker).strip() - end_marker = strip_hashline(end_marker).strip() + range_start = strip_hashline(range_start).strip() + range_end = strip_hashline(range_end).strip() # 2. Resolve path abs_path, rel_path = resolve_paths(coder, file_path) @@ -175,9 +176,9 @@ def execute(cls, coder, show, **kwargs): coder, f"File not found: {file_path}", file_path, - start_marker, - end_marker, - show_index, + range_start, + range_end, + read_index, ) ) continue @@ -190,9 +191,9 @@ def execute(cls, coder, show, **kwargs): coder, f"Could not read file: {file_path}", file_path, - start_marker, - end_marker, - show_index, + range_start, + range_end, + read_index, ) ) continue @@ -222,7 +223,7 @@ def execute(cls, coder, show, **kwargs): both_structured = False # found_by = "" - if start_marker is not None and end_marker is not None: + if range_start is not None and range_end is not None: def _is_valid_int(s): try: @@ -231,10 +232,10 @@ def _is_valid_int(s): except ValueError: return False - start_is_digit = _is_valid_int(start_marker) - end_is_digit = _is_valid_int(end_marker) - start_is_special = start_marker in ("@000", "000@") - end_is_special = end_marker in ("@000", "000@") + start_is_digit = _is_valid_int(range_start) + end_is_digit = _is_valid_int(range_end) + start_is_special = range_start in ("@000", "000@") + end_is_special = range_end in ("@000", "000@") both_structured = (start_is_digit or start_is_special) and ( end_is_digit or end_is_special ) @@ -248,14 +249,14 @@ def _is_valid_int(s): if both_structured: if start_is_digit: - start_line_num = int(start_marker) + start_line_num = int(range_start) - 1 start_line_num = max(1, min(start_line_num, num_lines)) start_indices = [start_line_num - 1] else: start_indices = [0] if end_is_digit: - end_line_num = int(end_marker) + end_line_num = int(range_end) - 1 end_line_num = max(1, min(end_line_num, num_lines)) end_indices = [end_line_num - 1] else: @@ -263,12 +264,12 @@ def _is_valid_int(s): elif mixed_special_search: if start_is_special: # Start is special marker, end is text pattern - if start_marker == "@000": + if range_start == "@000": start_indices = [0] else: # 000@ start_indices = [num_lines - 1] # Search for end pattern as text - end_pattern_lines = end_marker.split("\n") + end_pattern_lines = range_end.split("\n") end_indices = [] for i in range(len(lines) - len(end_pattern_lines) + 1): if all( @@ -278,7 +279,7 @@ def _is_valid_int(s): end_indices.append(i + len(end_pattern_lines) - 1) else: # Start is text pattern, end is special marker - start_pattern_lines = start_marker.split("\n") + start_pattern_lines = range_start.split("\n") start_indices = [] for i in range(len(lines) - len(start_pattern_lines) + 1): if all( @@ -286,12 +287,12 @@ def _is_valid_int(s): for j, p_line in enumerate(start_pattern_lines) ): start_indices.append(i) - if end_marker == "@000": + if range_end == "@000": end_indices = [0] else: # 000@ end_indices = [num_lines - 1] else: - start_pattern_lines = start_marker.split("\n") + start_pattern_lines = range_start.split("\n") start_indices = [] for i in range(len(lines) - len(start_pattern_lines) + 1): if all( @@ -300,7 +301,7 @@ def _is_valid_int(s): ): start_indices.append(i) - end_pattern_lines = end_marker.split("\n") + end_pattern_lines = range_end.split("\n") end_indices = [] for i in range(len(lines) - len(end_pattern_lines) + 1): if all( @@ -317,13 +318,13 @@ def _is_valid_int(s): cls.format_error( coder, ( - f"Start pattern '{start_marker}' too broad." + f"Start pattern '{range_start}' too broad." " Refine your search. Be more specific." ), file_path, - start_marker, - end_marker, - show_index, + range_start, + range_end, + read_index, ) ) continue @@ -369,13 +370,13 @@ def _is_valid_int(s): cls.format_error( coder, ( - f"Start pattern '{start_marker}' not found in {file_path}." + f"Start pattern '{range_start}' not found in {file_path}." " Refine your search." ), file_path, - start_marker, - end_marker, - show_index, + range_start, + range_end, + read_index, ) ) continue @@ -385,13 +386,13 @@ def _is_valid_int(s): cls.format_error( coder, ( - f"End pattern '{end_marker}' not found in {file_path}." + f"End pattern '{range_end}' not found in {file_path}." " Refine your search." ), file_path, - start_marker, - end_marker, - show_index, + range_start, + range_end, + read_index, ) ) continue @@ -401,19 +402,21 @@ def _is_valid_int(s): cls.format_error( coder, ( - f"End pattern '{end_marker}' not found after start pattern in" + f"End pattern '{range_end}' not found after start pattern in" f" {file_path}." ), file_path, - start_marker, - end_marker, - show_index, + range_start, + range_end, + read_index, ) ) continue s_idx, e_idx = best_pair - + s_idx, e_idx = cls._extend_range_with_stub( + coder, abs_path, s_idx, e_idx, num_lines + ) # For structured searches (line numbers, special markers) or mixed searches # (one special marker, one text pattern), cap large ranges with preview # Text pattern searches are not subject to capping @@ -421,7 +424,7 @@ def _is_valid_int(s): preview = cls._get_range_preview( abs_path, coder.io, start_idx=s_idx, end_idx=e_idx, line_numbers=True ) - if show_index > 0: + if read_index > 0: all_outputs.append("") all_outputs.append(preview) cls._last_invocation[abs_path] = {"start_idx": s_idx, "end_idx": e_idx} @@ -430,7 +433,7 @@ def _is_valid_int(s): # Store the found indices for future disambiguation cls._last_invocation[abs_path] = {"start_idx": s_idx, "end_idx": e_idx} - # found_by = f"range '{start_marker}' to '{end_marker}'" + # found_by = f"range '{range_start}' to '{range_end}'" try: padding_int = int(padding) @@ -448,9 +451,9 @@ def _is_valid_int(s): coder, "Internal error: Could not determine line range.", file_path, - start_marker, - end_marker, - show_index, + range_start, + range_end, + read_index, ) ) continue @@ -470,8 +473,8 @@ def _is_valid_int(s): # hashed_line = context_hashed_lines[i - start_line_idx] # output_lines.append(hashed_line) - # Add separator between multiple show operations - # if show_index > 0: + # Add separator between multiple read operations + # if read_index > 0: # all_outputs.append("") # all_outputs.extend(output_lines) @@ -494,14 +497,14 @@ def _is_valid_int(s): is_already_up_to_date = False add_to_ranges = False - last_turn = cls._last_read_turn.get(abs_path) + # last_turn = cls._last_read_turn.get(abs_path) if original_context_content and original_context_content == new_context_content: already_up_to_date.append(rel_path) is_already_up_to_date = True - if last_turn is None or coder.turn_count - last_turn < 3 and already_up_to_date: - add_to_ranges = True + # if last_turn is None or coder.turn_count - last_turn < 3 and already_up_to_date: + # add_to_ranges = True else: add_to_ranges = True @@ -524,7 +527,9 @@ def _is_valid_int(s): hashed_slice = hashed_lines[s_idx : e_idx + 1] if is_already_up_to_date: already_up_to_details.append( - cls.format_model_response(coder, rel_path, s_idx, e_idx, hashed_slice) + cls.format_model_response( + coder, rel_path, s_idx, e_idx, hashed_slice, current=True + ) ) else: new_context_details.append( @@ -564,6 +569,7 @@ def _is_valid_int(s): result_parts.append( f"Retrieved context for {len(new_context_details)} operation(s):\n\n" f"{detail_str}\n" + "Full results for these reads will be given in a follow up message.\n" ) if already_up_to_details: coder.io.tool_output( @@ -576,6 +582,7 @@ def _is_valid_int(s): "Content up to date and available in history from previous read for " f"{len(already_up_to_details)} operation(s):\n\n" f"{detail_str}\n" + "Current contents for these reads available in previous content ID message." ) if already_up_to_date and not new_context_retrieved: result_parts.append( @@ -585,6 +592,7 @@ def _is_valid_int(s): if all_outputs: result_parts.append("\n".join(all_outputs)) + result_parts.append("\nUse these outlines to refine your search.\n") if error_outputs: coder.io.tool_error(f"Errors encountered for {len(error_outputs)} operation(s)") @@ -604,19 +612,108 @@ def _is_valid_int(s): return handle_tool_error(coder, tool_name, e) @classmethod - def format_model_response(cls, coder, rel_path, s_idx, e_idx, hashed_slice): + def format_model_response(cls, coder, rel_path, s_idx, e_idx, hashed_slice, current=False): """Format a file's context range as hash-prefixed lines for the model.""" + # Read file content for stub lookups + try: + from cecli.tools.utils.helpers import resolve_paths + + abs_path, _ = resolve_paths(coder, rel_path) + last_turn = cls._last_read_turn[abs_path] or 0 + content = coder.io.read_text(abs_path) + file_lines = content.splitlines() if content else None + except Exception: + file_lines = None + + lines = [] + + # Try to return structural stub information instead of raw hashed lines + try: + if file_lines is not None and current and coder.turn_count - last_turn >= 2: + num_lines = len(file_lines) + + start_stub_s, start_stub_e = cls._extend_range_with_stub( + coder, abs_path, s_idx, s_idx, num_lines + ) + end_stub_s, end_stub_e = cls._extend_range_with_stub( + coder, abs_path, e_idx, e_idx, num_lines + ) + + # start_stub_s, start_stub_e = cls._reposition_indices(s_idx, start_stub_s, start_stub_e) + # end_stub_s, end_stub_e = cls._reposition_indices(e_idx, end_stub_s, end_stub_e) + + start_found = start_stub_s != s_idx or start_stub_e != s_idx + end_found = end_stub_s != e_idx or end_stub_e != e_idx + + if end_stub_s != start_stub_s or end_stub_e != start_stub_e: + start_stub_s = end_stub_s + start_stub_e = end_stub_e + start_found = True + end_found = False + + if start_found or end_found: + hashed_content = hashline(content) + hashed_lines = hashed_content.splitlines() + + if start_found: + lines.append( + f"File {rel_path} Snapshot (Lines {start_stub_s + 1} - {start_stub_e + 1}):" + ) + lines.extend(hashed_lines[start_stub_s:start_stub_e]) + + if end_found and start_stub_s != end_stub_s and start_stub_e != end_stub_e: + lines.append("...⋮...") + lines.append( + f"File {rel_path} Snapshot (Lines {end_stub_s + 1} - {end_stub_e + 1}):" + ) + lines.extend(hashed_lines[end_stub_s:end_stub_e]) + + lines.append("") + return "\n".join(lines) + except Exception: + pass + lines = [f"File {rel_path} Snapshot (Lines {s_idx + 1} - {e_idx + 1}):"] total = len(hashed_slice) - if total <= 10: + if total <= 15: lines.extend(hashed_slice) else: lines.extend(hashed_slice[:5]) - lines.append("...") + lines.append("...⋮...") lines.extend(hashed_slice[-5:]) lines.append("") return "\n".join(lines) + @classmethod + def _reposition_indices( + cls, target_idx: int, start_idx: int, end_idx: int, total_lines: int = 20 + ) -> tuple: + """ + Calculates the clamped start and end indices for a centered window. + Returns a tuple of (slice_start, slice_end) compatible with python slicing. + """ + # 1. Calculate ideal half-window size + half_window = total_lines // 2 + + # 2. Calculate initial left/right bounds + left = target_idx - half_window + right = target_idx + half_window + + # 3. Slide the window if it overflows boundaries + if left < start_idx: + right += start_idx - left + left = start_idx + + if right > end_idx: + left -= right - end_idx + right = end_idx + + # 4. Final safety clamp in case the range itself is smaller than total_lines + left = max(start_idx, left) + + # Return right + 1 so it's ready-to-use for standard Python slicing [start:end] + return left, right + 1 + @classmethod def clear_old_messages(cls, coder): from cecli.helpers.conversation import ConversationService, MessageTag @@ -680,18 +777,18 @@ def format_output(cls, coder, mcp_server, tool_response): coder.io.tool_error("Invalid Tool JSON") return - show_ops = params.get("show", []) - if show_ops: + read_ops = params.get("read", []) + if read_ops: coder.io.tool_output("") - for i, show_op in enumerate(show_ops): - file_path = show_op.get("file_path", "") - start_marker = strip_hashline(show_op.get("start_marker", "")).strip() - end_marker = strip_hashline(show_op.get("end_marker", "")).strip() + 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() - # Format as "show: • file_path • start_marker • end_marker • padding" + # Format as "read: • file_path • range_start • range_end • padding" formatted_query = ( - f"{color_start}range_{i + 1}:{color_end} {file_path} • {start_marker} •" - f" {end_marker}" + f"{color_start}range_{i + 1}:{color_end} {file_path} • {range_start} •" + f" {range_end}" ) coder.io.tool_output(formatted_query) coder.io.tool_output("") @@ -699,24 +796,24 @@ def format_output(cls, coder, mcp_server, tool_response): tool_footer(coder=coder, tool_response=tool_response, params=params) @classmethod - def format_error(cls, coder, error_text, file_path, start_marker, end_marker, operation_index): + def format_error(cls, coder, error_text, file_path, range_start, range_end, operation_index): """Format error output for the ReadRange tool.""" - # Truncate start_marker to first line with ellipsis if multiline - start_line = (start_marker or "N/A").split("\n")[0] - if start_marker and start_marker.count("\n") > 0: + # Truncate range_start to first line with ellipsis if multiline + start_line = (range_start or "N/A").split("\n")[0] + if range_start and range_start.count("\n") > 0: start_line = start_line + " ..." - # Truncate end_marker to first line with ellipsis if multiline - end_line = (end_marker or "N/A").split("\n")[0] - if end_marker and end_marker.count("\n") > 0: + # Truncate range_end to first line with ellipsis if multiline + end_line = (range_end or "N/A").split("\n")[0] + if range_end and range_end.count("\n") > 0: end_line = end_line + " ..." output = [ f"[Operation {operation_index + 1}]", f"file_path: {file_path or 'N/A'}", - f"start_marker: {start_line}", - f"end_marker: {end_line}", + f"range_start: {start_line}", + f"range_end: {end_line}", "", error_text, ] @@ -727,6 +824,57 @@ def format_error(cls, coder, error_text, file_path, start_marker, end_marker, op def on_duplicate_request(cls, coder, **kwargs): coder.edit_allowed = True + @classmethod + def _extend_range_with_stub(cls, coder, abs_path, s_idx, e_idx, num_lines): + """ + Extends the range [s_idx, e_idx] to include the stub result before + and up to the stub result after the specified range. + """ + from cecli.repomap import RepoMap + + try: + if not hasattr(RepoMap, "_stub_instance"): + RepoMap._stub_instance = RepoMap(map_tokens=0, io=coder.io) + rm = RepoMap._stub_instance + rel_fname = rm.get_rel_fname(abs_path) + tags = rm.get_tags(abs_path, rel_fname) + if not tags: + return s_idx, e_idx + + # Get all definition lines, plus import lines for structural context + lois = sorted( + list( + set( + tag.line + for tag in tags + if tag.kind == "def" or tag.specific_kind == "import" + ) + ) + ) + if not lois: + return s_idx, e_idx + + # Find the stub result before or at s_idx + # We want the largest line in lois that is <= s_idx + before_lines = [ln for ln in lois if ln <= s_idx] + new_s_idx = s_idx + if before_lines: + new_s_idx = before_lines[-1] + + # Find the stub result after e_idx + # We want the smallest line in lois that is > e_idx + after_lines = [ln for ln in lois if ln > e_idx] + new_e_idx = e_idx + if after_lines: + new_e_idx = after_lines[0] - 1 + else: + new_e_idx = num_lines - 1 + + return new_s_idx, new_e_idx + except Exception: + # Fallback to original range if anything goes wrong + return s_idx, e_idx + @classmethod def _get_range_preview(cls, abs_path, io, start_idx, end_idx, line_numbers=True): """Get a preview of a large file range between start_idx and end_idx. From 4582a5088d842cb7c834fdf13270d7286a2d0a8c Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 9 Jun 2026 02:35:24 -0400 Subject: [PATCH 14/47] Fix tests --- cecli/tools/read_range.py | 8 ++-- tests/tools/test_get_lines.py | 35 +++++++------- tests/tools/test_read_range_execute.py | 66 ++++++++++++-------------- 3 files changed, 53 insertions(+), 56 deletions(-) diff --git a/cecli/tools/read_range.py b/cecli/tools/read_range.py index 9bc4f75a1e7..de34c958c84 100644 --- a/cecli/tools/read_range.py +++ b/cecli/tools/read_range.py @@ -250,15 +250,15 @@ def _is_valid_int(s): if both_structured: if start_is_digit: start_line_num = int(range_start) - 1 - start_line_num = max(1, min(start_line_num, num_lines)) - start_indices = [start_line_num - 1] + start_line_num = max(0, min(start_line_num, num_lines - 1)) + start_indices = [start_line_num] else: start_indices = [0] if end_is_digit: end_line_num = int(range_end) - 1 - end_line_num = max(1, min(end_line_num, num_lines)) - end_indices = [end_line_num - 1] + end_line_num = max(0, min(end_line_num, num_lines - 1)) + end_indices = [end_line_num] else: end_indices = [num_lines - 1] elif mixed_special_search: diff --git a/tests/tools/test_get_lines.py b/tests/tools/test_get_lines.py index 59c5e15f2ed..686146a817c 100644 --- a/tests/tools/test_get_lines.py +++ b/tests/tools/test_get_lines.py @@ -54,17 +54,17 @@ def test_pattern_with_zero_line_number_is_allowed(coder_with_file): result = read_range.Tool.execute( coder, - show=[ + read=[ { "file_path": "example.txt", - "start_marker": "beta", - "end_marker": "beta", + "range_start": "beta", + "range_end": "beta", "padding": 0, } ], ) - # show_numbered_context now returns a new formatted context message + # read_range now returns a new formatted context message assert "Retrieved context for 1 operation(s)" in result coder.io.tool_error.assert_not_called() @@ -74,17 +74,17 @@ def test_empty_pattern_uses_line_number(coder_with_file): result = read_range.Tool.execute( coder, - show=[ + read=[ { "file_path": "example.txt", - "start_marker": "beta", - "end_marker": "beta", + "range_start": "beta", + "range_end": "beta", "padding": 0, } ], ) - # show_numbered_context now returns a static success message + # read_range now returns a static success message assert "Retrieved context for 1 operation(s)" in result coder.io.tool_error.assert_not_called() @@ -93,18 +93,19 @@ def test_conflicting_pattern_and_line_number_raise(coder_with_file): coder, file_path = coder_with_file # Test that missing start_text raises an error + # Test that missing range_start raises an error result = read_range.Tool.execute( coder, - show=[ + read=[ { "file_path": "example.txt", - "end_marker": "beta", + "range_end": "beta", "padding": 0, } ], ) - assert "Provide both 'start_marker' and 'end_marker'" in result + assert "Provide both 'range_start' and 'range_end'" in result coder.io.tool_error.assert_called() @@ -130,11 +131,11 @@ def test_multiline_pattern_search(coder_with_file): result = read_range.Tool.execute( coder, - show=[ + read=[ { "file_path": "example.txt", - "start_marker": "alpha\nbeta", - "end_marker": "beta\ngamma", + "range_start": "alpha\nbeta", + "range_end": "beta\ngamma", "padding": 0, } ], @@ -157,11 +158,11 @@ def test_empty_file_includes_edit_hint(tmp_path): conv.get_chunks.return_value.add_file_context_messages = Mock() result = read_range.Tool.execute( coder, - show=[ + read=[ { "file_path": "pubspec.yaml", - "start_marker": "@000", - "end_marker": "@000", + "range_start": "@000", + "range_end": "@000", } ], ) diff --git a/tests/tools/test_read_range_execute.py b/tests/tools/test_read_range_execute.py index d08e74126c3..bad0fde5981 100644 --- a/tests/tools/test_read_range_execute.py +++ b/tests/tools/test_read_range_execute.py @@ -156,11 +156,11 @@ def _teardown(self): def test_both_digits_valid_range( self, mock_coder, mock_file_context, mock_chunks, mock_manager ): - """Test: start_marker='5', end_marker='10' -> lines 4-9 (0-based).""" + """Test: range_start='5', range_end='10' -> lines 5-10 (1-based).""" content = "\n".join(f"line{i}" for i in range(1, 11)) self._setup(mock_coder, mock_file_context, mock_chunks, mock_manager, content) try: - show = [{"file_path": self.test_file, "start_marker": "5", "end_marker": "10"}] + show = [{"file_path": self.test_file, "range_start": "5", "range_end": "10"}] result = self.Tool.execute(self.coder, show) assert "Snapshot" in result assert "line5" in result @@ -169,11 +169,11 @@ def test_both_digits_valid_range( self._teardown() def test_both_digits_same_line(self, mock_coder, mock_file_context, mock_chunks, mock_manager): - """Test: start_marker='1', end_marker='1' -> just line 0.""" + """Test: range_start='1', range_end='1' -> just line 0.""" content = "\n".join(f"line{i}" for i in range(1, 11)) self._setup(mock_coder, mock_file_context, mock_chunks, mock_manager, content) try: - show = [{"file_path": self.test_file, "start_marker": "1", "end_marker": "1"}] + show = [{"file_path": self.test_file, "range_start": "1", "range_end": "1"}] result = self.Tool.execute(self.coder, show) assert "line1" in result finally: @@ -182,11 +182,11 @@ def test_both_digits_same_line(self, mock_coder, mock_file_context, mock_chunks, def test_both_digits_out_of_bounds( self, mock_coder, mock_file_context, mock_chunks, mock_manager ): - """Test: start_marker='1', end_marker='100' -> clamp to valid range.""" + """Test: range_start='1', range_end='100' -> clamp to valid range.""" content = "\n".join(f"line{i}" for i in range(1, 11)) self._setup(mock_coder, mock_file_context, mock_chunks, mock_manager, content) try: - show = [{"file_path": self.test_file, "start_marker": "1", "end_marker": "100"}] + show = [{"file_path": self.test_file, "range_start": "1", "range_end": "100"}] result = self.Tool.execute(self.coder, show) assert "line1" in result assert "line10" in result @@ -196,11 +196,11 @@ def test_both_digits_out_of_bounds( def test_both_digits_inverted_order( self, mock_coder, mock_file_context, mock_chunks, mock_manager ): - """Test: start_marker='10', end_marker='5': inverted matching swaps.""" + """Test: range_start='10', range_end='5': inverted matching swaps.""" content = "\n".join(f"line{i}" for i in range(1, 11)) self._setup(mock_coder, mock_file_context, mock_chunks, mock_manager, content) try: - show = [{"file_path": self.test_file, "start_marker": "10", "end_marker": "5"}] + show = [{"file_path": self.test_file, "range_start": "10", "range_end": "5"}] result = self.Tool.execute(self.coder, show) # Inverted: start=[9], end=[4], only one each -> swap to (4, 9) assert result is not None @@ -216,7 +216,7 @@ def test_special_start_end(self, mock_coder, mock_file_context, mock_chunks, moc content = "\n".join([f"line{i}" for i in range(1, 6)]) self._setup(mock_coder, mock_file_context, mock_chunks, mock_manager, content) try: - show = [{"file_path": self.test_file, "start_marker": "@000", "end_marker": "000@"}] + show = [{"file_path": self.test_file, "range_start": "@000", "range_end": "000@"}] result = self.Tool.execute(self.coder, show) assert "line1" in result assert "line5" in result @@ -228,7 +228,7 @@ def test_special_start_at_000(self, mock_coder, mock_file_context, mock_chunks, content = "\n".join([f"line{i}" for i in range(1, 6)]) self._setup(mock_coder, mock_file_context, mock_chunks, mock_manager, content) try: - show = [{"file_path": self.test_file, "start_marker": "@000", "end_marker": "@000"}] + show = [{"file_path": self.test_file, "range_start": "@000", "range_end": "@000"}] result = self.Tool.execute(self.coder, show) assert "line1" in result finally: @@ -239,7 +239,7 @@ def test_special_end_at_000(self, mock_coder, mock_file_context, mock_chunks, mo content = "\n".join([f"line{i}" for i in range(1, 6)]) self._setup(mock_coder, mock_file_context, mock_chunks, mock_manager, content) try: - show = [{"file_path": self.test_file, "start_marker": "000@", "end_marker": "000@"}] + show = [{"file_path": self.test_file, "range_start": "000@", "range_end": "000@"}] result = self.Tool.execute(self.coder, show) assert "line5" in result finally: @@ -252,11 +252,11 @@ def test_special_end_at_000(self, mock_coder, mock_file_context, mock_chunks, mo def test_special_start_digit_end( self, mock_coder, mock_file_context, mock_chunks, mock_manager ): - """Test: @000 to '3' -> first to line 2 (0-based).""" + """Test: @000 to '3' -> first to line 3 (1-based).""" content = "line1\nline2\nline3\nline4\nline5" self._setup(mock_coder, mock_file_context, mock_chunks, mock_manager, content) try: - show = [{"file_path": self.test_file, "start_marker": "@000", "end_marker": "3"}] + show = [{"file_path": self.test_file, "range_start": "@000", "range_end": "3"}] result = self.Tool.execute(self.coder, show) assert "line1" in result assert "line3" in result @@ -270,7 +270,7 @@ def test_digit_start_special_end( content = "line1\nline2\nline3\nline4\nline5" self._setup(mock_coder, mock_file_context, mock_chunks, mock_manager, content) try: - show = [{"file_path": self.test_file, "start_marker": "2", "end_marker": "000@"}] + show = [{"file_path": self.test_file, "range_start": "2", "range_end": "000@"}] result = self.Tool.execute(self.coder, show) assert "line2" in result assert "line5" in result @@ -291,8 +291,8 @@ def test_both_text_patterns(self, mock_coder, mock_file_context, mock_chunks, mo show = [ { "file_path": self.test_file, - "start_marker": "def foo():", - "end_marker": "def bar():", + "range_start": "def foo():", + "range_end": "def bar():", } ] result = self.Tool.execute(self.coder, show) @@ -310,8 +310,8 @@ def test_text_pattern_not_found(self, mock_coder, mock_file_context, mock_chunks show = [ { "file_path": self.test_file, - "start_marker": "nonexistent_pattern", - "end_marker": "also_nonexistent", + "range_start": "nonexistent_pattern", + "range_end": "also_nonexistent", } ] result = self.Tool.execute(self.coder, show) @@ -324,9 +324,7 @@ def test_text_pattern_multiline(self, mock_coder, mock_file_context, mock_chunks content = "def foo():\n return 1\n\ndef bar():\n return 2\n" self._setup(mock_coder, mock_file_context, mock_chunks, mock_manager, content) try: - show = [ - {"file_path": self.test_file, "start_marker": "def foo", "end_marker": "def bar"} - ] + show = [{"file_path": self.test_file, "range_start": "def foo", "range_end": "def bar"}] result = self.Tool.execute(self.coder, show) assert "Snapshot" in result finally: @@ -345,9 +343,7 @@ def test_special_start_text_end(self, mock_coder, mock_file_context, mock_chunks content = "header\nconfig_value = 42\ndebug_mode = True\nfooter" self._setup(mock_coder, mock_file_context, mock_chunks, mock_manager, content) try: - show = [ - {"file_path": self.test_file, "start_marker": "@000", "end_marker": "debug_mode"} - ] + show = [{"file_path": self.test_file, "range_start": "@000", "range_end": "debug_mode"}] result = self.Tool.execute(self.coder, show) # Should find '@000' at start and 'debug_mode' as text print(f"\n[special_start_text_end] result: {result[:300]}") @@ -365,7 +361,7 @@ def test_text_start_special_end(self, mock_coder, mock_file_context, mock_chunks self._setup(mock_coder, mock_file_context, mock_chunks, mock_manager, content) try: show = [ - {"file_path": self.test_file, "start_marker": "config_value", "end_marker": "000@"} + {"file_path": self.test_file, "range_start": "config_value", "range_end": "000@"} ] result = self.Tool.execute(self.coder, show) print(f"\n[text_start_special_end] result: {result[:300]}") @@ -381,7 +377,7 @@ def test_empty_file(self, mock_coder, mock_file_context, mock_chunks, mock_manag """Test with an empty file.""" self._setup(mock_coder, mock_file_context, mock_chunks, mock_manager, "") try: - show = [{"file_path": self.test_file, "start_marker": "@000", "end_marker": "000@"}] + show = [{"file_path": self.test_file, "range_start": "@000", "range_end": "000@"}] result = self.Tool.execute(self.coder, show) assert "empty" in result.lower() finally: @@ -391,7 +387,7 @@ def test_single_line_file(self, mock_coder, mock_file_context, mock_chunks, mock """Test with a single line file.""" self._setup(mock_coder, mock_file_context, mock_chunks, mock_manager, "only_line") try: - show = [{"file_path": self.test_file, "start_marker": "1", "end_marker": "1"}] + show = [{"file_path": self.test_file, "range_start": "1", "range_end": "1"}] result = self.Tool.execute(self.coder, show) assert "only_line" in result finally: @@ -412,15 +408,15 @@ def test_file_not_found(self, mock_coder, mock_file_context, mock_chunks, mock_m from cecli.tools.read_range import Tool - show = [{"file_path": "nonexistent/path.py", "start_marker": "1", "end_marker": "10"}] + show = [{"file_path": "nonexistent/path.py", "range_start": "1", "range_end": "10"}] result = Tool.execute(mock_coder, show) assert "not found" in result or "Errors" in result def test_missing_parameters(self, mock_coder, mock_file_context, mock_chunks, mock_manager): - """Test with missing start_marker and end_marker (empty strings).""" + """Test with missing range_start and range_end (empty strings).""" from cecli.tools.read_range import Tool - show = [{"file_path": "some_file.py", "start_marker": "", "end_marker": ""}] + show = [{"file_path": "some_file.py", "range_start": "", "range_end": ""}] result = Tool.execute(mock_coder, show) assert "Provide both" in result or "Errors" in result @@ -469,8 +465,8 @@ def resolve_side_effect(coder, file_path): Tool._last_read_turn = {} show = [ - {"file_path": "file1.py", "start_marker": "1", "end_marker": "3"}, - {"file_path": "file2.py", "start_marker": "2", "end_marker": "4"}, + {"file_path": "file1.py", "range_start": "1", "range_end": "3"}, + {"file_path": "file2.py", "range_start": "2", "range_end": "4"}, ] result = Tool.execute(mock_coder, show) assert "line1_1" in result @@ -510,8 +506,8 @@ def func_f(): show = [ { "file_path": self.test_file, - "start_marker": "def func_a", - "end_marker": "def func_c", + "range_start": "def func_a", + "range_end": "def func_c", } ] result = self.Tool.execute(self.coder, show) @@ -543,7 +539,7 @@ def func_f(): """ self._setup(mock_coder, mock_file_context, mock_chunks, mock_manager, content) try: - show = [{"file_path": self.test_file, "start_marker": "def", "end_marker": "def"}] + show = [{"file_path": self.test_file, "range_start": "def", "range_end": "def"}] result = self.Tool.execute(self.coder, show) assert "too broad" in result.lower() finally: From 1ad8d13a1d9df4435dde49993fb324d6ba4b0788 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 9 Jun 2026 08:53:25 -0400 Subject: [PATCH 15/47] Don't double submit messages and don't include full method if the end search is a boundary of a block --- cecli/tools/read_range.py | 38 ++++++++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/cecli/tools/read_range.py b/cecli/tools/read_range.py index de34c958c84..0c9d20886ea 100644 --- a/cecli/tools/read_range.py +++ b/cecli/tools/read_range.py @@ -112,6 +112,9 @@ def execute(cls, coder, read, **kwargs): all_outputs = [] already_up_to_details = [] new_context_details = [] + all_outputs_set = set() + new_context_set = set() + already_up_to_set = set() ranges = {} for read_index, read_op in enumerate(read): @@ -424,9 +427,13 @@ def _is_valid_int(s): preview = cls._get_range_preview( abs_path, coder.io, start_idx=s_idx, end_idx=e_idx, line_numbers=True ) - if read_index > 0: - all_outputs.append("") - all_outputs.append(preview) + + if preview not in all_outputs_set: + all_outputs_set.add(preview) + if len(all_outputs): + all_outputs.append("") + all_outputs.append(preview) + cls._last_invocation[abs_path] = {"start_idx": s_idx, "end_idx": e_idx} continue @@ -526,16 +533,22 @@ def _is_valid_int(s): ): hashed_slice = hashed_lines[s_idx : e_idx + 1] if is_already_up_to_date: - already_up_to_details.append( - cls.format_model_response( - coder, rel_path, s_idx, e_idx, hashed_slice, current=True - ) + model_response = cls.format_model_response( + coder, rel_path, s_idx, e_idx, hashed_slice, current=True ) + + if model_response not in already_up_to_set: + already_up_to_set.add(model_response) + already_up_to_details.append(model_response) else: - new_context_details.append( - cls.format_model_response(coder, rel_path, s_idx, e_idx, hashed_slice) + model_response = cls.format_model_response( + coder, rel_path, s_idx, e_idx, hashed_slice ) + if model_response not in new_context_set: + new_context_set.add(model_response) + new_context_details.append(model_response) + # Conditionally remove old file context messages # If the file was last read >= 3 turns ago, keep old messages (allow coexistence) # Otherwise, remove them to avoid duplicates @@ -661,7 +674,12 @@ def format_model_response(cls, coder, rel_path, s_idx, e_idx, hashed_slice, curr ) lines.extend(hashed_lines[start_stub_s:start_stub_e]) - if end_found and start_stub_s != end_stub_s and start_stub_e != end_stub_e: + if ( + end_found + and start_stub_s != end_stub_s + and start_stub_e != end_stub_e + and end_stub_e != e_idx + ): lines.append("...⋮...") lines.append( f"File {rel_path} Snapshot (Lines {end_stub_s + 1} - {end_stub_e + 1}):" From 69bbad594713f4ec8f79774393f90923b8ac9eac Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 9 Jun 2026 21:39:55 -0400 Subject: [PATCH 16/47] Update pair reconciliation in read range --- cecli/tools/read_range.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/cecli/tools/read_range.py b/cecli/tools/read_range.py index 0c9d20886ea..611989b3816 100644 --- a/cecli/tools/read_range.py +++ b/cecli/tools/read_range.py @@ -416,6 +416,22 @@ def _is_valid_int(s): ) continue + if best_pair is None: + error_outputs.append( + cls.format_error( + coder, + ( + f"End pattern '{range_end}' not found after start pattern in" + f" {file_path}." + ), + file_path, + range_start, + range_end, + read_index, + ) + ) + continue + s_idx, e_idx = best_pair s_idx, e_idx = cls._extend_range_with_stub( coder, abs_path, s_idx, e_idx, num_lines From 468a7f73ec82c0466ecc73fd5a7a07180bb5c4ac Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 10 Jun 2026 09:01:37 -0400 Subject: [PATCH 17/47] ReadRange description update --- cecli/helpers/conversation/integration.py | 2 +- cecli/tools/read_range.py | 40 +++++++++-------------- 2 files changed, 17 insertions(+), 25 deletions(-) diff --git a/cecli/helpers/conversation/integration.py b/cecli/helpers/conversation/integration.py index f649689323d..5e59b7dc6f5 100644 --- a/cecli/helpers/conversation/integration.py +++ b/cecli/helpers/conversation/integration.py @@ -842,7 +842,7 @@ def add_file_context_messages(self, promote_messages=True) -> None: user_msg = { "role": "user", - "content": f"Hash-Prefixed Context For:\n{rel_fname}\n\n{context_content}", + "content": f"ID-Prefixed Context For:\n{rel_fname}\n\n{context_content}", } assistant_msg = { diff --git a/cecli/tools/read_range.py b/cecli/tools/read_range.py index 611989b3816..969f5237b42 100644 --- a/cecli/tools/read_range.py +++ b/cecli/tools/read_range.py @@ -27,24 +27,21 @@ class Tool(BaseTool): "function": { "name": "ReadRange", "description": ( - "Get content ID prefixes of content between start and end markers in files." + "Get content ID prefixed content between start and end markers in files." " This is useful for files you are attempting to edit and for understanding their structure." " Accepts an array of `read` objects, each with file_path, range_start, range_end." - " These values should be lines of content in the file. They can contain up to 3" - " lines of content but newlines should generally be avoided. Avoid using generic keywords and" + " They can contain up to 3 lines of content. Avoid using singular generic keywords and" " symbols. Special markers @000 and 000@ represent the file boundaries and can be" " used for range_start and range_end for the first and last lines of the file" " respectively. Line numbers may also be used for range lookups." " It is best to use function names, variable declarations and other meaningful identifiers" " as range_start and range_end values." " Do not use both of the special markers together on non-empty file." - " Do not use content IDs cannot be used as the range_start and range_end values." - " These lookups will fail." " Do not use the same pattern for the range_start and range_end." " Do not use empty strings for the range_start and range_end." - " Prefer using this tool to cli tools for reading files." - " Calling this tool sequentially on increasingly finer grained searchers " - " will help with getting outlines of important structural features" + " Prefer using this tool over cli tools for reading files." + " Calling this tool sequentially on increasingly finer grained searches " + " will help with understanding important structural features." ), "parameters": { "type": "object", @@ -547,10 +544,10 @@ def _is_valid_int(s): and e_idx >= 0 and e_idx < len(hashed_lines) ): - hashed_slice = hashed_lines[s_idx : e_idx + 1] + # hashed_slice = hashed_lines[s_idx : e_idx + 1] if is_already_up_to_date: model_response = cls.format_model_response( - coder, rel_path, s_idx, e_idx, hashed_slice, current=True + coder, rel_path, s_idx, e_idx, hashed_lines, current=True ) if model_response not in already_up_to_set: @@ -558,7 +555,7 @@ def _is_valid_int(s): already_up_to_details.append(model_response) else: model_response = cls.format_model_response( - coder, rel_path, s_idx, e_idx, hashed_slice + coder, rel_path, s_idx, e_idx, hashed_lines ) if model_response not in new_context_set: @@ -641,7 +638,7 @@ def _is_valid_int(s): return handle_tool_error(coder, tool_name, e) @classmethod - def format_model_response(cls, coder, rel_path, s_idx, e_idx, hashed_slice, current=False): + def format_model_response(cls, coder, rel_path, s_idx, e_idx, hashed_lines, current=False): """Format a file's context range as hash-prefixed lines for the model.""" # Read file content for stub lookups try: @@ -649,17 +646,15 @@ def format_model_response(cls, coder, rel_path, s_idx, e_idx, hashed_slice, curr abs_path, _ = resolve_paths(coder, rel_path) last_turn = cls._last_read_turn[abs_path] or 0 - content = coder.io.read_text(abs_path) - file_lines = content.splitlines() if content else None except Exception: - file_lines = None + pass lines = [] # Try to return structural stub information instead of raw hashed lines try: - if file_lines is not None and current and coder.turn_count - last_turn >= 2: - num_lines = len(file_lines) + if hashed_lines and current and coder.turn_count - last_turn >= 2: + num_lines = len(hashed_lines) start_stub_s, start_stub_e = cls._extend_range_with_stub( coder, abs_path, s_idx, s_idx, num_lines @@ -681,9 +676,6 @@ def format_model_response(cls, coder, rel_path, s_idx, e_idx, hashed_slice, curr end_found = False if start_found or end_found: - hashed_content = hashline(content) - hashed_lines = hashed_content.splitlines() - if start_found: lines.append( f"File {rel_path} Snapshot (Lines {start_stub_s + 1} - {start_stub_e + 1}):" @@ -708,13 +700,13 @@ def format_model_response(cls, coder, rel_path, s_idx, e_idx, hashed_slice, curr pass lines = [f"File {rel_path} Snapshot (Lines {s_idx + 1} - {e_idx + 1}):"] - total = len(hashed_slice) + total = e_idx - s_idx if total <= 15: - lines.extend(hashed_slice) + lines.extend(hashed_lines[s_idx : e_idx + 1]) else: - lines.extend(hashed_slice[:5]) + lines.extend(hashed_lines[s_idx : s_idx + 5]) lines.append("...⋮...") - lines.extend(hashed_slice[-5:]) + lines.extend(hashed_lines[e_idx - 4 : e_idx + 1]) lines.append("") return "\n".join(lines) From 0b320316d312213b24d11c6fee8a7e5d22ed3e8c Mon Sep 17 00:00:00 2001 From: Jessica Mulein Date: Wed, 10 Jun 2026 21:07:21 -0700 Subject: [PATCH 18/47] fix(commands): transfer abs_fnames back after /agent temp-coder turn _generic_chat_command creates a temporary coder for /agent (and other slash commands with args). Files added by ContextManager during the turn were on the temp coder and got discarded when SwitchCoderSignal switched back to the original. Now transfer abs_fnames and abs_read_only_fnames from the temp coder back to the original before raising SwitchCoderSignal. This ensures files added during /agent turns persist in the session and are visible to subsequent GET /sessions/{id} (files_in_chat). --- cecli/commands/utils/base_command.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cecli/commands/utils/base_command.py b/cecli/commands/utils/base_command.py index d9e0d4c74d2..db6006234a5 100644 --- a/cecli/commands/utils/base_command.py +++ b/cecli/commands/utils/base_command.py @@ -159,6 +159,12 @@ async def _generic_chat_command(cls, io, coder, args, edit_format, placeholder=N await new_coder.generate(user_message=user_msg, preproc=False) coder.coder_commit_hashes = new_coder.coder_commit_hashes + # Transfer files added during the /agent (or other temp-coder) turn back to the original + if new_coder.abs_fnames - original_coder.abs_fnames: + original_coder.abs_fnames.update(new_coder.abs_fnames) + if new_coder.abs_read_only_fnames - original_coder.abs_read_only_fnames: + original_coder.abs_read_only_fnames.update(new_coder.abs_read_only_fnames) + # Clear manager and restore original state ConversationService.get_manager(original_coder).initialize( reset=True, From 0d2b4b00f088883a021c361c94268071e75d9c61 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 11 Jun 2026 00:25:30 -0400 Subject: [PATCH 19/47] Update announcements section for style --- cecli/coders/agent_coder.py | 14 ---- cecli/coders/base_coder.py | 159 ++++++++++++++++++++++++------------ cecli/tui/app.py | 3 +- 3 files changed, 111 insertions(+), 65 deletions(-) diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index 33a5d148c82..ecb440184d3 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -239,20 +239,6 @@ def show_announcements(self): if self.loaded_custom_tools: self.io.tool_output(f"Loaded custom tools: {', '.join(self.loaded_custom_tools)}") - skills = self.skills_manager.find_skills() - if skills: - skills_list = [] - for skill in skills: - skills_list.append(skill.name) - joined_skills = ", ".join(skills_list) - self.io.tool_output(f"Available Skills: {joined_skills}") - - registry = AgentService.get_registry() - if registry: - names = sorted(registry.keys()) - joined_names = ", ".join(names) - self.io.tool_output(f"Available Subagents: {joined_names}") - def get_local_tool_schemas(self): """Returns the JSON schemas for all local tools using the tool registry.""" schemas = [] diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index 4d5b26e97df..ad2fc2fe650 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -778,91 +778,118 @@ def cur_messages(self): """Get CUR messages from ConversationManager.""" return ConversationService.get_manager(self).get_messages_dict(MessageTag.CUR) + @staticmethod + def _strip_provider(model_name: str) -> str: + """Remove provider prefix from model name (e.g., 'openai/gpt-4' -> 'gpt-4').""" + if "/" in model_name: + return model_name.split("/", 1)[1] + return model_name + def get_announcements(self): - lines = [] - lines.append(f"cecli v{__version__}") + sections = {} - # Model + # --- MODELS --- main_model = self.main_model - weak_model = main_model.weak_model + + models_items = [f"{self._strip_provider(main_model.name)} (main)"] agent_model = main_model.agent_model + weak_model = main_model.weak_model - if weak_model is not main_model: - prefix = "Main model" - else: - prefix = "Model" + if agent_model and agent_model.name != main_model.name: + models_items.append(f"{self._strip_provider(agent_model.name)} (agent)") + + if weak_model and weak_model.name != main_model.name: + models_items.append(f"{self._strip_provider(weak_model.name)} (weak)") + if self.edit_format == "architect": + models_items.append(f"{self._strip_provider(main_model.editor_model.name)} (editor)") + + sections["Models"] = {"items": models_items} - output = f"{prefix}: {main_model.name} with {self.edit_format} edit format" + # --- SETTINGS --- + settings_items = [] - # Check for thinking token budget + # Edit format + settings_items.append(f"{self.edit_format} (edit format)") + + # Thinking tokens thinking_tokens = main_model.get_thinking_tokens() if thinking_tokens: - output += f", {thinking_tokens} think tokens" + settings_items.append(f"{thinking_tokens} think tokens") - # Check for reasoning effort + # Reasoning effort reasoning_effort = main_model.get_reasoning_effort() if reasoning_effort: - output += f", reasoning {reasoning_effort}" + settings_items.append(f"reasoning {reasoning_effort}") + # Prompt cache if self.add_cache_headers or main_model.caches_by_default: - output += ", prompt cache" - if main_model.info.get("supports_assistant_prefill"): - output += ", infinite output" - if self.copy_paste_mode: - output += ", copy/paste mode" + settings_items.append("prompt cache") - lines.append(output) + # Infinite output + if main_model.info.get("supports_assistant_prefill"): + settings_items.append("infinite output") - if self.edit_format == "architect": - output = ( - f"Editor model: {main_model.editor_model.name} with" - f" {main_model.editor_edit_format} edit format" - ) - lines.append(output) + # Copy/paste mode + if self.copy_paste_mode: + settings_items.append("copy/paste mode") - if weak_model is not main_model: - output = f"Weak model: {weak_model.name}" - lines.append(output) + if settings_items: + sections["Settings"] = {"items": settings_items} - if agent_model is not main_model: - output = f"Agent model: {agent_model.name}" - lines.append(output) + # --- ENVIRONMENT --- + env_items = [] + repo_map_tokens = None # Track for later warning check - # Repo if self.repo: rel_repo_dir = self.repo.get_rel_repo_dir() num_files = len(self.repo.get_tracked_files()) - - lines.append(f"Git repo: {rel_repo_dir} with {num_files:,} files") + env_items.append(f"{rel_repo_dir} ({num_files:,} files)") if num_files > 1000: - lines.append( + env_items.append( "Warning: For large repos, consider using --subtree-only and .cecli_ignore" ) - lines.append(f"See: {urls.large_repos}") + env_items.append(f"See: {urls.large_repos}") else: - lines.append("Git repo: none") + env_items.append("no git repo") - # Repo-map if self.repo_map: map_tokens = self.repo_map.max_map_tokens if map_tokens > 0: refresh = self.repo_map.refresh - lines.append(f"Repo-map: using {map_tokens} tokens, {refresh} refresh") - max_map_tokens = self.get_active_model().get_repo_map_tokens() * 2 - if map_tokens > max_map_tokens: - lines.append( - f"Warning: map-tokens > {max_map_tokens} is not recommended. Too much" - " irrelevant code can confuse LLMs." - ) + env_items.append(f"map ({map_tokens} tokens, {refresh} refresh)") + repo_map_tokens = map_tokens else: - lines.append("Repo-map: disabled because map_tokens == 0") + env_items.append("repo-map disabled") else: - lines.append("Repo-map: disabled") + env_items.append("repo-map disabled") + + sections["Environment"] = {"items": env_items} + # --- CAPABILITIES --- + capabilities = {} + + # Sub-agents + try: + from cecli.helpers.agents.service import AgentService + + registry = AgentService.get_registry() + if registry: + capabilities["Subagents"] = sorted(registry.keys()) + except Exception: + pass + + # Skills + if hasattr(self, "skills_manager") and self.skills_manager: + try: + skills = self.skills_manager.find_skills() + if skills: + capabilities["Skills"] = [s.name for s in skills] + except Exception: + pass + # MCP Servers if self.mcp_tools: mcp_servers = [] for server_name, server_tools in self.mcp_tools: - # Filter servers per instance configuration if ( self.registered_servers["included"] and server_name not in self.registered_servers["included"] @@ -871,17 +898,49 @@ def get_announcements(self): if server_name in self.registered_servers["excluded"]: continue mcp_servers.append(server_name) - if mcp_servers: - lines.append(f"MCP servers configured: {', '.join(mcp_servers)}") + capabilities["Servers"] = mcp_servers + + if capabilities: + # sections["Extensions"] = {"subsections": capabilities} + sections["Environment"]["subsections"] = capabilities + + # --- RENDER --- + lines = [] + + # Version line (CLI only; TUI has its own banner) + if not self.args.tui: + lines.append(f"cecli v{__version__}") + + for name, section in sections.items(): + if "items" in section: + lines.append(f"{name:15s}" + " • ".join(section["items"])) + if "subsections" in section: + last_key = next(reversed(section["subsections"])) + # lines.append(name) + for sub_name, sub_items in section["subsections"].items(): + connector = "└─" if sub_name == last_key else "├─" + lines.append(f" {connector} {sub_name:10} {' • '.join(sub_items)}") + + # Repo-map max_tokens warning + if repo_map_tokens is not None: + max_map_tokens = self.get_active_model().get_repo_map_tokens() * 2 + if repo_map_tokens > max_map_tokens: + lines.append( + f"Warning: map-tokens > {max_map_tokens} is not recommended. Too much" + " irrelevant code can confuse LLMs." + ) + # Read-only stubs for fname in self.abs_read_only_stubs_fnames: rel_fname = self.get_rel_fname(fname) lines.append(f"Added {rel_fname} to the chat (read-only stub).") + # Restored conversation if ConversationService.get_manager(self).get_messages_dict(MessageTag.DONE): lines.append("Restored previous conversation history.") + # Multiline mode if self.io.multiline_mode and not self.args.tui: lines.append("Multiline mode: Enabled. Enter inserts newline, Alt-Enter submits text") diff --git a/cecli/tui/app.py b/cecli/tui/app.py index 6d7a9ea320c..61acf5e3633 100644 --- a/cecli/tui/app.py +++ b/cecli/tui/app.py @@ -15,6 +15,7 @@ from textual.app import App, ComposeResult from textual.theme import Theme +from cecli import __version__ from cecli.editor import pipe_editor from cecli.helpers.agents.service import AgentService from cecli.helpers.coroutines import is_active @@ -360,7 +361,7 @@ def compose(self) -> ComposeResult: [bold {BANNER_COLORS[2]}] ▒▒║ ▒▒▒▒▒╗ ▒▒║ ▒▒║ ▒▒║[/bold {BANNER_COLORS[2]}] [bold {BANNER_COLORS[3]}] ▒▒║ ▒▒╔══╝ ▒▒║ ▒▒║ ▒▒║[/bold {BANNER_COLORS[3]}] [bold {BANNER_COLORS[4]}] ╚▒▒▒▒▒▒╗▒▒▒▒▒▒▒╗╚▒▒▒▒▒▒╗▒▒▒▒▒▒▒╗▒▒║[/bold {BANNER_COLORS[4]}] -[bold {BANNER_COLORS[5]}] ╚═════╝╚══════╝ ╚═════╝╚══════╝╚═╝[/bold {BANNER_COLORS[5]}] +[bold {BANNER_COLORS[5]}] ╚═════╝╚══════╝ ╚═════╝╚══════╝╚═╝ v{__version__}[/bold {BANNER_COLORS[5]}] """ From 822f1624d32b61b107059e092860d4aaaa223941 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 11 Jun 2026 00:25:37 -0400 Subject: [PATCH 20/47] Bump Version --- cecli/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cecli/__init__.py b/cecli/__init__.py index 5d522b369e5..1f6990a2630 100644 --- a/cecli/__init__.py +++ b/cecli/__init__.py @@ -1,6 +1,6 @@ from packaging import version -__version__ = "0.100.4.dev" +__version__ = "0.100.6.dev" safe_version = __version__ try: From f91518eb3e3c96eb86586331a2ecbb65765d6f62 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 11 Jun 2026 08:32:42 -0400 Subject: [PATCH 21/47] Update edit text description to get models to include demarcator explicitly --- cecli/tools/edit_text.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/cecli/tools/edit_text.py b/cecli/tools/edit_text.py index 874c3ca85c2..2e761e9b178 100644 --- a/cecli/tools/edit_text.py +++ b/cecli/tools/edit_text.py @@ -40,7 +40,7 @@ class Tool(BaseTool): "Can handle an array of up to 10 edits across multiple files. " "Each edit must include its own file_path and operation type. " "Use content ID ranges with the start_line and end_line parameters with format " - "`{4 char hash}` (without the braces). For empty files, use `@000` as the " + "`content_id::` (the content id with the :: demarcator). For empty files, use `@000` as the " "content ID references." ), "parameters": { @@ -64,25 +64,24 @@ class Tool(BaseTool): " after start_line). Defaults to 'replace'." ), }, - "text": { + "start_line": { "type": "string", "description": ( - "Text content for replace/insert operations. " - "Not required for delete operations." + "Content ID for start line. Only include the id and demarcator." ), }, - "start_line": { + "end_line": { "type": "string", "description": ( - "content ID for start line: `{4 char hash}` (without " - "the braces)" + "Content ID for end line. Only include the id and demarcator." ), }, - "end_line": { + "text": { "type": "string", "description": ( - "content ID for end line: `{4 char hash}` (without the" - " braces)" + "Text content for replace operations. " + "Empty string for delete operations. " + "Do not include content IDs inside replacement text" ), }, }, From b2b12bdf96a69b1bd1f25b50d6c5ba17208be2c5 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 11 Jun 2026 08:47:09 -0400 Subject: [PATCH 22/47] Forgive models for misusing EditText if they specify unique line content --- cecli/helpers/hashline.py | 78 +++++++++++++++++++++++++++++++++++++++ cecli/tools/edit_text.py | 8 ++++ 2 files changed, 86 insertions(+) diff --git a/cecli/helpers/hashline.py b/cecli/helpers/hashline.py index cba3057787a..2f1af29217d 100644 --- a/cecli/helpers/hashline.py +++ b/cecli/helpers/hashline.py @@ -236,6 +236,84 @@ def extract_hashline_range( return original_range_content +def resolve_content_to_hashline_ids( + original_content: str, + start_value: str, + end_value: str = None, +) -> tuple: + """ + Resolve potential line content values to proper hashline content IDs. + + If start_value or end_value does not look like a content ID (hash), + search for the content in the original file. If found exactly once, + return the hash ID for that line instead. + + This handles the case where LLMs return entire line content or fragments + instead of content IDs in edit parameters. + + Args: + original_content: Original file content (without hash prefixes) + start_value: The start_line value from the edit + end_value: The end_line value from the edit (optional) + + Returns: + tuple: (resolved_start, resolved_end) with hash IDs or original values + unchanged if resolution is not possible + """ + if not original_content: + return start_value, end_value + + def _looks_like_content_id(value: str) -> bool: + """Check if value looks like a content ID rather than line content.""" + if value in ("@000", "000@"): + return True + # Try to normalize - if it succeeds, it's a valid content ID + try: + normalize_hashline(value) + return True + except (ContentHashError, ValueError): + return False + + def _resolve_value(value: str) -> str: + if value is None: + return value + if _looks_like_content_id(value): + return value + + # Value doesn't look like a content ID - try to find it as line content + lines = original_content.splitlines() + value_stripped = value.rstrip("\r\n") + + # First try exact match (full line content) + matching_indices = [ + i for i, line in enumerate(lines) if line.rstrip("\r\n") == value_stripped + ] + + if len(matching_indices) == 1: + idx = matching_indices[0] + hp = HashPos(original_content) + hash_id = hp.generate_public_id(lines[idx], idx) + return hash_id + "::" + + # If no exact match, try substring match (value might be a fragment) + # Only resolve if exactly one line contains the value + containing_indices = [i for i, line in enumerate(lines) if value_stripped in line] + + if len(containing_indices) == 1: + idx = containing_indices[0] + hp = HashPos(original_content) + hash_id = hp.generate_public_id(lines[idx], idx) + return hash_id + "::" + + # Can't resolve uniquely - return original value + return value + + resolved_start = _resolve_value(start_value) + resolved_end = _resolve_value(end_value) if end_value is not None else end_value + + return resolved_start, resolved_end + + def find_best_line(content, target_line_num, content_to_lines, used_lines, hashlines): """ Find the best matching line for given content near target_line_num. diff --git a/cecli/tools/edit_text.py b/cecli/tools/edit_text.py index 2e761e9b178..14db154db9e 100644 --- a/cecli/tools/edit_text.py +++ b/cecli/tools/edit_text.py @@ -2,6 +2,7 @@ ContentHashError, apply_hashline_operations, get_hashline_diff, + resolve_content_to_hashline_ids, strip_hashline, ) from cecli.tools.utils.base_tool import BaseTool @@ -185,6 +186,13 @@ def execute( edit_start_line = edit.get("start_line") edit_end_line = edit.get("end_line") + # Try to resolve line content values to content IDs + # This handles cases where LLMs pass actual line content + # instead of content ID markers + edit_start_line, edit_end_line = resolve_content_to_hashline_ids( + original_content, edit_start_line, edit_end_line + ) + # Validate required fields based on operation type if operation in ("replace", "insert"): if edit_text is None: From e95372700592aacbe1dc8c9e77678b593b34508f Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 11 Jun 2026 11:08:43 -0400 Subject: [PATCH 23/47] EditText should explain where diffs are to LLMs --- cecli/helpers/conversation/files.py | 6 +++--- cecli/tools/utils/helpers.py | 3 +++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/cecli/helpers/conversation/files.py b/cecli/helpers/conversation/files.py index 3acfdee9c52..94212a29eff 100644 --- a/cecli/helpers/conversation/files.py +++ b/cecli/helpers/conversation/files.py @@ -281,8 +281,8 @@ def update_file_diff(self, fname: str) -> Optional[str]: diff_message = { "role": "user", "content": ( - f"{rel_fname} has been updated. Review this git diff of the changes to" - f" ensure the modifications are intended:\n\n{diff}" + f"{rel_fname} has been updated. Review this diff of the changes to" + f" ensure all modifications are appropriate:\n\n{diff}" ), } @@ -290,7 +290,7 @@ def update_file_diff(self, fname: str) -> Optional[str]: "role": "assistant", "content": ( f"Thank you for sharing this diff of the updates to {rel_fname}." - " I will review their contents next turn." + " I will review their contents." ), } diff --git a/cecli/tools/utils/helpers.py b/cecli/tools/utils/helpers.py index f05e2eda8f9..c46b3aee8cf 100644 --- a/cecli/tools/utils/helpers.py +++ b/cecli/tools/utils/helpers.py @@ -338,6 +338,9 @@ def format_tool_result( result_for_llm += f" Change ID: {change_id}." if diff_snippet: result_for_llm += f" Diff snippet:\n{diff_snippet}" + else: + result_for_llm += " A diff will be provided in a future message." + return result_for_llm From be2ee44bf046c3914efbc39edcddda8056bcd181 Mon Sep 17 00:00:00 2001 From: Jessica Mulein Date: Thu, 11 Jun 2026 08:57:19 -0700 Subject: [PATCH 24/47] fix(tools): sanitize ExploreCode queries for Cymbal FTS5 safety MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit py-cymbal's Cymbal CLI interprets hyphens in search queries as SQL FTS5 NOT operators, causing 'no such column' crashes when the model passes hyphenated terms like 'vault-store' or 'home-entry'. Sanitize all symbol queries by replacing hyphens with underscores before passing to Cymbal. This is semantically correct (code symbols use underscores, not hyphens) and prevents the crash regardless of which py-cymbal version is installed. The root cause is in py-cymbal's Go binary (unquoted FTS5 input) — a fix has been reported to the Cymbal team (dwash). This cecli-side workaround provides immediate defense. --- cecli/tools/explore_code.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/cecli/tools/explore_code.py b/cecli/tools/explore_code.py index 2f9d9621197..685189fadda 100644 --- a/cecli/tools/explore_code.py +++ b/cecli/tools/explore_code.py @@ -120,7 +120,10 @@ def execute(cls, coder, queries, **kwargs): try: if action == "search": - results = c.search(symbol, limit=limit) + # Sanitize symbol: Cymbal's CLI interprets hyphens as SQL operators. + # Replace hyphens with underscores (common in code) and strip special chars. + safe_symbol = symbol.replace("-", "_") if symbol else symbol + results = c.search(safe_symbol, limit=limit) all_results.append(cls._format_search_results(results, symbol)) elif action == "investigate": symbol_name = symbol @@ -131,8 +134,11 @@ def execute(cls, coder, queries, **kwargs): file_hint = parts[0] symbol_name = parts[1] + # Sanitize for Cymbal search + safe_name = symbol_name.replace("-", "_") if symbol_name else symbol_name + try: - investigation = c.investigate(symbol_name, file_hint) + investigation = c.investigate(safe_name, file_hint) all_results.append( cls._format_investigation_results(investigation, symbol) ) @@ -151,7 +157,8 @@ def execute(cls, coder, queries, **kwargs): else: raise e elif action == "find_references": - references = c.find_references(symbol, limit=limit) + safe_symbol = symbol.replace("-", "_") if symbol else symbol + references = c.find_references(safe_symbol, limit=limit) all_results.append(cls._format_reference_results(references, symbol)) else: all_failed_queries.append( From 4a7393c67f3c22dd5c3790b42942a7addf1bf612 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 11 Jun 2026 13:56:54 -0400 Subject: [PATCH 25/47] ReadRange should describe how to do whole file reads --- cecli/tools/edit_text.py | 4 ++-- cecli/tools/read_range.py | 11 ++++++----- tests/tools/test_read_range_execute.py | 4 ++-- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/cecli/tools/edit_text.py b/cecli/tools/edit_text.py index 14db154db9e..f57f7997206 100644 --- a/cecli/tools/edit_text.py +++ b/cecli/tools/edit_text.py @@ -42,7 +42,8 @@ class Tool(BaseTool): "Each edit must include its own file_path and operation type. " "Use content ID ranges with the start_line and end_line parameters with format " "`content_id::` (the content id with the :: demarcator). For empty files, use `@000` as the " - "content ID references." + "content ID references. " + "Edits within a file must not be adjacent or overlapping." ), "parameters": { "type": "object", @@ -91,7 +92,6 @@ class Tool(BaseTool): "description": "Array of edits to apply.", }, "change_id": {"type": "string"}, - "dry_run": {"type": "boolean", "default": False}, }, "required": ["edits"], }, diff --git a/cecli/tools/read_range.py b/cecli/tools/read_range.py index 969f5237b42..195544689ec 100644 --- a/cecli/tools/read_range.py +++ b/cecli/tools/read_range.py @@ -36,12 +36,13 @@ class Tool(BaseTool): " respectively. Line numbers may also be used for range lookups." " It is best to use function names, variable declarations and other meaningful identifiers" " as range_start and range_end values." - " Do not use both of the special markers together on non-empty file." " Do not use the same pattern for the range_start and range_end." " Do not use empty strings for the range_start and range_end." - " Prefer using this tool over cli tools for reading files." - " Calling this tool sequentially on increasingly finer grained searches " - " will help with understanding important structural features." + " Use this tool instead of cli tools for reading file contents." + " Line number and special marker ranges greater than 200 lines will return" + " preview content for further, more scoped investigation." + " Call this tool sequentially on increasingly finer grained searches " + " to help with understanding important structural features in large files." ), "parameters": { "type": "object", @@ -436,7 +437,7 @@ def _is_valid_int(s): # For structured searches (line numbers, special markers) or mixed searches # (one special marker, one text pattern), cap large ranges with preview # Text pattern searches are not subject to capping - if (both_structured or mixed_special_search) and (e_idx - s_idx > 200): + if both_structured or (mixed_special_search and (e_idx - s_idx > 200)): preview = cls._get_range_preview( abs_path, coder.io, start_idx=s_idx, end_idx=e_idx, line_numbers=True ) diff --git a/tests/tools/test_read_range_execute.py b/tests/tools/test_read_range_execute.py index bad0fde5981..3b8326fd0db 100644 --- a/tests/tools/test_read_range_execute.py +++ b/tests/tools/test_read_range_execute.py @@ -162,7 +162,7 @@ def test_both_digits_valid_range( try: show = [{"file_path": self.test_file, "range_start": "5", "range_end": "10"}] result = self.Tool.execute(self.coder, show) - assert "Snapshot" in result + assert "File range too large" in result assert "line5" in result assert "line10" in result finally: @@ -456,7 +456,7 @@ def resolve_side_effect(coder, file_path): cs_patch = patch("cecli.helpers.conversation.ConversationService", mock_cs) cs_patch.start() - mock_coder.io.read_text.side_effect = [content1, content2] + mock_coder.io.read_text.side_effect = [content1, content1, content2, content2] try: from cecli.tools.read_range import Tool From 16cef65a94cb73e4eb2f868fe613e9bd55367e30 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 11 Jun 2026 17:35:39 -0400 Subject: [PATCH 26/47] Replace emojiis with unicode symbols --- cecli/coders/agent_coder.py | 6 +++--- cecli/coders/base_coder.py | 4 ++-- cecli/commands/utils/helpers.py | 2 +- cecli/reasoning_tags.py | 4 ++-- cecli/tools/command.py | 6 +++--- cecli/tools/command_interactive.py | 2 +- cecli/tools/context_manager.py | 22 +++++++++++----------- cecli/tools/delegate.py | 4 ++-- cecli/tools/explore_code.py | 2 +- cecli/tools/grep.py | 4 ++-- cecli/tools/ls.py | 2 +- cecli/tools/read_range.py | 4 ++-- cecli/tools/undo_change.py | 2 +- cecli/tools/update_todo_list.py | 2 +- cecli/tools/utils/helpers.py | 2 +- 15 files changed, 34 insertions(+), 34 deletions(-) diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index ecb440184d3..b6b2a42ba0c 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -611,7 +611,7 @@ def get_context_summary(self): percentage = total_tokens / max_input_tokens * 100 result += f" ({percentage:.1f}% of limit)" if percentage > 80: - result += "\n\n⚠️ **Context is getting full!**\n" + result += "\n\n⚠ **Context is getting full!**\n" result += "- Remove non-essential files via the `ContextManager` tool.\n" result += "- Keep only essential files in context for best performance" result += "\n" @@ -1265,7 +1265,7 @@ def _add_file_to_context(self, file_path, explicit=False): abs_path = self.abs_root_path(file_path) rel_path = self.get_rel_fname(abs_path) if not os.path.isfile(abs_path): - self.io.tool_output(f"⚠️ File '{file_path}' not found") + self.io.tool_output(f"⚠ File '{file_path}' not found") return "File not found" if abs_path in self.abs_fnames: if explicit: @@ -1285,7 +1285,7 @@ def _add_file_to_context(self, file_path, explicit=False): file_tokens = self.get_active_model().token_count(content) if file_tokens > self.large_file_token_threshold: self.io.tool_output( - f"⚠️ '{file_path}' is very large ({file_tokens} tokens). Use" + f"⚠ '{file_path}' is very large ({file_tokens} tokens). Use" " /context-management to toggle truncation off if needed." ) self.abs_read_only_fnames.add(abs_path) diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index ad2fc2fe650..301145799d4 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -1107,7 +1107,7 @@ def get_files_content(self, fnames=None): # Add message about showing definitions instead of full content # self.io.tool_output( - # f"⚠️ '{relative_fname}' is very large ({file_tokens} tokens). " + # f"⚠ '{relative_fname}' is very large ({file_tokens} tokens). " # "Use /context-management to toggle truncation off if needed." # ) @@ -1175,7 +1175,7 @@ def get_read_only_files_content(self): # Add message about showing definitions instead of full content # self.io.tool_output( - # f"⚠️ '{relative_fname}' is very large ({file_tokens} tokens). " + # f"⚠ '{relative_fname}' is very large ({file_tokens} tokens). " # "Use /context-management to toggle truncation off if needed." # ) diff --git a/cecli/commands/utils/helpers.py b/cecli/commands/utils/helpers.py index 1f1c66f98fd..0fb5ec6ee18 100644 --- a/cecli/commands/utils/helpers.py +++ b/cecli/commands/utils/helpers.py @@ -273,7 +273,7 @@ def format_command_result( io.tool_error(f"Error in {command_name}: {str(error)}") return f"Error: {str(error)}" else: - io.tool_output(f"✅ {success_message}") + io.tool_output(f"✓ {success_message}") return f"Successfully executed {command_name}." diff --git a/cecli/reasoning_tags.py b/cecli/reasoning_tags.py index b7e7153009a..42508bf1c51 100644 --- a/cecli/reasoning_tags.py +++ b/cecli/reasoning_tags.py @@ -7,8 +7,8 @@ # Standard tag identifier REASONING_TAG = "thinking-content-" + "7bbeb8e1441453ad999a0bbba8a46d4b" # Output formatting -REASONING_START = "--------------\n► **THINKING**" -REASONING_END = "------------\n► **ANSWER**" +REASONING_START = "--------------\n" +REASONING_END = "--------------\n" def remove_reasoning_content(res, reasoning_tag): diff --git a/cecli/tools/command.py b/cecli/tools/command.py index 09ba627bc94..a9448e91572 100644 --- a/cecli/tools/command.py +++ b/cecli/tools/command.py @@ -161,7 +161,7 @@ async def _execute_background(cls, coder, command_string, use_pty=False): """ Execute command in background. """ - coder.io.tool_output(f"⚙️ Starting background command: {command_string}") + coder.io.tool_output(f"⛭ Starting background command: {command_string}") # Use static manager to start background command command_key = BackgroundCommandManager.start_background_command( @@ -193,7 +193,7 @@ async def _execute_with_timeout(cls, coder, command_string, timeout, use_pty=Fal from cecli.helpers.background_commands import CircularBuffer - coder.io.tool_output(f"⚙️ Executing shell command with {timeout}s timeout.") + coder.io.tool_output(f"⛭ Executing shell command with {timeout}s timeout.") shell = os.environ.get("SHELL", "/bin/sh") @@ -322,7 +322,7 @@ async def _execute_foreground(cls, coder, command_string): tui = coder.tui() should_print = False - coder.io.tool_output("⚙️ Executing shell command.") + coder.io.tool_output("⛭ Executing shell command.") # Use run_cmd_subprocess for non-interactive execution exit_status, combined_output = run_cmd_subprocess( diff --git a/cecli/tools/command_interactive.py b/cecli/tools/command_interactive.py index 1c591e51a37..4d5fe7a0262 100644 --- a/cecli/tools/command_interactive.py +++ b/cecli/tools/command_interactive.py @@ -56,7 +56,7 @@ async def execute(cls, coder, command_string, **kwargs): coder.io.tool_output(f"Skipped execution of shell command: {command_string}") return "Shell command execution skipped by user." - coder.io.tool_output(f"⚙️ Starting interactive shell command: {command_string}") + coder.io.tool_output(f"⛭ Starting interactive shell command: {command_string}") tui = coder.tui() if coder.tui else None diff --git a/cecli/tools/context_manager.py b/cecli/tools/context_manager.py index e426a46714d..4149c6bb034 100644 --- a/cecli/tools/context_manager.py +++ b/cecli/tools/context_manager.py @@ -94,7 +94,7 @@ def execute( "You must specify at least one of: remove, editable, view, create, or stop" ) - coder.io.tool_output("⚙️ Modifying Context.") + coder.io.tool_output("⛭ Modifying Context.") messages = [] for f in create_files: @@ -169,7 +169,7 @@ def _remove(cls, coder, file_path): removed = True if not removed: - coder.io.tool_output(f"⚠️ File '{file_path}' not in context") + coder.io.tool_output(f"⚠ File '{file_path}' not in context") return f"File not in context: {file_path}" coder.recently_removed[rel_path] = {"removed_at": time.time()} @@ -178,7 +178,7 @@ def _remove(cls, coder, file_path): ConversationService.get_chunks(coder).defer_removal(abs_path) ConversationService.get_chunks(coder).defer_removal(rel_path) - coder.io.tool_output(f"🗑️ Removed '{file_path}' from context") + coder.io.tool_output(f"✗ Removed '{file_path}' from context") return ( f"Removed: {file_path}\n" "Old file contents may remain visible. This is an acceptable system behavior." @@ -195,7 +195,7 @@ def _stop_command(cls, coder, command_key): command_key ) if success: - coder.io.tool_output(f"🛑 Stopped background command '{command_key}'") + coder.io.tool_output(f"✗ Stopped background command '{command_key}'") return ( f"Background command stopped: {command_key}\n" f"Exit code: {exit_code}\n" @@ -203,7 +203,7 @@ def _stop_command(cls, coder, command_key): ) else: coder.io.tool_output( - f"⚠️ Background command '{command_key}' not found or not running" + f"⚠ Background command '{command_key}' not found or not running" ) return f"Command not found or not running: {command_key}" except Exception as e: @@ -216,10 +216,10 @@ def _editable(cls, coder, file_path): try: abs_path = cls._resolve_file_path(coder, file_path) if abs_path in coder.abs_fnames: - coder.io.tool_output(f"📝 File '{file_path}' is already editable") + coder.io.tool_output(f"🗀 File '{file_path}' is already editable") return f"Already editable: {file_path}" if not os.path.isfile(abs_path): - coder.io.tool_output(f"⚠️ File '{file_path}' not found on disk") + coder.io.tool_output(f"⚠ File '{file_path}' not found on disk") return f"File not found: {file_path}" was_read_only = False if abs_path in coder.abs_read_only_fnames: @@ -227,10 +227,10 @@ def _editable(cls, coder, file_path): was_read_only = True coder.abs_fnames.add(abs_path) if was_read_only: - coder.io.tool_output(f"📝 Moved '{file_path}' from read-only to editable") + coder.io.tool_output(f"🗀 Moved '{file_path}' from read-only to editable") return f"Made editable (moved): {file_path}" else: - coder.io.tool_output(f"📝 Added '{file_path}' directly to editable context") + coder.io.tool_output(f"🗀 Added '{file_path}' directly to editable context") return f"Made editable (added): {file_path}" except Exception as e: coder.io.tool_error(f"Error making editable '{file_path}': {str(e)}") @@ -254,7 +254,7 @@ def _create(cls, coder, file_path): # Check if file already exists if os.path.exists(abs_path): - coder.io.tool_output(f"⚠️ File '{file_path}' already exists") + coder.io.tool_output(f"⚠ File '{file_path}' already exists") return f"File already exists: {file_path}" # Create parent directories if they don't exist @@ -267,7 +267,7 @@ def _create(cls, coder, file_path): # Add the file to editable context coder.abs_fnames.add(abs_path) - coder.io.tool_output(f"📝 Created '{file_path}' and made it editable") + coder.io.tool_output(f"🗀 Created '{file_path}' and made it editable") return f"Created and made editable: {file_path}" except Exception as e: diff --git a/cecli/tools/delegate.py b/cecli/tools/delegate.py index 244fe0e44d9..d4b55c80a58 100644 --- a/cecli/tools/delegate.py +++ b/cecli/tools/delegate.py @@ -90,9 +90,9 @@ async def _spawn_one(name: str, prompt: str) -> tuple[str, str]: lines = [] for name, result in started_agents: if result.startswith("failed:"): - lines.append(f"❌ **{name}**: {result}") + lines.append(f"✗ **{name}**: {result}") else: - lines.append(f"✅ **{name}** agent started with id `{result}`") + lines.append(f"✓ **{name}** agent started with id `{result}`") n_total = len(started_agents) n_ok = sum(1 for _, r in started_agents if not r.startswith("failed:")) diff --git a/cecli/tools/explore_code.py b/cecli/tools/explore_code.py index 685189fadda..8adcf61767d 100644 --- a/cecli/tools/explore_code.py +++ b/cecli/tools/explore_code.py @@ -180,7 +180,7 @@ def execute(cls, coder, queries, **kwargs): for failed_msg in all_failed_queries: coder.io.tool_error(failed_msg) else: - coder.io.tool_output("✅ All queries successful.") + coder.io.tool_output("✓ All queries successful.") return "\n\n" + "=" * 40 + "\n\n".join(all_results) diff --git a/cecli/tools/grep.py b/cecli/tools/grep.py index cf709995b6c..3a1f49c5c5f 100644 --- a/cecli/tools/grep.py +++ b/cecli/tools/grep.py @@ -166,7 +166,7 @@ def execute( cmd_args.extend(["--", pattern, str(search_dir_path)]) command_string = oslex.join(cmd_args) - coder.io.tool_output(f"⚙️ Executing {tool_name}: {command_string}") + coder.io.tool_output(f"⛭ Executing {tool_name}: {command_string}") exit_status, combined_output = run_cmd_subprocess( command_string, @@ -218,7 +218,7 @@ def execute( ) # Subtracting for the markdown block markers if match_count < 0: match_count = 0 - ui_summaries.append(f"✅ Matches found for '{pattern}'.") + ui_summaries.append(f"✓ Matches found for '{pattern}'.") ui_message = "\n\n".join(ui_summaries) coder.io.tool_output(ui_message) diff --git a/cecli/tools/ls.py b/cecli/tools/ls.py index 0f6905ade8a..50112f817b1 100644 --- a/cecli/tools/ls.py +++ b/cecli/tools/ls.py @@ -55,7 +55,7 @@ def execute(cls, coder, path=None, **kwargs): # Check if path exists if not os.path.exists(abs_path): - coder.io.tool_output(f"⚠️ Path '{dir_path}' not found") + coder.io.tool_output(f"⚠ Path '{dir_path}' not found") return "Directory not found" # Get directory contents diff --git a/cecli/tools/read_range.py b/cecli/tools/read_range.py index 195544689ec..d908830448f 100644 --- a/cecli/tools/read_range.py +++ b/cecli/tools/read_range.py @@ -38,7 +38,7 @@ class Tool(BaseTool): " as range_start and range_end values." " Do not use the same pattern for the range_start and range_end." " Do not use empty strings for the range_start and range_end." - " Use this tool instead of cli tools for reading file contents." + " Always use the ReadRange tool instead of cli tools for reading file contents." " Line number and special marker ranges greater than 200 lines will return" " preview content for further, more scoped investigation." " Call this tool sequentially on increasingly finer grained searches " @@ -589,7 +589,7 @@ def _is_valid_int(s): if already_up_to_details or new_context_details: if new_context_details: coder.io.tool_output( - f"✅ Retrieved context for {len(new_context_details)} operation(s)" + f"✓ Retrieved context for {len(new_context_details)} operation(s)" ) detail_str = "\n".join(new_context_details) diff --git a/cecli/tools/undo_change.py b/cecli/tools/undo_change.py index 7ac09d2f863..bed61f32d49 100644 --- a/cecli/tools/undo_change.py +++ b/cecli/tools/undo_change.py @@ -69,7 +69,7 @@ def execute(cls, coder, change_id=None, file_path=None, **kwargs): ) # Track that the file was modified by the undo change_type = change_info["type"] - coder.io.tool_output(f"✅ Undid {change_type} change '{change_id}' in {file_path}") + coder.io.tool_output(f"✓ Undid {change_type} change '{change_id}' in {file_path}") return f"Successfully undid {change_type} change '{change_id}'." else: # This case should ideally not be reached if tracker returns success diff --git a/cecli/tools/update_todo_list.py b/cecli/tools/update_todo_list.py index e7864bc0ad4..0a528f88d2a 100644 --- a/cecli/tools/update_todo_list.py +++ b/cecli/tools/update_todo_list.py @@ -121,7 +121,7 @@ def execute(cls, coder, tasks, append=False, change_id=None, dry_run=False, **kw # Check if content exceeds 4096 characters and warn if len(new_content) > 4096: coder.io.tool_warning( - "⚠️ Todo list content exceeds 4096 characters. Consider summarizing the plan" + "⚠ Todo list content exceeds 4096 characters. Consider summarizing the plan" " before proceeding." ) diff --git a/cecli/tools/utils/helpers.py b/cecli/tools/utils/helpers.py index c46b3aee8cf..e97bc28e204 100644 --- a/cecli/tools/utils/helpers.py +++ b/cecli/tools/utils/helpers.py @@ -328,7 +328,7 @@ def format_tool_result( return full_message else: # Use the provided success message, potentially adding change_id and diff - full_message = f"✅ {success_message}" + full_message = f"✓ {success_message}" if change_id: full_message += f" (change_id: {change_id})" coder.io.tool_output(full_message) # Log the success action From 16ccbfc14521be63780b90e79712790de9592edd Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 11 Jun 2026 18:31:16 -0400 Subject: [PATCH 27/47] Change spacing of tool results in TUI --- cecli/tools/command.py | 15 ++++++++------ cecli/tools/command_interactive.py | 22 +++++++++++++------- cecli/tools/context_manager.py | 33 ++++++++++++++++++++---------- cecli/tools/explore_code.py | 2 +- cecli/tools/grep.py | 12 ++++++----- cecli/tools/ls.py | 8 +++++--- cecli/tools/read_range.py | 14 +++++++++---- cecli/tools/thinking.py | 2 +- cecli/tools/undo_change.py | 4 +++- cecli/tui/io.py | 3 ++- 10 files changed, 75 insertions(+), 40 deletions(-) diff --git a/cecli/tools/command.py b/cecli/tools/command.py index a9448e91572..6be67f1a322 100644 --- a/cecli/tools/command.py +++ b/cecli/tools/command.py @@ -161,7 +161,7 @@ async def _execute_background(cls, coder, command_string, use_pty=False): """ Execute command in background. """ - coder.io.tool_output(f"⛭ Starting background command: {command_string}") + coder.io.tool_output(f"⛭ Starting background command: {command_string}", type="tool-result") # Use static manager to start background command command_key = BackgroundCommandManager.start_background_command( @@ -193,7 +193,9 @@ async def _execute_with_timeout(cls, coder, command_string, timeout, use_pty=Fal from cecli.helpers.background_commands import CircularBuffer - coder.io.tool_output(f"⛭ Executing shell command with {timeout}s timeout.") + coder.io.tool_output( + f"⛭ Executing shell command with {timeout}s timeout.", type="tool-result" + ) shell = os.environ.get("SHELL", "/bin/sh") @@ -278,7 +280,7 @@ async def _execute_with_timeout(cls, coder, command_string, timeout, use_pty=Fal # Output to TUI console if TUI exists (same logic as _execute_foreground) if coder.tui and coder.tui(): - coder.io.tool_output(output_content) + coder.io.tool_output(output_content, type="tool-result") if exit_code == 0: return ( @@ -296,7 +298,8 @@ async def _execute_with_timeout(cls, coder, command_string, timeout, use_pty=Fal if elapsed >= timeout: # Timeout elapsed, process continues in background coder.io.tool_output( - f"⏱️ Command exceeded {timeout}s timeout, continuing in background..." + f"⏱️ Command exceeded {timeout}s timeout, continuing in background...", + type="tool-result", ) # Get any output captured so far @@ -322,7 +325,7 @@ async def _execute_foreground(cls, coder, command_string): tui = coder.tui() should_print = False - coder.io.tool_output("⛭ Executing shell command.") + coder.io.tool_output("⛭ Executing shell command.", type="tool-result") # Use run_cmd_subprocess for non-interactive execution exit_status, combined_output = run_cmd_subprocess( @@ -360,7 +363,7 @@ async def _execute_foreground(cls, coder, command_string): ) if tui: - coder.io.tool_output(output_content) + coder.io.tool_output(output_content, type="tool-result") if exit_status == 0: return f"Shell command executed successfully (exit code 0). Output:\n{output_content}" diff --git a/cecli/tools/command_interactive.py b/cecli/tools/command_interactive.py index 4d5fe7a0262..7972041577e 100644 --- a/cecli/tools/command_interactive.py +++ b/cecli/tools/command_interactive.py @@ -53,10 +53,14 @@ async def execute(cls, coder, command_string, **kwargs): if not confirmed: # This happens if the user explicitly says 'no' this time. # If 'Always' was chosen previously, confirm_ask returns True directly. - coder.io.tool_output(f"Skipped execution of shell command: {command_string}") + coder.io.tool_output( + f"Skipped execution of shell command: {command_string}", type="tool-result" + ) return "Shell command execution skipped by user." - coder.io.tool_output(f"⛭ Starting interactive shell command: {command_string}") + coder.io.tool_output( + f"⛭ Starting interactive shell command: {command_string}", type="tool-result" + ) tui = coder.tui() if coder.tui else None @@ -71,19 +75,23 @@ def _run_interactive(): if tui: # Notify user and suspend TUI for interactive command - coder.io.tool_output(">>> Suspending TUI for interactive command <<<") + coder.io.tool_output( + ">>> Suspending TUI for interactive command <<<", type="tool-result" + ) exit_status, combined_output = tui.run_obstructive(_run_interactive) else: - coder.io.tool_output(">>> You may need to interact with the command below <<<") + coder.io.tool_output( + ">>> You may need to interact with the command below <<<", type="tool-result" + ) coder.io.tool_output(" \n") await coder.io.stop_input_task() await asyncio.sleep(1) exit_status, combined_output = _run_interactive() await asyncio.sleep(1) - coder.io.tool_output(" \n") - coder.io.tool_output(" \n") + coder.io.tool_output(" \n", type="tool-result") + coder.io.tool_output(" \n", type="tool-result") - coder.io.tool_output(">>> Interactive command finished <<<") + coder.io.tool_output(">>> Interactive command finished <<<", type="tool-result") # Format the output for the result message, include more content output_content = combined_output or "" diff --git a/cecli/tools/context_manager.py b/cecli/tools/context_manager.py index 4149c6bb034..c5883f97e16 100644 --- a/cecli/tools/context_manager.py +++ b/cecli/tools/context_manager.py @@ -94,7 +94,7 @@ def execute( "You must specify at least one of: remove, editable, view, create, or stop" ) - coder.io.tool_output("⛭ Modifying Context.") + coder.io.tool_output("⛭ Modifying Context", type="tool-result") messages = [] for f in create_files: @@ -169,7 +169,7 @@ def _remove(cls, coder, file_path): removed = True if not removed: - coder.io.tool_output(f"⚠ File '{file_path}' not in context") + coder.io.tool_output(f"⚠ File '{file_path}' not in context", type="tool-result") return f"File not in context: {file_path}" coder.recently_removed[rel_path] = {"removed_at": time.time()} @@ -178,7 +178,7 @@ def _remove(cls, coder, file_path): ConversationService.get_chunks(coder).defer_removal(abs_path) ConversationService.get_chunks(coder).defer_removal(rel_path) - coder.io.tool_output(f"✗ Removed '{file_path}' from context") + coder.io.tool_output(f"✗ Removed '{file_path}' from context", type="tool-result") return ( f"Removed: {file_path}\n" "Old file contents may remain visible. This is an acceptable system behavior." @@ -195,7 +195,9 @@ def _stop_command(cls, coder, command_key): command_key ) if success: - coder.io.tool_output(f"✗ Stopped background command '{command_key}'") + coder.io.tool_output( + f"✗ Stopped background command '{command_key}'", type="tool-result" + ) return ( f"Background command stopped: {command_key}\n" f"Exit code: {exit_code}\n" @@ -203,7 +205,8 @@ def _stop_command(cls, coder, command_key): ) else: coder.io.tool_output( - f"⚠ Background command '{command_key}' not found or not running" + f"⚠ Background command '{command_key}' not found or not running", + type="tool-result", ) return f"Command not found or not running: {command_key}" except Exception as e: @@ -216,10 +219,12 @@ def _editable(cls, coder, file_path): try: abs_path = cls._resolve_file_path(coder, file_path) if abs_path in coder.abs_fnames: - coder.io.tool_output(f"🗀 File '{file_path}' is already editable") + coder.io.tool_output( + f"🗀 File '{file_path}' is already editable", type="tool-result" + ) return f"Already editable: {file_path}" if not os.path.isfile(abs_path): - coder.io.tool_output(f"⚠ File '{file_path}' not found on disk") + coder.io.tool_output(f"⚠ File '{file_path}' not found on disk", type="tool-result") return f"File not found: {file_path}" was_read_only = False if abs_path in coder.abs_read_only_fnames: @@ -227,10 +232,14 @@ def _editable(cls, coder, file_path): was_read_only = True coder.abs_fnames.add(abs_path) if was_read_only: - coder.io.tool_output(f"🗀 Moved '{file_path}' from read-only to editable") + coder.io.tool_output( + f"🗀 Moved '{file_path}' from read-only to editable", type="tool-result" + ) return f"Made editable (moved): {file_path}" else: - coder.io.tool_output(f"🗀 Added '{file_path}' directly to editable context") + coder.io.tool_output( + f"🗀 Added '{file_path}' directly to editable context", type="tool-result" + ) return f"Made editable (added): {file_path}" except Exception as e: coder.io.tool_error(f"Error making editable '{file_path}': {str(e)}") @@ -254,7 +263,7 @@ def _create(cls, coder, file_path): # Check if file already exists if os.path.exists(abs_path): - coder.io.tool_output(f"⚠ File '{file_path}' already exists") + coder.io.tool_output(f"⚠ File '{file_path}' already exists", type="tool-result") return f"File already exists: {file_path}" # Create parent directories if they don't exist @@ -267,7 +276,9 @@ def _create(cls, coder, file_path): # Add the file to editable context coder.abs_fnames.add(abs_path) - coder.io.tool_output(f"🗀 Created '{file_path}' and made it editable") + coder.io.tool_output( + f"🗀 Created '{file_path}' and made it editable", type="tool-result" + ) return f"Created and made editable: {file_path}" except Exception as e: diff --git a/cecli/tools/explore_code.py b/cecli/tools/explore_code.py index 8adcf61767d..8f7c8b734a1 100644 --- a/cecli/tools/explore_code.py +++ b/cecli/tools/explore_code.py @@ -180,7 +180,7 @@ def execute(cls, coder, queries, **kwargs): for failed_msg in all_failed_queries: coder.io.tool_error(failed_msg) else: - coder.io.tool_output("✓ All queries successful.") + coder.io.tool_output("✓ All queries successful.", type="tool-result") return "\n\n" + "=" * 40 + "\n\n".join(all_results) diff --git a/cecli/tools/grep.py b/cecli/tools/grep.py index 3a1f49c5c5f..78d618ea1b9 100644 --- a/cecli/tools/grep.py +++ b/cecli/tools/grep.py @@ -166,7 +166,9 @@ def execute( cmd_args.extend(["--", pattern, str(search_dir_path)]) command_string = oslex.join(cmd_args) - coder.io.tool_output(f"⛭ Executing {tool_name}: {command_string}") + coder.io.tool_output( + f"⛭ Executing {tool_name}: {command_string}", type="tool-result" + ) exit_status, combined_output = run_cmd_subprocess( command_string, @@ -207,9 +209,9 @@ def execute( for search_op, result in zip(searches, all_results): pattern = search_op.get("pattern") if "No matches found" in result: - ui_summaries.append(f"No matches found for '{pattern}'.") + ui_summaries.append(f"✗ No matches found for '{pattern}'.") elif "Error" in result: - ui_summaries.append(f"Error searching for '{pattern}'.") + ui_summaries.append(f"✗ Error searching for '{pattern}'.") else: # Count lines in the output to give a sense of scale # The result string contains the matches in a code block @@ -220,8 +222,8 @@ def execute( match_count = 0 ui_summaries.append(f"✓ Matches found for '{pattern}'.") - ui_message = "\n\n".join(ui_summaries) - coder.io.tool_output(ui_message) + ui_message = "\n".join(ui_summaries) + coder.io.tool_output(ui_message, type="tool-result") return final_message diff --git a/cecli/tools/ls.py b/cecli/tools/ls.py index 50112f817b1..89c5b791b5f 100644 --- a/cecli/tools/ls.py +++ b/cecli/tools/ls.py @@ -55,7 +55,7 @@ def execute(cls, coder, path=None, **kwargs): # Check if path exists if not os.path.exists(abs_path): - coder.io.tool_output(f"⚠ Path '{dir_path}' not found") + coder.io.tool_output(f"⚠ Path '{dir_path}' not found", type="tool-result") return "Directory not found" # Get directory contents @@ -76,7 +76,9 @@ def execute(cls, coder, path=None, **kwargs): contents.append(os.path.relpath(abs_path, coder.root)) if contents: - coder.io.tool_output(f"📋 Listed {len(contents)} file(s) in '{dir_path}'") + coder.io.tool_output( + f"📋 Listed {len(contents)} file(s) in '{dir_path}'", type="tool-result" + ) sorted_contents = sorted(contents) if len(sorted_contents) > 10: return ( @@ -85,7 +87,7 @@ def execute(cls, coder, path=None, **kwargs): else: return f"Found {len(sorted_contents)} files: {', '.join(sorted_contents)}" else: - coder.io.tool_output(f"📋 No files found in '{dir_path}'") + coder.io.tool_output(f"📋 No files found in '{dir_path}'", type="tool-result") return "No files found in directory" except Exception as e: coder.io.tool_error(f"Error in ls: {str(e)}") diff --git a/cecli/tools/read_range.py b/cecli/tools/read_range.py index d908830448f..708f634a6a8 100644 --- a/cecli/tools/read_range.py +++ b/cecli/tools/read_range.py @@ -589,7 +589,8 @@ def _is_valid_int(s): if already_up_to_details or new_context_details: if new_context_details: coder.io.tool_output( - f"✓ Retrieved context for {len(new_context_details)} operation(s)" + f"✓ Retrieved context for {len(new_context_details)} operation(s)", + type="tool-result", ) detail_str = "\n".join(new_context_details) @@ -600,8 +601,11 @@ def _is_valid_int(s): ) if already_up_to_details: coder.io.tool_output( - "Lines already up to date in context for" - f" {len(already_up_to_details)} operation(s)" + ( + "Lines already up to date in context for" + f" {len(already_up_to_details)} operation(s)" + ), + type="tool-result", ) detail_str = "\n".join(already_up_to_details) @@ -622,7 +626,9 @@ def _is_valid_int(s): result_parts.append("\nUse these outlines to refine your search.\n") if error_outputs: - coder.io.tool_error(f"Errors encountered for {len(error_outputs)} operation(s)") + coder.io.tool_error( + f"Errors encountered for {len(error_outputs)} operation(s)", type="tool-result" + ) result_parts.append("Errors:\n" + "\n".join(error_outputs)) diff --git a/cecli/tools/thinking.py b/cecli/tools/thinking.py index a52d6e735e5..3cedc9eae6f 100644 --- a/cecli/tools/thinking.py +++ b/cecli/tools/thinking.py @@ -34,7 +34,7 @@ def execute(cls, coder, content, **kwargs): A place to allow the model to record freeform text as it iterates over tools to ideally help it guide itself to a proper solution """ - coder.io.tool_output("🧠 Thoughts recorded in context") + coder.io.tool_output("🧠 Thoughts recorded in context", type="tool-result") return "🧠 Thoughts recorded in context. Please proceed with your task" @classmethod diff --git a/cecli/tools/undo_change.py b/cecli/tools/undo_change.py index bed61f32d49..67a4596362b 100644 --- a/cecli/tools/undo_change.py +++ b/cecli/tools/undo_change.py @@ -69,7 +69,9 @@ def execute(cls, coder, change_id=None, file_path=None, **kwargs): ) # Track that the file was modified by the undo change_type = change_info["type"] - coder.io.tool_output(f"✓ Undid {change_type} change '{change_id}' in {file_path}") + coder.io.tool_output( + f"✓ Undid {change_type} change '{change_id}' in {file_path}", type="tool-result" + ) return f"Successfully undid {change_type} change '{change_id}'." else: # This case should ideally not be reached if tracker returns success diff --git a/cecli/tui/io.py b/cecli/tui/io.py index 8b8816ccb2c..93409419cc0 100644 --- a/cecli/tui/io.py +++ b/cecli/tui/io.py @@ -314,7 +314,8 @@ def _reroute_output(self, text, msg_type, **kwargs): # Check if this is a tool result (comes right after tool call) if self._expect_tool_result and text.strip(): - self._expect_tool_result = False + if msg_type != "tool-result": + self._expect_tool_result = False msg = { "type": "tool_result", "text": text, From 2499118536ad09c28d31691ab121b60e7968677d Mon Sep 17 00:00:00 2001 From: Jessica Mulein Date: Thu, 11 Jun 2026 15:34:11 -0700 Subject: [PATCH 28/47] fix(coders): initialize reflected_message in __init__ to prevent AttributeError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reflected_message attribute was only initialized in init_before_message(), which is called from the normal run() loop. Code paths that bypass run() — such as run_one_shot() used by spec generation, or run_message() used by headless agent sessions — could trigger check_for_file_mentions() before init_before_message() was ever called, raising: AttributeError: 'EditBlockCoder' object has no attribute 'reflected_message' This occurred in production when a local model (qwen3.6:35b) emitted a [ContextManager] file reference during a spec-gen explore turn, causing check_for_file_mentions to access self.reflected_message on line 2541. Fix: set self.reflected_message = None in Coder.__init__() so the attribute always exists regardless of entry point. --- cecli/coders/base_coder.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index c28dc866cc6..45b87ce924c 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -422,6 +422,7 @@ def __init__( self.registered_servers = {"included": set(), "excluded": set()} self.interrupt_event = asyncio.Event() self.uuid = str(generate_unique_id()) + self.reflected_message = None if uuid: self.uuid = str(uuid) From 33db318e12d5628885063895d94755dbba7fad39 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 11 Jun 2026 23:12:21 -0400 Subject: [PATCH 29/47] Continue updating interface --- cecli/coders/agent_coder.py | 21 +++++++++++++++------ cecli/helpers/conversation/integration.py | 2 +- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index b6b2a42ba0c..d5358fb8a77 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -1265,16 +1265,20 @@ def _add_file_to_context(self, file_path, explicit=False): abs_path = self.abs_root_path(file_path) rel_path = self.get_rel_fname(abs_path) if not os.path.isfile(abs_path): - self.io.tool_output(f"⚠ File '{file_path}' not found") + self.io.tool_output(f"⚠ File '{file_path}' not found", type="tool-result") return "File not found" if abs_path in self.abs_fnames: if explicit: - self.io.tool_output(f"📎 File '{file_path}' already in context as editable") + self.io.tool_output( + f"🗀 File '{file_path}' already in context as editable", type="tool-result" + ) return "File already in context as editable" return "File already in context as editable" if abs_path in self.abs_read_only_fnames: if explicit: - self.io.tool_output(f"📎 File '{file_path}' already in context as read-only") + self.io.tool_output( + f"🗀 File '{file_path}' already in context as read-only", type="tool-result" + ) return "File already in context as read-only" return "File already in context as read-only" try: @@ -1285,13 +1289,18 @@ def _add_file_to_context(self, file_path, explicit=False): file_tokens = self.get_active_model().token_count(content) if file_tokens > self.large_file_token_threshold: self.io.tool_output( - f"⚠ '{file_path}' is very large ({file_tokens} tokens). Use" - " /context-management to toggle truncation off if needed." + ( + f"⚠ '{file_path}' is very large ({file_tokens} tokens). Use" + " /context-management to toggle truncation off if needed." + ), + type="tool-result", ) self.abs_read_only_fnames.add(abs_path) self.files_added_in_exploration.add(rel_path) if explicit: - self.io.tool_output(f"📎 Viewed '{file_path}' (added to context as read-only)") + self.io.tool_output( + f"🗀 Viewed '{file_path}' (added to context as read-only)", type="tool-result" + ) return "Viewed file (added to context as read-only)" else: return "Added file to context as read-only" diff --git a/cecli/helpers/conversation/integration.py b/cecli/helpers/conversation/integration.py index 5e59b7dc6f5..801afc95e4f 100644 --- a/cecli/helpers/conversation/integration.py +++ b/cecli/helpers/conversation/integration.py @@ -179,7 +179,7 @@ def add_randomized_cta(self) -> None: ), ( "Continue making progress. If you have reached the goal, summarize the results." - " Otherwise, call the next necessary tool." + " Otherwise, call the next necessary tools." ), ( "Please use the proper tools to fulfill the next steps of this task based on" From 40eb044821b0092f18ec487273c3c143da175946 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 11 Jun 2026 23:31:41 -0400 Subject: [PATCH 30/47] Use file_glob instead of file pattern so models can disambiguate --- cecli/tools/grep.py | 6 +++--- tests/tools/test_grep.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cecli/tools/grep.py b/cecli/tools/grep.py index 78d618ea1b9..f740c89a27f 100644 --- a/cecli/tools/grep.py +++ b/cecli/tools/grep.py @@ -34,7 +34,7 @@ class Tool(BaseTool): "type": "string", "description": "The pattern to search for.", }, - "file_pattern": { + "file_glob": { "type": "string", "default": "*", "description": "Glob pattern for files to search.", @@ -105,7 +105,7 @@ def execute( all_results = [] for search_op in searches: pattern = strip_hashline(search_op.get("pattern")) - file_pattern = search_op.get("file_pattern", "*") + file_pattern = search_op.get("file_glob", "*") directory = search_op.get("directory", search_op.get("path", ".")) use_regex = search_op.get("use_regex", True) case_insensitive = search_op.get("case_insensitive", True) @@ -249,7 +249,7 @@ def format_output(cls, coder, mcp_server, tool_response): coder.io.tool_output("") for i, search_op in enumerate(searches): pattern = search_op.get("pattern", "") - file_pattern = search_op.get("file_pattern", "*") + file_pattern = search_op.get("file_glob", "*") directory = search_op.get("directory", ".") use_regex = search_op.get("use_regex", False) case_insensitive = search_op.get("case_insensitive", False) diff --git a/tests/tools/test_grep.py b/tests/tools/test_grep.py index f6d8278a4b8..82bfb3cb3cd 100644 --- a/tests/tools/test_grep.py +++ b/tests/tools/test_grep.py @@ -41,7 +41,7 @@ def test_dash_prefixed_pattern_is_searched_literally(search_term, tmp_path, monk searches=[ { "pattern": search_term, - "file_pattern": "*.txt", + "file_glob": "*.txt", "directory": ".", "use_regex": False, "case_insensitive": False, From f1a0c9be460b4dfcc31ac06c9886daedc999b7aa Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 12 Jun 2026 00:32:31 -0400 Subject: [PATCH 31/47] Don't duplicate reasoning content potentially --- cecli/helpers/requests.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cecli/helpers/requests.py b/cecli/helpers/requests.py index 9c10099240b..c4954a3270b 100644 --- a/cecli/helpers/requests.py +++ b/cecli/helpers/requests.py @@ -13,6 +13,8 @@ def add_reasoning_content(messages): for msg in messages: if msg.get("role") == "assistant" and "reasoning_content" not in msg: msg["reasoning_content"] = "" + if msg.get("provider_specific_fields", None): + msg["provider_specific_fields"].pop("reasoning_content", None) return messages From 616afbab7fd28bc0e2d4f08b360f463302937ee6 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 12 Jun 2026 01:08:29 -0400 Subject: [PATCH 32/47] Remove "type" from stream print --- cecli/io.py | 1 + cecli/tui/io.py | 1 + 2 files changed, 2 insertions(+) diff --git a/cecli/io.py b/cecli/io.py index 8bf7a3c657e..4a703eaa22f 100644 --- a/cecli/io.py +++ b/cecli/io.py @@ -1674,6 +1674,7 @@ def reset_streaming_response(self, **kwargs): def stream_print(self, *messages, **kwargs): kwargs.pop("coder_uuid", None) + kwargs.pop("type", None) with self.console.capture() as capture: self.console.print(*messages, **kwargs) diff --git a/cecli/tui/io.py b/cecli/tui/io.py index 93409419cc0..bdc47056a51 100644 --- a/cecli/tui/io.py +++ b/cecli/tui/io.py @@ -134,6 +134,7 @@ def stream_print(self, *messages, **kwargs): """ # Pop coder_uuid from kwargs before passing to console coder_uuid = kwargs.pop("coder_uuid", None) + coder_uuid = kwargs.pop("type", None) # Capture Rich rendering with forced ANSI output console = self._get_tui_console() From daa8efd5fdd230ec3becdfac46a49348da7cdc64 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 12 Jun 2026 01:38:40 -0400 Subject: [PATCH 33/47] Update system prompts, don't use context summary by default --- cecli/coders/agent_coder.py | 2 +- cecli/prompts/agent.yml | 35 +++++++++++++++++------------------ cecli/prompts/subagent.yml | 37 ++++++++++++++++++------------------- 3 files changed, 36 insertions(+), 38 deletions(-) diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index d5358fb8a77..79ca987f4ef 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -174,7 +174,7 @@ def _get_agent_config(self): config, "include_context_blocks", { - "context_summary", + # "context_summary", # "directory_structure", "environment_info", # "git_status", diff --git a/cecli/prompts/agent.yml b/cecli/prompts/agent.yml index 71a3477377e..df0208676a4 100644 --- a/cecli/prompts/agent.yml +++ b/cecli/prompts/agent.yml @@ -19,28 +19,25 @@ repo_content_prefix: | These files should be helpful for navigating the codebase. main_system: | - ## Core Directives **Act Proactively**: Autonomously use tools to fulfill the request. **Be Decisive**: Do not repeat searches or ask redundant questions. Trust your findings and be confident in your edits. **Be Efficient**: Use multiple tools each response when exploring. Batch tool calls when the schema allows you to. Respect usage limits while maximizing the utility of each response. **Be Persistent**: Do not take short cuts. Work through your task until completion. No task takes too long as long as you are making progress towards the goal. - - - ### 1. FILE FORMAT + ## FILE FORMAT File contents will be prefixed with identifiers. Each line starts with a case-sensitive content ID followed by `::`. These are used to target where editing tools will perform edits. They are algorithmically generated, maintained, and subject to change. Do not search for these content IDs. Focus on the lines they identify. - **Example File Format :** + **Example File** + ``` il9n::#!/usr/bin/env python3 faoZ:: uXdn::def example_method(): WAR5:: return "example" vwkS:: - + ``` - ## Core Workflow 1. **Plan**: Start by using `UpdateTodoList` to outline the task. 2. **Explore**: Use discovery tools (`ExploreCode`, `Grep`, `Ls`) to research and gather understanding for you task. Modify search terms when errors are encountered. @@ -52,25 +49,27 @@ main_system: | - Break complex goals into meaningful sub-tasks so the problem remains tractable - Use `UpdateTodoList` to keep the state synchronized as you complete subtasks. - **Atomic Scope:** Include the **entire function or logical block** in edits. Never return partial syntax or broken closures. Do not attempt to replace just the beginning or end of a closure. - **Indentation**: Preserve all necessary whitespace (spaces, tabs, and newlines) as well as stylistic indentation and line spacings. - - - Use the `.cecli/temp` directory for all temporary, test, or scratch files. - Always reply in {language}. - -system_reminder: | - ## Operational Rules - **Scope**: No unrequested refactors. Avoid full-file rewrites. Only modify what you are asked to. - **Hygiene**: Use `ContextManager`/`RemoveSkill` to evict unneeded files/skills immediately after use. - **Outputs**: Tool calls trigger turns. Never include tool syntax in final user summaries. - **Sandbox**: Perform all verification and temp logic in `.cecli/temp`. - - **Responses**: Reason out loud through the problem but be brief. + - **Responses**: Reason out loud through the problem but be brief. + - **Edits:** Include the **entire function or logical block** in edits. Never return partial syntax or broken closures. Do not attempt to replace just the beginning or end of a closure. + - **Indentation**: Preserve all necessary whitespace (spaces, tabs, and newlines) as well as stylistic indentation and line spacings. + - **Finishing Up**: Be detailed in your `Yield` tool summary in describing your task, findings, efforts and results. + + Always reply in {language}. + +system_reminder: | + + - Prefer the `ReadRange` tool to cli commands for file reading + - Pay close attention to indentation and styling when editing files + - Batch tool calls as often as possible {lazy_prompt} {shell_cmd_reminder} - + "" try_again: | My previous exploration was insufficient. I will now adjust my strategy, use more specific search patterns, and manage my context more aggressively to find the correct solution. \ No newline at end of file diff --git a/cecli/prompts/subagent.yml b/cecli/prompts/subagent.yml index 7786b0f6aa9..acda6b49fa3 100644 --- a/cecli/prompts/subagent.yml +++ b/cecli/prompts/subagent.yml @@ -4,28 +4,25 @@ _inherits: [agent, base] main_system: | - ## Core Directives **Act Proactively**: Autonomously use tools to fulfill the request. **Be Decisive**: Do not repeat searches or ask redundant questions. Trust your findings and be confident in your edits. **Be Efficient**: Use multiple tools each response when exploring. Batch tool calls when the schema allows you to. Respect usage limits while maximizing the utility of each response. **Be Persistent**: Do not take short cuts. Work through your task until completion. No task takes too long as long as you are making progress towards the goal. - - - ### 1. FILE FORMAT - File contents will be prefixed with identifiers. Each line starts with a case-sensitive content hash followed by `::`. These are used to target where editing tools will perform edits. - They are algorithmically generated, maintained, and subject to change. Do not search for these content hashes. Focus on the lines they identify. + ## FILE FORMAT + File contents will be prefixed with identifiers. Each line starts with a case-sensitive content ID followed by `::`. These are used to target where editing tools will perform edits. + They are algorithmically generated, maintained, and subject to change. Do not search for these content IDs. Focus on the lines they identify. - **Example File Format :** + **Example File** + ``` il9n::#!/usr/bin/env python3 faoZ:: uXdn::def example_method(): WAR5:: return "example" vwkS:: - + ``` - ## Core Workflow 1. **Plan**: Start by using `UpdateTodoList` to outline the task. 2. **Explore**: Use discovery tools (`ExploreCode`, `Grep`, `Ls`) to research and gather understanding for you task. Modify search terms when errors are encountered. @@ -37,21 +34,23 @@ main_system: | - Break complex goals into meaningful sub-tasks so the problem remains tractable - Use `UpdateTodoList` to keep the state synchronized as you complete subtasks. - **Atomic Scope:** Include the **entire function or logical block** in edits. Never return partial syntax or broken closures. Do not attempt to replace just the beginning or end of a closure. - **Indentation**: Preserve all necessary whitespace (spaces, tabs, and newlines) as well as stylistic indentation and line spacings. - - - Use the `.cecli/temp` directory for all temporary, test, or scratch files. - Always reply in {language}. - -system_reminder: | - ## Operational Rules - **Scope**: No unrequested refactors. Avoid full-file rewrites. Only modify what you are asked to. - **Hygiene**: Use `ContextManager`/`RemoveSkill` to evict unneeded files/skills immediately after use. - **Outputs**: Tool calls trigger turns. Never include tool syntax in final user summaries. - **Sandbox**: Perform all verification and temp logic in `.cecli/temp`. - - **Responses**: Reason out loud through the problem but be brief. + - **Responses**: Reason out loud through the problem but be brief. + - **Edits:** Include the **entire function or logical block** in edits. Never return partial syntax or broken closures. Do not attempt to replace just the beginning or end of a closure. + - **Indentation**: Preserve all necessary whitespace (spaces, tabs, and newlines) as well as stylistic indentation and line spacings. + - **Finishing Up**: Be detailed in your `Yield` tool summary in describing your task, findings, efforts and results. + + Always reply in {language}. + +system_reminder: | + + - Prefer the `ReadRange` tool to cli commands for file reading + - Pay close attention to indentation and styling when editing files + - Batch tool calls as often as possible **Finishing Up**: Be very detailed in your `Yield` tool summary in describing your task, findings, efforts and results. From 1af7c54a1b5a38d135f147612691ef76c9362093 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 12 Jun 2026 01:51:42 -0400 Subject: [PATCH 34/47] Update todo list to only inject the active current task after conversation stream --- cecli/coders/agent_coder.py | 6 +++--- cecli/tools/update_todo_list.py | 30 +++++++++++++++++++++--------- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index 79ca987f4ef..d01ddc09320 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -91,6 +91,7 @@ def __init__(self, *args, **kwargs): self.allowed_context_blocks = set() self.context_block_tokens = {} self.context_blocks_cache = {} + self.current_tasks = [] self.hot_reload_enabled = False self.tokens_calculated = False self.skip_cli_confirmations = False @@ -1428,9 +1429,8 @@ def get_todo_list(self): if content is None or not content.strip(): return None result = '\n' - result += "## Current Todo List\n\n" - result += "Below is the current todo list managed via the `UpdateTodoList` tool:\n\n" - result += f"```\n{content}\n```\n" + result += "## Current Active Tasks\n\n" + result += f"```{"\n".join(self.current_tasks)}```\n" result += "" return result except Exception as e: diff --git a/cecli/tools/update_todo_list.py b/cecli/tools/update_todo_list.py index 0a528f88d2a..812068f0c13 100644 --- a/cecli/tools/update_todo_list.py +++ b/cecli/tools/update_todo_list.py @@ -64,6 +64,7 @@ def execute(cls, coder, tasks, append=False, change_id=None, dry_run=False, **kw # Define the todo file path todo_file_path = coder.local_agent_folder("todo.txt") abs_path = coder.abs_root_path(todo_file_path) + coder.current_tasks = [] # Format tasks into string done_tasks = [] @@ -83,6 +84,7 @@ def execute(cls, coder, tasks, append=False, change_id=None, dry_run=False, **kw # Check if this is the current task if task_item.get("current", False): remaining_tasks.append(f"→ {task_item['task']}") + coder.current_tasks.append(f"- {task_item['task']}") else: remaining_tasks.append(f"○ {task_item['task']}") @@ -152,7 +154,16 @@ def execute(cls, coder, tasks, append=False, change_id=None, dry_run=False, **kw coder.io.write_text(abs_path, new_content) # Track the change - final_change_id = coder.change_tracker.track_change( + # final_change_id = coder.change_tracker.track_change( + # file_path=todo_file_path, + # change_type="updatetodolist", + # original_content=existing_content, + # new_content=new_content, + # metadata=metadata, + # change_id=change_id, + # ) + + coder.change_tracker.track_change( file_path=todo_file_path, change_type="updatetodolist", original_content=existing_content, @@ -164,14 +175,15 @@ def execute(cls, coder, tasks, append=False, change_id=None, dry_run=False, **kw coder.coder_edited_files.add(todo_file_path) # Format and return result - action = "appended to" if append else "updated" - success_message = f"Successfully {action} todo list" - return format_tool_result( - coder, - tool_name, - success_message, - change_id=final_change_id, - ) + # action = "appended to" if append else "updated" + # success_message = f"Successfully {action} todo list" + # return format_tool_result( + # coder, + # tool_name, + # success_message, + # change_id=final_change_id, + # ) + return new_content except ToolError as e: return handle_tool_error(coder, tool_name, e, add_traceback=False) From 186fbbfce6e4a8b4c45014b712714715df29b6e3 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 12 Jun 2026 01:54:54 -0400 Subject: [PATCH 35/47] Descrive the fact that diff messages have content IDs --- cecli/helpers/conversation/files.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/cecli/helpers/conversation/files.py b/cecli/helpers/conversation/files.py index 94212a29eff..cb29ccdd0a8 100644 --- a/cecli/helpers/conversation/files.py +++ b/cecli/helpers/conversation/files.py @@ -272,16 +272,19 @@ def update_file_diff(self, fname: str) -> Optional[str]: self._file_diffs[abs_fname] = diff rel_fname = fname + prefix_str = "" if coder: rel_fname = coder.get_rel_fname(fname) + prefix_str = "content ID prefixed " if getattr(coder, "hashlines") else "" # Add diff message to conversation content_hash = xxhash.xxh3_128_hexdigest(diff.encode("utf-8")) + diff_message = { "role": "user", "content": ( - f"{rel_fname} has been updated. Review this diff of the changes to" + f"{rel_fname} has been updated. Review this {prefix_str}diff of the changes to" f" ensure all modifications are appropriate:\n\n{diff}" ), } @@ -289,7 +292,7 @@ def update_file_diff(self, fname: str) -> Optional[str]: assistant_msg = { "role": "assistant", "content": ( - f"Thank you for sharing this diff of the updates to {rel_fname}." + f"Thank you for sharing this {prefix_str}diff of the updates to {rel_fname}." " I will review their contents." ), } From 17a6b508b5501b05f7d9c84a75a49ad8e7b4e6f5 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 12 Jun 2026 04:28:56 -0400 Subject: [PATCH 36/47] Update verification rules in system prompts --- cecli/prompts/agent.yml | 2 +- cecli/prompts/subagent.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cecli/prompts/agent.yml b/cecli/prompts/agent.yml index df0208676a4..14fabc4be67 100644 --- a/cecli/prompts/agent.yml +++ b/cecli/prompts/agent.yml @@ -42,7 +42,7 @@ main_system: | 1. **Plan**: Start by using `UpdateTodoList` to outline the task. 2. **Explore**: Use discovery tools (`ExploreCode`, `Grep`, `Ls`) to research and gather understanding for you task. Modify search terms when errors are encountered. 3. **Execute**: Mark files as editable with `ContextManager` before attempting edits. Proactively use skills if they are available. Review diff outputs after edit to ensure the proper changes were made. - 4. **Verify & Recover**: If an edit fails or introduces linting errors, use `UndoChange` immediately. + 4. **Verify & Recover**: If an edit fails or introduces linting errors, fix the error immediately. Use `UndoChange` if the errors are too complex to incrementally modify. 5. **Yield**: Use the `Yield` tool after accomplishing the goal and verifying any changes made. Provide helpful summaries of any changes. ## Todo List Management diff --git a/cecli/prompts/subagent.yml b/cecli/prompts/subagent.yml index acda6b49fa3..6653821d753 100644 --- a/cecli/prompts/subagent.yml +++ b/cecli/prompts/subagent.yml @@ -27,7 +27,7 @@ main_system: | 1. **Plan**: Start by using `UpdateTodoList` to outline the task. 2. **Explore**: Use discovery tools (`ExploreCode`, `Grep`, `Ls`) to research and gather understanding for you task. Modify search terms when errors are encountered. 3. **Execute**: Mark files as editable with `ContextManager` before attempting edits. Proactively use skills if they are available. Review diff outputs after edit to ensure the proper changes were made. - 4. **Verify & Recover**: If an edit fails or introduces linting errors, use `UndoChange` immediately. + 4. **Verify & Recover**: If an edit fails or introduces linting errors, fix the error immediately. Use `UndoChange` if the errors are too complex to incrementally modify. 5. **Yield**: Use the `Yield` tool after accomplishing the goal and verifying any changes made. Provide helpful summaries of any changes. ## Todo List Management From 3bf529b6d6030ec0e407ffd9415f239aac06aaaf Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 12 Jun 2026 04:29:25 -0400 Subject: [PATCH 37/47] Prevent duplicate assistant messages in message stream --- cecli/helpers/requests.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/cecli/helpers/requests.py b/cecli/helpers/requests.py index c4954a3270b..607c8f50887 100644 --- a/cecli/helpers/requests.py +++ b/cecli/helpers/requests.py @@ -164,6 +164,28 @@ def add_continue_for_no_prefill(model, messages, tools): return messages +def prevent_consecutive_assistant_messages(messages): + """Insert '(empty request)' user messages between consecutive assistant messages. + + Args: + messages: List of message dictionaries + + Returns: + List of messages with '(empty request)' inserted between consecutive + assistant messages + """ + result = [] + for i, msg in enumerate(messages): + result.append(msg) + if ( + i < len(messages) - 1 + and msg.get("role") == "assistant" + and messages[i + 1].get("role") == "assistant" + ): + result.append({"role": "user", "content": "(empty request)"}) + return result + + def model_request_parser(model, messages, tools): messages = thought_signature(model, messages) messages = remove_empty_tool_calls(messages) @@ -171,4 +193,5 @@ def model_request_parser(model, messages, tools): messages = ensure_alternating_roles(messages) messages = add_reasoning_content(messages) messages = add_continue_for_no_prefill(model, messages, tools) + messages = prevent_consecutive_assistant_messages(messages) return messages From 50bd62f0488bf76a345d196d48463d1e234b3623 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 12 Jun 2026 04:29:49 -0400 Subject: [PATCH 38/47] Force the agent to be correct with its changes instead of trying to fix them for it --- cecli/helpers/hashline.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/cecli/helpers/hashline.py b/cecli/helpers/hashline.py index 2f1af29217d..91a97e0dab0 100644 --- a/cecli/helpers/hashline.py +++ b/cecli/helpers/hashline.py @@ -1453,23 +1453,23 @@ def apply_hashline_operations( # Check for overlapping lines to prevent duplication # This handles cases where the model underspecifies the range and # the replacement text includes lines that already exist after the range - max_overlap_check = 2 # Check up to 2 lines for overlap + # max_overlap_check = 2 # Check up to 2 lines for overlap # Check for overlapping lines BEFORE the range (bidirectional stitching) - start_idx, replacement_lines = _apply_start_stitching( - hashed_lines, - start_idx, - end_idx, - replacement_lines, - resolved_ops, - resolved, - max_overlap_check, - ) + # start_idx, replacement_lines = _apply_start_stitching( + # hashed_lines, + # start_idx, + # end_idx, + # replacement_lines, + # resolved_ops, + # resolved, + # max_overlap_check, + # ) # Now check for overlapping lines AFTER the range - end_idx, replacement_lines = _apply_end_stitching( - hashed_lines, start_idx, end_idx, replacement_lines, max_overlap_check - ) + # end_idx, replacement_lines = _apply_end_stitching( + # hashed_lines, start_idx, end_idx, replacement_lines, max_overlap_check + # ) hashed_lines[start_idx : end_idx + 1] = replacement_lines else: From 53b74e034e94f7b86e2a59004cd484860b4d1350 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 12 Jun 2026 04:30:18 -0400 Subject: [PATCH 39/47] Exclude git tools by default since the agent is better using git with the command line --- cecli/coders/agent_coder.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index d01ddc09320..8d60219a42f 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -161,7 +161,9 @@ def _get_agent_config(self): config, ["tools_includelist", "tools_whitelist"], [] ) config["tools_excludelist"] = nested.getter( - config, ["tools_excludelist", "tools_blacklist"], [] + config, + ["tools_excludelist", "tools_blacklist"], + ["gitbranch", "gitdiff", "gitlog", "gitremote", "gitshow", "gitstatus"], ) config["servers_includelist"] = nested.getter( @@ -809,6 +811,9 @@ async def gather_and_await(): "# Fix any linting errors below, if possible and then continue with your task.", 1, ) + ConversationService.get_manager(self).remove_message_by_hash_key( + ("lint_errors", "agent") + ) ConversationService.get_manager(self).add_message( message_dict=dict(role="user", content=lint_errors), tag=MessageTag.CUR, From bd332844692f54422c224dd22784affb45eda74e Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 12 Jun 2026 04:30:53 -0400 Subject: [PATCH 40/47] Prompt updates for reading/writing --- cecli/helpers/conversation/integration.py | 6 ++- cecli/tools/edit_text.py | 6 +-- cecli/tools/read_range.py | 46 ++++++++++++++++------- 3 files changed, 41 insertions(+), 17 deletions(-) diff --git a/cecli/helpers/conversation/integration.py b/cecli/helpers/conversation/integration.py index 801afc95e4f..c895fc69422 100644 --- a/cecli/helpers/conversation/integration.py +++ b/cecli/helpers/conversation/integration.py @@ -278,7 +278,11 @@ def cleanup_files(self) -> None: self._last_clear_count += 1 - if should_clear and self._last_clear_count >= 20: + if ( + should_clear + and self._last_clear_count >= 20 + and diff_tokens + other_tokens > coder.context_compaction_max_tokens * 0.5 + ): self._last_clear_count = 0 # Clear all diff messages diff --git a/cecli/tools/edit_text.py b/cecli/tools/edit_text.py index f57f7997206..23b585807f3 100644 --- a/cecli/tools/edit_text.py +++ b/cecli/tools/edit_text.py @@ -38,7 +38,7 @@ class Tool(BaseTool): "description": ( "Edit text in one or more files using content ID markers. " "Supports replace, delete, and insert operations in a single call. " - "Can handle an array of up to 10 edits across multiple files. " + "Can handle an array of edits across multiple files. " "Each edit must include its own file_path and operation type. " "Use content ID ranges with the start_line and end_line parameters with format " "`content_id::` (the content id with the :: demarcator). For empty files, use `@000` as the " @@ -112,9 +112,9 @@ def execute( Can handle single edit or array of edits across multiple files. Each edit object must include its own file_path. """ - if not coder.edit_allowed: - from cecli.helpers.conversation import ConversationService, MessageTag + from cecli.helpers.conversation import ConversationService, MessageTag + if not coder.edit_allowed: ConversationService.get_manager(coder).add_message( message_dict=dict( role="user", diff --git a/cecli/tools/read_range.py b/cecli/tools/read_range.py index 708f634a6a8..33e1e4e80dc 100644 --- a/cecli/tools/read_range.py +++ b/cecli/tools/read_range.py @@ -83,6 +83,7 @@ class Tool(BaseTool): _last_invocation = {} # file_path -> {start_idx, end_idx} _last_read_turn: Dict[str, int] = {} # abs_path -> turn_count when last read + _special_marker_count: Dict[str, int] = {} # abs_path -> count of both-special-marker reads @classmethod def execute(cls, coder, read, **kwargs): @@ -185,7 +186,8 @@ def execute(cls, coder, read, **kwargs): continue # 3. Read file content - content = coder.io.read_text(abs_path) + content: str = coder.io.read_text(abs_path) + if content is None: error_outputs.append( cls.format_error( @@ -262,6 +264,7 @@ def _is_valid_int(s): end_indices = [end_line_num] else: end_indices = [num_lines - 1] + elif mixed_special_search: if start_is_special: # Start is special marker, end is text pattern @@ -434,26 +437,40 @@ def _is_valid_int(s): s_idx, e_idx = cls._extend_range_with_stub( coder, abs_path, s_idx, e_idx, num_lines ) + + # Store the found indices for future disambiguation + cls._last_invocation[abs_path] = {"start_idx": s_idx, "end_idx": e_idx} + # For structured searches (line numbers, special markers) or mixed searches # (one special marker, one text pattern), cap large ranges with preview # Text pattern searches are not subject to capping if both_structured or (mixed_special_search and (e_idx - s_idx > 200)): - preview = cls._get_range_preview( - abs_path, coder.io, start_idx=s_idx, end_idx=e_idx, line_numbers=True + + preview, has_stub = cls._get_range_preview( + coder, abs_path, start_idx=s_idx, end_idx=e_idx, line_numbers=True ) + if has_stub and abs_path not in coder.abs_fnames: + token_count = coder.main_model.token_count(content) + # Track special marker usage for auto-editable detection + if token_count <= coder.large_file_token_threshold: + cls._special_marker_count[abs_path] = ( + cls._special_marker_count.get(abs_path, 0) + 1 + ) + if cls._special_marker_count[abs_path] > 1: + coder.abs_fnames.add(abs_path) + preview = f"Full contents of {rel_path} added to cotext." + if abs_path in coder.abs_read_only_fnames: + coder.abs_read_only_fnames.remove(abs_path) + if preview not in all_outputs_set: all_outputs_set.add(preview) if len(all_outputs): all_outputs.append("") all_outputs.append(preview) - cls._last_invocation[abs_path] = {"start_idx": s_idx, "end_idx": e_idx} continue - # Store the found indices for future disambiguation - cls._last_invocation[abs_path] = {"start_idx": s_idx, "end_idx": e_idx} - # found_by = f"range '{range_start}' to '{range_end}'" try: @@ -909,7 +926,7 @@ def _extend_range_with_stub(cls, coder, abs_path, s_idx, e_idx, num_lines): return s_idx, e_idx @classmethod - def _get_range_preview(cls, abs_path, io, start_idx, end_idx, line_numbers=True): + def _get_range_preview(cls, coder, abs_path, start_idx, end_idx, line_numbers=True): """Get a preview of a large file range between start_idx and end_idx. For code files (where tree-sitter can parse structure), uses @@ -929,6 +946,9 @@ def _get_range_preview(cls, abs_path, io, start_idx, end_idx, line_numbers=True) """ from cecli.repomap import RepoMap + io = coder.io + abs_path, rel_path = resolve_paths(coder, abs_path) + stub = RepoMap.get_file_stub( abs_path, io, start_line=start_idx, end_line=end_idx, line_numbers=line_numbers ) @@ -937,12 +957,12 @@ def _get_range_preview(cls, abs_path, io, start_idx, end_idx, line_numbers=True) if stub and stub != "# No outline available": total_lines = end_idx - start_idx + 1 parts = [ - f"File range too large ({total_lines} lines).", - "Showing structural outline of the range:", + f"Showing structural information for {rel_path}:", + "Use this information to further narrow your search", "", stub, ] - return "\n".join(parts) + return "\n".join(parts), True content = io.read_text(abs_path) if not content: @@ -956,7 +976,7 @@ def _get_range_preview(cls, abs_path, io, start_idx, end_idx, line_numbers=True) total_lines = actual_end - actual_start + 1 if total_lines <= 0: - return "" + return "", False if total_lines <= 20: # Return all lines @@ -987,4 +1007,4 @@ def _get_range_preview(cls, abs_path, io, start_idx, end_idx, line_numbers=True) line_num = idx + 1 parts.append(f" {line_num:>5} | {line_content}") - return "\n".join(parts) + return "\n".join(parts), False From 8986ff40f919d1a89a141f7586aeda0e345893d2 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 12 Jun 2026 09:06:34 -0400 Subject: [PATCH 41/47] Remind the model it can use multiple lines for ReadRange --- cecli/tools/read_range.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cecli/tools/read_range.py b/cecli/tools/read_range.py index 33e1e4e80dc..f9fafa4a3cb 100644 --- a/cecli/tools/read_range.py +++ b/cecli/tools/read_range.py @@ -34,10 +34,11 @@ class Tool(BaseTool): " symbols. Special markers @000 and 000@ represent the file boundaries and can be" " used for range_start and range_end for the first and last lines of the file" " respectively. Line numbers may also be used for range lookups." - " It is best to use function names, variable declarations and other meaningful identifiers" - " as range_start and range_end values." + " It is best to use function names, variable declarations, entire line contents" + " and other meaningful identifiers as range_start and range_end values." " Do not use the same pattern for the range_start and range_end." " Do not use empty strings for the range_start and range_end." + " Do not use content IDs for the range_start and range_end values as they change between edits." " Always use the ReadRange tool instead of cli tools for reading file contents." " Line number and special marker ranges greater than 200 lines will return" " preview content for further, more scoped investigation." From 2da275a7a93a068dfc76a3e77041db5e71d4c092 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 12 Jun 2026 13:32:41 -0400 Subject: [PATCH 42/47] Only display context blocks and reminders every 5 turns --- cecli/helpers/conversation/integration.py | 23 +++++++++++++++++++++++ cecli/prompts/agent.yml | 1 + cecli/prompts/subagent.yml | 1 + cecli/tools/grep.py | 4 ++-- 4 files changed, 27 insertions(+), 2 deletions(-) diff --git a/cecli/helpers/conversation/integration.py b/cecli/helpers/conversation/integration.py index c895fc69422..a245f50df64 100644 --- a/cecli/helpers/conversation/integration.py +++ b/cecli/helpers/conversation/integration.py @@ -104,6 +104,9 @@ def add_system_messages(self) -> None: priority=75 + i, ) + if self._cancel_post_message_injections(): + return + # Add system reminder as a pre-prompt context block use_reminders = getattr(coder.args, "use_reminders", True) if ( @@ -165,6 +168,9 @@ def add_randomized_cta(self) -> None: if not coder: return + # if self._cancel_post_message_injections(): + # return + message = random.choice( [ "Given the above, please call any tools necessary to make progress on your task", @@ -348,6 +354,9 @@ def add_file_list_reminder(self) -> None: if not coder: return + if self._cancel_post_message_injections(): + return + # Get relative paths for display readonly_rel_files = [] if hasattr(coder, "abs_read_only_fnames"): @@ -980,6 +989,9 @@ def add_post_message_context_blocks(self) -> None: if not hasattr(coder, "use_enhanced_context") or not coder.use_enhanced_context: return + if self._cancel_post_message_injections(): + return + # Add post-message blocks as dict with block type as key message_blocks = {} @@ -1063,6 +1075,17 @@ def defer_removal(self, file_path: str): def flush_removals(self): self._deferred_removals.clear() + def _cancel_post_message_injections(self): + coder = self.get_coder() + if not coder: + return False + + # Add system reminder as a pre-prompt context block + if coder.edit_format in ("agent", "subagent") and coder.turn_count % 5 != 0: + return True + + return False + def _shuffle_reminders(self, content: str) -> str: """ If the string is a critical_reminders block, shuffle all bulleted points diff --git a/cecli/prompts/agent.yml b/cecli/prompts/agent.yml index 14fabc4be67..4143d42d18e 100644 --- a/cecli/prompts/agent.yml +++ b/cecli/prompts/agent.yml @@ -66,6 +66,7 @@ system_reminder: | - Prefer the `ReadRange` tool to cli commands for file reading - Pay close attention to indentation and styling when editing files - Batch tool calls as often as possible + - Reason out loud through problems but be brief. {lazy_prompt} {shell_cmd_reminder} diff --git a/cecli/prompts/subagent.yml b/cecli/prompts/subagent.yml index 6653821d753..e10c1935e1d 100644 --- a/cecli/prompts/subagent.yml +++ b/cecli/prompts/subagent.yml @@ -51,6 +51,7 @@ system_reminder: | - Prefer the `ReadRange` tool to cli commands for file reading - Pay close attention to indentation and styling when editing files - Batch tool calls as often as possible + - Reason out loud through problems but be brief. **Finishing Up**: Be very detailed in your `Yield` tool summary in describing your task, findings, efforts and results. diff --git a/cecli/tools/grep.py b/cecli/tools/grep.py index f740c89a27f..c519cdd8d97 100644 --- a/cecli/tools/grep.py +++ b/cecli/tools/grep.py @@ -109,8 +109,8 @@ def execute( directory = search_op.get("directory", search_op.get("path", ".")) use_regex = search_op.get("use_regex", True) case_insensitive = search_op.get("case_insensitive", True) - context_before = search_op.get("context_before", 5) - context_after = search_op.get("context_after", 5) + context_before = search_op.get("context_before", 2) + context_after = search_op.get("context_after", 2) try: search_dir_path = Path(repo.root) / directory From 6a4a92cbc894b3d3a7170e05e1e1f81da29f72e7 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 12 Jun 2026 14:31:53 -0400 Subject: [PATCH 43/47] Specify that Yield can be used for sub agents as well --- cecli/tools/_yield.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cecli/tools/_yield.py b/cecli/tools/_yield.py index 4697ab96561..158b72bf32d 100644 --- a/cecli/tools/_yield.py +++ b/cecli/tools/_yield.py @@ -17,7 +17,10 @@ class Tool(BaseTool): "type": "function", "function": { "name": "Yield", - "description": "Yield control back to the user, indicating all sub-goals are complete.", + "description": ( + "Yield control to subagents, to await their results or back to the user," + " indicating all sub-goals are complete." + ), "parameters": { "type": "object", "properties": { From dfecfaf036a758d271affc9046e30d7fdbf21f28 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 12 Jun 2026 15:10:23 -0400 Subject: [PATCH 44/47] Fix current tasks assignment for to do list on lower versions of python --- cecli/coders/agent_coder.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index 8d60219a42f..409b371e97a 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -1433,9 +1433,11 @@ def get_todo_list(self): content = self.io.read_text(abs_path) if content is None or not content.strip(): return None + + current_tasks = "\n".join(self.current_tasks) result = '\n' result += "## Current Active Tasks\n\n" - result += f"```{"\n".join(self.current_tasks)}```\n" + result += f"```{current_tasks}```\n" result += "" return result except Exception as e: From 865d871324ec240c5bf1b5561116a07190f776ae Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 12 Jun 2026 18:57:46 -0400 Subject: [PATCH 45/47] Update thinking block display in TUI mode to not duplicate text --- cecli/coders/base_coder.py | 49 ++++++++++++++++++++++++++++---------- cecli/reasoning_tags.py | 19 ++++++++++++++- 2 files changed, 54 insertions(+), 14 deletions(-) diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index aa1d366bdb0..6d774522bc7 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -2561,7 +2561,9 @@ async def format_in_executor(): return finally: if self.mdstream: - content_to_show = self.live_incremental_response(True) + content_to_show = ( + "" if self.tui and self.tui() else self.live_incremental_response(True) + ) self.stream_wrapper(content_to_show, final=True) self.mdstream = None @@ -3584,24 +3586,13 @@ async def show_send_output_stream(self, completion): except AttributeError: reasoning_content = None - if reasoning_content: - if nested.getter(self.args, "show_thinking"): - if not self.got_reasoning_content: - text += f"<{REASONING_TAG}>\n\n" - text += reasoning_content - self.got_reasoning_content = True - received_content = True - self.token_profiler.on_token() - self.io.update_spinner_suffix(reasoning_content) - self.partial_response_reasoning_content += reasoning_content - try: content = chunk.choices[0].delta.content if content: if self.got_reasoning_content and not self.ended_reasoning_content: text += f"\n\n\n\n" - self.ended_reasoning_content = True + self.ended_reasoning_content = True text += content received_content = True self.token_profiler.on_token() @@ -3609,6 +3600,22 @@ async def show_send_output_stream(self, completion): except AttributeError: pass + if reasoning_content: + if ( + nested.getter(self.args, "show_thinking") + and not self.ended_reasoning_content + ): + if not self.got_reasoning_content: + text += f"<{REASONING_TAG}>\n\n" + + text += reasoning_content + self.got_reasoning_content = True + received_content = True + + self.token_profiler.on_token() + self.io.update_spinner_suffix(reasoning_content) + self.partial_response_reasoning_content += reasoning_content + self.partial_response_content += text chunk_index += 1 @@ -3635,6 +3642,16 @@ async def show_send_output_stream(self, completion): except (asyncio.CancelledError, KeyboardInterrupt): raise KeyboardInterrupt + if ( + self.show_pretty() + and nested.getter(self.args, "show_thinking") + and self.got_reasoning_content + and not self.ended_reasoning_content + ): + self.partial_response_content += f"\n\n\n\n" + content_to_show = self.live_incremental_response(False) + self.stream_wrapper(content_to_show, final=False) + # The Part Doing the Heavy Lifting Now self.consolidate_chunks() @@ -3658,6 +3675,11 @@ def consolidate_chunks(self): func_err = None content_err = None + last_chunk = self.partial_response_chunks[len(self.partial_response_chunks) - 1] + if last_chunk: + if getattr(last_chunk, "usage", None): + response.usage = last_chunk.usage + # Collect provider-specific fields from chunks to preserve them # We need to track both by ID (primary) and index (fallback) since # early chunks might not have IDs established yet @@ -3849,6 +3871,7 @@ def calculate_and_show_tokens_and_cost(self, messages, completion=None): cache_hit_tokens = ( getattr(completion.usage, "prompt_cache_hit_tokens", 0) or getattr(completion.usage, "cache_read_input_tokens", 0) + or nested.getter(completion.usage, "prompt_tokens_details.cached_tokens", 0) or 0 ) cache_write_tokens = getattr(completion.usage, "cache_creation_input_tokens", 0) or 0 diff --git a/cecli/reasoning_tags.py b/cecli/reasoning_tags.py index 42508bf1c51..83842a599b6 100644 --- a/cecli/reasoning_tags.py +++ b/cecli/reasoning_tags.py @@ -8,7 +8,7 @@ REASONING_TAG = "thinking-content-" + "7bbeb8e1441453ad999a0bbba8a46d4b" # Output formatting REASONING_START = "--------------\n" -REASONING_END = "--------------\n" +REASONING_END = "----------\n" def remove_reasoning_content(res, reasoning_tag): @@ -22,6 +22,7 @@ def remove_reasoning_content(res, reasoning_tag): Returns: str: Text with reasoning content removed """ + reasoning_tag = unwrap_tag(reasoning_tag) if not reasoning_tag: return res @@ -52,6 +53,7 @@ def replace_reasoning_tags(text, tag_name): Returns: str: Text with reasoning tags replaced with standard format """ + tag_name = unwrap_tag(tag_name) if not text: return text @@ -75,8 +77,23 @@ def format_reasoning_content(reasoning_content, tag_name): Returns: str: Formatted reasoning content with tags """ + tag_name = unwrap_tag(tag_name) if not reasoning_content: return "" formatted = f"<{tag_name}>\n\n{reasoning_content}\n\n" return formatted + + +def unwrap_tag(text: str) -> str: + # Remove any leading/trailing whitespace just in case + if text: + clean_text = text.strip() + + # Check if it has both the opening and closing brackets + if clean_text.startswith("<") and clean_text.endswith(">"): + # Slice off the first and last characters + return clean_text[1:-1] + + # Return the original string (or stripped string) if it doesn't match + return text From 703154837d9b5c33511b3f5a00ad53d2b3e1d346 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 12 Jun 2026 19:22:02 -0400 Subject: [PATCH 46/47] Update resolve_content_hashline_ids to allow for sub string matching --- cecli/helpers/hashline.py | 78 ++++++++++++++++++++++++--------------- 1 file changed, 48 insertions(+), 30 deletions(-) diff --git a/cecli/helpers/hashline.py b/cecli/helpers/hashline.py index 91a97e0dab0..35a3a868689 100644 --- a/cecli/helpers/hashline.py +++ b/cecli/helpers/hashline.py @@ -245,8 +245,13 @@ def resolve_content_to_hashline_ids( Resolve potential line content values to proper hashline content IDs. If start_value or end_value does not look like a content ID (hash), - search for the content in the original file. If found exactly once, - return the hash ID for that line instead. + search for the content in the original file using substring matching. + + For start_value: Only resolves if exactly one line contains it as a + substring (unique match). + + For end_value: Resolves by finding the closest line (by position) to + the resolved start line that contains it as a substring. This handles the case where LLMs return entire line content or fragments instead of content IDs in edit parameters. @@ -274,42 +279,55 @@ def _looks_like_content_id(value: str) -> bool: except (ContentHashError, ValueError): return False - def _resolve_value(value: str) -> str: - if value is None: - return value - if _looks_like_content_id(value): - return value - - # Value doesn't look like a content ID - try to find it as line content - lines = original_content.splitlines() + def _find_substring_matches(lines, value): + """Find all line indices where the value appears as a substring.""" value_stripped = value.rstrip("\r\n") + return [i for i, line in enumerate(lines) if value_stripped in line] - # First try exact match (full line content) - matching_indices = [ - i for i, line in enumerate(lines) if line.rstrip("\r\n") == value_stripped - ] + def _resolve_to_hash_id(lines, idx, hp): + """Generate a hash ID for the line at the given index.""" + hash_id = hp.generate_public_id(lines[idx], idx) + return hash_id + "::" - if len(matching_indices) == 1: - idx = matching_indices[0] - hp = HashPos(original_content) - hash_id = hp.generate_public_id(lines[idx], idx) - return hash_id + "::" + lines = original_content.splitlines() + hp = HashPos(original_content) - # If no exact match, try substring match (value might be a fragment) - # Only resolve if exactly one line contains the value - containing_indices = [i for i, line in enumerate(lines) if value_stripped in line] + # Resolve start_value first (must be unique substring match) + resolved_start = start_value + resolved_start_idx = None + if start_value is not None and not _looks_like_content_id(start_value): + containing_indices = _find_substring_matches(lines, start_value) if len(containing_indices) == 1: - idx = containing_indices[0] - hp = HashPos(original_content) - hash_id = hp.generate_public_id(lines[idx], idx) - return hash_id + "::" + resolved_start_idx = containing_indices[0] + resolved_start = _resolve_to_hash_id(lines, resolved_start_idx, hp) + elif start_value is not None and _looks_like_content_id(start_value): + # Already a content ID - try to resolve it to find the line position + # for proximity matching with end_value + try: + normalized = normalize_hashline(start_value) + candidates = hp.resolve_to_lines(normalized) + if candidates: + resolved_start_idx = candidates[0] + except (ContentHashError, ValueError): + pass - # Can't resolve uniquely - return original value - return value + # Resolve end_value based on proximity to start position + resolved_end = end_value - resolved_start = _resolve_value(start_value) - resolved_end = _resolve_value(end_value) if end_value is not None else end_value + if end_value is not None and not _looks_like_content_id(end_value): + containing_indices = _find_substring_matches(lines, end_value) + if len(containing_indices) == 1: + # Unique match - resolve directly + idx = containing_indices[0] + resolved_end = _resolve_to_hash_id(lines, idx, hp) + elif len(containing_indices) > 1 and resolved_start_idx is not None: + # Multiple matches - pick closest to start position + closest_idx = min( + containing_indices, + key=lambda idx: abs(idx - resolved_start_idx), + ) + resolved_end = _resolve_to_hash_id(lines, closest_idx, hp) return resolved_start, resolved_end From ad3ffb77d67a6b5c7751d97a7c9c495952297591 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 12 Jun 2026 20:11:51 -0400 Subject: [PATCH 47/47] #569: Add command white list in agent_config with `allowed_commands` --- cecli/coders/agent_coder.py | 1 + cecli/tools/command.py | 9 +++++++++ cecli/tools/command_interactive.py | 14 +++++++++++++- cecli/website/docs/config/agent-mode.md | 2 ++ cecli/website/docs/config/skills.md | 1 + 5 files changed, 26 insertions(+), 1 deletion(-) diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index 409b371e97a..91ab4d7cdc6 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -153,6 +153,7 @@ def _get_agent_config(self): config, "skip_cli_confirmations", nested.getter(config, "yolo", []) ) config["command_timeout"] = nested.getter(config, "command_timeout", 30) + config["allowed_commands"] = nested.getter(config, "allowed_commands", []) config["hot_reload"] = nested.getter(config, "hot_reload", False) config["allow_nested_delegation"] = nested.getter(config, "allow_nested_delegation", False) diff --git a/cecli/tools/command.py b/cecli/tools/command.py index 6be67f1a322..f09d32f2cdf 100644 --- a/cecli/tools/command.py +++ b/cecli/tools/command.py @@ -1,4 +1,5 @@ # Import necessary functions +import fnmatch import os import platform @@ -141,6 +142,14 @@ async def _get_confirmation(cls, coder, command_string, background): if coder.skip_cli_confirmations: return True + # Check if command matches any allowed_commands patterns + if hasattr(coder, "agent_config"): + allowed_commands = coder.agent_config.get("allowed_commands", []) + if allowed_commands: + for pattern in allowed_commands: + if fnmatch.fnmatch(command_string, pattern): + return True + command_string = coder.format_command_with_prefix(command_string) if background: diff --git a/cecli/tools/command_interactive.py b/cecli/tools/command_interactive.py index 7972041577e..30a27a60ec8 100644 --- a/cecli/tools/command_interactive.py +++ b/cecli/tools/command_interactive.py @@ -1,5 +1,6 @@ # Import necessary functions import asyncio +import fnmatch from cecli.run_cmd import run_cmd from cecli.tools.utils.base_tool import BaseTool @@ -30,6 +31,17 @@ class Tool(BaseTool): }, } + @staticmethod + def _is_command_allowed(coder, command_string): + """Check if command matches any allowed_commands patterns.""" + if hasattr(coder, "agent_config"): + allowed_commands = coder.agent_config.get("allowed_commands", []) + for pattern in allowed_commands: + if fnmatch.fnmatch(command_string, pattern): + return True + + return False + @classmethod async def execute(cls, coder, command_string, **kwargs): """ @@ -40,7 +52,7 @@ async def execute(cls, coder, command_string, **kwargs): confirmed = ( True - if coder.skip_cli_confirmations + if coder.skip_cli_confirmations or cls._is_command_allowed(coder, command_string) else await coder.io.confirm_ask( "Allow execution of this command?", subject=command_string, diff --git a/cecli/website/docs/config/agent-mode.md b/cecli/website/docs/config/agent-mode.md index f6c29774c55..d66ac7c14e7 100644 --- a/cecli/website/docs/config/agent-mode.md +++ b/cecli/website/docs/config/agent-mode.md @@ -151,6 +151,7 @@ Agent Mode can also be configured directly in your configuration file. See the [ - **`large_file_token_threshold`**: Maximum token threshold for large file warnings (default: 32768) - **`skip_cli_confirmations`**: YOLO mode, be brave and let the LLM cook, can also use the option `yolo` (default: False) +- **`allowed_commands`**: Array of glob patterns for commands that can be executed without prompting. Commands matching any pattern will skip the confirmation dialog. Example: `["wc -l*"]` (default: []) - **`tools_includelist`**: Array of tool names to allow (only these tools will be available) - **`tools_excludelist`**: Array of tool names to exclude (these tools will be disabled) - **`tools_paths`**: Array of directories or Python files containing custom tools to load @@ -282,6 +283,7 @@ agent-config: # Performance and behavior settings large_file_token_threshold: 32768 # Token threshold for large file warnings (default: 32768) skip_cli_confirmations: false # YOLO mode - be brave and let the LLM cook + allowed_commands: ["wc -l*"] # Commands matching these glob patterns will not prompt for confirmation # Skills configuration (see Skills documentation for details) skills_paths: ["~/my-skills", "./project-skills"] # Directories to search for skills skills_includelist: ["python-refactoring", "react-components"] # Optional: Whitelist of skills to include diff --git a/cecli/website/docs/config/skills.md b/cecli/website/docs/config/skills.md index 171ec817191..a53286097da 100644 --- a/cecli/website/docs/config/skills.md +++ b/cecli/website/docs/config/skills.md @@ -178,6 +178,7 @@ agent-config: | # Other Agent Mode settings "large_file_token_threshold": 12500, # Token threshold for large file warnings "skip_cli_confirmations": false, # YOLO mode - be brave and let the LLM cook + "allowed_commands": ["wc -l*"], # Commands matching these glob patterns will not prompt for confirmation "tools_includelist": ["view", "makeeditable", "replacetext", "finished"], # Optional: Whitelist of tools "tools_excludelist": ["command", "commandinteractive"], # Optional: Blacklist of tools "include_context_blocks": ["todo_list", "git_status"], # Optional: Context blocks to include