Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ The current priorities are to improve core capabilities and user experience of t

4. **Context Delivery** - [Discussion](https://github.com/dwash96/cecli/issues/47)
* [ ] Use workflow for internal discovery to better target file snippets needed for specific tasks
* [ ] Add support for partial files and code snippets in model completion messages
* [x] Add support for partial files and code snippets in model completion messages
* [x] Update message request structure for optimal caching

5. **TUI Experience** - [Discussion](https://github.com/dwash96/cecli/issues/48)
Expand All @@ -162,6 +162,38 @@ The current priorities are to improve core capabilities and user experience of t
* [x] Add a plugin-like system for allowing agent mode to use user-defined tools in simple python files
* [x] Add a dynamic tool discovery tool to allow the system to have only the tools it needs in context

7. **Sub Agents**
* [ ] Add `/fork` and `/rejoin` commands to manually manage parts of the conversation history
* [ ] Add an instance-able view of the conversation system so sub agents get their own context and workspaces
* [ ] Modify coder classes to have discrete identifiers for themselves/management utilities for them to have their own slices of the world
* [ ] Refactor global files like todo lists to live inside instance folders to avoid state conflicts
* [ ] Add a `spawn` tool that launches a sub agent as a background command that the parent model waits for to finish
* [ ] Add visibility into active sub agent calls in TUI

8. **Hooks**
* [ ] Add hooks base class for user defined python hooks with an execute method with type and priority settings
* [ ] Add hook manager that can accept user defined files and command line commands
* [ ] Integrate hook manager with coder classes with hooks for `start`, `on_message`, `end_message`, `pre_tool`, and `post_tool`

9. **Efficient File Editing**
* [ ] Explore use of hashline file representation for more targeted file editing
* [ ] Assuming viability, update SEARCH part of SEARCH/REPLACE with hashline identification
* [ ] Update agent mode edit tools to work with hashline identification
* [ ] Update internal file diff representation to support hashline propagation

10. **Dynamic Context Management**
* [ ] Update compaction to use observational memory sub agent calls to generate decision records that are used as the compaction basis
* [ ] Persist decision records to disk for sessions with some settings for managing lifetimes of such persistence
* [ ] Integrate RLM to extract information from decision records on disk and other definable notes
* [ ] Add a "describe" tool that launches a sub agent workflow that populates an RLM call's context with:
* Current Conversation History
* Past Decision Records
* Repo Map Found Files

11. **Quality of Life**
* [ ] Add hot keys support for running repeatable commands like switching between preferred models
* [ ] Unified error message logging inside of `.cecli` directory

### All Contributors (Both Cecli and Aider main)

<table>
Expand Down
2 changes: 1 addition & 1 deletion cecli/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from packaging import version

__version__ = "0.96.10.dev"
__version__ = "0.97.0.dev"
safe_version = __version__

try:
Expand Down
598 changes: 83 additions & 515 deletions cecli/coders/agent_coder.py

Large diffs are not rendered by default.

141 changes: 99 additions & 42 deletions cecli/coders/base_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@

import httpx
from litellm import experimental_mcp_client
from litellm.types.utils import ModelResponse
from litellm.types.utils import ChatCompletionMessageToolCall, Function, ModelResponse
from prompt_toolkit.patch_stdout import patch_stdout
from rich.console import Console

Expand Down Expand Up @@ -64,7 +64,7 @@
from cecli.sessions import SessionManager
from cecli.tools.utils.output import print_tool_response
from cecli.tools.utils.registry import ToolRegistry
from cecli.utils import format_tokens, is_image_file
from cecli.utils import copy_tool_call, format_tokens, is_image_file

from ..dump import dump # noqa: F401
from ..prompts.utils.registry import PromptObject, PromptRegistry
Expand Down Expand Up @@ -2357,23 +2357,19 @@ async def send_message(self, inp):
return

async def process_tool_calls(self, tool_call_response):
if tool_call_response is None:
return False

# Handle different response structures
try:
# Try to get tool calls from the standard OpenAI response format
if hasattr(tool_call_response, "choices") and tool_call_response.choices:
message = tool_call_response.choices[0].message
if hasattr(message, "tool_calls") and message.tool_calls:
original_tool_calls = message.tool_calls
else:
return False
else:
# Handle other response formats
return False
except (AttributeError, IndexError):
return False
# Use partial_response_tool_calls if available (populated by consolidate_chunks)
# otherwise try to extract from tool_call_response
original_tool_calls = []
if self.partial_response_tool_calls:
original_tool_calls = self.partial_response_tool_calls
elif tool_call_response is not None:
try:
if hasattr(tool_call_response, "choices") and tool_call_response.choices:
message = tool_call_response.choices[0].message
if hasattr(message, "tool_calls") and message.tool_calls:
original_tool_calls = message.tool_calls
except (AttributeError, IndexError):
pass

if not original_tool_calls:
return False
Expand Down Expand Up @@ -2404,10 +2400,13 @@ async def process_tool_calls(self, tool_call_response):
continue

# Create a new tool call for each JSON chunk, with a unique ID.
new_function = tool_call.function.model_copy(update={"arguments": chunk})
new_tool_call = tool_call.model_copy(
update={"id": f"{tool_call.id}-{i}", "function": new_function}
)
new_tool_call = copy_tool_call(tool_call)
if hasattr(new_tool_call, "model_copy"):
new_tool_call.function.arguments = chunk
new_tool_call.id = f"{tool_call.id}-{i}"
else:
new_tool_call.function.arguments = chunk
new_tool_call.id = f"{getattr(tool_call, 'id', 'call')}-{i}"
expanded_tool_calls.append(new_tool_call)

# Collect all tool calls grouped by server
Expand Down Expand Up @@ -2551,7 +2550,7 @@ async def _exec_server_tools(server, tool_calls_list):

all_results_content = []
for args in parsed_args_list:
new_tool_call = tool_call.model_copy(deep=True)
new_tool_call = copy_tool_call(tool_call)
new_tool_call.function.arguments = json.dumps(args)

call_result = await experimental_mcp_client.call_openai_tool(
Expand Down Expand Up @@ -2806,6 +2805,7 @@ def add_assistant_reply_to_cur_messages(self):
ConversationManager.add_message(
message_dict=msg,
tag=MessageTag.CUR,
hash_key=("assistant_message", str(msg), str(time.monotonic_ns())),
)

def get_file_mentions(self, content, ignore_current=False):
Expand Down Expand Up @@ -3202,22 +3202,9 @@ def consolidate_chunks(self):
# Add provider-specific fields directly to the tool call object
tool_call.provider_specific_fields = provider_specific_fields_by_index[i]

# Create dictionary version with provider-specific fields
tool_call_dict = tool_call.model_dump()

# Add provider-specific fields to the dictionary too (in case model_dump() doesn't include them)
if tool_id in provider_specific_fields_by_id:
tool_call_dict["provider_specific_fields"] = provider_specific_fields_by_id[
tool_id
]
elif i in provider_specific_fields_by_index:
tool_call_dict["provider_specific_fields"] = (
provider_specific_fields_by_index[i]
)

# Only append to partial_response_tool_calls if it's empty
if len(self.partial_response_tool_calls) == 0:
self.partial_response_tool_calls.append(tool_call_dict)
self.partial_response_tool_calls.append(tool_call)

self.partial_response_function_call = (
response.choices[0].message.tool_calls[0].function
Expand Down Expand Up @@ -3253,6 +3240,70 @@ def consolidate_chunks(self):
except AttributeError as e:
content_err = e

# If no native tool calls, check if the content contains JSON tool calls
# This handles models that write JSON in text instead of using native calling
if not self.partial_response_tool_calls and self.partial_response_content:
try:
# Simple extraction of JSON-like structures that look like tool calls
# Only look for tool calls if it looks like JSON
if "{" in self.partial_response_content or "[" in self.partial_response_content:
json_chunks = utils.split_concatenated_json(self.partial_response_content)
extracted_calls = []
chunk_index = 0

for chunk in json_chunks:
chunk_index += 1
try:
json_obj = json.loads(chunk)
if (
isinstance(json_obj, dict)
and "name" in json_obj
and "arguments" in json_obj
):
# Create a Pydantic model for the tool call
function_obj = Function(
name=json_obj["name"],
arguments=(
json.dumps(json_obj["arguments"])
if isinstance(json_obj["arguments"], (dict, list))
else str(json_obj["arguments"])
),
)
tool_call_obj = ChatCompletionMessageToolCall(
type="function",
function=function_obj,
id=f"call_{len(extracted_calls)}_{int(time.time())}_{chunk_index}",
)
extracted_calls.append(tool_call_obj)
elif isinstance(json_obj, list):
for item in json_obj:
if (
isinstance(item, dict)
and "name" in item
and "arguments" in item
):
function_obj = Function(
name=item["name"],
arguments=(
json.dumps(item["arguments"])
if isinstance(item["arguments"], (dict, list))
else str(item["arguments"])
),
)
tool_call_obj = ChatCompletionMessageToolCall(
type="function",
function=function_obj,
id=f"call_{len(extracted_calls)}_{int(time.time())}_{chunk_index}",
)
extracted_calls.append(tool_call_obj)
except json.JSONDecodeError:
continue

if extracted_calls:
self.partial_response_tool_calls = extracted_calls
except Exception:
pass

return response, func_err, content_err

def stream_wrapper(self, content, final):
Expand Down Expand Up @@ -3298,13 +3349,19 @@ def preprocess_response(self):
tool_list = []
tool_id_set = set()

for tool_call_dict in self.partial_response_tool_calls:
for tool_call in self.partial_response_tool_calls:
# Handle both dictionary and object tool calls
if isinstance(tool_call, dict):
tool_id = tool_call.get("id")
else:
tool_id = getattr(tool_call, "id", None)

# LLM APIs sometimes return duplicates and that's annoying part 2
if tool_call_dict.get("id") in tool_id_set:
if tool_id in tool_id_set:
continue

tool_id_set.add(tool_call_dict.get("id"))
tool_list.append(tool_call_dict)
tool_id_set.add(tool_id)
tool_list.append(tool_call)

self.partial_response_tool_calls = tool_list

Expand Down
Loading
Loading