Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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.2.0.dev"
__version__ = "1.2.3.dev"
safe_version = __version__

try:
Expand Down
2 changes: 1 addition & 1 deletion cecli/coders/base_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -3363,7 +3363,7 @@ def get_tool_list(self):
continue

for tool in server_tools:
if server_name == "Local":
if server_name.lower() == "local":
# Apply per-instance tool name filtering
tool_name = tool.get("function", {}).get("name", "")
if (
Expand Down
4 changes: 2 additions & 2 deletions cecli/commands/load_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,13 @@ async def execute(cls, io, coder, args, **kwargs):
# This moves them from "implicitly include all" to explicit state-machine
# management, preventing the new server from being implicitly available
# to all coders.
connected_names = {s.name for s in coder.mcp_manager.connected_servers}
connected_names = {s.name.lower() for s in coder.mcp_manager.connected_servers}
if connected_names:
for c in iter_all_coders(coder):
if not c.registered_servers["included"]:
included = set(connected_names) - c.registered_servers["excluded"]
if c.edit_format in ("agent", "subagent"):
included.add("Local") # "local" is always available
included.add("local") # "local" is always available
c.registered_servers["included"] = included

# Process connections with interrupt support
Expand Down
2 changes: 1 addition & 1 deletion cecli/commands/remove_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ async def execute(cls, io, coder, args, **kwargs):
server_name = item.name if hasattr(item, "name") else item

# Never remove the "local" server
if server_name == "Local":
if server_name.lower() == "local":
results.append("Cannot remove 'Local' server")
continue

Expand Down
4 changes: 2 additions & 2 deletions cecli/commands/utils/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,7 @@ def update_server_registration(coder, server_name, operation, force=False):
When False, respect the opposing set (excluded wins
for include, included wins for exclude).
"""
name = server_name
name = server_name.lower()
included = coder.registered_servers["included"]
excluded = coder.registered_servers["excluded"]

Expand Down Expand Up @@ -379,7 +379,7 @@ def is_server_globally_excluded(coder, server_name):
Returns:
True if the server is excluded from every coder.
"""
name = server_name
name = server_name.lower()
for other in iter_all_coders(coder):
incl = other.registered_servers["included"]
excl = other.registered_servers["excluded"]
Expand Down
10 changes: 9 additions & 1 deletion cecli/helpers/agents/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -565,11 +565,19 @@ async def _create_sub_agent_coder(
)

if agent_config:
# Reset the per-instance tool/server filters so AgentCoder.post_init()
# rebuilds them from *this* sub-agent's own agent-config (tools_includelist/
# excludelist, servers_includelist/excludelist) instead of inheriting the
# parent's filters verbatim. Deliberately do NOT reset mcp_manager here:
# doing so used to discard the parent's already-connected MCP servers and
# replace them with a brand new, empty manager, making it impossible for a
# sub-agent's servers_includelist to ever match anything. Leaving mcp_manager
# unset lets it inherit from from_coder (see Coder.create()) so the same
# connected MCP servers remain available and can then be filtered.
kwargs.update(
dict(
registered_tools=None,
registered_servers=None,
mcp_manager=None,
)
)

Expand Down
19 changes: 15 additions & 4 deletions cecli/helpers/conversation/integration.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import json
import random
import time
import weakref
from typing import Any, Dict, List

Expand Down Expand Up @@ -959,13 +960,23 @@ def add_sub_agent_states(self) -> None:
if not block:
return

if not hasattr(coder, "_last_child_agent_hash") or not coder._last_child_agent_hash:
coder._last_child_agent_hash = ""

message_hash = xxhash.xxh3_128_hexdigest(block.encode("utf-8"))

if message_hash == coder._last_child_agent_hash:
return # No change in sub-agent states, skip adding message

coder._last_child_agent_hash = message_hash

ConversationService.get_manager(coder).add_message(
message_dict={"role": "user", "content": block},
tag=MessageTag.STATIC,
priority=DEFAULT_TAG_PRIORITY[MessageTag.REMINDER] + 25, # After post_message blocks
mark_for_delete=0,
hash_key=("sub_agent_states",),
force=True,
priority=DEFAULT_TAG_PRIORITY[
MessageTag.CUR
], # Inject on change in normal message sequence
hash_key=("sub_agent_states", str(time.monotonic_ns())),
)

def defer_removal(self, file_path: str):
Expand Down
12 changes: 0 additions & 12 deletions cecli/helpers/conversation/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -358,18 +358,6 @@ def get_messages_dict(
with safe_open(".cecli/logs/conversation.log", "w") as f:
json.dump(messages_dict, f, indent=4, default=lambda o: "<not serializable>")

# Add cache control headers when getting all messages (for LLM consumption)
# Only add cache control if the coder has add_cache_headers = True
if tag is None:
if (
coder
and hasattr(coder, "add_cache_headers")
and coder.add_cache_headers
and hasattr(coder, "main_model")
and not coder.main_model.caches_by_default
and not getattr(coder.main_model, "uses_messages_api", False)
):
messages_dict = self._add_cache_control(messages_dict)
return messages_dict

def clear_tag(self, tag: str, ratio: float = 0) -> None:
Expand Down
4 changes: 2 additions & 2 deletions cecli/helpers/llms/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,8 @@ def resolve_model_config(model: str) -> Dict[str, Any]:

# Effective cache flags for the domain adapters. Anthropic messages-API
# models never cache "by default": prompt caching only happens when the
# request carries explicit ``cache_control`` breakpoints, so the messages
# domain injects them (agent.cache_control True + caches_by_default False).
# request asks for it, so the messages domain requests top-level automatic
# caching (agent.cache_control True + caches_by_default False).
if family == "messages":
agent_block["cache_control"] = True
agent_block["caches_by_default"] = False
Expand Down
190 changes: 63 additions & 127 deletions cecli/helpers/llms/domains/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,6 @@

DEFAULT_TIMEOUT = 120.0

#: Anthropic allows at most 4 ``cache_control`` breakpoints per request.
MAX_CACHE_BREAKPOINTS = 4


def anthropic_payload(
resolved: Dict[str, Any],
Expand All @@ -45,9 +42,12 @@ def anthropic_payload(
kwargs: Dict[str, Any],
) -> Dict[str, Any]:
system = system_prompt(messages)
wire_messages = _coalesce_anthropic_messages(
[anthropic_message(m) for m in messages if m.get("role") != "system"]
)
payload: Dict[str, Any] = {
"model": resolved["route"],
"messages": [anthropic_message(m) for m in messages if m.get("role") != "system"],
"messages": wire_messages,
"max_tokens": (
kwargs.get("max_tokens") or resolved.get("llm_block", {}).get("max_tokens") or 4096
),
Expand Down Expand Up @@ -95,14 +95,13 @@ def anthropic_payload(
extra_body.pop("thinking", None)
payload.update(extra_body)

# Anthropic prompt caching is a byte-exact prefix match: it only happens
# when the request carries explicit ``cache_control`` breakpoints. The
# model config flags messages-API models ``cache_control`` True and
# ``caches_by_default`` False (see config.resolve_model_config), so the
# messages domain owns the marking here rather than the conversation
# manager.
# Anthropic prompt caching: request automatic caching via the top-level
# ``cache_control`` field. The model config flags messages-API models
# ``cache_control`` True and ``caches_by_default`` False (see
# config.resolve_model_config), so the messages domain requests it here
# rather than managing explicit per-block breakpoints.
if agent_block.get("cache_control") and not agent_block.get("caches_by_default"):
_apply_anthropic_cache_control(payload)
payload["cache_control"] = {"type": "ephemeral"}

return payload

Expand Down Expand Up @@ -274,6 +273,7 @@ async def anthropic_stream(
"id": block.get("id", ""),
"name": block.get("name", ""),
"input": block.get("input") or {},
"_input_raw": "",
}

elif evt == "content_block_delta":
Expand All @@ -291,7 +291,21 @@ async def anthropic_stream(
elif dtype == "signature_delta":
entry["signature"] = delta.get("signature")

elif dtype == "input_json_delta":
entry["_input_raw"] += delta.get("partial_json") or ""

elif evt == "content_block_stop":
entry = blocks.get(current) if current is not None else None

if entry is not None:
raw = entry.pop("_input_raw", None)

if raw is not None:
try:
entry["input"] = json.loads(raw)
except json.JSONDecodeError:
pass

current = None

chunk = parse_anthropic_chunk(json_obj)
Expand Down Expand Up @@ -609,138 +623,60 @@ def _anthropic_user_blocks(content: List[Dict[str, Any]]) -> List[Dict[str, Any]
return blocks


def _apply_anthropic_cache_control(payload: Dict[str, Any]) -> None:
"""Attach ephemeral ``cache_control`` breakpoints to a copy of the stream.

Anthropic prompt caching is a byte-exact prefix match: a breakpoint on the
last content block of a message caches every token up to and including it.
Following the conversation manager's placement (``manager.py
_add_cache_control``), we mark the three stable boundaries of a multi-turn
exchange:

- the last system block at the start of the stream (which also covers the
tool definitions rendered before it), and
- the last content block of the two most recent non-tool user/assistant
turns.
def _coalesce_anthropic_messages(wire_messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Merge consecutive user turns into a single user message.

The message stream is copied and only the key messages are replaced, so
caller-owned lists are never mutated. Anthropic allows at most
``MAX_CACHE_BREAKPOINTS`` breakpoints per request, so the marking stops
early once that budget (including any caller-supplied markers) is
exhausted.
The conversation manager emits one user message per tool result and can
inject file-context text as its own user message directly after the
results, so the raw wire list can contain several ``user`` messages in a
row. The Messages API requires alternating roles and, per the tool-use
spec, ``tool_result`` blocks must come first in the user message that
follows an assistant ``tool_use`` turn. Text runs (across messages) are
concatenated with ``"\n---\n"`` separators.
"""
import copy
result: List[Dict[str, Any]] = []
pending: List[Dict[str, Any]] = []

budget = MAX_CACHE_BREAKPOINTS - _count_cache_breakpoints(payload)
def flush() -> None:
if not pending:
return

if budget <= 0:
return
tool_results = [b for b in pending if b.get("type") == "tool_result"]
others = [b for b in pending if b.get("type") != "tool_result"]
content: List[Dict[str, Any]] = list(tool_results)
text_parts: List[str] = []

system = payload.get("system")
for block in others:
if block.get("type") == "text":
text_parts.append(block.get("text") or "")

if isinstance(system, str):
payload["system"] = [
{"type": "text", "text": system, "cache_control": {"type": "ephemeral"}}
]
budget -= 1

elif isinstance(system, list) and system:
system = list(system)
last = system[-1]

if isinstance(last, dict):
last = copy.deepcopy(last)
system[-1] = last

if _mark_cache_breakpoint(last):
budget -= 1
continue

payload["system"] = system
if text_parts:
content.append({"type": "text", "text": "\n---\n".join(text_parts)})
text_parts = []

messages = payload.get("messages") or []
result = list(messages)
marked = 0
content.append(block)

for i in range(len(result) - 1, -1, -1):
if marked >= 2 or budget <= 0:
break
if text_parts:
content.append({"type": "text", "text": "\n---\n".join(text_parts)})

msg = result[i]
result.append({"role": "user", "content": content})
pending.clear()

if _is_tool_turn(msg):
for msg in wire_messages:
if msg.get("role") != "user":
flush()
result.append(msg)
continue

content = msg.get("content")
blocks = content if isinstance(content, list) else [{"type": "text", "text": content}]
pending.extend(blocks)

if isinstance(content, str):
msg = copy.deepcopy(msg)
msg["content"] = [
{"type": "text", "text": content, "cache_control": {"type": "ephemeral"}}
]
result[i] = msg
marked += 1
budget -= 1

elif isinstance(content, list) and content:
msg = copy.deepcopy(msg)
blocks = list(msg["content"])

if _mark_cache_breakpoint(blocks[-1]):
marked += 1
budget -= 1

msg["content"] = blocks
result[i] = msg

payload["messages"] = result
flush()


def _count_cache_breakpoints(payload: Dict[str, Any]) -> int:
"""Count existing ``cache_control`` markers in a payload (system + messages)."""
count = 0
system = payload.get("system")

if isinstance(system, list):
count += sum(isinstance(block, dict) and "cache_control" in block for block in system)

for tool in payload.get("tools") or []:
if isinstance(tool, dict) and "cache_control" in tool:
count += 1

for msg in payload.get("messages") or []:
content = msg.get("content")

if isinstance(content, list):
count += sum(isinstance(block, dict) and "cache_control" in block for block in content)

return count


def _mark_cache_breakpoint(block: Any) -> bool:
"""Add an ephemeral breakpoint to ``block`` unless it already has one."""
if not isinstance(block, dict) or "cache_control" in block:
return False

block["cache_control"] = {"type": "ephemeral"}

return True


def _is_tool_turn(msg: Dict[str, Any]) -> bool:
"""True when a wire message is a tool turn (transient content).

Matches the conversation manager's placement, which skips tool messages
and assistant tool-call turns when choosing cache breakpoints.
"""
content = msg.get("content")

if not isinstance(content, list) or not content:
return False

return any(
isinstance(block, dict) and block.get("type") in ("tool_result", "tool_use")
for block in content
)
return result


__all__ = [
Expand Down
Loading
Loading