Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
e844e53
feat(sessions): optional AES-256-GCM encryption for saved sessions
JessicaMulein May 27, 2026
da1ae6b
cli-29: finally fix interruption exception on acompletion
May 27, 2026
cdef302
fix(agent): guard missing verbose on headless args
JessicaMulein May 27, 2026
3cdd958
fix: Catch BaseException in worker thread to prevent tracebacks
May 28, 2026
e863bd2
fix: Update spinner methods to accept coder_uuid
May 28, 2026
a1336cd
feat: Add agent-specific status messages to TUI
May 28, 2026
951988e
chore: fix session tests and pass CI pre-commit
JessicaMulein May 28, 2026
c73c7a4
fix(coder): Ollama-friendly empty LLM tool warning
JessicaMulein May 28, 2026
3af6b45
Allow reaping sub agents by name/identifier
May 29, 2026
d39efb5
Sub agent changes:
May 29, 2026
d88848e
fix: Update cecli utils and fix TUI tests
May 29, 2026
8aa0c92
feat: Add agent name prefixes to TUI status messages
May 29, 2026
2baaf4e
fix: Pass coder_uuid to spinner start calls
May 29, 2026
baffb29
fix: Prefix primary agent status when sub-agents exist
May 29, 2026
79e99db
fix: Add UUID disambiguation for duplicate agent names
May 29, 2026
3929bfb
refactor: Add coder_uuid to TUI messages and tests
May 30, 2026
5c6936f
cli-39: fixed linting
May 30, 2026
4c7aba0
fix: Uncomment conversation promotion and ensure agent_name is string
May 30, 2026
5d59ec1
fix: Improve footer widget robustness in test environments
May 30, 2026
9b6f1ee
fix: Improve agent name resolution and conversation history handling
May 30, 2026
6d89952
- Make `Delegate` tool non-blocking
May 30, 2026
fb996af
Fix yield tool not finishing prematurely
May 30, 2026
99c62a0
Allow automatic reaping of sub agents
May 30, 2026
dfc1f7c
Merge pull request #538 from Digital-Defiance/fix/empty-llm-ollama-wa…
dwash96 May 30, 2026
8f6c57c
Merge pull request #535 from Digital-Defiance/fix/agent-headless-verbose
dwash96 May 30, 2026
7df2876
Update tool parsing referencing PR #536 but a bit more idiomatically …
May 31, 2026
cb8b714
Skills should be loadable by subagents
May 31, 2026
7f8296e
Switch to newly spawned agent on creation
May 31, 2026
d84adf6
Update model-metadata disable default litellm metadata fetch on start…
May 31, 2026
ea5e87b
feat: fix pipeline error in `cecli/io.py` by updating method signatur…
May 31, 2026
d7264d3
Only run reflection after observation completes, pass coder instance …
Jun 1, 2026
2124b1a
Merge pull request #540 from szmania/cli-29-interruption-exception-ta…
dwash96 Jun 1, 2026
51ee41d
Merge pull request #539 from szmania/cli-39-agent-specific-status-mes…
dwash96 Jun 1, 2026
83d78f1
Fix observation tests
Jun 1, 2026
69fd020
Add quiet parameter from PR #536
Jun 1, 2026
8e2f52d
Add session quiet test from PR #536
Jun 1, 2026
7aaf208
Repo map messaging adjustments from PR #536
Jun 1, 2026
7ee2920
Add `--exempt-paths` to allow for the behavior in PR #532 but more ge…
Jun 1, 2026
4cd3c67
Remove regex env var from exempt paths argument
Jun 1, 2026
e9decca
Fix response name shadowing in mcp tool parsing
Jun 1, 2026
527fd3d
Merge remote-tracking branch 'bright-vision/feat/session-encryption-u…
Jun 1, 2026
e0c81f1
Propagate quiet parameter to session read method
Jun 1, 2026
File filter

Filter by extension

Filter by extension


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

__version__ = "0.99.12.dev"
__version__ = "0.100.2.dev"
safe_version = __version__

try:
Expand Down
28 changes: 28 additions & 0 deletions cecli/args.py
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,24 @@ def get_parser(default_config_files, git_root):
" (default: False)"
),
)
group.add_argument(
"--session-encrypt",
action=argparse.BooleanOptionalAction,
default=False,
help=(
"Encrypt saved sessions on disk (AES-256-GCM). Requires CECLI_SESSION_KEY or"
" --session-key-file (default: False)"
),
)
group.add_argument(
"--session-key-file",
metavar="SESSION_KEY_FILE",
default=None,
help=(
"File containing a urlsafe-base64 32-byte session encryption key"
" (default: use CECLI_SESSION_KEY only)"
),
).complete = shtab.FILE
group.add_argument(
"--mcp-servers",
metavar="MCP_CONFIG_JSON",
Expand Down Expand Up @@ -536,6 +554,16 @@ def get_parser(default_config_files, git_root):
" False)"
),
)
group.add_argument(
"--exempt-paths",
action="append",
default=[],
help=(
"Specify a regex pattern for paths that should be exempted from file creation. "
"When /add matches a path matching any exempt pattern, it will not offer to "
"create the file. Can be used multiple times."
),
)
##########
group = parser.add_argument_group("Output settings")
group.add_argument(
Expand Down
105 changes: 79 additions & 26 deletions cecli/coders/agent_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
from datetime import datetime
from pathlib import Path

from cecli import utils
from cecli.change_tracker import ChangeTracker
from cecli.helpers import nested, responses
from cecli.helpers.agents.service import AgentService
Expand Down Expand Up @@ -265,7 +264,8 @@ def get_local_tool_schemas(self):

async def initialize_mcp_tools(self):
if not self.mcp_manager:
self.mcp_manager = McpServerManager([], self.io, self.args.verbose)
verbose = getattr(self.args, "verbose", False) if self.args else False
self.mcp_manager = McpServerManager([], self.io, verbose)

server_name = "Local"
server = self.mcp_manager.get_server(server_name)
Expand Down Expand Up @@ -540,6 +540,10 @@ def format_chat_chunks(self):

# Add post-message context blocks (priority 250 - between CUR and REMINDER)
ConversationService.get_chunks(self).add_post_message_context_blocks()

# Add sub-agent states context block (same priority as post-message blocks)
ConversationService.get_chunks(self).add_sub_agent_states()

ConversationService.get_chunks(self).add_randomized_cta()

return ConversationService.get_manager(self).get_messages_dict()
Expand Down Expand Up @@ -727,25 +731,23 @@ async def _execute_local_tools(self, tool_calls_list):
continue

if args_string:
json_chunks = utils.split_concatenated_json(args_string)
for chunk in json_chunks:
try:
parsed_args_list.append(json.loads(chunk))
except json.JSONDecodeError as e:
self.model_kwargs = {}
self.io.tool_warning(
f"Malformed JSON arguments in tool {tool_name}: {chunk}"
)
tool_responses.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
"content": (
f"Malformed JSON arguments in tool {tool_name}: {str(e)}"
),
}
)
continue
parsed = responses.parse_tool_arguments(args_string)
if isinstance(parsed, dict) and "@error" in parsed:
self.io.tool_warning(
f"Malformed JSON arguments in tool {tool_name}: {parsed['@error']}"
)
tool_responses.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
"content": (
f"Malformed JSON arguments in tool {tool_name}: {parsed['@error']}"
),
}
)
continue
parsed_args_list = [parsed]

if not parsed_args_list and not args_string:
parsed_args_list.append({})
all_results_content = []
Expand Down Expand Up @@ -837,20 +839,22 @@ async def gather_and_await():

async def _execute_mcp_tools(self, server, tool_calls):
"""Execute MCP tools via LiteLLM."""
responses = []
tool_responses = []
for tool_call in tool_calls:
# Use existing _execute_mcp_tool logic
result = await self._execute_mcp_tool(
server, tool_call.function.name, json.loads(tool_call.function.arguments)
server,
tool_call.function.name,
responses.parse_tool_arguments(tool_call.function.arguments),
)
responses.append(
tool_responses.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
"content": result,
}
)
return responses
return tool_responses

def get_active_model(self):
if self.main_model.agent_model:
Expand All @@ -871,13 +875,21 @@ async def reply_completed(self):
content = self.partial_response_content
tool_calls_found = bool(self.partial_response_tool_calls)

# Reap all finished sub-agents with auto_reap enabled
try:
service = AgentService.get_instance(self)
await service.reap_all_finished_agents(parent=service.get_parent(self))
except Exception:
logger.warning("Failed to reap finished sub-agents", exc_info=True)

# 1. Handle Tool Execution Follow-up (Reflection)
if self.agent_finished:
self.tool_usage_history = []
self.tool_call_vectors = []
self.reflected_message = None
if self.files_edited_by_tools:
_ = await self.auto_commit(self.files_edited_by_tools)

return False

# 2. Check for unfinished and recently finished background commands
Expand Down Expand Up @@ -938,7 +950,7 @@ async def reply_completed(self):
if self.tool_call_vectors:
if content and not tool_calls_found and self.num_reflections < self.max_reflections:
self.reflected_message = (
"Continue with your task. If you have completed it, call the `Finished` tool."
"Continue with your task. If you have completed it, call the `Yield` tool."
)
return True

Expand Down Expand Up @@ -1490,12 +1502,53 @@ def get_sub_agents_context(self):
result += "\n"

result += "Use the `Delegate` tool with the sub-agent name to delegate tasks.\n"
result += "Use the `Yield` tool to wait for responses for all active sub agents.\n"
result += "</context>"
return result
except Exception as e:
self.io.tool_error(f"Error generating sub-agents context: {str(e)}")
return None

def get_child_agent_states(self):
"""Get the state of all active child sub-agents.

Returns a formatted context block with each child sub-agent's name,
UUID, and current status, or None if no children exist.
This is used by ConversationChunks.add_sub_agent_states() to provide
the model with visibility into active sub-agent states.
"""
if not self.use_enhanced_context:
return None

# Sub-agents should only see child states when nested delegation is enabled
if hasattr(self, "parent_uuid") and self.parent_uuid:
if not self.agent_config.get("allow_nested_delegation", False):
return None

try:
service = AgentService.get_instance(self)
children = service.get_children(self)

if not children:
return None

result = '<context name="sub_agent_states" from="agent">\n'
result += "## Active Sub-Agent States\n\n"
result += f"Found {len(children)} active child sub-agent(s):\n\n"

for info in children:
result += f"**{info.name}**:\n"
result += f" - UUID: `{info.coder.uuid}`\n"
result += f" - Status: {info.status.value}\n"
if info.error:
result += f" - Error: {info.error}\n"
result += "\n"
result += "</context>"
return result
except Exception as e:
self.io.tool_error(f"Error generating child agent states: {str(e)}")
return None

def get_background_command_output(self):
"""
Get background command output to append after the main message.
Expand Down
38 changes: 30 additions & 8 deletions cecli/coders/base_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -1597,7 +1597,7 @@ async def output_task(self, preproc):
self.io.output_task = asyncio.create_task(self.generate(user_message, preproc))

# Start spinner for output task
self.io.start_spinner("Processing...")
self.io.start_spinner("Processing...", coder_uuid=getattr(self, "uuid", None))
await self.io.recreate_input()

# Monitor output task
Expand Down Expand Up @@ -1914,6 +1914,7 @@ async def summarize_and_update(messages, tag):
messages,
compaction_prompt,
self.context_compaction_summary_tokens,
coder=self,
)
if not text:
raise ValueError(f"Summarization of {tag} messages returned empty.")
Expand Down Expand Up @@ -2306,6 +2307,16 @@ async def check_tokens(self, messages):
def get_active_model(self):
return self.main_model

def empty_llm_tool_warning(self) -> str:
"""Ollama-friendly copy for local models; cloud hint otherwise."""
name = str(getattr(getattr(self, "main_model", None), "name", "") or "")
if "ollama" in name.lower():
return (
"Empty response from the local model (Ollama). "
"The model may have timed out, unloaded, or hit context limits."
)
return "Empty response received from LLM. Check API keys, quota, or provider status."

async def send_message(self, inp):
# Notify IO that LLM processing is starting
self.io.llm_started()
Expand Down Expand Up @@ -2365,7 +2376,7 @@ async def format_in_executor():
if not self.tui:
spinner_text += f" • ${self.format_cost(self.total_cost)} session"

self.io.start_spinner(spinner_text)
self.io.start_spinner(spinner_text, coder_uuid=getattr(self, "uuid", None))
if self.stream:
self.mdstream = True
else:
Expand Down Expand Up @@ -2452,9 +2463,7 @@ async def format_in_executor():
self.mdstream = None

# Ensure any waiting spinner is stopped
self.io.start_spinner("Processing Answer...")

self.partial_response_content = self.get_multi_response_content_in_progress(True)
self.io.start_spinner("Processing Answer...", coder_uuid=getattr(self, "uuid", None))
self.remove_reasoning_content()
self.multi_response_content = ""

Expand Down Expand Up @@ -2645,6 +2654,13 @@ def _expand_concatenated_json(self, tool_calls):
expanded_tool_calls.append(tool_call)
continue

merged = responses.merge_glued_json_objects(json_chunks)
if merged is not None:
new_tool_call = copy_tool_call(tool_call)
new_tool_call.function.arguments = json.dumps(merged)
expanded_tool_calls.append(new_tool_call)
continue

# We have concatenated JSON, so expand it into multiple tool calls.
for i, chunk in enumerate(json_chunks):
if not chunk.strip():
Expand Down Expand Up @@ -3255,7 +3271,6 @@ async def send(self, messages, model=None, functions=None, tools=None):
functions,
self.stream,
self.temperature,
# This could include any tools, but for now it is just MCP tools
tools=tools,
override_kwargs=self.model_kwargs.copy(),
interrupt_event=self.interrupt_event,
Expand Down Expand Up @@ -3363,7 +3378,7 @@ 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("Empty response received from LLM. Check your provider account?")
self.io.tool_warning(self.empty_llm_tool_warning())

self.io.assistant_output(show_resp, pretty=self.show_pretty())

Expand Down Expand Up @@ -3520,7 +3535,7 @@ 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("Empty response received from LLM. Check your provider account?")
self.io.tool_warning(self.empty_llm_tool_warning())

def consolidate_chunks(self):
if self.partial_response_consolidated:
Expand Down Expand Up @@ -3628,12 +3643,19 @@ def consolidate_chunks(self):
extracted_calls = responses.extract_tools_from_content_json(
self.partial_response_content
)

if not extracted_calls:
extracted_calls = responses.extract_tools_from_content_xml(
self.partial_response_content
)

if not extracted_calls:
extracted_calls = responses.extract_tools_from_pseudo_json(
self.partial_response_content
)

if extracted_calls:
self.tool_reflection = True
self.partial_response_tool_calls = extracted_calls

self.partial_response_consolidated = (response, func_err, content_err)
Expand Down
3 changes: 0 additions & 3 deletions cecli/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@
from .history_search import HistorySearchCommand
from .hooks import HooksCommand
from .include_skill import IncludeSkillCommand
from .invoke_agent import InvokeAgentCommand
from .lint import LintCommand
from .list_sessions import ListSessionsCommand
from .list_skills import ListSkillsCommand
Expand Down Expand Up @@ -117,7 +116,6 @@
CommandRegistry.register(HelpCommand)
CommandRegistry.register(HistorySearchCommand)
CommandRegistry.register(HooksCommand)
CommandRegistry.register(InvokeAgentCommand)
CommandRegistry.register(ReapAgentCommand)
CommandRegistry.register(SpawnAgentCommand)
CommandRegistry.register(SwitchAgentCommand)
Expand Down Expand Up @@ -200,7 +198,6 @@
"HistorySearchCommand",
"HooksCommand",
"IncludeSkillCommand",
"InvokeAgentCommand",
"ReapAgentCommand",
"SpawnAgentCommand",
"SwitchAgentCommand",
Expand Down
14 changes: 14 additions & 0 deletions cecli/commands/add.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,20 @@ async def execute(cls, io, coder, args, **kwargs):
if len(confirm_fname) > 64:
confirm_fname = f".../{os.path.basename(confirm_fname)}"

# Check if the path matches any exempt-path regex patterns
exempt_paths = getattr(coder.args, "exempt_paths", None) or []
if exempt_paths:
try:
rel_norm = os.path.relpath(fname, coder.root).replace("\\", "/")
except ValueError:
rel_norm = str(fname).replace("\\", "/")
if any(re.search(p, rel_norm) for p in exempt_paths):
io.tool_error(
f"Path '{confirm_fname}' matches an exempt-path pattern. "
"Skipping file creation."
)
continue

if await io.confirm_ask(
f"No files matched '{confirm_fname}'. Do you want to create this file?"
):
Expand Down
Loading
Loading