Skip to content
Open
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
2 changes: 1 addition & 1 deletion cecli/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from packaging import version

__version__ = "1.5.0.dev"
__version__ = "1.10.0.dev"
safe_version = __version__

try:
Expand Down
2 changes: 1 addition & 1 deletion cecli/args.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ def get_parser(default_config_files, git_root):
metavar="EDIT_FORMAT",
choices=edit_format_choices,
default=None,
help="Specify what edit format the LLM should use (default depends on model)",
help="Specify what edit format the LLM should use (default: agent)",
)
group.add_argument(
"--ask",
Expand Down
33 changes: 33 additions & 0 deletions cecli/coders/agent_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,10 @@ async def initialize_mcp_tools(self):
verbose = getattr(self.args, "verbose", False) if self.args else False
self.mcp_manager = McpServerManager([], self.io, verbose)

await self._initialize_local_server()
await self._connect_configured_servers()

async def _initialize_local_server(self):
server_name = "Local"
server = self.mcp_manager.get_server(server_name)

Expand All @@ -369,6 +373,35 @@ async def initialize_mcp_tools(self):
else:
await self.mcp_manager.connect_server(server_name)

async def _connect_configured_servers(self):
"""Open connections for servers named in this agent's ``servers`` allowlist.

``McpServerManager.spawn_child`` only mirrors servers the parent already
had connected, so a workflow's declarative ``servers:`` list would never
open a connection for servers that are disabled by default (or whose
parent connection failed).
"""
included = getattr(self, "registered_servers", {}).get("included") or set()

for name in sorted(included):
if name == "local":
continue

server = self.mcp_manager.get_server(name)
if server is None:
self.io.tool_warning(
f"MCP server '{name}' is required by this agent but is not configured"
)
continue

if server.is_connected:
continue

if not await self.mcp_manager.connect_server(server.name):
self.io.tool_warning(
f"Failed to connect MCP server '{server.name}' required by this agent"
)

async def _execute_mcp_tool(self, server, tool_name, params):
"""Helper to execute a single MCP tool call, created from legacy format."""
from cecli.tools.utils.responses import ToolResponse
Expand Down
14 changes: 5 additions & 9 deletions cecli/coders/base_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -998,12 +998,12 @@ def get_announcements(self):
settings_items.append(f"reasoning {reasoning_effort}")

# Prompt cache
if self.add_cache_headers or main_model.caches_by_default:
settings_items.append("prompt cache")
# if self.add_cache_headers or main_model.caches_by_default:
# settings_items.append("prompt cache")

# Infinite output
if main_model.info.get("supports_assistant_prefill") and self.verbose:
settings_items.append("infinite output")
# if main_model.info.get("supports_assistant_prefill") and self.verbose:
# settings_items.append("infinite output")

# Copy/paste mode
if self.copy_paste_mode:
Expand Down Expand Up @@ -1033,10 +1033,6 @@ def get_announcements(self):
refresh = self.repo_map.refresh
env_items.append(f"map ({map_tokens} tokens, {refresh} refresh)")
repo_map_tokens = map_tokens
else:
env_items.append("repo-map disabled")
else:
env_items.append("repo-map disabled")

sections["Environment"] = {"items": env_items}
# --- CAPABILITIES ---
Expand Down Expand Up @@ -1076,7 +1072,7 @@ def get_announcements(self):
if mcp_servers:
capabilities["Servers"] = mcp_servers

if capabilities:
if capabilities and not getattr(self.args, "tui", False):
# sections["Extensions"] = {"subsections": capabilities}
sections["Environment"]["subsections"] = capabilities

Expand Down
22 changes: 20 additions & 2 deletions cecli/helpers/agents/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,15 @@ async def _create_sub_agent_coder(
inherited.setdefault("allow_nested_delegation", True)
agent_config = inherited

# Workflow agents always keep the Local server available: its tools are
# the file/exec primitives a worker needs, and an explicit ``servers:``
# allowlist must not filter them out.
if name.startswith("wf:") and agent_config.get("servers_includelist"):
servers = [str(server).lower() for server in agent_config["servers_includelist"]]
if "local" not in servers:
servers.append("local")
agent_config["servers_includelist"] = servers

kwargs = dict(
io=parent_coder.io,
from_coder=parent_coder,
Expand Down Expand Up @@ -909,6 +918,17 @@ async def _cleanup():
info.summary = info.summary or DEFAULT_SUMMARY_INTERRUPTED
logger.debug("Sub-agent %s generate cancelled (interrupted)", info.name)
raise
except SwitchCoderSignal as switch:
# A command run mid-turn (e.g. /load-mcp) asks for this
# sub-agent's coder to be rebuilt. Absorb the signal here:
# letting it escape would recreate the *primary* coder from a
# sub-agent. The switch also drains any prompts staged behind
# the command, mirroring run_one()'s post-command drain.
await self.switch_sub_agent_coder(info, switch, preproc=True)
await self._inject_sub_agent_result(info)
if info.status == SubAgentStatus.RUNNING:
info.status = SubAgentStatus.FINISHED
info.summary = info.summary or DEFAULT_SUMMARY_COMPLETED
except Exception as exc:
stop_reason, error = "error", str(exc) or exc.__class__.__name__
await self._inject_sub_agent_result(info)
Expand All @@ -921,8 +941,6 @@ async def _cleanup():
exc_info=True,
)
raise
except SwitchCoderSignal:
raise
finally:
_subagent_event(
info.coder, info.coder.uuid, "idle", stop_reason=stop_reason, error=error
Expand Down
3 changes: 3 additions & 0 deletions cecli/helpers/diff/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .diff_helpers import unified_diff_compacted

__all__ = ["unified_diff_compacted"]
154 changes: 154 additions & 0 deletions cecli/helpers/diff/diff_helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
"""Generic unified-diff helpers.

These helpers are independent of hashline/HashPos formatting: they operate on
plain text lines and only use :mod:`difflib`.
"""

import difflib


def unified_diff_compacted(old_lines, new_lines, fromfile="", tofile="", context_lines=3):
"""Build a unified diff with git-style change-group compaction.

``difflib`` can anchor a change to the first of several identical blocks,
which makes unchanged lines look added or removed. Git compacts change
groups to their last possible position; this mirrors that for pure
insertions and deletions.

Returns:
list[str]: Unified diff lines without trailing newlines, or an empty
list when the inputs are identical.
"""
matcher = difflib.SequenceMatcher(None, old_lines, new_lines)
opcodes = _compact_diff_opcodes(matcher.get_opcodes(), old_lines, new_lines)
diff_lines = []

for group in _group_diff_opcodes(opcodes, context_lines):
first = group[0]
last = group[-1]
diff_lines.append(
f"@@ -{_format_diff_range(first[1], last[2])} +"
f"{_format_diff_range(first[3], last[4])} @@"
)

for tag, i1, i2, j1, j2 in group:
if tag == "equal":
diff_lines.extend(" " + line for line in old_lines[i1:i2])
elif tag in ("replace", "delete"):
diff_lines.extend("-" + line for line in old_lines[i1:i2])
if tag in ("replace", "insert"):
diff_lines.extend("+" + line for line in new_lines[j1:j2])

if not diff_lines:
return []

return [f"--- {fromfile}", f"+++ {tofile}"] + diff_lines


def _compact_diff_opcodes(opcodes, old_lines, new_lines):
"""Slide pure insert/delete groups down over repeated lines."""
ops = [list(op) for op in opcodes]
index = 0

while index < len(ops):
if ops[index][0] == "equal":
index += 1
continue

start = index
while index < len(ops) and ops[index][0] != "equal":
index += 1

end = index

if end - start != 1:
continue

tag = ops[start][0]
if tag not in ("insert", "delete"):
continue

while end < len(ops) and ops[end][0] == "equal":
equal = ops[end]

if tag == "insert":
if ops[start][3] >= ops[start][4] or equal[3] >= equal[4]:
break
if new_lines[ops[start][3]] != new_lines[equal[3]]:
break
else:
if ops[start][1] >= ops[start][2] or equal[1] >= equal[2]:
break
if old_lines[ops[start][1]] != old_lines[equal[1]]:
break

if start == 0 or ops[start - 1][0] != "equal":
ops.insert(
start,
["equal", ops[start][1], ops[start][1], ops[start][3], ops[start][3]],
)
start += 1
end += 1

previous = ops[start - 1]
previous[2] += 1
previous[4] += 1

ops[start][1] += 1
ops[start][2] += 1
ops[start][3] += 1
ops[start][4] += 1

equal[1] += 1
equal[3] += 1

if equal[1] == equal[2]:
del ops[end]

index = end

return [tuple(op) for op in ops]


def _group_diff_opcodes(opcodes, context_lines):
"""Group opcodes into hunks with ``context_lines`` of surrounding context."""
codes = [list(op) for op in opcodes]
if not codes:
codes = [["equal", 0, 1, 0, 1]]

if codes[0][0] == "equal":
tag, i1, i2, j1, j2 = codes[0]
codes[0] = [tag, max(i1, i2 - context_lines), i2, max(j1, j2 - context_lines), j2]

if codes[-1][0] == "equal":
tag, i1, i2, j1, j2 = codes[-1]
codes[-1] = [tag, i1, min(i2, i1 + context_lines), j1, min(j2, j1 + context_lines)]

spacing = context_lines * 2
group = []

for tag, i1, i2, j1, j2 in codes:
if tag == "equal" and i2 - i1 > spacing:
group.append((tag, i1, min(i2, i1 + context_lines), j1, min(j2, j1 + context_lines)))
yield group
group = []
i1, j1 = max(i1, i2 - context_lines), max(j1, j2 - context_lines)

group.append((tag, i1, i2, j1, j2))

if group and not (len(group) == 1 and group[0][0] == "equal"):
yield group


def _format_diff_range(start, stop):
"""Format a unified-diff range the same way :mod:`difflib` does."""
beginning = start + 1
length = stop - start

if length == 1:
return str(beginning)

if not length:
beginning -= 1

return f"{beginning},{length}"
9 changes: 4 additions & 5 deletions cecli/helpers/hashline.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import re
from collections import Counter

from cecli.helpers.diff.diff_helpers import unified_diff_compacted
from cecli.helpers.hashpos.hashpos import ( # noqa
HASH_DELIMITER,
UNIQUE_HASH_DELIMITER,
Expand Down Expand Up @@ -716,16 +717,14 @@ def get_hashline_content_diff(
old_hashlines, old_content_to_lines, old_content_lines = _parse_content_for_diff(old_content)
new_hashlines, new_content_to_lines, new_content_lines = _parse_content_for_diff(new_content)

# Generate content-only diff
content_diff = difflib.unified_diff(
# Generate content-only diff with change groups compacted to a stable position.
content_diff_lines = unified_diff_compacted(
old_content_lines,
new_content_lines,
fromfile=fromfile,
tofile=tofile,
lineterm="",
n=context_lines,
context_lines=context_lines,
)
content_diff_lines = list(content_diff)

# If there's no content change, return empty string
if not content_diff_lines:
Expand Down
30 changes: 30 additions & 0 deletions cecli/helpers/io_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,22 @@ def reset_streaming_response(self, **kwargs) -> Any:
kwargs["coder_uuid"] = self._coder_uuid
return self._target.reset_streaming_response(**kwargs)

def ring_bell(self, **kwargs: Any) -> Any:
"""Forward ring_bell with coder_uuid and is_workflow injected."""
if "coder_uuid" not in kwargs:
kwargs["coder_uuid"] = self._coder_uuid
if "is_workflow" not in kwargs:
kwargs["is_workflow"] = self._is_workflow_agent()
return self._target.ring_bell(**kwargs)

def notify_user_input_required(self, **kwargs: Any) -> Any:
"""Forward notify_user_input_required with coder_uuid and is_workflow injected."""
if "coder_uuid" not in kwargs:
kwargs["coder_uuid"] = self._coder_uuid
if "is_workflow" not in kwargs:
kwargs["is_workflow"] = self._is_workflow_agent()
return self._target.notify_user_input_required(**kwargs)

async def get_input(self, *args, **kwargs):
"""Get input for this specific coder via per-coder queue.

Expand Down Expand Up @@ -310,6 +326,20 @@ def __setattr__(self, name: str, value: Any) -> None:
else:
setattr(self._target, name, value)

def _is_workflow_agent(self) -> bool:
"""Return True if the proxied coder is a workflow (wf:*) sub-agent."""
_ref = getattr(self, "_coder", None)
coder = _ref() if _ref is not None else None
if coder is None:
return False
try:
from cecli.helpers.agents.service import AgentService

name = AgentService.get_agent_name(coder)
return name is not None and name.startswith("wf:")
except Exception:
return False


# --- THE TYPE HINTING TRICK ---
# At type-checking time, make IOProxy(target, coder) appear to return
Expand Down
4 changes: 2 additions & 2 deletions cecli/helpers/server/acp/agent/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,8 +194,8 @@ def display_user_input(self, inp):
def rule(self):
pass

def ring_bell(self):
def ring_bell(self, **kwargs):
pass

def notify_user_input_required(self):
def notify_user_input_required(self, **kwargs):
pass
Loading
Loading