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
103 changes: 65 additions & 38 deletions cecli/coders/agent_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from cecli.change_tracker import ChangeTracker
from cecli.helpers import nested, responses
from cecli.helpers.agents.service import AgentService
from cecli.helpers.agents.tree import AgentTree
from cecli.helpers.background_commands import BackgroundCommandManager
from cecli.helpers.conversation import ConversationService, MessageTag
from cecli.helpers.similarity import (
Expand Down Expand Up @@ -62,15 +63,15 @@ def __init__(self, *args, **kwargs):
self.tool_similarity_threshold = 0.95
self.max_tool_vector_history = 20
self.read_tools = {
"command",
"shell",
"explorecode",
"readfile",
"grep",
"updatetodolist",
"searchfacts",
}
self.write_tools = {
"command",
"shell",
"editfile",
"undochange",
"replacefacts",
Expand Down Expand Up @@ -233,7 +234,7 @@ def _get_agent_config(self, config=None):
config,
["tools_excludelist", "tools_blacklist"],
[
"create",
"manageextensions",
"gitbranch",
"gitdiff",
"gitlog",
Expand Down Expand Up @@ -782,7 +783,7 @@ def get_context_summary(self):
result += f" ({percentage:.1f}% of limit)"
if percentage > 80:
result += "\n\n⚠ **Context is getting full!**\n"
result += "- Remove non-essential files, skills and tool servers via the `ResourceManager` tool.\n"
result += "- Remove non-essential files, skills and tool servers via the `ManageResources` tool.\n"
result += "- Keep only essential files, skills, and MCP servers in context for best performance"
result += "\n</context>"
if not hasattr(self, "context_blocks_cache"):
Expand Down Expand Up @@ -821,7 +822,10 @@ def get_environment_info(self):
primary_uuid = str(service.coder.uuid)
self_name = service.get_agent_name(self) or "primary"
result += f"- Agent: {self_name} ({self_uuid}) [self]\n"
result += f"- Primary agent: primary ({primary_uuid})\n"
# Workflow agents run in an isolated tree and must not see the
# primary agent (nor it them).
if AgentTree.is_visible(self_uuid, primary_uuid):
result += f"- Primary agent: primary ({primary_uuid})\n"
if self.repo:
try:
rel_repo_dir = self.repo.get_rel_repo_dir()
Expand Down Expand Up @@ -1515,7 +1519,7 @@ async def check_for_file_mentions(self, content):

Override parent's method to disable implicit file mention handling in agent mode.
Files should only be added via explicit tool commands
(`ResourceManager`).
(`ManageResources`).
"""
pass

Expand Down Expand Up @@ -1812,6 +1816,11 @@ def get_sub_agents_context(self):
return None
try:
registry = AgentService.get_registry()
# Workflow agents live in isolated trees; the primary must not see
# them as delegatable sub-agent types.
registry = {
name: config for name, config in registry.items() if not name.startswith("wf:")
}
if not registry:
return None

Expand Down Expand Up @@ -1842,9 +1851,11 @@ def get_sub_agent_states(self):
independent sub-agents. The independent ones are those reachable via
the ``Broadcast`` tool.

Finished/errored sub-agents are excluded. Returns None if there are no
active sub-agents, if enhanced context is disabled, or if the caller is
a sub-agent without nested delegation enabled.
Finished/errored sub-agents are excluded. Agents inside a workflow tree
see every active agent in that tree rather than only their own children.
Returns None if there are no active sub-agents, if enhanced context is
disabled, or if the caller is a sub-agent without nested delegation
enabled.

The primary agent and calling agent's own identity are deliberately not
included here — they live in ``get_environment_info()`` (a static block)
Expand All @@ -1868,34 +1879,50 @@ def get_sub_agent_states(self):
from cecli.helpers.agents.service import SubAgentStatus

originator_uuid = str(self.uuid)

# Dependent children are the coder's direct children.
children = service.get_children(self)
dependent_children = [
info
for info in children
if not info.independent
and not info.detached
and info.status not in (SubAgentStatus.FINISHED, SubAgentStatus.ERROR)
]

# Independent agents aren't all direct children, so iterate over
# every active sub-agent reachable via the Broadcast tool and dedupe
# against the dependent children already captured above.
dependent_uuids = {info.coder.uuid for info in dependent_children}
independent_agents = []
seen = set()
for info in service.sub_agents.values():
if (
info.independent
visible_uuids = AgentTree.get_visible_uuids(originator_uuid)
workflow_root = AgentTree.get_workflow_root(originator_uuid)

if workflow_root is not None:
# Inside a workflow tree, every active agent in that tree is
# visible: the workflow root, its children and their descendants.
dependent_children = [
info
for info in service.sub_agents.values()
if str(info.coder.uuid) != originator_uuid
and str(info.coder.uuid) in visible_uuids
and not info.detached
and str(info.coder.uuid) != originator_uuid
and info.status not in (SubAgentStatus.ERROR,)
and info.coder.uuid not in dependent_uuids
and info.coder.uuid not in seen
):
seen.add(info.coder.uuid)
independent_agents.append(info)
and info.status not in (SubAgentStatus.FINISHED, SubAgentStatus.ERROR)
]
independent_agents = []
else:
# Outside a workflow tree, dependent agents are the coder's
# direct children; independent agents are the others reachable
# through Broadcast in the same visibility domain.
children = service.get_children(self)
dependent_children = [
info
for info in children
if not info.independent
and not info.detached
and str(info.coder.uuid) in visible_uuids
and info.status not in (SubAgentStatus.FINISHED, SubAgentStatus.ERROR)
]

dependent_uuids = {info.coder.uuid for info in dependent_children}
independent_agents = []
seen = set()
for info in service.sub_agents.values():
if (
info.independent
and not info.detached
and str(info.coder.uuid) != originator_uuid
and str(info.coder.uuid) in visible_uuids
and info.status not in (SubAgentStatus.ERROR,)
and info.coder.uuid not in dependent_uuids
and info.coder.uuid not in seen
):
seen.add(info.coder.uuid)
independent_agents.append(info)

if not dependent_children and not independent_agents:
return None
Expand Down Expand Up @@ -1937,7 +1964,7 @@ def get_background_command_output(self):

Emits a roster of active commands (keeping command keys in context)
plus any new incremental output. Output that has been paged to disk is
advertised by command key and read on demand through ``ResourceManager``
advertised by command key and read on demand through ``ManageResources``
paging.
"""
command_info = BackgroundCommandManager.list_background_commands()
Expand Down Expand Up @@ -1970,7 +1997,7 @@ def get_background_command_output(self):
output += f"\nNew output ({command_key}):\n{cmd_output}\n"

if paged_keys:
output += "\nPaged output is available via `ResourceManager` (up to 3 pages):\n"
output += "\nPaged output is available via `ManageResources` (up to 3 pages):\n"
for command_key in paged_keys:
output += f'{{"paging": [{{"target": "{command_key}", "page": 1}}]}}\n'

Expand Down
22 changes: 10 additions & 12 deletions cecli/coders/base_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -1054,8 +1054,11 @@ def get_announcements(self):
from cecli.helpers.agents.service import AgentService

registry = AgentService.get_registry()
if registry:
capabilities["Subagents"] = sorted(registry.keys())
# Workflow agents belong to isolated trees, so they are not
# advertised as general-purpose subagent types.
subagents = sorted(name for name in registry if not name.startswith("wf:"))
if subagents:
capabilities["Subagents"] = subagents
except Exception:
pass

Expand Down Expand Up @@ -2297,13 +2300,13 @@ async def compact_context_if_needed(self, force=False, message=""):
is_min_size = all_tokens >= self.context_compaction_max_tokens

# Condition 2: Percentage check (is chat history a significant part of the context?)
is_worth_by_percentage = all_tokens > 0 and (compactable_tokens / all_tokens) >= 0.20
is_worth_by_percentage = all_tokens > 0 and (compactable_tokens / all_tokens) >= 0.15

# Condition 3: Absolute savings check (would compacting save enough tokens to fit?)
tokens_over_limit = all_tokens - (self.context_compaction_max_tokens or all_tokens)
potential_savings = compactable_tokens * 0.90
is_worth_by_absolute_savings = (
tokens_over_limit > 0 and potential_savings >= tokens_over_limit
is_worth_by_absolute_savings = tokens_over_limit > 0 and potential_savings >= (
tokens_over_limit * 0.50
)

if not force:
Expand Down Expand Up @@ -2787,10 +2790,6 @@ async def check_tokens(self, messages):
self.enable_context_compaction
and input_tokens >= self.context_compaction_max_tokens * 0.95
):
self.io.tool_output(
f"Estimated chat context of {input_tokens:,} tokens exceeds the"
f" {max_input_tokens:,} token limit. Attempting to compact..."
)
await self.compact_context_if_needed()

# After compaction, re-format messages and re-check tokens
Expand All @@ -2804,13 +2803,12 @@ async def check_tokens(self, messages):
if getattr(self, "_last_compaction_warning_time", 0) + 300 < time.time():
self._last_compaction_warning_time = time.time()
self.io.tool_error(
f"Your estimated chat context of {input_tokens:,} tokens still exceeds the"
f" {max_input_tokens:,} token limit for {self.get_active_model().name}!"
f"Your estimated chat context of {input_tokens:,} tokens exceeds the"
f" {max_input_tokens:,} token limit for {self.get_active_model().name}"
)
self.io.tool_output("To reduce the chat context:")
self.io.tool_output("- Use /drop to remove unneeded files from the chat")
self.io.tool_output("- Use /clear to clear the chat history")
self.io.tool_output("- Break your code into smaller files")
self.io.tool_output(
"It's probably safe to try and send the request, most providers won't charge if"
" the context limit is exceeded."
Expand Down
47 changes: 29 additions & 18 deletions cecli/commands/agent_tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from cecli.commands.utils.base_command import BaseCommand
from cecli.helpers.agents.service import AgentService, SubAgentInfo, SubAgentStatus
from cecli.helpers.agents.tree import AgentTree


class TreeNode:
Expand Down Expand Up @@ -30,17 +31,15 @@ async def execute(cls, io, coder, args, **kwargs):
return

# Collect all agents: primary coder + sub-agents from sub_agents dict
all_infos: List[SubAgentInfo] = []

# Primary agent as a synthetic SubAgentInfo
primary_coder = agent_service.coder
primary_uuid = str(primary_coder.uuid)
primary_info = SubAgentInfo(
name="primary",
coder=primary_coder,
parent_uuid=str(primary_coder.parent_uuid or ""),
status=SubAgentStatus.RUNNING,
)
all_infos.append(primary_info)
all_infos: List[SubAgentInfo] = [primary_info]

# Sub-agents from the service's sub_agents dict
for info in agent_service.sub_agents.values():
Expand All @@ -51,26 +50,38 @@ async def execute(cls, io, coder, args, **kwargs):
io.tool_output("No active sub-agents found.")
return

root_nodes = cls._build_tree(all_infos)
# The shared tree is process-wide, so structure the display from the
# primary's subtree only.
infos_by_uuid = {str(info.coder.uuid): info for info in all_infos}
subtree = AgentTree.get_subtree(primary_uuid)
if not subtree:
subtree = list(infos_by_uuid)

root_nodes = cls._build_tree(primary_uuid, infos_by_uuid, subtree)
tree_output = cls._render_tree(root_nodes)
io.tool_output(tree_output)

@classmethod
def _build_tree(cls, all_agents: List[SubAgentInfo]) -> List[TreeNode]:
"""Build the agent tree from a flat list of agents."""
nodes = {str(agent.coder.uuid): TreeNode(agent) for agent in all_agents}
root_nodes = []

for agent in all_agents:
parent_uuid = str(agent.coder.parent_uuid)
if parent_uuid and parent_uuid in nodes:
parent_node = nodes[parent_uuid]
child_node = nodes[str(agent.coder.uuid)]
parent_node.add_child(child_node)
def _build_tree(cls, root_uuid, infos, subtree_uuids) -> List[TreeNode]:
"""Build the display tree from the shared :class:`AgentTree`."""
nodes = {uuid: TreeNode(infos[uuid]) for uuid in subtree_uuids if uuid in infos}
root = nodes.get(root_uuid)
if root is None:
return []

for uuid, node in nodes.items():
if uuid == root_uuid:
continue

parent = nodes.get(AgentTree.get_parent(uuid))
if parent is not None:
parent.add_child(node)
else:
root_nodes.append(nodes[str(agent.coder.uuid)])
# Keep a node reachable even if its parent fell outside the
# session subtree (e.g. a spliced node left over).
root.add_child(node)

return root_nodes
return [root]

@classmethod
def _render_tree(cls, nodes: List[TreeNode]) -> str:
Expand Down
10 changes: 5 additions & 5 deletions cecli/helpers/agents/defaults/creator.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ You create and maintain cecli extensions and configuration:
- **custom tools** (`Tool` classes loaded from tools directories)
- **custom commands** (`CustomCommand` classes loaded via `custom.command-paths`)
- **workflows** (YAML workflow definitions)
- **project/global configuration** (persisted to `creator.yml` through the `Create` tool)
- **project/global configuration** (persisted to `creator.yml` through the `ManageExtensions` tool)

Do not implement general application features, fix unrelated bugs, or debug the
user's project. If a request is outside this scope, explain that briefly and
Expand All @@ -64,11 +64,11 @@ Common locations:
- Workflows: `.cecli/workflows/*.yml`, `~/.cecli/workflows/*.yml`
- Custom commands: paths listed under `custom.command-paths`

These are read/reference only — the `Create` tool owns all writes.
These are read/reference only — the `ManageExtensions` tool owns all writes.

## Persistence is the Create tool's job
## Persistence is the ManageExtensions tool's job

All extension and config writes go through `Create` — never edit `creator.yml`
All extension and config writes go through `ManageExtensions` — never edit `creator.yml`
or user config files (`.cecli.conf.yml`, `conf.yml`) yourself. Each call
writes the backing files, records the needed `creator.yml` entry (global
`~/.cecli/creator.yml` or project-local `.cecli/creator.yml`, deep-merged with
Expand All @@ -91,7 +91,7 @@ non-extension settings use `type: config` with a dotted key path as `name`, and
1. Clarify the goal with `Broadcast` when the request depends on prior context.
2. Consult the documentation before choosing a format or config key.
3. Inspect existing files and config before changing anything so you preserve
user work; use `Create(action: "list")` to see what is already persisted.
user work; use `ManageExtensions(action: "list")` to see what is already persisted.
4. Make the smallest change that fully implements the request.
5. Validate: run `python -m py_compile` for Python files, parse YAML/JSON, and
re-read edited files to confirm the result.
Expand Down
2 changes: 1 addition & 1 deletion cecli/helpers/agents/overrides.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ def load_agent_overrides(primary_root: Optional[str] = None) -> Dict[str, Any]:
def refresh_agent_overrides(primary_root: Optional[str] = None) -> Dict[str, Any]:
"""Re-read the ``creator.yml`` files and refresh the spawn-time index.

Both startup (``main_async``) and the Create tool use this single reader
Both startup (``main_async``) and the ManageExtensions tool use this single reader
path: it re-reads the global and local ``creator.yml`` files, updates
``AgentService``'s override index, and re-registers/removes dynamic
``creator:<name>`` definitions. Call it after any mutation of a
Expand Down
Loading
Loading