From b9332e0a09973f2c84b4423d5e7619d6e8d64326 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Mon, 24 Aug 2026 18:15:05 -0400 Subject: [PATCH 01/32] Bump Version --- cecli/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cecli/__init__.py b/cecli/__init__.py index 19645dbc949..6fe485a8330 100644 --- a/cecli/__init__.py +++ b/cecli/__init__.py @@ -1,6 +1,6 @@ from packaging import version -__version__ = "1.3.1.dev" +__version__ = "1.3.2.dev" safe_version = __version__ try: From a4db2d83a1b1a03b4b4ff5a226fd2e8bc3b2be70 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Mon, 24 Aug 2026 23:09:22 -0400 Subject: [PATCH 02/32] #657: Memorizer should only load the local server --- cecli/helpers/agents/defaults/memorizer.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cecli/helpers/agents/defaults/memorizer.md b/cecli/helpers/agents/defaults/memorizer.md index 9c9618c5ee9..3b454bf7d30 100644 --- a/cecli/helpers/agents/defaults/memorizer.md +++ b/cecli/helpers/agents/defaults/memorizer.md @@ -16,6 +16,8 @@ agent-config: - gitremote - gitshow - gitstatus + servers_includelist: + - local exclude_context_blocks: - context_summary - directory_structure From 77c3553a3dc92ff8c69fb0a3bbe33702292274a6 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Tue, 25 Aug 2026 08:36:04 -0400 Subject: [PATCH 03/32] Fix auto-update script to bypass daily version check --- cecli/main.py | 2 +- cecli/utils.py | 74 ++++++++++++++++++++++++++++++++++++------- cecli/versioncheck.py | 12 +++---- 3 files changed, 69 insertions(+), 19 deletions(-) diff --git a/cecli/main.py b/cecli/main.py index 262f7ab1646..37da564e52e 100644 --- a/cecli/main.py +++ b/cecli/main.py @@ -1004,7 +1004,7 @@ def get_io(pretty): return await main_async(argv, input, output, right_repo_root, return_coder=return_coder) if (args.check_update or args.upgrade) and not args.just_check_update and not suppress_pre_init: - await check_version(pre_init_io, verbose=args.verbose) + await check_version(pre_init_io, verbose=args.verbose, upgrade=args.upgrade) elif args.just_check_update: update_available = await check_version(pre_init_io, just_check=True, verbose=args.verbose) return await graceful_exit(None, 0 if not update_available else 1) diff --git a/cecli/utils.py b/cecli/utils.py index f4341e03e7f..87d0f721401 100644 --- a/cecli/utils.py +++ b/cecli/utils.py @@ -299,16 +299,61 @@ def get_pip_install(args): return cmd +def get_self_upgrade_command(): + """Return the command used to upgrade cecli for the current install method.""" + # pipx sets these environment variables inside each tool's venv + if os.environ.get("PIPX_VENV_DIR") or os.environ.get("PIPX_PACKAGE_DIR"): + return ["pipx", "upgrade", "cecli-dev"] + + if _is_uv_tool_env(): + return ["uv", "tool", "upgrade", "cecli-dev"] + + return get_pip_install(["cecli-dev"]) + + +def _is_pip_install_command(cmd): + """Return True if cmd is a `python -m pip install ...` invocation.""" + try: + i = cmd.index("-m") + except ValueError: + return False + return i + 2 < len(cmd) and cmd[i + 1] == "pip" and cmd[i + 2] == "install" + + +def _is_uv_tool_env(): + """Return True if running inside a `uv tool install` environment.""" + cfg = Path(sys.prefix) / "pyvenv.cfg" + try: + if not (cfg.exists() and "uv = " in cfg.read_text(errors="replace")): + return False + except OSError: + return False + + prefix = Path(sys.prefix) + tool_dir = os.environ.get("UV_TOOL_DIR") + if tool_dir: + try: + if prefix.is_relative_to(tool_dir): + return True + except (ValueError, OSError): + pass + + # Default uv tool installs live under ".../uv/tools/". + # A plain `uv venv` project venv does not have the uv/tools path parts. + return "uv" in prefix.parts and "tools" in prefix.parts + + def run_install(cmd): print() print("Installing:", printable_shell_command(cmd)) - # First ensure pip is available - ensurepip_cmd = [sys.executable, "-m", "ensurepip", "--upgrade"] - try: - subprocess.run(ensurepip_cmd, capture_output=True, check=False) - except Exception: - pass # Continue even if ensurepip fails + if _is_pip_install_command(cmd): + # First ensure pip is available + ensurepip_cmd = [sys.executable, "-m", "ensurepip", "--upgrade"] + try: + subprocess.run(ensurepip_cmd, capture_output=True, check=False) + except Exception: + pass # Continue even if ensurepip fails try: from cecli.waiting import Spinner @@ -386,7 +431,9 @@ def touch_file(fname): return False -async def check_pip_install_extra(io, module, prompt, pip_install_cmd, self_update=False): +async def check_pip_install_extra( + io, module, prompt, pip_install_cmd=None, self_update=False, cmd=None +): if module: for _attempt in range(2): try: @@ -403,7 +450,8 @@ async def check_pip_install_extra(io, module, prompt, pip_install_cmd, self_upda except (ImportError, ModuleNotFoundError, RuntimeError): break - cmd = get_pip_install(pip_install_cmd) + if cmd is None: + cmd = get_pip_install(pip_install_cmd or []) if prompt: io.tool_warning(prompt) @@ -414,9 +462,13 @@ async def check_pip_install_extra(io, module, prompt, pip_install_cmd, self_upda print(printable_shell_command(cmd)) # plain print so it doesn't line-wrap return - if not await io.confirm_ask( - "Run pip install?", default="y", subject=printable_shell_command(cmd) - ): + run_prompt = "Run pip install?" + if cmd and cmd[0] == "uv": + run_prompt = "Run uv tool upgrade?" + elif cmd and cmd[0] == "pipx": + run_prompt = "Run pipx upgrade?" + + if not await io.confirm_ask(run_prompt, default="y", subject=printable_shell_command(cmd)): return success, output = run_install(cmd) diff --git a/cecli/versioncheck.py b/cecli/versioncheck.py index 95ff02b32b7..b0681501a9e 100644 --- a/cecli/versioncheck.py +++ b/cecli/versioncheck.py @@ -40,7 +40,7 @@ async def install_upgrade(io, latest_version=None): io.tool_warning(text) return True success = await utils.check_pip_install_extra( - io, None, new_ver_text, ["cecli-dev"], self_update=True + io, None, new_ver_text, cmd=utils.get_self_upgrade_command(), self_update=True ) if success: io.tool_output("Re-run cecli to use new version.") @@ -48,8 +48,8 @@ async def install_upgrade(io, latest_version=None): return -async def check_version(io, just_check=False, verbose=False): - if not just_check and VERSION_CHECK_FNAME.exists(): +async def check_version(io, just_check=False, verbose=False, upgrade=False): + if not just_check and not upgrade and VERSION_CHECK_FNAME.exists(): day = 60 * 60 * 24 since = time.time() - os.path.getmtime(VERSION_CHECK_FNAME) if 0 < since < day: @@ -66,10 +66,8 @@ async def check_version(io, just_check=False, verbose=False): current_version = cecli.__version__ if just_check or verbose: io.tool_output(f"Current version: {current_version}") - io.tool_output(f"Latest version: {latest_version}") - is_update_available = ( - packaging.version.parse(latest_version).release - > packaging.version.parse(current_version).release + is_update_available = packaging.version.parse(latest_version) > packaging.version.parse( + current_version ) except Exception as err: io.tool_error(f"Error checking pypi for new version: {err}") From f936226420ef051cc12256ad4544c1b522e7f206 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Thu, 27 Aug 2026 21:35:15 -0400 Subject: [PATCH 04/32] #657: Refactor MCP server loop session handling to stay pinned to the same underlying task to prevent MCP library from screaming at us --- cecli/mcp/server.py | 355 ++++++++++++++++++++++++++------------------ 1 file changed, 213 insertions(+), 142 deletions(-) diff --git a/cecli/mcp/server.py b/cecli/mcp/server.py index 108b8ea73c3..c1c29918f97 100644 --- a/cecli/mcp/server.py +++ b/cecli/mcp/server.py @@ -68,7 +68,14 @@ def __init__(self, server_config, io=None, verbose=False): # concurrent disconnect on the same loop never blocks the loop thread. self._cleanup_lock: threading.Lock = threading.Lock() self._disconnecting = False + # The session (and its transport context) is owned by a single task so + # the AnyIO task group inside the transport is entered and exited on the + # same task, no matter which task calls disconnect(). self.exit_stack = AsyncExitStack() + self._cancel_keepalive_on_close = True + self._session_task: asyncio.Task | None = None + self._session_ready: asyncio.Future | None = None + self._shutdown_event: asyncio.Event | None = None @property def is_connected(self) -> bool: @@ -78,16 +85,21 @@ def is_connected(self) -> bool: async def connect(self): """Connect to the MCP server and return the session. - If a session is already active, returns the existing session. - Otherwise, establishes a new connection and initializes the session. + If a session is already active, returns the existing session. Otherwise, + establishes a new connection and initializes the session. + + The session is owned by a dedicated task that both opens and closes the + transport, so the AnyIO task group inside the transport context is always + entered and exited on the same task, regardless of which task later calls + disconnect(). Returns: ClientSession: The active session if mcp is not disabled """ current_loop = asyncio.get_running_loop() if self.session is not None: - # Event loop affinity check: streams from stdio_client() are bound - # to the loop that created them. Reconnect if the loop changed. + # Event loop affinity check: streams from the transport are bound to + # the loop that created them. Reconnect if the loop changed. if self._connection_loop is current_loop: if self.verbose and self.io: self.io.tool_output(f"Using existing session for MCP server: {self.name}") @@ -99,51 +111,57 @@ async def connect(self): if self.verbose and self.io: self.io.tool_output(f"Establishing new connection to MCP server: {self.name}") - command = self.config["command"] - - env = os.environ.copy() - if self.config.get("env"): - env.update(self.config["env"]) - - server_params = StdioServerParameters( - command=command, - args=self.config.get("args"), - env=env, - ) + self._connection_loop = current_loop + self._shutdown_event = asyncio.Event() + self._session_ready = current_loop.create_future() + self._session_task = asyncio.create_task(self._run_session()) try: - os.makedirs(".cecli/logs/", exist_ok=True) - with safe_open(".cecli/logs/mcp-errors.log", "w") as err_file: - stdio_transport = await self.exit_stack.enter_async_context( - stdio_client(server_params, errlog=err_file) - ) - read, write = stdio_transport - session = await self.exit_stack.enter_async_context(ClientSession(read, write)) - await session.initialize() - self.session = session - self._connection_loop = current_loop - return session - except Exception as e: - logging.error(f"Error initializing server {self.name}: {e}") + session = await self._session_ready + except BaseException: await self.disconnect() raise - async def disconnect(self): - """ - Disconnect from the MCP server and clean up resources. + return session + + async def disconnect(self, cancel_keepalive: bool = True): + """Disconnect from the MCP server and clean up resources. Idempotent and safe to call from any task or thread: only one caller performs the teardown; concurrent callers (possibly on another event loop) return immediately once the session is marked gone. + + The transport is closed on the session owner task (via a shutdown + signal), so the AnyIO task group inside the transport's async context is + exited on the same task that entered it. """ with self._cleanup_lock: if self._disconnecting: self.session = None return self._disconnecting = True + self._cancel_keepalive_on_close = cancel_keepalive try: - await self.exit_stack.aclose() + task = self._session_task + if task is not None and not task.done(): + # Retire this task as owner before signalling, so an owner task on + # another loop won't clear state a newer session has taken over. + self._session_task = None + if self._shutdown_event is not None: + self._shutdown_event.set() + + try: + await asyncio.wait_for(task, timeout=15) + except asyncio.TimeoutError: + task.cancel() + except RuntimeError: + # Session task lives on a different loop; it is torn down there. + pass + + elif task is None: + await self._close_session(cancel_keepalive) + await self.exit_stack.aclose() except (asyncio.CancelledError, RuntimeError, GeneratorExit): # Expected during shutdown - anyio cancel scopes don't play # well with asyncio teardown. Resources are still cleaned up. @@ -152,6 +170,9 @@ async def disconnect(self): logging.error(f"Error during cleanup of server {self.name}: {e}") finally: self.session = None + self._connection_loop = None + self._session_ready = None + self._shutdown_event = None self._disconnecting = False async def reconnect(self): @@ -196,6 +217,109 @@ def is_session_expired_error(exc): return False + async def _open_session(self): + """Open the MCP transport and initialize the session. + + All context managers are entered into ``self.exit_stack`` and the active + session is stored on ``self.session``. This runs on the session owner + task (see ``_run_session``). Subclasses override this for + transport-specific setup. + + Returns: + ClientSession: The initialized session + """ + command = self.config["command"] + + env = os.environ.copy() + if self.config.get("env"): + env.update(self.config["env"]) + + server_params = StdioServerParameters( + command=command, + args=self.config.get("args"), + env=env, + ) + + os.makedirs(".cecli/logs/", exist_ok=True) + with safe_open(".cecli/logs/mcp-errors.log", "w") as err_file: + stdio_transport = await self.exit_stack.enter_async_context( + stdio_client(server_params, errlog=err_file) + ) + read, write = stdio_transport + session = await self.exit_stack.enter_async_context(ClientSession(read, write)) + await session.initialize() + self.session = session + + return session + + async def _close_session(self, cancel_keepalive: bool = True): + """Tear down session-specific resources. + + Always runs on the session owner task, immediately before the transport + context is closed. Subclasses override to clean up extra state. + """ + return None + + async def _run_session(self): + """Own the transport context for the lifetime of the session. + + Opens the transport and session, then blocks until a shutdown is + requested, then closes the transport. Both the open and the close happen + in this single task so the AnyIO task group inside the transport context + (for example ``stdio_client``) is entered and exited on the same task. + + Even when opening fails partway, any contexts already pushed onto + ``self.exit_stack`` are still closed here, so nothing leaks. + """ + # This session owns its own transport stack so an older owner task that + # is still winding down can never close a newer session's contexts. + exit_stack = AsyncExitStack() + self.exit_stack = exit_stack + + try: + try: + session = await self._open_session() + except BaseException as exc: + if not self._session_ready.done(): + if isinstance(exc, asyncio.CancelledError): + # Deliver cancellation as a future cancellation, not as an + # exception value, so a connect() caller that never awaits + # the future won't trigger a "Future exception was never + # retrieved" warning. + self._session_ready.cancel() + else: + logging.error(f"Error initializing server {self.name}: {exc}") + self._session_ready.set_exception(exc) + + return + + if not self._session_ready.done(): + self._session_ready.set_result(session) + + await self._shutdown_event.wait() + except BaseException: + pass + finally: + try: + await self._close_session(self._cancel_keepalive_on_close) + except asyncio.CancelledError: + pass + except Exception as e: + logging.error(f"Error during cleanup of server {self.name}: {e}") + + try: + await exit_stack.aclose() + except (asyncio.CancelledError, RuntimeError, GeneratorExit): + pass + except Exception as e: + logging.error(f"Error during cleanup of server {self.name}: {e}") + + # Only the current owner may clear shared state; if a newer session + # has taken over, leave its session/loop fields alone. + if self._session_task is asyncio.current_task(): + self.session = None + self._connection_loop = None + class HttpBasedMcpServer(McpServer): """Base class for HTTP-based MCP servers (HTTP streaming and SSE).""" @@ -288,66 +412,48 @@ def _create_transport(self, url, http_client): """ raise NotImplementedError("Subclasses must implement _create_transport") - async def connect(self): - current_loop = asyncio.get_running_loop() - if self.session is not None: - if self._connection_loop is current_loop: - if self.verbose and self.io: - self.io.tool_output(f"Using existing session for {self.name}") - return self.session - if self.verbose and self.io: - self.io.tool_output(f"Reconnecting {self.name} (event loop changed)") - await self.disconnect() - - if self.verbose and self.io: - self.io.tool_output(f"Establishing new connection to {self.name}") - - try: - url = self.config.get("url") - headers = self.config.get("headers", {}) - - oauth_provider = None - if not headers: - oauth_provider = await self._create_oauth_provider() - - http_client_cls = _get_http_client_module().AsyncClient - http_client = await self.exit_stack.enter_async_context( - http_client_cls( - auth=oauth_provider, - follow_redirects=True, - headers=headers, - timeout=30, - ) + async def _open_session(self): + url = self.config.get("url") + headers = self.config.get("headers", {}) + + oauth_provider = None + if not headers: + oauth_provider = await self._create_oauth_provider() + + http_client_cls = _get_http_client_module().AsyncClient + http_client = await self.exit_stack.enter_async_context( + http_client_cls( + auth=oauth_provider, + follow_redirects=True, + headers=headers, + timeout=30, ) - self._http_client = http_client + ) + self._http_client = http_client - transport = await self.exit_stack.enter_async_context( - self._create_transport(url, http_client=http_client) - ) + transport = await self.exit_stack.enter_async_context( + self._create_transport(url, http_client=http_client) + ) - read, write = _unpack_transport(transport) + read, write = _unpack_transport(transport) - session = await self.exit_stack.enter_async_context(ClientSession(read, write)) - await session.initialize() - self.session = session - await self.start_keepalive() - self._connection_loop = current_loop + session = await self.exit_stack.enter_async_context(ClientSession(read, write)) + await session.initialize() + self.session = session - if oauth_provider is not None and oauth_provider.context.oauth_metadata: - token_endpoint = oauth_provider._get_token_endpoint() - server_info = get_mcp_oauth_token(self.name) - if "client_info" not in server_info: - server_info["client_info"] = {} + await self.start_keepalive() - server_info["client_info"]["token_endpoint"] = token_endpoint + if oauth_provider is not None and oauth_provider.context.oauth_metadata: + token_endpoint = oauth_provider._get_token_endpoint() + server_info = get_mcp_oauth_token(self.name) + if "client_info" not in server_info: + server_info["client_info"] = {} - save_mcp_oauth_token(self.name, server_info) + server_info["client_info"]["token_endpoint"] = token_endpoint - return session - except Exception as e: - logging.error(f"Error initializing {self.name}: {e}") - await self.disconnect() - raise + save_mcp_oauth_token(self.name, server_info) + + return session async def start_keepalive(self): """Start the background keepalive loop if configured.""" @@ -459,41 +565,21 @@ async def reconnect(self): f"Reconnection attempt {attempt} failed for {self.name}: {e}" ) - async def disconnect(self, cancel_keepalive: bool = True): - """ - Disconnect from the MCP server and clean up resources. + async def _close_session(self, cancel_keepalive: bool = True): + if cancel_keepalive and self._keepalive_task: + self._keepalive_task.cancel() - Idempotent and safe to call from any task or thread: only one caller - performs the teardown; concurrent callers (possibly on another event - loop) return immediately once the session is marked gone. - """ - with self._cleanup_lock: - if self._disconnecting: - self.session = None - return - self._disconnecting = True + try: + await asyncio.wait_for(self._keepalive_task, timeout=15) + except asyncio.CancelledError: + pass - try: - if cancel_keepalive and self._keepalive_task: - self._keepalive_task.cancel() - try: - await asyncio.wait_for(self._keepalive_task, timeout=15) - except asyncio.CancelledError: - pass - logger.info(f"Keepalive task stopped for {self.name}") - if hasattr(self, "_oauth_shutdown"): - self._oauth_shutdown() - await self.exit_stack.aclose() - except (asyncio.CancelledError, RuntimeError, GeneratorExit): - # Expected during shutdown - anyio cancel scopes don't play - # well with asyncio teardown. Resources are still cleaned up. - pass - except Exception as e: - logging.error(f"Error during cleanup of server {self.name}: {e}") - finally: - self.session = None - self._http_client = None - self._disconnecting = False + logger.info(f"Keepalive task stopped for {self.name}") + + if hasattr(self, "_oauth_shutdown"): + self._oauth_shutdown() + + self._http_client = None class HttpStreamingServer(HttpBasedMcpServer): @@ -507,32 +593,17 @@ def _create_transport(self, url, http_client): class SseServer(McpServer): """SSE (Server-Sent Events) MCP server using mcp.client.sse_client.""" - async def connect(self): - current_loop = asyncio.get_running_loop() - if self.session is not None: - if self._connection_loop is current_loop: - logging.info(f"Using existing session for SSE MCP server: {self.name}") - return self.session - logging.info(f"Reconnecting SSE MCP server {self.name} (event loop changed)") - await self.disconnect() + async def _open_session(self): + url = self.config.get("url") + headers = self.config.get("headers", {}) - logging.info(f"Establishing new connection to SSE MCP server: {self.name}") - try: - url = self.config.get("url") - headers = self.config.get("headers", {}) - sse_transport = await self.exit_stack.enter_async_context( - sse_client(url, headers=headers) - ) - read, write = sse_transport - session = await self.exit_stack.enter_async_context(ClientSession(read, write)) - await session.initialize() - self.session = session - self._connection_loop = current_loop - return session - except Exception as e: - logging.error(f"Error initializing SSE server {self.name}: {e}") - await self.disconnect() - raise + sse_transport = await self.exit_stack.enter_async_context(sse_client(url, headers=headers)) + read, write = sse_transport + session = await self.exit_stack.enter_async_context(ClientSession(read, write)) + await session.initialize() + self.session = session + + return session class LocalServer(McpServer): From 94b951e20e55cab019f5bff4c6c960dff3d5a039 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Thu, 27 Aug 2026 22:03:20 -0400 Subject: [PATCH 05/32] Keep sub agents from crashing system in cli mode by letting them operate --- cecli/io.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/cecli/io.py b/cecli/io.py index caf97bacfa6..a3bf286315f 100644 --- a/cecli/io.py +++ b/cecli/io.py @@ -1354,6 +1354,13 @@ async def _confirm_ask( while True: try: if self.prompt_session: + if ( + getattr( + getattr(self.prompt_session, "app", None), "_is_running", False + ) + is True + ): + return True # Call prompt_async directly instead of using input_task # This allows KeyboardInterrupt to propagate properly res = await self.prompt_session.prompt_async(question) From c34cd042aa707eedbebeaa2389bcaf659a7da184 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Thu, 27 Aug 2026 22:55:02 -0400 Subject: [PATCH 06/32] Add --max-tool-calls argument --- cecli/args.py | 6 ++++++ cecli/coders/agent_coder.py | 2 ++ cecli/coders/base_coder.py | 1 + 3 files changed, 9 insertions(+) diff --git a/cecli/args.py b/cecli/args.py index 1dcebfb2584..43d7e8b1e92 100644 --- a/cecli/args.py +++ b/cecli/args.py @@ -331,6 +331,12 @@ def get_parser(default_config_files, git_root): default=3, help="Maximum number of retries a model gets on malformed outputs (default: 3)", ) + group.add_argument( + "--max-tool-calls", + type=int, + default=25, + help="Maximum number of tool calls allowed per message (default: 25)", + ) group.add_argument( "--cost-limit", type=float, diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index 08370f38e46..93a0c3e4c3e 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -133,6 +133,8 @@ def post_init(self): self.io.tool_warning(err) self.start_up_errors = [] + # Preserve the original 10000 setting now that max tool calls is configurable + self.max_tool_calls = self.max_tool_calls * 400 def _setup_agent(self): os.makedirs(".cecli/temp", exist_ok=True) diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index 052886e1f47..d6702b70476 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -520,6 +520,7 @@ def __init__( self.max_compaction_retries = max_compaction_retries self.max_reflections = nested.getter(self.args, "max_reflections", 3) + self.max_tool_calls = nested.getter(self.args, "max_tool_calls", 25) if not fnames: fnames = [] From 42f3ace95f608ffdd8ef26b4d964aef03b198e34 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Thu, 27 Aug 2026 23:33:53 -0400 Subject: [PATCH 07/32] Update file edit structure --- cecli/coders/agent_coder.py | 9 -------- cecli/helpers/hashpos/transformations.py | 28 ++++++++++++++++-------- cecli/tools/edit_file.py | 6 ++--- 3 files changed, 22 insertions(+), 21 deletions(-) diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index 93a0c3e4c3e..9a018066651 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -1223,15 +1223,6 @@ def _generate_tool_context(self, repetitive_tools): for i, tool in enumerate(recent_history, 1): context_parts.append(f"{i}. {tool}") - if not self.edit_allowed: - context_parts.append("\n\n") - context_parts.append("## File Editing Tools Disabled") - context_parts.append( - "File editing tools are currently disabled. Use `ReadFile` to determine the" - " current content ID prefixes needed to perform an edit and activate them when" - " you are ready to edit a file." - ) - context_parts.append("\n\n") repetition_warning = None diff --git a/cecli/helpers/hashpos/transformations.py b/cecli/helpers/hashpos/transformations.py index 6026f57cdaa..d481918cb90 100644 --- a/cecli/helpers/hashpos/transformations.py +++ b/cecli/helpers/hashpos/transformations.py @@ -10,13 +10,8 @@ from __future__ import annotations import re -from typing import TYPE_CHECKING - -from cecli.helpers.hashpos.hashpos import UNIQUE_HASH_DELIMITER - -if TYPE_CHECKING: - from cecli.helpers.hashpos.hashpos import HashPos +from cecli.helpers.hashpos.hashpos import UNIQUE_HASH_DELIMITER, HashPos # ──────────────────────────────────────────────────────────── # Pattern Matching @@ -432,9 +427,24 @@ def reposition_indices( def strip_hashline_prefix(value: str) -> str: """Strip the virtual prefix from a ReadFile output line reference.""" - - if isinstance(value, str) and value.startswith(UNIQUE_HASH_DELIMITER): - return value[len(UNIQUE_HASH_DELIMITER) :].lstrip() + if not isinstance(value, str): + return value + + # Unique-line delimiter (——): not spatially resolvable, so strip it and match + # the remaining content as text. + if value.startswith(UNIQUE_HASH_DELIMITER): + return value[len(UNIQUE_HASH_DELIMITER) :] + + # Valid canonical duplicate content ID (—XXXX—): keep it intact so that + # resolve_content_to_hashline_ids() can target a specific occurrence. + if HashPos.HASH_PREFIX_RE.match(value): + return value + + # Malformed marker: a short string immediately followed by an em-dash (e.g. + # 'о‘星—'). This is not a valid content ID, so strip it. + stripped = HashPos._LOOSE_PREFIX_RE.sub("", value, count=1) + if stripped != value: + return stripped return value diff --git a/cecli/tools/edit_file.py b/cecli/tools/edit_file.py index edd7f24f560..a2f792f958b 100644 --- a/cecli/tools/edit_file.py +++ b/cecli/tools/edit_file.py @@ -61,8 +61,8 @@ class Tool(BaseTool): f"duplicate lines by their hashed prefix (e.g., '{HASH_DELIMITER}WecX{HASH_DELIMITER}'); use " "'@000' for empty files. Identifiers track content, so edits can re-prefix identical lines " "elsewhere — re-read the file after editing for fresh identifiers. Multiple edits to one " - "file are applied bottom-to-top; overlapping or contained ranges are merged or rejected " - "automatically." + "file are applied bottom-to-top automatically; overlapping or contained ranges are merged " + "or rejected automatically." ), "parameters": { "type": "object", @@ -94,7 +94,7 @@ class Tool(BaseTool): "type": "string", "description": ( "The replacement text for 'replace'. " - "For 'delete' leave this empty (\"\"). " + "For 'delete' leave this as an empty string (\"\"). " "Supplied as-is; do not include identifier prefixes." ), }, From e6ad5274964ded11c81897d3f4f88f2975a0bb14 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Fri, 28 Aug 2026 08:23:38 -0400 Subject: [PATCH 08/32] #660: Catch timeout errors outside of git repo, add max scan time that takes potential errors in to account in large folders and in the case of inaccessible network drives --- cecli/helpers/file_system/builders.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/cecli/helpers/file_system/builders.py b/cecli/helpers/file_system/builders.py index 5389ea50ef8..6dbb7cdca67 100644 --- a/cecli/helpers/file_system/builders.py +++ b/cecli/helpers/file_system/builders.py @@ -8,6 +8,7 @@ import hashlib import os import subprocess +import time from pathlib import Path from .ignore import FileIgnoreFilter @@ -118,6 +119,8 @@ def collect( Walk filesystem collecting files relative to root using Breadth-First Search. Highly optimized: lazy iteration, single-pass evaluation, and raw string paths. """ + start_time = time.monotonic() + paths = [] # Only use pathlib for initial resolution, then stick to raw strings for speed @@ -132,6 +135,9 @@ def collect( path_count = 0 while dirs_to_scan: + if time.monotonic() - start_time > 120: + break + next_dirs = [] for current_path, rel_dir, depth in dirs_to_scan: @@ -159,7 +165,7 @@ def collect( # --- Handle Files --- elif entry.is_file(follow_symlinks=False): - # Short-circuit if we hit the 64 file cap (saves ignore_filter overhead) + # Short-circuit if we hit the 256 file cap (saves ignore_filter overhead) if not is_subfolder_of_user and file_count >= 256: continue @@ -177,7 +183,7 @@ def collect( # Global safety hard-stop if not is_subfolder_of_user and path_count >= max_files: return sorted(paths) - except PermissionError: + except (PermissionError, OSError, TimeoutError): continue # Skip folders we don't have read access to dirs_to_scan = next_dirs From b52f1a0f86e17bac81d8b81b3cc25061c199ef20 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Sat, 29 Aug 2026 08:07:42 -0400 Subject: [PATCH 09/32] Add chromadb as a main dependency so interactive help can just work --- requirements.txt | 227 +++++++++++++++++++++++++++- requirements/common-constraints.txt | 4 +- requirements/requirements-help.in | 2 +- requirements/requirements.in | 1 + 4 files changed, 231 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index 434705d7ce4..56d75692d3a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,17 @@ # This file was autogenerated by uv via the following command: # uv pip compile --no-strip-extras --constraint=requirements/common-constraints.txt --output-file=tmp.requirements.txt requirements/requirements.in +aiohappyeyeballs==2.6.1 + # via + # -c requirements/common-constraints.txt + # aiohttp +aiohttp==3.14.3 + # via + # -c requirements/common-constraints.txt + # kubernetes +aiosignal==1.4.0 + # via + # -c requirements/common-constraints.txt + # aiohttp annotated-types==0.7.0 # via # -c requirements/common-constraints.txt @@ -15,8 +27,13 @@ anyio==4.11.0 attrs==25.4.0 # via # -c requirements/common-constraints.txt + # aiohttp # jsonschema # referencing +bcrypt==5.0.0 + # via + # -c requirements/common-constraints.txt + # chromadb beautifulsoup4==4.14.2 # via # -c requirements/common-constraints.txt @@ -25,11 +42,16 @@ blinker==1.9.0 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in +build==1.3.0 + # via + # -c requirements/common-constraints.txt + # chromadb certifi==2025.11.12 # via # -c requirements/common-constraints.txt # httpcore # httpx + # kubernetes # requests cffi==2.0.0 # via @@ -42,9 +64,14 @@ charset-normalizer==3.4.9 # -c requirements/common-constraints.txt # -r requirements/requirements.in # requests +chromadb==1.5.9 + # via + # -c requirements/common-constraints.txt + # -r requirements/requirements.in click==8.3.1 # via # -c requirements/common-constraints.txt + # typer # uvicorn configargparse==1.7.1 # via @@ -63,6 +90,27 @@ diskcache==5.6.3 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in +durationpy==0.10 + # via + # -c requirements/common-constraints.txt + # kubernetes +filelock==3.20.0 + # via + # -c requirements/common-constraints.txt + # huggingface-hub +flatbuffers==25.12.19 + # via + # -c requirements/common-constraints.txt + # onnxruntime +frozenlist==1.8.0 + # via + # -c requirements/common-constraints.txt + # aiohttp + # aiosignal +fsspec==2025.10.0 + # via + # -c requirements/common-constraints.txt + # huggingface-hub gitdb==4.0.12 # via # -c requirements/common-constraints.txt @@ -71,29 +119,52 @@ gitpython==3.1.45 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in +googleapis-common-protos==1.75.1 + # via + # -c requirements/common-constraints.txt + # opentelemetry-exporter-otlp-proto-grpc +grpcio==1.83.0 + # via + # -c requirements/common-constraints.txt + # chromadb + # opentelemetry-exporter-otlp-proto-grpc h11==0.16.0 # via # -c requirements/common-constraints.txt # httpcore # uvicorn +hf-xet==1.2.0 + # via + # -c requirements/common-constraints.txt + # huggingface-hub httpcore==1.0.9 # via # -c requirements/common-constraints.txt # httpx +httptools==0.8.0 + # via + # -c requirements/common-constraints.txt + # uvicorn httpx==0.28.1 # via # -c requirements/common-constraints.txt + # chromadb # mcp httpx-sse==0.4.3 # via # -c requirements/common-constraints.txt # mcp +huggingface-hub==0.36.0 + # via + # -c requirements/common-constraints.txt + # tokenizers idna==3.11 # via # -c requirements/common-constraints.txt # anyio # httpx # requests + # yarl importlib-metadata==8.7.0 # via # -c requirements/common-constraints.txt @@ -102,6 +173,7 @@ importlib-resources==6.5.2 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in + # chromadb json-repair==0.60.1 # via # -c requirements/common-constraints.txt @@ -110,11 +182,16 @@ jsonschema==4.25.1 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in + # chromadb # mcp jsonschema-specifications==2025.9.1 # via # -c requirements/common-constraints.txt # jsonschema +kubernetes==36.0.3 + # via + # -c requirements/common-constraints.txt + # chromadb linkify-it-py==2.0.3 # via # -c requirements/common-constraints.txt @@ -141,10 +218,19 @@ mdurl==0.1.2 # via # -c requirements/common-constraints.txt # markdown-it-py +mmh3==5.2.1 + # via + # -c requirements/common-constraints.txt + # chromadb mslex==1.3.0 # via # -c requirements/common-constraints.txt # oslex +multidict==6.7.0 + # via + # -c requirements/common-constraints.txt + # aiohttp + # yarl ngram==4.0.3 # via # -c requirements/common-constraints.txt @@ -152,20 +238,67 @@ ngram==4.0.3 numpy==2.3.5 # via # -c requirements/common-constraints.txt + # chromadb + # onnxruntime # rustworkx # soundfile +oauthlib==3.3.1 + # via + # -c requirements/common-constraints.txt + # requests-oauthlib +onnxruntime==1.29.0 + # via + # -c requirements/common-constraints.txt + # chromadb +opentelemetry-api==1.44.0 + # via + # -c requirements/common-constraints.txt + # chromadb + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-sdk + # opentelemetry-semantic-conventions +opentelemetry-exporter-otlp-proto-common==1.44.0 + # via + # -c requirements/common-constraints.txt + # opentelemetry-exporter-otlp-proto-grpc +opentelemetry-exporter-otlp-proto-grpc==1.44.0 + # via + # -c requirements/common-constraints.txt + # chromadb +opentelemetry-proto==1.44.0 + # via + # -c requirements/common-constraints.txt + # opentelemetry-exporter-otlp-proto-common + # opentelemetry-exporter-otlp-proto-grpc +opentelemetry-sdk==1.44.0 + # via + # -c requirements/common-constraints.txt + # chromadb + # opentelemetry-exporter-otlp-proto-grpc +opentelemetry-semantic-conventions==0.65b0 + # via + # -c requirements/common-constraints.txt + # opentelemetry-sdk orjson==3.11.9 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in + # chromadb oslex==0.1.3 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in +overrides==7.7.0 + # via + # -c requirements/common-constraints.txt + # chromadb packaging==25.0 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in + # build + # huggingface-hub + # onnxruntime pathspec==0.12.1 # via # -c requirements/common-constraints.txt @@ -186,6 +319,17 @@ prompt-toolkit==3.0.52 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in +propcache==0.4.1 + # via + # -c requirements/common-constraints.txt + # aiohttp + # yarl +protobuf==7.36.0 + # via + # -c requirements/common-constraints.txt + # googleapis-common-protos + # onnxruntime + # opentelemetry-proto psutil==7.1.3 # via # -c requirements/common-constraints.txt @@ -198,6 +342,10 @@ py-cymbal==0.2.1 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in +pybase64==1.5.0 + # via + # -c requirements/common-constraints.txt + # chromadb pycparser==2.23 # via # -c requirements/common-constraints.txt @@ -205,6 +353,7 @@ pycparser==2.23 pydantic==2.12.4 # via # -c requirements/common-constraints.txt + # chromadb # mcp # pydantic-settings pydantic-core==2.41.5 @@ -214,6 +363,7 @@ pydantic-core==2.41.5 pydantic-settings==2.12.0 # via # -c requirements/common-constraints.txt + # chromadb # mcp pydub==0.25.1 # via @@ -236,11 +386,24 @@ pyperclip==1.11.0 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in +pypika==0.51.1 + # via + # -c requirements/common-constraints.txt + # chromadb +pyproject-hooks==1.2.0 + # via + # -c requirements/common-constraints.txt + # build +python-dateutil==2.9.0.post0 + # via + # -c requirements/common-constraints.txt + # kubernetes python-dotenv==1.2.2 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in # pydantic-settings + # uvicorn python-multipart==0.0.20 # via # -c requirements/common-constraints.txt @@ -249,6 +412,10 @@ pyyaml==6.0.3 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in + # chromadb + # huggingface-hub + # kubernetes + # uvicorn rapidfuzz==3.14.5 # via # -c requirements/common-constraints.txt @@ -262,11 +429,20 @@ requests==2.32.5 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in + # huggingface-hub + # kubernetes + # requests-oauthlib +requests-oauthlib==2.0.0 + # via + # -c requirements/common-constraints.txt + # kubernetes rich==14.2.0 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in + # chromadb # textual + # typer rpds-py==0.29.0 # via # -c requirements/common-constraints.txt @@ -276,10 +452,19 @@ rustworkx==0.17.1 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in +shellingham==1.5.4 + # via + # -c requirements/common-constraints.txt + # typer shtab==1.8.0 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in +six==1.17.0 + # via + # -c requirements/common-constraints.txt + # kubernetes + # python-dateutil smmap==5.0.2 # via # -c requirements/common-constraints.txt @@ -312,10 +497,18 @@ starlette==0.50.0 # via # -c requirements/common-constraints.txt # mcp +tenacity==9.1.2 + # via + # -c requirements/common-constraints.txt + # chromadb textual==8.2.8 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in +tokenizers==0.22.1 + # via + # -c requirements/common-constraints.txt + # chromadb tomlkit==0.14.0 # via # -c requirements/common-constraints.txt @@ -324,6 +517,8 @@ tqdm==4.67.1 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in + # chromadb + # huggingface-hub # via # -c requirements/common-constraints.txt # -r requirements/requirements.in @@ -353,17 +548,31 @@ truststore==0.10.4 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in +typer==0.20.0 + # via + # -c requirements/common-constraints.txt + # chromadb typing-extensions==4.15.0 # via # -c requirements/common-constraints.txt + # aiohttp + # aiosignal # anyio # beautifulsoup4 + # chromadb + # grpcio + # huggingface-hub # mcp + # opentelemetry-api + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-sdk + # opentelemetry-semantic-conventions # pydantic # pydantic-core # referencing # starlette # textual + # typer # typing-inspection typing-inspection==0.4.2 # via @@ -378,27 +587,43 @@ uc-micro-py==1.0.3 urllib3==2.5.0 # via # -c requirements/common-constraints.txt + # kubernetes # requests -uvicorn==0.38.0 +uvicorn[standard]==0.38.0 # via # -c requirements/common-constraints.txt + # chromadb # mcp +uvloop==0.22.1 + # via + # -c requirements/common-constraints.txt + # uvicorn watchfiles==1.1.1 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in + # uvicorn wcwidth==0.2.14 # via # -c requirements/common-constraints.txt # prompt-toolkit +websocket-client==1.9.0 + # via + # -c requirements/common-constraints.txt + # kubernetes websockets==16.1.1 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in + # uvicorn xxhash==3.6.0 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in +yarl==1.22.0 + # via + # -c requirements/common-constraints.txt + # aiohttp zipp==3.23.0 # via # -c requirements/common-constraints.txt diff --git a/requirements/common-constraints.txt b/requirements/common-constraints.txt index 4fd11b636ba..f1e10a0bf65 100644 --- a/requirements/common-constraints.txt +++ b/requirements/common-constraints.txt @@ -48,7 +48,9 @@ charset-normalizer==3.4.9 # -r requirements/requirements.in # requests chromadb==1.5.9 - # via -r requirements/requirements-help.in + # via + # -r requirements/requirements-help.in + # -r requirements/requirements.in click==8.3.1 # via # pip-tools diff --git a/requirements/requirements-help.in b/requirements/requirements-help.in index 1bbbacbc7d0..b0d20386377 100644 --- a/requirements/requirements-help.in +++ b/requirements/requirements-help.in @@ -1,4 +1,4 @@ -chromadb +chromadb>=1.5.9 # numpy is pulled in by chromadb's onnxruntime embedding stack numpy>=1.26.4 \ No newline at end of file diff --git a/requirements/requirements.in b/requirements/requirements.in index 0e3ba766d00..c83aca94357 100644 --- a/requirements/requirements.in +++ b/requirements/requirements.in @@ -33,6 +33,7 @@ beautifulsoup4>=4.13.4 pypandoc>=1.15 # storage/caching +chromadb>=1.5.9 diskcache>=5.6.3 # general string parsing From fec3d0e19a5464b6ed25fc2a1f9825197cfb0520 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Sat, 29 Aug 2026 21:22:27 -0400 Subject: [PATCH 10/32] Strip first 8 leading chars to prevent corruption --- cecli/helpers/hashpos/hashpos.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cecli/helpers/hashpos/hashpos.py b/cecli/helpers/hashpos/hashpos.py index bd5a150fa98..538fc483200 100644 --- a/cecli/helpers/hashpos/hashpos.py +++ b/cecli/helpers/hashpos/hashpos.py @@ -74,8 +74,8 @@ class HashPos: rf"^({UNIQUE_HASH_DELIMITER}|{HASH_DELIMITER}[{_B1024_REGEX_SET}]{{4}}{HASH_DELIMITER})$" ) - # Loose prefix for robust stripping: Matches a emdash-wrapped 4-char string containing non-ASCII - _LOOSE_PREFIX_RE = re.compile(rf"^[{HASH_DELIMITER}]?\S{{0,4}}{HASH_DELIMITER}") + # Loose prefix for robust stripping: Matches a emdash-wrapped 8-char string containing non-ASCII + _LOOSE_PREFIX_RE = re.compile(rf"^[{HASH_DELIMITER}]?\S{{0,8}}{HASH_DELIMITER}") def __init__(self, source_text: str = ""): self.lines = source_text.splitlines() From db05efd45ca5b25c1de9916651ef571bf4708d6f Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Sun, 30 Aug 2026 15:34:18 -0400 Subject: [PATCH 11/32] Workspaces As Subagents --- cecli/coders/agent_coder.py | 29 +- cecli/coders/base_coder.py | 83 +++- cecli/commands/utils/helpers.py | 2 +- cecli/commands/workspace.py | 69 +-- cecli/helpers/agents/service.py | 50 ++- cecli/helpers/conversation/integration.py | 18 +- cecli/helpers/file_system/service.py | 124 +++++- cecli/helpers/monorepo/__init__.py | 0 cecli/helpers/monorepo/config.py | 174 -------- cecli/helpers/monorepo/project.py | 90 ---- cecli/helpers/monorepo/workspace.py | 48 -- cecli/helpers/monorepo/worktree.py | 21 - cecli/helpers/skills.py | 54 ++- cecli/helpers/workspaces/__init__.py | 13 + cecli/helpers/workspaces/config.py | 189 ++++++++ .../paths.py} | 99 +++-- cecli/helpers/workspaces/subagents.py | 121 +++++ cecli/helpers/workspaces/workspace.py | 100 +++++ cecli/main.py | 75 ++-- cecli/repo.py | 420 ++++-------------- cecli/tools/utils/registry.py | 30 +- cecli/tui/app.py | 52 ++- cecli/tui/widgets/footer.py | 12 +- cecli/website/docs/config.md | 16 +- cecli/website/docs/config/workspaces.md | 152 ++++--- tests/basic/test_coder.py | 5 +- tests/basic/test_file_system_service.py | 206 +++++++++ tests/basic/test_skills.py | 26 +- tests/helpers/monorepo/LOCAL_WORKSPACE.md | 66 --- tests/helpers/monorepo/test_config.py | 75 ---- tests/helpers/monorepo/test_config_active.py | 79 ---- tests/helpers/monorepo/test_ignore_logic.py | 103 ----- .../helpers/monorepo/test_local_workspace.py | 235 ---------- .../monorepo/test_repomap_workspace.py | 231 ---------- tests/helpers/monorepo/test_workspace.py | 52 --- tests/helpers/workspaces/conftest.py | 11 + tests/helpers/workspaces/test_config.py | 115 +++++ tests/helpers/workspaces/test_paths.py | 116 +++++ tests/helpers/workspaces/test_subagents.py | 106 +++++ tests/helpers/workspaces/test_workspace.py | 58 +++ tests/subagents/test_service.py | 27 ++ tests/tools/test_registry.py | 13 + 42 files changed, 1739 insertions(+), 1826 deletions(-) delete mode 100644 cecli/helpers/monorepo/__init__.py delete mode 100644 cecli/helpers/monorepo/config.py delete mode 100644 cecli/helpers/monorepo/project.py delete mode 100644 cecli/helpers/monorepo/workspace.py delete mode 100644 cecli/helpers/monorepo/worktree.py create mode 100644 cecli/helpers/workspaces/__init__.py create mode 100644 cecli/helpers/workspaces/config.py rename cecli/helpers/{monorepo/local_workspace.py => workspaces/paths.py} (69%) create mode 100644 cecli/helpers/workspaces/subagents.py create mode 100644 cecli/helpers/workspaces/workspace.py create mode 100644 tests/basic/test_file_system_service.py delete mode 100644 tests/helpers/monorepo/LOCAL_WORKSPACE.md delete mode 100644 tests/helpers/monorepo/test_config.py delete mode 100644 tests/helpers/monorepo/test_config_active.py delete mode 100644 tests/helpers/monorepo/test_ignore_logic.py delete mode 100644 tests/helpers/monorepo/test_local_workspace.py delete mode 100644 tests/helpers/monorepo/test_repomap_workspace.py delete mode 100644 tests/helpers/monorepo/test_workspace.py create mode 100644 tests/helpers/workspaces/conftest.py create mode 100644 tests/helpers/workspaces/test_config.py create mode 100644 tests/helpers/workspaces/test_paths.py create mode 100644 tests/helpers/workspaces/test_subagents.py create mode 100644 tests/helpers/workspaces/test_workspace.py diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index 9a018066651..f4d1f5e39b9 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -105,8 +105,27 @@ def __init__(self, *args, **kwargs): self.sub_agent_paths = self.agent_config.get("subagent_paths", []) self._setup_agent() - AgentService.build_registry(self.sub_agent_paths) - ToolRegistry.build_registry(agent_config=self.agent_config) + # primary_root is needed early so the skills and tool registries can + # resolve relative paths against the primary workspace root rather than + # this coder's (possibly overridden) local root. base_coder re-affirms + # and keeps this value in super().__init__(). + self.primary_root = kwargs.get("primary_root") or getattr(self, "primary_root", None) + + # Allow a sub-agent to target a different working root (multi-project workspace). + if not kwargs.get("root"): + configured_root = self.agent_config.get("root") + if configured_root: + kwargs["root"] = configured_root + + self._initialize_skills_manager( + self.agent_config, root=kwargs.get("root", self.primary_root) + ) + AgentService.build_registry( + self.sub_agent_paths, root=kwargs.get("root", self.primary_root) + ) + ToolRegistry.build_registry( + agent_config=self.agent_config, root=kwargs.get("root", self.primary_root) + ) self.loaded_custom_tools = ToolRegistry.loaded_custom_tools super().__init__(*args, **kwargs) @@ -292,21 +311,19 @@ def _get_agent_config(self, config=None): config["tools_excludelist"].append("loadskill") config["tools_excludelist"].append("removeskill") - self._initialize_skills_manager(config) return config - def _initialize_skills_manager(self, config): + def _initialize_skills_manager(self, config, root=None): """ Initialize the skills manager with the configured directory paths and filters. """ try: - git_root = str(self.repo.root) if self.repo else None self.skills_manager = SkillsManager( directory_paths=config.get("skills_paths", []), include_list=config.get("skills_includelist", []), exclude_list=config.get("skills_excludelist", []), initialize_list=config.get("skills_init", []), - git_root=git_root, + root=root or self.root or self.primary_root, coder=self, ) except Exception as e: diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index d6702b70476..f5fd40e7080 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -60,7 +60,7 @@ remove_reasoning_content, replace_reasoning_tags, ) -from cecli.repo import ANY_GIT_ERROR, GitRepo, GitRepoProxy +from cecli.repo import ANY_GIT_ERROR, GitRepoProxy from cecli.repomap import RepoMap from cecli.report import update_error_prefix from cecli.run_cmd import run_cmd_async @@ -193,6 +193,8 @@ def total_cached_tokens(self, value): abs_read_only_stubs_fnames = None abs_rules_fnames = None repo = None + root = "." + primary_root = None last_coder_commit_hash = None coder_edited_files = None last_asked_for_commit_time = 0 @@ -327,6 +329,7 @@ async def create( uuid=from_coder.uuid, parent_uuid=from_coder.parent_uuid, repo=from_coder.repo, + primary_root=from_coder.primary_root, summarizer=from_coder.summarizer, ) use_kwargs.update(update) # override to complete the switch @@ -451,6 +454,8 @@ def __init__( registered_servers=None, uuid: str = "", parent_uuid: str = "", + root=None, + primary_root=None, init_metadata={}, ): from cecli.helpers.agents.service import AgentService @@ -622,13 +627,12 @@ def __init__( self.repo = repo if use_git and self.repo is None: try: - self.repo = GitRepoProxy( - GitRepo( - self.io, - fnames, - None, - models=main_model.commit_message_models(), - ) + self.repo = GitRepoProxy.for_root( + None, + self.io, + fnames=fnames, + git_dname=None, + models=main_model.commit_message_models(), ) except FileNotFoundError: pass @@ -665,12 +669,47 @@ def __init__( if not self.repo: self.root = utils.find_common_root(self.abs_fnames) - # Initialize the FileSystemService singleton for all agents - FileSystemService.get_instance( + # Allow sub-agent classes to override the working root (multi-project workspaces). + if root is not None: + self.root = os.path.normpath(os.path.abspath(root)) + + # A sub-agent may operate on a different base path than its parent. In + # that case its repo must be scoped to *this* root (per-base-path), + # rather than inheriting the parent's repo, so the coder's repo/fs match + # its own root. + if use_git and self.repo is not None and os.path.normpath(self.repo.root) != self.root: + try: + self.repo = GitRepoProxy.for_root( + self.root, + self.io, + fnames=[self.root], + git_dname=None, + models=main_model.commit_message_models(), + ) + except FileNotFoundError: + self.repo = None + + # Store the root of the primary coder so skills files, custom tools, and + # sub-agent paths can be resolved relative to the primary workspace even + # when this coder operates on a different base path. + self.primary_root = ( + primary_root + if primary_root is not None + else getattr(self, "primary_root", None) or self.root + ) + + # Initialize the per-base-path FileSystemService for this coder + self.fs = FileSystemService.for_root( root=self.root if hasattr(self, "root") else ".", repo=self.repo if hasattr(self, "repo") else None, ) + # Auto-return the per-root service (and its git repo) when the last + # coder sharing this base path is destroyed. + _fs_key = FileSystemService._normalize_root(self.root if hasattr(self, "root") else ".") + FileSystemService._inc_ref(_fs_key) + weakref.finalize(self, FileSystemService._release, _fs_key) + if read_only_fnames: self.abs_read_only_fnames = set() for fname in read_only_fnames: @@ -711,11 +750,7 @@ def __init__( has_map_prompt = nested.getter(self, "gpt_prompts.repo_content_prefix") if use_repo_map and self.repo and has_map_prompt: - repo_root = ( - self.repo.workspace_path - if (self.repo and getattr(self.repo, "workspace_path", None)) - else self.root - ) + repo_root = self.root self.repo_map = RepoMap( map_tokens, self.map_cache_dir, @@ -1049,6 +1084,20 @@ def abs_root_path(self, path): fences = all_fences fence = fences[0] + def resolve_relative_to_primary_root(self, path: str) -> str: + """Resolve a path relative to the primary coder's root (falling back to self.root). + + Used for skills files, custom tools, and sub-agent paths that are defined + relative to the primary workspace even when this coder operates on a + different base path. + """ + if not path: + return path + if os.path.isabs(path): + return os.path.normpath(path) + base = self.primary_root or self.root + return os.path.normpath(os.path.join(base, path)) + def show_pretty(self): if not self.pretty: return False @@ -4568,8 +4617,8 @@ def is_file_safe(self, fname): return def get_all_relative_files(self): - """Get all files known to the file service singleton.""" - fs = FileSystemService.get_instance() + """Get all files known to the file service for this coder's base path.""" + fs = getattr(self, "fs", None) or FileSystemService.get_instance() if fs.trie: # Auto-rebuild if the repository state has changed # (e.g., new commits, staged files, or HEAD change) diff --git a/cecli/commands/utils/helpers.py b/cecli/commands/utils/helpers.py index b70fa0c40c1..2b4670c0ba6 100644 --- a/cecli/commands/utils/helpers.py +++ b/cecli/commands/utils/helpers.py @@ -159,7 +159,7 @@ def get_file_completions( root = Path(coder.root) if hasattr(coder, "root") else Path.cwd() # Try using FileSystemService for efficient lookups - fs = FileSystemService.get_instance() + fs = getattr(coder, "fs", None) or FileSystemService.get_instance() fs_available = fs.trie is not None or fs.ngram is not None if completion_type == "glob": diff --git a/cecli/commands/workspace.py b/cecli/commands/workspace.py index ba53f260fe5..4fe1aa4fb22 100644 --- a/cecli/commands/workspace.py +++ b/cecli/commands/workspace.py @@ -1,68 +1,29 @@ -import subprocess - from cecli.commands.utils.base_command import BaseCommand -from cecli.decoding import safe_open class WorkspaceCommand(BaseCommand): NORM_NAME = "workspace" - DESCRIPTION = "Print information about the current workspace" + DESCRIPTION = "Print information about the active workspace sub-agents" show_completion_notification = True @classmethod async def execute(cls, io, coder, args, **kwargs): - """Execute the workspace command.""" - if not coder or not coder.repo: - io.tool_output("No repository or workspace active.") - return - - workspace_path = getattr(coder.repo, "workspace_path", None) - if not workspace_path: - io.tool_output("Not currently working within a cecli workspace.") - return - - import json + """Show the registered ws:{name} workspace sub-agents and their roots.""" + from cecli.helpers.agents.service import AgentService - metadata_path = workspace_path / ".cecli-workspace.json" - config = {} - if metadata_path.exists(): - try: - with safe_open(metadata_path, "r") as f: - config = json.load(f) - except Exception: - pass + registry = AgentService.get_registry() + ws_agents = sorted((name, cfg) for name, cfg in registry.items() if name.startswith("ws:")) - ws_name = config.get("name", workspace_path.name) - is_active = config.get("active", False) - - io.print(f"Current Workspace: {ws_name}{' (Active)' if is_active else ''}") - io.print(f"Root Directory: {workspace_path}") - io.print("-" * 40) - io.print("Projects:") - - projects = config.get("projects", []) - for proj in projects: - proj_name = proj.get("name") - if not proj_name: - continue - - proj_root = workspace_path / proj_name / "main" - branch_info = "Unknown" - if proj_root.exists(): - try: - branch_info = subprocess.check_output( - ["git", "-C", str(proj_root), "rev-parse", "--abbrev-ref", "HEAD"], - stderr=subprocess.DEVNULL, - encoding="utf-8", - ).strip() - except Exception: - branch_info = "Error retrieving branch" + if not ws_agents: + io.tool_output("No workspace sub-agents are active.") + return - repo_url = proj.get("repo", "N/A") - io.print(f" - {proj_name}:") - io.print(f" Branch: {branch_info}") - io.print(f" Remote: {repo_url}") - io.print(f" Path: {proj_root}") + io.print("Workspace Sub-Agents:") + for name, cfg in ws_agents: + metadata = getattr(cfg, "metadata", {}) or {} + io.print(f" - {name}") + io.print(f" Root: {metadata.get('root')}") + io.print(f" Layout: {metadata.get('layout')}") io.print("") @classmethod @@ -70,5 +31,5 @@ def get_help(cls) -> str: """Get help text for the workspace command.""" help_text = super().get_help() help_text += "\nUsage:\n" - help_text += " /workspace # Show details of the active monorepo workspace\n" + help_text += " /workspace # List active workspace sub-agents\n" return help_text diff --git a/cecli/helpers/agents/service.py b/cecli/helpers/agents/service.py index 2f39d013e8e..aa9ede170c4 100644 --- a/cecli/helpers/agents/service.py +++ b/cecli/helpers/agents/service.py @@ -14,6 +14,7 @@ from uuid import uuid4 import cecli.models as models +from cecli.helpers import nested from cecli.helpers.coroutines import fire_and_forget logger = logging.getLogger(__name__) @@ -186,7 +187,7 @@ def mark_sub_agent_finished( return @classmethod - def build_registry(cls, paths: List[str]) -> None: + def build_registry(cls, paths: List[str], root: Optional[str] = None) -> None: """Scan directories for .md sub-agent definition files and load them. Each .md file should contain YAML front matter with: @@ -204,16 +205,45 @@ def build_registry(cls, paths: List[str]) -> None: ```` - The parent coder's agent model ```` - The parent coder's main model ```` - The currently active (foreground) coder's main model + + Args: + paths: Directories to scan for sub-agent ``.md`` definition files. + root: Optional base directory used to resolve relative ``paths`` + (e.g. the workspace or project root in multi-project workspaces). """ from pathlib import Path from .config import parse_subagent_file + # Resolve relative sub-agent directories against ``root`` so + # multi-project workspaces can reference local sub-agent definitions + # relative to their project/workspace root. Absolute and ``~`` paths + # are left untouched. + base = Path(root).expanduser().resolve() if root else None + + def _resolve(directory: str) -> str: + raw = Path(directory).expanduser() + if raw.is_absolute(): + return str(raw) + if base is not None: + return str((base / raw).resolve()) + return str(raw) + + paths = [_resolve(directory) for directory in paths] + # Always check the default sub-agents directory in the user's home default_dir = str(Path.home() / ".cecli" / "subagents") if default_dir not in paths: paths = [default_dir] + list(paths) + # Also check the local default sub-agents directory under the given root + # (e.g. {root}/.cecli/subagents) before the user-configured paths so + # project-scoped sub-agents take precedence over the global default. + if base is not None: + local_default_dir = str(base / ".cecli" / "subagents") + if local_default_dir not in paths: + paths.insert(1, local_default_dir) + # Also scan the built-in defaults directory for .yml definitions import cecli.helpers.agents.defaults as _defaults_pkg @@ -553,6 +583,22 @@ async def _create_sub_agent_coder( metadata = getattr(config, "metadata", {}).copy() agent_config = metadata.get("agent-config", {}) + # Optional per-sub-agent root override for multi-project workspaces. + configured_root = metadata.get("root") + if not configured_root and isinstance(agent_config, dict): + configured_root = agent_config.get("root") + + # Workspace sub-agents (ws:*) inherit the parent/primary coder's + # agent_config as their default and additionally enable nested + # delegation so they can themselves serve as delegation bases. The + # ws-agent's own metadata agent-config is merged on top. + if name.startswith("ws:"): + inherited = dict(nested.getter(parent_coder, "agent_config", {}) or {}) + incoming = dict(agent_config) + inherited.update(incoming) + inherited["allow_nested_delegation"] = True + agent_config = inherited + kwargs = dict( io=parent_coder.io, from_coder=parent_coder, @@ -563,6 +609,8 @@ async def _create_sub_agent_coder( map_tokens=0, init_metadata={"agent_config": agent_config}, ) + if configured_root: + kwargs["root"] = configured_root if agent_config: # Reset the per-instance tool/server filters so AgentCoder.post_init() diff --git a/cecli/helpers/conversation/integration.py b/cecli/helpers/conversation/integration.py index 3ffdc22883e..e20bad60313 100644 --- a/cecli/helpers/conversation/integration.py +++ b/cecli/helpers/conversation/integration.py @@ -1,4 +1,5 @@ import json +import os import random import time import weakref @@ -501,6 +502,8 @@ def add_rules_messages(self) -> List[Dict[str, Any]]: """ Get rules file messages for reference. These are always reloaded from disk and use the RULES tag. + If no explicit rules files are configured, fall back to AGENTS.md + and CLAUDE.md files found in the coder root directory. """ coder = self.get_coder() if not coder: @@ -514,10 +517,21 @@ def add_rules_messages(self) -> List[Dict[str, Any]]: return [] messages = [] - if not hasattr(coder, "abs_rules_fnames") or not coder.abs_rules_fnames: + rules_files = [] + if hasattr(coder, "abs_rules_fnames") and coder.abs_rules_fnames: + rules_files = sorted(coder.abs_rules_fnames) + else: + # No explicit rules configured; fall back to convention files + # (AGENTS.md / CLAUDE.md) found in the coder root directory. + for fallback_name in ("AGENTS.md", "CLAUDE.md"): + fallback_path = coder.abs_root_path(fallback_name) + if os.path.isfile(fallback_path): + rules_files.append(fallback_path) + + if not rules_files: return messages - for fname in sorted(coder.abs_rules_fnames): + for fname in rules_files: # Read file content directly from disk try: content = coder.io.read_text(fname) diff --git a/cecli/helpers/file_system/service.py b/cecli/helpers/file_system/service.py index 2965490859e..94edd58d23c 100644 --- a/cecli/helpers/file_system/service.py +++ b/cecli/helpers/file_system/service.py @@ -1,9 +1,15 @@ """ -FileSystemService: Global singleton for file path resolution and discovery. +FileSystemService: per-base-path singleton for file path resolution and discovery. -All agents (Coder, sub-agents, etc.) share one instance via get_instance(). -Initialized once on first call with root/repo params; subsequent calls -ignore them and return the existing singleton. +Each distinct base path owns exactly one instance, carrying its own repo, +marisa-trie, and trigram index. Use FileSystemService.for_root(root, repo) +to obtain the instance for a base path. Coders that share a base path share +the same instance; coders on different base paths get independent instances, +so multiple repositories can be served concurrently. + +get_instance() remains as a backward-compatible accessor that returns the +most-recently-active base path's instance when no root is given, and delegates +to for_root() when a root is provided. Uses marisa-trie for memory-efficient prefix/string matching and ngram for trigram-based fuzzy search. Supports construction from @@ -24,12 +30,15 @@ class FileSystemService: """ Provides file path resolution, prefix queries, and fuzzy search. - Singleton — all agents and sub-agents share one instance. - Use FileSystemService.get_instance() to obtain it. + Singleton per base path — all coders that share a base path use one + instance; coders on different base paths get independent instances. Use + FileSystemService.for_root() to obtain the instance for a base path. """ - # --- Singleton state --- - _instance = None + # --- Per-base-path singleton registry --- + _instances: dict[str, "FileSystemService"] = {} + _active_root: str | None = None + _refcounts: dict[str, int] = {} # --- Instance attributes --- root: str = "." @@ -44,7 +53,7 @@ def __init__( ): """ Initialize the service. Not intended for direct use — - call get_instance() instead. + call for_root() instead. """ self.root = os.path.normpath(root) self.repo = repo @@ -67,35 +76,104 @@ def ngram(self): # --------------------------------------------------------------- # Singleton access # --------------------------------------------------------------- + @staticmethod + def _normalize_root(root: str) -> str: + """Normalize a base path into a canonical registry key.""" + return os.path.normpath(os.path.abspath(root)) + @classmethod def reset_instance(cls) -> None: - """Reset the singleton — used primarily in test teardown.""" - cls._instance = None + """Reset all per-base-path singletons — used primarily in test teardown.""" + cls._instances.clear() + cls._refcounts.clear() + cls._active_root = None + + @classmethod + def for_root(cls, root: str | None = None, repo=None) -> "FileSystemService": + """ + Return the singleton for a base path. + + Each distinct base path owns exactly one instance carrying its own + repo and indices. The first call for a base path builds it; later + calls return the cached instance. If a different repo is supplied for + an already-cached base path, the instance's repo is re-pointed and the + index is invalidated so the next rebuild uses the new repo. + """ + root = root or "." + key = cls._normalize_root(root) + service = cls._instances.get(key) + + if service is None: + service = cls._create(root=root, repo=repo) + cls._instances[key] = service + elif repo is not None and service.repo is not repo: + service.repo = repo + service._build_hash = "" # Invalidate for lazy rebuild with new repo + cls._active_root = key + return service @classmethod def get_instance(cls, root: str | None = None, repo=None) -> "FileSystemService": """ - Return the global singleton. + Backward-compatible accessor (see the module docstring). - On first call, creates and builds the instance using root/repo. - Subsequent calls return the existing instance. If a non-None root - is explicitly provided and differs from the current root, the - singleton is rebuilt (e.g. when a new coder is created in a - different working directory). + With a root, delegates to for_root(). Without a root, returns the + most-recently-active base path's instance, building one for the + current directory if nothing is active yet. """ - if cls._instance is None: - cls._instance = cls._create(root=root or ".", repo=repo) - elif root is not None and root != cls._instance.root: - cls._instance = cls._create(root=root, repo=repo) - return cls._instance + if root is not None: + return cls.for_root(root=root, repo=repo) + if cls._active_root is None: + return cls.for_root(root=".", repo=repo) + return cls._instances[cls._active_root] + + @classmethod + def evict(cls, root: str | None = None) -> None: + """Drop the instance for a base path (e.g. on coder teardown).""" + if root is None: + return + key = cls._normalize_root(root) + cls._instances.pop(key, None) + cls._refcounts.pop(key, None) + if cls._active_root == key: + cls._active_root = None @classmethod def _create(cls, root: str, repo=None) -> "FileSystemService": - """Internal factory — builds and returns the singleton.""" + """Internal factory — builds and returns a per-base-path instance.""" service = cls(root=root, repo=repo) service.build() return service + @classmethod + def _inc_ref(cls, key: str) -> None: + """Bump the reference count for a base path held by a live coder.""" + cls._refcounts[key] = cls._refcounts.get(key, 0) + 1 + + @classmethod + def _release(cls, key: str) -> None: + """Drop one reference; evict the base-path service (and its git repo) + when the last coder using it is destroyed.""" + count = cls._refcounts.get(key, 0) + if count <= 1: + cls._evict_pair(key) + else: + cls._refcounts[key] = count - 1 + + @classmethod + def _evict_pair(cls, key: str) -> None: + """Evict the FileSystemService and the matching GitRepoProxy for a base path.""" + cls._instances.pop(key, None) + cls._refcounts.pop(key, None) + if cls._active_root == key: + cls._active_root = None + try: + from cecli.repo import GitRepoProxy + + GitRepoProxy.evict(key) + except Exception: + pass + def has_git(self) -> bool: """Check if a git repo is available at root.""" return self.repo is not None or (os.path.isdir(os.path.join(self.root, ".git"))) diff --git a/cecli/helpers/monorepo/__init__.py b/cecli/helpers/monorepo/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/cecli/helpers/monorepo/config.py b/cecli/helpers/monorepo/config.py deleted file mode 100644 index 6fb4fdb29c2..00000000000 --- a/cecli/helpers/monorepo/config.py +++ /dev/null @@ -1,174 +0,0 @@ -import json -from pathlib import Path -from typing import Any, Dict, Optional - -import yaml - -from cecli.decoding import safe_open - - -def resolve_workspace_config(config_arg: Optional[str] = None) -> Optional[Any]: - """ - Common logic to resolve workspace configuration from hierarchy: - 1. config_arg (JSON string) - 2. Local .cecli.workspaces.yml/yaml - 3. Global ~/.cecli/workspaces.yml/yaml - 4. Fallback to .cecli.conf.yml - """ - workspace_conf = None - - # 1. Try config_arg (JSON string from main.py) - if config_arg: - try: - loaded = json.loads(config_arg) - if isinstance(loaded, dict): - workspace_conf = loaded.get("workspaces") or loaded.get("workspace") or loaded - elif isinstance(loaded, list): - workspace_conf = loaded - except json.JSONDecodeError: - try: - loaded = yaml.safe_load(config_arg) - if isinstance(loaded, dict): - workspace_conf = loaded.get("workspaces") or loaded.get("workspace") or loaded - elif isinstance(loaded, list): - workspace_conf = loaded - except yaml.YAMLError: - pass - - # 2. Look for local .cecli.workspaces.yml/yaml - if not workspace_conf: - for local_name in [".cecli.workspaces.yml", ".cecli.workspaces.yaml"]: - local_path = Path(local_name) - if local_path.exists(): - try: - with safe_open(local_path, "r") as f: - loaded = yaml.safe_load(f) - if loaded: - workspace_conf = ( - loaded.get("workspaces") or loaded.get("workspace") or loaded - ) - if workspace_conf: - break - except Exception: - pass - - # 3. Look for global ~/.cecli/workspaces.yml/yaml - if not workspace_conf: - for global_name in ["workspaces.yml", "workspaces.yaml"]: - global_path = Path.home() / ".cecli" / global_name - if global_path.exists(): - try: - with safe_open(global_path, "r") as f: - loaded = yaml.safe_load(f) - if loaded: - workspace_conf = ( - loaded.get("workspaces") or loaded.get("workspace") or loaded - ) - if workspace_conf: - break - except Exception: - pass - - return workspace_conf - - -def load_workspace_config_file(path: Path) -> Dict[str, Any]: - """Load and validate a repo-local ``.cecli.workspaces.yml`` file.""" - from cecli.helpers.monorepo.local_workspace import load_workspace_file - - config = load_workspace_file(path) - validate_config(config) - return config - - -def load_workspace_config( - config_arg: Optional[str] = None, name: Optional[str] = None -) -> Dict[str, Any]: - """ - Load workspace configuration from hierarchy. - If name is provided, select that specific workspace from a list. - """ - workspace_conf = resolve_workspace_config(config_arg) - - config = {} - # Handle list of workspaces or single dict - if isinstance(workspace_conf, list): - if name: - selected_ws = next((ws for ws in workspace_conf if ws.get("name") == name), None) - if not selected_ws: - raise ValueError(f"Workspace '{name}' not found in configuration") - config = selected_ws - else: - active_workspaces = [ws for ws in workspace_conf if ws.get("active")] - if len(active_workspaces) > 1: - active_names = [ws.get("name", "unknown") for ws in active_workspaces] - raise ValueError(f"Multiple workspaces marked as active: {', '.join(active_names)}") - - active_ws = active_workspaces[0] if active_workspaces else None - - # If no workspace is explicitly marked active, but there is only one, use it - if not active_ws and len(workspace_conf) == 1: - active_ws = workspace_conf[0] - config = active_ws if active_ws else {} - elif isinstance(workspace_conf, dict): - config = workspace_conf - - validate_config(config) - return config - - -def validate_config(config: Dict[str, Any]) -> None: - """ - Validate workspace config shape. - - Each project must have a ``name`` and exactly one of ``path`` (local git - root) or ``repo`` (clone URL). At most one project may set ``primary: true``. - """ - if not config: - return - - if "name" not in config: - raise ValueError("Workspace configuration must include a 'name'") - - if "projects" not in config: - config["projects"] = [] - - project_names = set() - primary_count = 0 - for project in config["projects"]: - if "name" not in project: - raise ValueError("Each project must have a 'name'") - has_path = bool(project.get("path")) - has_repo = bool(project.get("repo")) - if has_path == has_repo: - raise ValueError( - f"Project '{project['name']}' must have exactly one of 'path' or 'repo'" - ) - if project.get("primary"): - primary_count += 1 - if project["name"] in project_names: - raise ValueError(f"Duplicate project name: {project['name']}") - project_names.add(project["name"]) - if primary_count > 1: - raise ValueError("Only one project may be marked primary: true") - - -def find_active_workspace_name(config_arg: Optional[str] = None) -> Optional[str]: - """ - Find the name of the active workspace from the config without resolving it fully. - Used in main.py to automatically activate a workspace. - """ - workspace_conf = resolve_workspace_config(config_arg) - - if isinstance(workspace_conf, list): - active_ws = next((ws for ws in workspace_conf if ws.get("active")), None) - if active_ws: - return active_ws.get("name") - # If there's only one workspace, it's considered active - if len(workspace_conf) == 1: - return workspace_conf[0].get("name") - elif isinstance(workspace_conf, dict): - # If it's a single dict, it's considered active by default - return workspace_conf.get("name") - - return None diff --git a/cecli/helpers/monorepo/project.py b/cecli/helpers/monorepo/project.py deleted file mode 100644 index 516208a2296..00000000000 --- a/cecli/helpers/monorepo/project.py +++ /dev/null @@ -1,90 +0,0 @@ -import subprocess -from pathlib import Path -from typing import Any, Dict - -from cecli.helpers.monorepo.worktree import WorktreeManager - - -class Project: - def __init__(self, workspace_path: Path, config: Dict[str, Any]): - self.workspace_path = workspace_path - self.config = config - self.name = config["name"] - self.repo_url = config["repo"] - self.ignore_file = config.get("ignore") - self.base_path = workspace_path / self.name - self.main_path = self.base_path / "main" - - def initialize(self) -> None: - """Clone the repository and setup worktrees.""" - if not self.main_path.exists(): - self.main_path.mkdir(parents=True, exist_ok=True) - - target_branch = self.config.get("branch") - use_current = self.config.get("use_current_branch", True) - - clone_cmd = ["git", "clone", "--depth", "1"] - if target_branch and not use_current: - clone_cmd += ["--branch", target_branch] - - clone_cmd += [self.repo_url, str(self.main_path)] - - subprocess.run(clone_cmd, check=True) - - # Ensure correct branch is checked out - target_branch = self.config.get("branch") - use_current = self.config.get("use_current_branch", True) - - if target_branch and not use_current: - try: - # Check current branch - current_branch = subprocess.check_output( - ["git", "-C", str(self.main_path), "rev-parse", "--abbrev-ref", "HEAD"], - encoding="utf-8", - ).strip() - - if current_branch != target_branch: - # Try to checkout directly - res = subprocess.run( - ["git", "-C", str(self.main_path), "checkout", target_branch], - check=False, - capture_output=True, - ) - - if res.returncode != 0: - # If checkout fails, check if it exists on origin - subprocess.run( - ["git", "-C", str(self.main_path), "fetch", "origin", target_branch], - check=False, - ) - - # Try checking out the remote branch - res = subprocess.run( - [ - "git", - "-C", - str(self.main_path), - "checkout", - "-b", - target_branch, - f"origin/{target_branch}", - ], - check=False, - capture_output=True, - ) - - if res.returncode != 0: - # If it still fails, it doesn't exist on origin, so create it locally - subprocess.run( - ["git", "-C", str(self.main_path), "checkout", "-b", target_branch], - check=True, - ) - except Exception: - # Fallback for unexpected errors - pass - # Handle worktrees - worktrees_config = self.config.get("worktrees", []) - if worktrees_config: - wt_manager = WorktreeManager(self.main_path) - for wt_cfg in worktrees_config: - wt_manager.create(wt_cfg["name"], wt_cfg["branch"]) diff --git a/cecli/helpers/monorepo/workspace.py b/cecli/helpers/monorepo/workspace.py deleted file mode 100644 index 8011057c74b..00000000000 --- a/cecli/helpers/monorepo/workspace.py +++ /dev/null @@ -1,48 +0,0 @@ -import os -from pathlib import Path -from typing import Any, Dict - -from cecli.decoding import safe_open -from cecli.helpers.monorepo.project import Project - - -class WorkspaceManager: - def __init__(self, workspace_name: str, config: Dict[str, Any]): - self.name = workspace_name - self.config = config - self.path = Path(os.path.expanduser(f"~/.cecli/workspaces/{workspace_name}")) - - def exists(self) -> bool: - """Check if workspace directory exists""" - return self.path.exists() - - def initialize(self) -> None: - """Create workspace structure and clone repositories""" - self.path.mkdir(parents=True, exist_ok=True) - - projects_config = self.config.get("projects", []) - for proj_cfg in projects_config: - project = Project(self.path, proj_cfg) - project.initialize() - - # Copy ignore files to workspace root - for proj_cfg in projects_config: - ignore_file = proj_cfg.get("ignore") - if ignore_file: - ignore_path = Path(ignore_file).expanduser() - if ignore_path.exists(): - import shutil - - dest_path = self.path / f"{proj_cfg['name']}.ignore" - shutil.copy2(ignore_path, dest_path) - - # Write metadata - import json - - metadata_path = self.path / ".cecli-workspace.json" - with safe_open(metadata_path, "w") as f: - json.dump(self.config, f, indent=2) - - def get_working_directory(self) -> Path: - """Return workspace root path for cd""" - return self.path diff --git a/cecli/helpers/monorepo/worktree.py b/cecli/helpers/monorepo/worktree.py deleted file mode 100644 index 22be86b35f5..00000000000 --- a/cecli/helpers/monorepo/worktree.py +++ /dev/null @@ -1,21 +0,0 @@ -import subprocess -from pathlib import Path - - -class WorktreeManager: - def __init__(self, main_repo_path: Path): - self.main_repo_path = main_repo_path - self.worktrees_dir = main_repo_path.parent / "worktrees" - - def create(self, name: str, branch: str) -> None: - """Create a git worktree.""" - wt_path = self.worktrees_dir / name - if wt_path.exists(): - return - - self.worktrees_dir.mkdir(parents=True, exist_ok=True) - - subprocess.run( - ["git", "-C", str(self.main_repo_path), "worktree", "add", str(wt_path), branch], - check=True, - ) diff --git a/cecli/helpers/skills.py b/cecli/helpers/skills.py index 7d6fbea31f4..2c8d11e9897 100644 --- a/cecli/helpers/skills.py +++ b/cecli/helpers/skills.py @@ -52,7 +52,7 @@ def __init__( include_list: Optional[List[str]] = None, exclude_list: Optional[List[str]] = None, initialize_list: Optional[List[str]] = None, - git_root: Optional[str] = None, + root: Optional[str] = None, coder=None, ): """ @@ -63,7 +63,7 @@ def __init__( include_list: Optional list of skill names to include (whitelist) exclude_list: Optional list of skill names to exclude (blacklist) initialize_list: Optional list of skill names to load and include on startup - git_root: Optional git root directory for relative path resolution + root: Optional base root directory for relative path resolution coder: Optional reference to the coder instance (weak reference) """ # Always include the default skills directory in the user's home @@ -71,12 +71,29 @@ def __init__( if default_skill_dir not in directory_paths: directory_paths = [default_skill_dir] + list(directory_paths) - # Resolve every path and drop exact directory duplicates. + # Local path resolution base: use root so sub-agents with an overridden + # local root still resolve project skills relative to the primary + # workspace (instead of the working directory). + local_anchor = Path(root).expanduser().resolve() if root else Path.cwd() + + # Also include the local default skills directory under the coder root + # so project-scoped skills can live in {root}/.cecli/skills alongside the + # global ~/.cecli/skills default. + if root: + local_default_skill_dir = str(local_anchor / ".cecli" / "skills") + if local_default_skill_dir not in directory_paths: + directory_paths.append(local_default_skill_dir) + + # Resolve every path relative to local_anchor and drop exact duplicates. resolved_paths = [] seen_paths = set() for p in directory_paths: try: - path = Path(p).expanduser().resolve() + candidate = Path(p).expanduser() + if candidate.is_absolute(): + path = candidate.resolve() + else: + path = (local_anchor / candidate).resolve() except Exception: continue if path in seen_paths: @@ -85,18 +102,17 @@ def __init__( resolved_paths.append(path) # Order paths: local project dirs first, then configured, home dirs last. - git_root_path = Path(git_root).expanduser().resolve() if git_root else None ordered = sorted( enumerate(resolved_paths), key=lambda item: ( - self._directory_priority(item[1], git_root_path), + self._directory_priority(item[1], local_anchor), item[0], ), ) self.directory_paths = [path for _, path in ordered] self.include_list = set(include_list) if include_list else None self.exclude_list = set(exclude_list) if exclude_list else set() - self.git_root = Path(git_root).expanduser().resolve() if git_root else None + self.root = Path(root).expanduser().resolve() if root else None self._coder_ref = weakref.ref(coder) if coder else None # Weak reference to coder instance # Cache for loaded skills @@ -134,12 +150,12 @@ def __init__( # Save initial state from config @staticmethod - def _directory_priority(path: Path, git_root: Optional[Path] = None) -> int: + def _directory_priority(path: Path, root: Optional[Path] = None) -> int: """Return the ordering priority of a skill directory. Lower values are scanned first and therefore win by-name conflicts: - 0 - local project directory (under the git root or working directory) + 0 - local project directory (under the root or working directory) 1 - any other configured directory 2 - a home directory (including the implicit ``~/.cecli/skills`` default) """ @@ -149,7 +165,7 @@ def _directory_priority(path: Path, git_root: Optional[Path] = None) -> int: if path == (home / ".cecli" / "skills"): return 2 - local_anchor = git_root if git_root is not None else Path.cwd() + local_anchor = root if root is not None else Path.cwd() try: path.relative_to(local_anchor) return 0 @@ -834,7 +850,7 @@ def skill_summary_loader( directory_paths: List[str], include_list: Optional[List[str]] = None, exclude_list: Optional[List[str]] = None, - git_root: Optional[str] = None, + root: Optional[str] = None, ) -> str: """ High-level function to load and summarize all available skills. @@ -843,12 +859,12 @@ def skill_summary_loader( directory_paths: List of directory paths to search for skills include_list: Optional list of skill names to include (whitelist) exclude_list: Optional list of skill names to exclude (blacklist) - git_root: Optional git root directory for relative path resolution + root: Optional base root directory for relative path resolution Returns: Formatted summary of all available skills """ - manager = cls(directory_paths, include_list, exclude_list, git_root) + manager = cls(directory_paths, include_list, exclude_list, root) summaries = manager.get_all_skill_summaries() if not summaries: @@ -862,15 +878,13 @@ def skill_summary_loader( return result @staticmethod - def resolve_skill_directories( - base_paths: List[str], git_root: Optional[str] = None - ) -> List[Path]: + def resolve_skill_directories(base_paths: List[str], root: Optional[str] = None) -> List[Path]: """ Resolve skill directory paths relative to various locations. Args: base_paths: List of base directory paths - git_root: Optional git root directory + root: Optional base root directory Returns: List of resolved Path objects @@ -878,9 +892,9 @@ def resolve_skill_directories( resolved_paths = [] for base_path in base_paths: - # Try to resolve relative to git root first - if git_root and not Path(base_path).is_absolute(): - git_path = Path(git_root) / base_path + # Try to resolve relative to root first + if root and not Path(base_path).is_absolute(): + git_path = Path(root) / base_path if git_path.exists(): resolved_paths.append(git_path.resolve()) continue diff --git a/cecli/helpers/workspaces/__init__.py b/cecli/helpers/workspaces/__init__.py new file mode 100644 index 00000000000..c15daf112fd --- /dev/null +++ b/cecli/helpers/workspaces/__init__.py @@ -0,0 +1,13 @@ +"""Workspaces: local multi-project workspaces with implicit ``ws:{name}`` sub-agents. + +Replaces the former ``cecli.helpers.monorepo`` module. Workspaces are driven +entirely by a local ``.cecli.workspaces.yml`` configuration file that lists +existing git roots via ``path:`` entries. Each project automatically gets an +implicit ``ws:{project}`` sub-agent (mirroring the ``worker`` default) whose +``root`` points at that project, so workspaces can be spun up as agents. +""" + +from .subagents import register_workspace_subagents +from .workspace import WorkspaceManager + +__all__ = ["register_workspace_subagents", "WorkspaceManager"] diff --git a/cecli/helpers/workspaces/config.py b/cecli/helpers/workspaces/config.py new file mode 100644 index 00000000000..80121bc9ef2 --- /dev/null +++ b/cecli/helpers/workspaces/config.py @@ -0,0 +1,189 @@ +"""Workspace configuration loading and validation (local, path-based). + +Workspaces are defined by a local ``.cecli.workspaces.yml`` file listing +existing git roots via ``path:`` entries. There is no ``repo:``/clone mode: +every project must point at an on-disk git root. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Dict, Optional + +import yaml + +from cecli.decoding import safe_open + +WORKSPACE_FILENAMES = (".cecli.workspaces.yml", ".cecli.workspaces.yaml") + + +def resolve_workspace_config(config_arg: Optional[str] = None) -> Optional[Any]: + """Resolve the raw workspace config from the same hierarchy as before: + + 1. ``config_arg`` (JSON/YAML string or path to a file) + 2. Local ``.cecli.workspaces.yml`` / ``.cecli.workspaces.yaml`` + 3. Global ``~/.cecli/workspaces.yml`` / ``.cecli/workspaces.yaml`` + """ + workspace_conf = None + + if config_arg: + candidate = Path(config_arg).expanduser() + if candidate.is_file(): + workspace_conf = _load_yaml_file(candidate) + else: + workspace_conf = _parse_workspace_string(config_arg) + + if not workspace_conf: + for name in WORKSPACE_FILENAMES: + local_path = Path(name) + if local_path.is_file(): + workspace_conf = _load_yaml_file(local_path) + if workspace_conf: + break + + if not workspace_conf: + for name in ("workspaces.yml", "workspaces.yaml"): + global_path = Path.home() / ".cecli" / name + if global_path.is_file(): + workspace_conf = _load_yaml_file(global_path) + if workspace_conf: + break + + return workspace_conf + + +def _load_yaml_file(path: Path) -> Optional[Any]: + try: + with safe_open(path, "r") as f: + loaded = yaml.safe_load(f) + except Exception: + return None + if not loaded: + return None + if isinstance(loaded, dict): + return loaded.get("workspaces") or loaded.get("workspace") or loaded + return loaded + + +def _parse_workspace_string(config_arg: str) -> Optional[Any]: + try: + loaded = json.loads(config_arg) + except (json.JSONDecodeError, TypeError): + try: + loaded = yaml.safe_load(config_arg) + except yaml.YAMLError: + return None + if isinstance(loaded, dict): + return loaded.get("workspaces") or loaded.get("workspace") or loaded + return loaded + + +def load_workspace_config_file(path: Path) -> Dict[str, Any]: + """Load and validate a repo-local ``.cecli.workspaces.yml`` file.""" + from .paths import load_workspace_file + + config = load_workspace_file(path) + validate_config(config) + return config + + +def load_workspace_config( + config_arg: Optional[str] = None, + name: Optional[str] = None, +) -> Dict[str, Any]: + """Load workspace config from the hierarchy, optionally selecting by name.""" + workspace_conf = resolve_workspace_config(config_arg) + + config: Dict[str, Any] = {} + if isinstance(workspace_conf, list): + if name: + selected = next((ws for ws in workspace_conf if ws.get("name") == name), None) + if not selected: + raise ValueError(f"Workspace '{name}' not found in configuration") + config = selected + else: + active = [ws for ws in workspace_conf if ws.get("active")] + if len(active) > 1: + names = [ws.get("name", "unknown") for ws in active] + raise ValueError(f"Multiple workspaces marked as active: {', '.join(names)}") + active_ws = active[0] if active else None + if not active_ws and len(workspace_conf) == 1: + active_ws = workspace_conf[0] + config = active_ws if active_ws else {} + elif isinstance(workspace_conf, dict): + config = workspace_conf + + validate_config(config) + return config + + +def validate_config(config: Dict[str, Any]) -> None: + """Validate a workspace config. + + Each project must have a ``name`` and **exactly one** of ``path`` (local + git root) or ``repo`` (clone URL). At most one project may set + ``primary: true``. + """ + if not config: + return + + if "name" not in config: + raise ValueError("Workspace configuration must include a 'name'") + + if "projects" not in config: + config["projects"] = [] + + project_names = set() + primary_count = 0 + for project in config["projects"]: + if "name" not in project: + raise ValueError("Each project must have a 'name'") + has_path = bool(project.get("path")) + has_repo = bool(project.get("repo")) + if not (has_path or has_repo): + raise ValueError( + f"Project '{project['name']}' must have exactly one of 'path' or 'repo'" + ) + if has_path and has_repo: + raise ValueError( + f"Project '{project['name']}' must have exactly one of 'path' or 'repo'" + ) + if project.get("primary"): + primary_count += 1 + if project["name"] in project_names: + raise ValueError(f"Duplicate project name: {project['name']}") + project_names.add(project["name"]) + + if primary_count > 1: + raise ValueError("Only one project may be marked primary: true") + + +def workspace_layout(config: Dict[str, Any]) -> str: + """Return the workspace layout: ``clone`` if any project uses ``repo``, else ``local``. + + An explicit ``layout`` field on the workspace overrides the inference. + """ + explicit = config.get("layout") + if explicit in ("clone", "local"): + return explicit + for proj in config.get("projects") or []: + if proj.get("repo"): + return "clone" + return "local" + + +def find_active_workspace_name(config_arg: Optional[str] = None) -> Optional[str]: + """Return the active workspace name without fully resolving it.""" + workspace_conf = resolve_workspace_config(config_arg) + + if isinstance(workspace_conf, list): + active = next((ws for ws in workspace_conf if ws.get("active")), None) + if active: + return active.get("name") + if len(workspace_conf) == 1: + return workspace_conf[0].get("name") + elif isinstance(workspace_conf, dict): + return workspace_conf.get("name") + + return None diff --git a/cecli/helpers/monorepo/local_workspace.py b/cecli/helpers/workspaces/paths.py similarity index 69% rename from cecli/helpers/monorepo/local_workspace.py rename to cecli/helpers/workspaces/paths.py index 51df9b486c9..79360ab4039 100644 --- a/cecli/helpers/monorepo/local_workspace.py +++ b/cecli/helpers/workspaces/paths.py @@ -1,18 +1,11 @@ -""" -Repo-local multi-project workspaces (``path:`` git roots). - -Cecli already supports **clone** workspaces under ``~/.cecli/workspaces/`` with -``repo:`` URLs and paths like ``{project}/main/{file}``. This module adds -**local** layout: projects point at existing directories on disk, and tracked -paths are prefixed as ``{project}/{file}`` (no ``/main/`` segment). - -Config file names (at the workspace root — usually the primary project directory): +"""Path helpers for local and clone workspaces. -- ``.cecli.workspaces.yml`` -- ``.cecli.workspaces.yaml`` +A workspace lists projects that are either: -Each project must have exactly one of ``path`` (absolute local git root) or -``repo`` (clone URL; handled by existing clone workspace code). +- **local** — an existing git root referenced by an absolute ``path:``; tracked + paths are prefixed ``{project}/{file}``. +- **clone** — a remote ``repo:`` URL cloned under ``~/.cecli/workspaces/``; + tracked paths are prefixed ``{project}/main/{file}``. """ from __future__ import annotations @@ -20,7 +13,7 @@ import json import subprocess from pathlib import Path -from typing import Any +from typing import Any, Callable import yaml @@ -61,32 +54,42 @@ def primary_project(config: dict[str, Any]) -> dict[str, Any] | None: for proj in projects: if proj.get("primary"): return proj - if len(projects) == 1: - return projects[0] return projects[0] if projects else None -def project_git_root(workspace_root: Path, project: dict[str, Any], *, layout: str) -> Path | None: +def project_path(workspace_root: Path, project: dict[str, Any], *, layout: str) -> Path | None: + """Resolve a project's on-disk git root for the given layout, or None.""" name = project.get("name") if not name: return None - path_val = project.get("path") - if path_val: - root = Path(str(path_val)).expanduser().resolve() - if not root.is_dir(): + + if layout == "clone": + clone_root = workspace_root / name / "main" + if not clone_root.is_dir(): return None try: subprocess.check_output( - ["git", "-C", str(root), "rev-parse", "--show-toplevel"], + ["git", "-C", str(clone_root), "rev-parse", "--show-toplevel"], stderr=subprocess.DEVNULL, ) - return root + return clone_root except Exception: return None - if layout != "clone": + + path_val = project.get("path") + if not path_val: + return None + root = Path(str(path_val)).expanduser().resolve() + if not root.is_dir(): + return None + try: + subprocess.check_output( + ["git", "-C", str(root), "rev-parse", "--show-toplevel"], + stderr=subprocess.DEVNULL, + ) + return root + except Exception: return None - clone_root = workspace_root / name / "main" - return clone_root if clone_root.is_dir() else None def project_path_prefix(project: dict[str, Any], *, layout: str) -> str: @@ -103,15 +106,19 @@ def resolve_workspace_file_path( *, layout: str, ) -> tuple[Path, Path, str] | None: - """ - Map a workspace-relative path to ``(project_git_root, absolute_file, path_in_project_repo)``. + """Map a workspace-relative path to ``(project_git_root, abs_file, path_in_repo)``. + + ``workspace_rel`` is ``{project}/{file}`` (local) or ``{project}/main/{file}`` + (clone). If the leading segment is not a project name, it resolves against + the primary project. """ rel = workspace_rel.replace("\\", "/").lstrip("/") if not rel: return None - parts = Path(rel).parts + parts = rel.split("/") if not parts: return None + projects = config.get("projects") or [] by_name = {str(p.get("name")): p for p in projects if p.get("name")} @@ -120,17 +127,17 @@ def resolve_workspace_file_path( proj = by_name.get(parts[0]) if not proj: return None - git_root = project_git_root(workspace_root, proj, layout=layout) + git_root = project_path(workspace_root, proj, layout=layout) if not git_root: return None in_repo = "/".join(parts[2:]) if len(parts) > 2 else "" abs_path = git_root / in_repo if in_repo else git_root return git_root, abs_path, in_repo - # Local layout: name/rest or bare path under primary-only tree + # name/rest for local, or name/main/rest handled above if parts[0] in by_name: proj = by_name[parts[0]] - git_root = project_git_root(workspace_root, proj, layout=layout) + git_root = project_path(workspace_root, proj, layout=layout) if not git_root: return None in_repo = "/".join(parts[1:]) if len(parts) > 1 else "" @@ -139,10 +146,9 @@ def resolve_workspace_file_path( primary = primary_project(config) if primary: - git_root = project_git_root(workspace_root, primary, layout=layout) + git_root = project_path(workspace_root, primary, layout=layout) if git_root: - in_repo = rel - return git_root, git_root / in_repo, in_repo + return git_root, git_root / rel, rel return None @@ -151,15 +157,15 @@ def union_tracked_files( config: dict[str, Any], *, layout: str, - ignored_file=None, + ignored_file: Callable[[str], bool] | None = None, ) -> list[str]: - """All tracked files as workspace-relative paths.""" + """All tracked files as workspace-relative paths for the given layout.""" out: list[str] = [] for proj in config.get("projects") or []: name = proj.get("name") if not name: continue - git_root = project_git_root(workspace_root, proj, layout=layout) + git_root = project_path(workspace_root, proj, layout=layout) if not git_root: continue prefix = project_path_prefix(proj, layout=layout) @@ -174,7 +180,7 @@ def union_tracked_files( for line in lines: if not line.strip(): continue - rel = f"{prefix}/{line}" if prefix else line + rel = f"{prefix}/{line}" rel = rel.replace("\\", "/") if ignored_file and ignored_file(rel): continue @@ -193,7 +199,7 @@ def project_head_shas( name = proj.get("name") if not name: continue - git_root = project_git_root(workspace_root, proj, layout=layout) + git_root = project_path(workspace_root, proj, layout=layout) if not git_root: shas.append(f"{name}:unknown") continue @@ -209,25 +215,22 @@ def project_head_shas( return shas -def write_workspace_metadata(workspace_root: Path, config: dict[str, Any], *, layout: str) -> None: +def write_workspace_metadata(workspace_root: Path, config: dict[str, Any]) -> None: meta_dir = workspace_root / ".cecli" meta_dir.mkdir(parents=True, exist_ok=True) - payload = {**config, "_layout": layout} (meta_dir / ".workspace-meta.json").write_text( - json.dumps(payload, indent=2), + json.dumps(config, indent=2), encoding="utf-8", ) -def read_workspace_metadata(workspace_root: Path) -> tuple[dict[str, Any], str] | None: - legacy = workspace_root / ".cecli-workspace.json" +def read_workspace_metadata(workspace_root: Path) -> dict[str, Any] | None: modern = workspace_root / METADATA_NAME + legacy = workspace_root / ".cecli-workspace.json" path = modern if modern.is_file() else legacy if legacy.is_file() else None if not path: return None try: - data = json.loads(path.read_text(encoding="utf-8")) - layout = data.pop("_layout", "clone") - return data, layout + return json.loads(path.read_text(encoding="utf-8")) except Exception: return None diff --git a/cecli/helpers/workspaces/subagents.py b/cecli/helpers/workspaces/subagents.py new file mode 100644 index 00000000000..7dd364da936 --- /dev/null +++ b/cecli/helpers/workspaces/subagents.py @@ -0,0 +1,121 @@ +"""Implicit ``ws:{name}`` workspace sub-agents. + +When a workspace is active, each project becomes a sub-agent named +``ws:{project}``. The agent mirrors the ``worker`` default sub-agent but has +its ``root`` overridden to the project's git root and ``allow_nested_delegation`` +enabled so it can itself serve as a base for further delegations. +""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path +from typing import Any, Dict, List, Optional + +from .config import workspace_layout +from .paths import project_path + +logger = logging.getLogger(__name__) + + +def register_workspace_subagents( + workspace_config: Dict[str, Any] | None, + workspace_root: Optional[Path | str] = None, +) -> List[str]: + """Create and register a ``ws:{name}`` sub-agent for each workspace project. + + Each project may define a ``metadata`` block that supplies its sub-agent + setup the same way a sub-agent .md file does: ``model`` / ``hooks`` / + ``auto_reap`` become the config fields, and any other keys (e.g. + ``agent-config``) are merged into the sub-agent metadata. + + ``root``, ``name`` and ``description`` are always derived from the + workspace/project definition and cannot be overridden by the metadata block. + + Returns the list of registered agent names. + """ + from cecli.helpers.agents.config import SubAgentConfig + from cecli.helpers.agents.service import AgentService + + config = workspace_config or {} + projects = config.get("projects") or [] + + # Make sure the built-in defaults (including ``worker``) are loaded so the + # workspace agents can mirror them, regardless of call ordering. + if "worker" not in AgentService.get_registry(): + AgentService.build_registry([]) + + worker = AgentService.get_registry().get("worker") + + layout = workspace_layout(config) + if workspace_root is not None: + root_base = Path(workspace_root).resolve() + elif layout == "clone": + root_base = Path(os.path.expanduser(f"~/.cecli/workspaces/{config.get('name')}")) + else: + root_base = Path(".") + + registered: List[str] = [] + for proj in projects: + name = proj.get("name") + if not name: + continue + root = project_path(root_base, proj, layout=layout) + if not root: + continue + + # A project may supply its own sub-agent setup under ``metadata``, + # matching how .md sub-agent definitions do: ``model`` / ``hooks`` / + # ``auto_reap`` map to the config fields; everything else is merged + # into the sub-agent metadata. + project_meta = dict(proj.get("metadata") or {}) + + # ``root``, ``name`` and ``description`` are always derived from the + # workspace/project definition and cannot be overridden by the metadata block. + project_meta.pop("root", None) + project_meta.pop("name", None) + project_meta.pop("description", None) + config_keys = {"model", "hooks", "auto_reap"} + + model = project_meta.get("model", worker.model if worker else None) + hooks = ( + dict(project_meta["hooks"]) + if "hooks" in project_meta + else (dict(worker.hooks) if worker else {}) + ) + auto_reap = ( + project_meta["auto_reap"] + if "auto_reap" in project_meta + else (worker.auto_reap if worker else None) + ) + + agent_name = f"ws:{name}" + metadata = dict(worker.metadata) if worker else {} + for key, value in project_meta.items(): + if key in config_keys: + continue + metadata[key] = value + metadata["root"] = str(root) + metadata["layout"] = layout + + agent_config = dict(metadata.get("agent-config") or {}) + agent_config["allow_nested_delegation"] = True + metadata["agent-config"] = agent_config + + metadata["description"] = f"Workspace sub-agent for project '{name}' at path {root}" + + agent = SubAgentConfig( + name=agent_name, + prompt=(worker.prompt if worker else ""), + model=model, + hooks=hooks, + auto_reap=auto_reap, + metadata=metadata, + ) + + AgentService.register_subagent(agent_name, agent) + registered.append(agent_name) + logger.info("Registered workspace sub-agent '%s' -> %s", agent_name, root) + + return registered diff --git a/cecli/helpers/workspaces/workspace.py b/cecli/helpers/workspaces/workspace.py new file mode 100644 index 00000000000..08e461449f7 --- /dev/null +++ b/cecli/helpers/workspaces/workspace.py @@ -0,0 +1,100 @@ +"""Workspace manager supporting clone and local layouts. + +- **clone** — projects with a ``repo:`` URL are cloned under + ``~/.cecli/workspaces/{name}/{project}/main``; the working directory is the + workspace root. +- **local** — projects point at existing on-disk git roots via ``path:``; the + working directory is the primary project's git root. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path +from typing import Any, Dict, Optional + +from .config import workspace_layout +from .paths import primary_project, project_path + + +class WorkspaceManager: + def __init__( + self, + workspace_name: str, + config: Dict[str, Any], + root: Optional[Path | str] = None, + ): + self.name = workspace_name + self.config = config + self.layout = workspace_layout(config) + + if self.layout == "clone": + self.path = Path(os.path.expanduser(f"~/.cecli/workspaces/{workspace_name}")) + elif root is not None: + self.path = Path(root).resolve() + else: + primary = primary_project(config) + self.path = ( + Path(str(primary["path"])).expanduser().resolve() + if primary and primary.get("path") + else Path.cwd().resolve() + ) + self.root = self.path + + def exists(self) -> bool: + """Check whether the workspace root directory exists.""" + return self.path.exists() + + def initialize(self) -> None: + """Create the workspace root, clone ``repo:`` projects, and write metadata.""" + self.path.mkdir(parents=True, exist_ok=True) + + if self.layout == "clone": + for proj in self.config.get("projects") or []: + if proj.get("repo"): + self._clone_project(self.path, proj) + + from .paths import write_workspace_metadata + + write_workspace_metadata(self.path, self.config) + + def get_working_directory(self) -> Path: + """Return the workspace root (clone) or the primary project's git root (local).""" + if self.layout == "clone": + return self.path + primary = primary_project(self.config) + if primary: + root = project_path(self.path, primary, layout="local") + if root: + return root + return self.path + + def _clone_project(self, workspace_root: Path, project: Dict[str, Any]) -> None: + """Clone a ``repo:`` project under ``{workspace_root}/{name}/main``.""" + name = project.get("name") + repo_url = project.get("repo") + if not name or not repo_url: + return + + main_path = workspace_root / name / "main" + if main_path.exists(): + return + main_path.mkdir(parents=True, exist_ok=True) + + target_branch = project.get("branch") + use_current = project.get("use_current_branch", True) + + clone_cmd = ["git", "clone", "--depth", "1"] + if target_branch and not use_current: + clone_cmd += ["--branch", target_branch] + clone_cmd += [repo_url, str(main_path)] + + subprocess.run(clone_cmd, check=True) + + ignore_file = project.get("ignore") + if ignore_file: + ignore_path = Path(ignore_file).expanduser() + if ignore_path.exists(): + shutil.copy2(ignore_path, workspace_root / f"{name}.ignore") diff --git a/cecli/main.py b/cecli/main.py index 37da564e52e..dd40d83b99e 100644 --- a/cecli/main.py +++ b/cecli/main.py @@ -627,7 +627,7 @@ async def main_async( from cecli.mcp import McpServerManager, load_mcp_servers from cecli.models import ModelSettings from cecli.onboarding import offer_openrouter_oauth, select_default_model - from cecli.repo import GitRepo, GitRepoProxy + from cecli.repo import GitRepoProxy from cecli.report import report_uncaught_exceptions, set_args_error_data from cecli.versioncheck import check_version from cecli.watch import FileWatcher @@ -689,13 +689,14 @@ async def main_async( args, unknown = parser.parse_known_args(argv) os.unlink(_tmp_cfg) - uses_workspace = False if args.workspaces or args.workspace_name: - from cecli.helpers.monorepo.config import ( + from cecli.helpers.workspaces.config import ( find_active_workspace_name, load_workspace_config, + workspace_layout, ) - from cecli.helpers.monorepo.workspace import WorkspaceManager + from cecli.helpers.workspaces.subagents import register_workspace_subagents + from cecli.helpers.workspaces.workspace import WorkspaceManager # Interpolate environment variables in the workspaces argument if args.workspaces: @@ -705,14 +706,17 @@ async def main_async( ws_name = args.workspace_name or find_active_workspace_name(ws_config_arg) if ws_name: config = load_workspace_config(ws_config_arg, name=ws_name) - workspace_manager = WorkspaceManager(ws_name, config) - if not workspace_manager.exists(): - workspace_manager.initialize() + # Clone workspaces must be materialised so the implicit ws:{name} + # sub-agents can point their roots at the cloned checkouts. The + # primary agent's root is left unchanged: workspaces are just + # sub-agents with overridden roots. + if workspace_layout(config) == "clone": + wm = WorkspaceManager(ws_name, config) + if not wm.exists(): + wm.initialize() - os.chdir(workspace_manager.get_working_directory()) - git_root = get_git_root() - uses_workspace = True + register_workspace_subagents(config) if git_root: git_conf = Path(git_root) / conf_fname @@ -720,26 +724,6 @@ async def main_async( all_config_paths.append(str(git_conf)) cecli_conf_yml_files.append(str(git_conf)) - # ── Re-merge if workspace changed git_root ───────────────────────── - if uses_workspace: - merged_config = config_utils.read_and_merge_all_configs( - all_config_paths, conf_yml_files, cecli_conf_yml_files - ) - - _tmp_fd, _tmp_cfg = tempfile.mkstemp(suffix=".yml", prefix="cecli_merged_") - os.close(_tmp_fd) - - with safe_open(_tmp_cfg, "w") as f: - yaml.dump(merged_config, f) - - parser = get_parser([_tmp_cfg], git_root) - args, unknown = parser.parse_known_args(argv) - - # Re-load dotenv files in case the new git_root has an env_file - loaded_dotenvs = load_dotenv_files(git_root, args.env_file, args.encoding) - os.unlink(_tmp_cfg) - args, unknown = parser.parse_known_args(argv) - set_args_error_data(args) # Override the module-level default encoding with the resolved config value @@ -1185,22 +1169,21 @@ def get_io(pretty): repo = None if args.git: try: - repo = GitRepoProxy( - GitRepo( - io, - fnames, - git_dname, - args.cecli_ignore, - models=main_model.commit_message_models(), - attribute_author=args.attribute_author, - attribute_committer=args.attribute_committer, - attribute_commit_message_author=args.attribute_commit_message_author, - attribute_commit_message_committer=args.attribute_commit_message_committer, - commit_prompt=args.commit_prompt, - subtree_only=args.subtree_only, - git_commit_verify=args.git_commit_verify, - attribute_co_authored_by=args.attribute_co_authored_by, - ) + repo = GitRepoProxy.for_root( + None, + io, + fnames=fnames, + git_dname=git_dname, + cecli_ignore_file=args.cecli_ignore, + models=main_model.commit_message_models(), + attribute_author=args.attribute_author, + attribute_committer=args.attribute_committer, + attribute_commit_message_author=args.attribute_commit_message_author, + attribute_commit_message_committer=args.attribute_commit_message_committer, + commit_prompt=args.commit_prompt, + subtree_only=args.subtree_only, + git_commit_verify=args.git_commit_verify, + attribute_co_authored_by=args.attribute_co_authored_by, ) except FileNotFoundError: pass diff --git a/cecli/repo.py b/cecli/repo.py index 7d052f58cc1..c0481051427 100644 --- a/cecli/repo.py +++ b/cecli/repo.py @@ -56,6 +56,20 @@ def set_git_env(var_name, value, original_value): del os.environ[var_name] +def _close_git_repo(repo: "GitRepo") -> None: + """Safely close a GitRepo that was just opened but will not be used. + + Prevents leaking the underlying ``git.Repo`` file handle when + ``GitRepoProxy.for_root`` discovers an already-cached repository. + """ + try: + with repo._git_lock: + if getattr(repo, "repo", None) is not None: + repo.repo.close() + except Exception: + pass + + class CommitInfo: """ Data-only container for extracted commit information. @@ -121,14 +135,7 @@ def __init__( self.subtree_only = subtree_only self.git_commit_verify = git_commit_verify self.ignore_file_cache = {} - self.is_workspace = False - self.workspace_path = None - self.workspace_config = {} - self.workspace_layout = "clone" - self.workspace_ignore_specs = {} - self.workspace_ignore_ts = {} self._git_lock = threading.RLock() - # Workspace detection and config loading occurs later in __init__ if git_dname: check_fnames = [git_dname] @@ -159,36 +166,11 @@ def __init__( if num_repos == 0: raise FileNotFoundError if num_repos > 1: - from cecli.helpers.monorepo.config import load_workspace_config_file - from cecli.helpers.monorepo.local_workspace import ( - find_workspace_config_file, - ) - - ws_file = find_workspace_config_file(Path(repo_paths[0])) - if not ws_file: - self.io.tool_error( - "Files are in different git repos. Add a .cecli.workspaces.yml at a" - " common ancestor with path: entries for each project." - ) - raise FileNotFoundError - self.workspace_config = load_workspace_config_file(ws_file) - primary = next( - (p for p in self.workspace_config.get("projects", []) if p.get("primary")), - None, + self.io.tool_error( + "Files are in different git repos. Each coder operates on a single base path." ) - if primary and primary.get("path"): - self._init_repo_path = str(Path(str(primary["path"])).expanduser().resolve()) - else: - self._init_repo_path = str(Path(repo_paths[0]).resolve()) - else: - self._init_repo_path = repo_paths.pop() - - # Detect if we're in a workspace - self.workspace_path = self._detect_workspace_path(self._init_repo_path) - if self.workspace_path: - self.is_workspace = True - self._load_workspace_config() - self.refresh_cecli_ignore() + raise FileNotFoundError + self._init_repo_path = repo_paths.pop() self.init_repo() if cecli_ignore_file: @@ -200,73 +182,11 @@ def init_repo(self): self.repo = git.Repo(self._init_repo_path, odbt=git.GitCmdObjectDB) self.root = utils.safe_abs_path(self.repo.working_tree_dir) - if self.is_workspace: - self.root = self.workspace_path - try: self.repo.head.commit # just access to check for errors, discard except ANY_GIT_ERROR: - if not self.is_workspace: - self.repo = git.Repo(self._init_repo_path, odbt=git.GitCmdObjectDB) - self.root = utils.safe_abs_path(self.repo.working_tree_dir) - - def _load_workspace_config(self) -> None: - from cecli.helpers.monorepo.config import ( - load_workspace_config, - load_workspace_config_file, - ) - from cecli.helpers.monorepo.local_workspace import ( - find_workspace_config_file, - read_workspace_metadata, - write_workspace_metadata, - ) - - ws_file = find_workspace_config_file(Path(self.workspace_path)) - if ws_file: - self.workspace_layout = "local" - self.workspace_config = load_workspace_config_file(ws_file) - write_workspace_metadata( - Path(self.workspace_path), self.workspace_config, layout="local" - ) - return - meta = read_workspace_metadata(Path(self.workspace_path)) - if meta: - self.workspace_config, self.workspace_layout = meta - return - self.workspace_layout = "clone" - try: - self.workspace_config = load_workspace_config(name=Path(self.workspace_path).name) - except Exception: - self.workspace_config = {} - - def _detect_workspace_path(self, start_path: str): - """Check if current directory is within a workspace""" - from cecli.helpers.monorepo.local_workspace import find_workspace_config_file - - current = Path(start_path).resolve() - ws_file = find_workspace_config_file(current) - if ws_file: - return ws_file.parent.resolve() - - workspace_root = Path("~/.cecli/workspaces").expanduser() - - # Walk up directory tree looking for workspace root - while current != current.parent: - if workspace_root in current.parents or current == workspace_root: - # If we are inside the workspace root, the workspace is the first child of workspace_root - try: - rel = current.relative_to(workspace_root) - if rel.parts: - return workspace_root / rel.parts[0] - except (IndexError, ValueError): - pass - - # Alternative check: look for .cecli-workspace.json - if (current / ".cecli-workspace.json").exists(): - return current - - current = current.parent - return None + self.repo = git.Repo(self._init_repo_path, odbt=git.GitCmdObjectDB) + self.root = utils.safe_abs_path(self.repo.working_tree_dir) def __del__(self): with self._git_lock: @@ -343,9 +263,6 @@ async def commit(self, fnames=None, context=None, message=None, coder_edits=Fals - User commit with explicit no-committer: coder_edits=False, --no-attribute-committer -> Author=You, Committer=You """ - if self.is_workspace and getattr(self, "workspace_layout", "clone") == "local": - return await self._commit_local_workspace(fnames, context, message, coder_edits, coder) - with self._git_lock: if not fnames and not self.repo.is_dirty(): return @@ -682,171 +599,25 @@ def get_tracked_files(self): return res - async def _commit_local_workspace( - self, fnames=None, context=None, message=None, coder_edits=False, coder=None - ): - from collections import defaultdict - - from cecli.helpers.monorepo.local_workspace import resolve_workspace_file_path - - layout = getattr(self, "workspace_layout", "local") - config = self.workspace_config or {} - readonly = { - str(p.get("name")) - for p in config.get("projects", []) - if p.get("readonly") and p.get("name") - } - - by_root: dict[str, list[str]] = defaultdict(list) - if fnames: - for fname in fnames: - resolved = resolve_workspace_file_path( - Path(self.workspace_path), str(fname), config, layout=layout - ) - if not resolved: - continue - git_root, _abs_path, in_repo = resolved - parts = Path(str(fname)).parts - if parts and parts[0] in readonly: - continue - if in_repo: - by_root[str(git_root)].append(in_repo) - else: - for proj in config.get("projects", []): - name = proj.get("name") - if not name or name in readonly: - continue - from cecli.helpers.monorepo.local_workspace import project_git_root - - git_root = project_git_root(Path(self.workspace_path), proj, layout=layout) - if not git_root: - continue - sub = GitRepo(self.io, [str(git_root)], None) - for rel in sub.get_dirty_files() or []: - by_root[str(git_root)].append(rel) - - last = None - for root, rels in by_root.items(): - sub = GitRepo(self.io, [root], None) - last = await sub.commit( - rels, context=context, message=message, coder_edits=coder_edits, coder=coder - ) - return last - - def get_workspace_files(self): - """ - If in a workspace, return all tracked files from all projects. - Paths are relative to the workspace root. - """ - if not self.workspace_path: - return self.get_tracked_files() - - import hashlib - - layout = getattr(self, "workspace_layout", "clone") - config = self.workspace_config or {} - if not config.get("projects"): - return self.get_tracked_files() - - from cecli.helpers.monorepo.local_workspace import ( - project_head_shas, - union_tracked_files, - ) - - project_shas = project_head_shas(Path(self.workspace_path), config, layout=layout) - cache_key = hashlib.sha1(",".join(project_shas).encode()).hexdigest() - - if hasattr(self, "_workspace_files_cache"): - cached_key, cached_files = self._workspace_files_cache - if cached_key == cache_key: - return cached_files - - if layout == "local": - all_files = union_tracked_files( - Path(self.workspace_path), - config, - layout=layout, - ignored_file=self.ignored_file, - ) - self._workspace_files_cache = (cache_key, all_files) - return all_files - - import json - import subprocess - - metadata_path = self.workspace_path / ".cecli-workspace.json" - if not metadata_path.exists(): - return self.get_tracked_files() - - try: - with safe_open(metadata_path, "r") as f: - config = json.load(f) - except Exception: - return self.get_tracked_files() - - all_files = [] - for proj in config.get("projects", []): - proj_name = proj.get("name") - if not proj_name: - continue - - proj_root = self.workspace_path / proj_name / "main" - if not proj_root.exists(): - continue - - try: - res = subprocess.check_output( - ["git", "-C", str(proj_root), "ls-files"], - stderr=subprocess.DEVNULL, - encoding="utf-8", - ).splitlines() - - for f in res: - rel_path = f"{proj_name}/main/{f}" - if not self.ignored_file(rel_path): - all_files.append(rel_path) - except Exception: - continue - - self._workspace_files_cache = (cache_key, all_files) - return all_files - def normalize_path(self, path): orig_path = path res = self.normalized_path.get(orig_path) if res: return res - if self.is_workspace: - try: - # In workspace mode, try to make it relative to workspace_path first - path = str( - Path( - PurePosixPath( - (Path(self.workspace_path) / path).relative_to(self.workspace_path) - ) - ) - ) - except ValueError: - # Fallback to standard relative_to(self.root) - path = str(Path(PurePosixPath((Path(self.root) / path).relative_to(self.root)))) - else: - path = str(Path(PurePosixPath((Path(self.root) / path).relative_to(self.root)))) + path = str(Path(PurePosixPath((Path(self.root) / path).relative_to(self.root)))) self.normalized_path[orig_path] = path return path def refresh_cecli_ignore(self): - if not self.cecli_ignore_file and not self.is_workspace: + if not self.cecli_ignore_file: return current_time = time.time() if current_time - self.cecli_ignore_last_check < 1: return - if self.is_workspace: - self._refresh_workspace_ignores() - self.cecli_ignore_last_check = current_time if not self.cecli_ignore_file or not self.cecli_ignore_file.is_file(): @@ -862,35 +633,6 @@ def refresh_cecli_ignore(self): lines, ) - def _refresh_workspace_ignores(self): - if not hasattr(self, "workspace_config") or not self.workspace_config: - return - - if not hasattr(self, "workspace_ignore_specs"): - self.workspace_ignore_specs = {} - self.workspace_ignore_ts = {} - - projects = self.workspace_config.get("projects", []) - for proj in projects: - proj_name = proj.get("name") - ignore_file = proj.get("ignore") - if not proj_name or not ignore_file: - continue - - ignore_path = self.workspace_path / f"{proj_name}.ignore" - if not ignore_path.is_file(): - continue - - mtime = ignore_path.stat().st_mtime - if mtime != self.workspace_ignore_ts.get(proj_name): - self.workspace_ignore_ts[proj_name] = mtime - self.ignore_file_cache = {} - lines = ignore_path.read_text().splitlines() - self.workspace_ignore_specs[proj_name] = pathspec.PathSpec.from_lines( - pathspec.patterns.GitWildMatchPattern, - lines, - ) - def _get_gitignore_spec(self, dir_path): """Get or create a GitIgnoreSpec for a directory, caching for performance.""" dir_path = Path(dir_path).resolve() @@ -1002,32 +744,6 @@ def ignored_file_raw(self, fname): if cwd_path not in fname_path.parents and fname_path != cwd_path: return True - if self.is_workspace: - # Check project-specific ignores - try: - fname_rel = self.normalize_path(fname) - parts = Path(fname_rel).parts - if parts: - proj_name = parts[0] - if ( - hasattr(self, "workspace_ignore_specs") - and proj_name in self.workspace_ignore_specs - ): - # Check against project-specific spec - # The spec expects paths relative to the project root (usually proj/main/) - layout = getattr(self, "workspace_layout", "clone") - if layout == "clone" and len(parts) > 2 and parts[1] == "main": - proj_rel_path = str(Path(*parts[2:])) - else: - proj_rel_path = str(Path(*parts[1:])) - - if self.workspace_ignore_specs[proj_name].match_file(proj_rel_path): - return True - # If not matched by project-specific ignore, continue to global ignore - # but don't return False yet as there might be a global .cecli.ignore - except (ValueError, IndexError): - pass - if not self.cecli_ignore_file or not self.cecli_ignore_file.is_file(): return False @@ -1070,22 +786,8 @@ def get_non_ignored_files_from_root(self): def get_repo_files(self) -> list[str]: """ - Get all relevant files from the repository, respecting workspace - structure and custom ignore rules. - - This is a unified file collection method that encapsulates the - logic for choosing the right file source depending on the repo's - configuration: - - - Workspace repos (``workspace_path`` is set) → ``get_workspace_files()`` - - Non-workspace with ``cecli_ignore`` file → ``get_non_ignored_files_from_root()`` - - Non-workspace without ``cecli_ignore`` → ``get_tracked_files()`` - - Returns: - Sorted list of root-relative file paths + Get all relevant files from the repository for this single base path. """ - if hasattr(self, "workspace_path") and self.workspace_path: - return self.get_workspace_files() if self.cecli_ignore_file and self.cecli_ignore_file.is_file(): return self.get_non_ignored_files_from_root() return self.get_tracked_files() @@ -1127,19 +829,6 @@ def path_in_repo(self, path): return self.normalize_path(path) in tracked_files def abs_root_path(self, path): - if self.is_workspace and getattr(self, "workspace_layout", "clone") == "local": - from cecli.helpers.monorepo.local_workspace import ( - resolve_workspace_file_path, - ) - - resolved = resolve_workspace_file_path( - Path(self.workspace_path), - str(path), - self.workspace_config or {}, - layout="local", - ) - if resolved: - return utils.safe_abs_path(resolved[1]) res = Path(self.root) / path return utils.safe_abs_path(res) @@ -1278,6 +967,64 @@ def __init__(self, target): max_workers=1, thread_name_prefix="git-repo" ) + _instances: dict[str, "GitRepoProxy"] = {} + + @classmethod + def for_root( + cls, + root: str | None, + io, + fnames=None, + git_dname=None, + cecli_ignore_file=None, + **kwargs, + ) -> "GitRepoProxy": + """ + Return a per-base-path ``GitRepoProxy`` singleton. + + Coders that share a base path share one proxy (and its underlying + ``git.Repo`` plus single-thread executor), so multi-repository work can + keep each repository on its own proxy while coders on the same base path + stay consistent. Coders on different base paths get independent proxies. + + If *root* is provided it is used as the registry key and to discover the + repository; otherwise it is derived from *fnames* (the same discovery the + ``GitRepo`` constructor performs). + """ + if root is None: + target = GitRepo(io, fnames, git_dname, cecli_ignore_file, **kwargs) + key = os.path.normpath(os.path.abspath(target.root)) + proxy = cls._instances.get(key) + if proxy is None: + proxy = cls(target) + cls._instances[key] = proxy + else: + _close_git_repo(target) # Discard the just-opened duplicate + return proxy + + key = os.path.normpath(os.path.abspath(root)) + proxy = cls._instances.get(key) + if proxy is None: + target = GitRepo(io, fnames or [root], git_dname, cecli_ignore_file, **kwargs) + proxy = cls(target) + cls._instances[key] = proxy + return proxy + + @classmethod + def evict(cls, root: str | None = None) -> None: + """Drop the proxy for a base path (e.g. on coder teardown).""" + if root is None: + return + key = os.path.normpath(os.path.abspath(root)) + cls._instances.pop(key, None) + + @classmethod + def reset_instances(cls) -> None: + """Reset all per-base-path proxies — used primarily in test teardown.""" + cls._instances.clear() + + # ------------------------------------------------------------------ + # Sync methods that access self.repo (the git.Repo object) # ------------------------------------------------------------------ # Sync methods that access self.repo (the git.Repo object) # ------------------------------------------------------------------ @@ -1350,9 +1097,6 @@ def get_non_ignored_files_from_root(self): def get_repo_files(self): return self._executor.submit(self._target.get_repo_files).result() - def get_workspace_files(self): - return self._executor.submit(self._target.get_workspace_files).result() - # ------------------------------------------------------------------ # Async methods – called from the main asyncio task only, and # already protected by ``_git_lock`` on the target; we do *not* @@ -1360,7 +1104,7 @@ def get_workspace_files(self): # with LLM calls (``get_commit_message``) that must stay async. # ------------------------------------------------------------------ - # commit() and _commit_local_workspace() are forwarded via __getattr__ + # commit() is forwarded via __getattr__ # ------------------------------------------------------------------ # Access to the raw ``git.Repo`` – return a sub-proxy that routes diff --git a/cecli/tools/utils/registry.py b/cecli/tools/utils/registry.py index 7b5e516c749..bca50fcac87 100644 --- a/cecli/tools/utils/registry.py +++ b/cecli/tools/utils/registry.py @@ -63,13 +63,17 @@ def list_tools(cls) -> List[str]: return list(cls._tools.keys()) @classmethod - def build_registry(cls, agent_config: Optional[Dict] = None) -> Dict[str, Type]: + def build_registry( + cls, agent_config: Optional[Dict] = None, root: Optional[str] = None + ) -> Dict[str, Type]: """ Build a filtered registry of tools based on agent configuration. Args: agent_config: Agent configuration dictionary with optional tools_includelist/tools_excludelist keys + root: Optional base directory used to resolve relative + ``tools_paths``. Defaults to the working directory. Returns: A dictionary mapping normalized tool names to tool classes. @@ -78,12 +82,34 @@ def build_registry(cls, agent_config: Optional[Dict] = None) -> Dict[str, Type]: if agent_config is None: agent_config = {} + # Resolve tools_paths relative to root when provided (used by + # workspace sub-agents so custom tools resolve against the primary + # workspace root, not the sub-agent's overridden local root). + base = Path(root).expanduser().resolve() if root else None + + def _resolve(tool_path: str) -> Path: + raw = Path(tool_path).expanduser() + if raw.is_absolute(): + return raw.resolve() + if base is not None: + return (base / raw).resolve() + return raw.resolve() + # Load tools from tool_paths if specified tools_paths = agent_config.get("tools_paths", agent_config.get("tool_paths", [])) loaded_custom_tools = [] + # Always scan the default custom-tools directories: the global default + # in the user's home and the local default under the given root. The + # global default is scanned first, the local default next so it takes + # precedence, and any explicitly configured tools_paths last. + default_tools_dirs = [Path.home() / ".cecli" / "tools"] + if base is not None: + default_tools_dirs.append(base / ".cecli" / "tools") + tools_paths = [str(p) for p in default_tools_dirs] + list(tools_paths) + for tool_path in tools_paths: - path = Path(tool_path) + path = _resolve(tool_path) if path.is_dir(): # Find all Python files in the directory for py_file in path.glob("*.py"): diff --git a/cecli/tui/app.py b/cecli/tui/app.py index 1b7cb8f9541..0910b788d47 100644 --- a/cecli/tui/app.py +++ b/cecli/tui/app.py @@ -502,18 +502,17 @@ def _update_key_hints_for_commands(self, text: str, is_completion: bool = False) self.update_key_hints_left(KeyHints.DEFAULT_LEFT_TEXT) def _load_git_info(self): - """Load git branch and dirty count (deferred to avoid blocking startup).""" + """Load git branch (deferred to avoid blocking startup).""" footer = self.query_one(MainFooter) if self.worker.coder.repo: try: branch = self.worker.coder.repo.repo.active_branch.name or "main" - dirty = self.worker.coder.repo.get_dirty_files() - footer.update_git(branch, len(dirty) if dirty else 0) + footer.update_git(branch) except Exception: if self.worker.coder.repo: - footer.update_git("main", 0) + footer.update_git("main") else: - footer.update_git("No Repo", 0) + footer.update_git("No Repo") def check_output_queue(self): """Process messages from coder worker.""" @@ -1247,6 +1246,8 @@ def action_switch_to_primary(self) -> None: # Update input autocomplete data for the primary agent self.enable_input({}, coder=self.worker.coder) + self._refresh_footer() + def action_switch_prev_agent(self) -> None: """Switch to the previous agent (primary or sub-agent), wrapping around.""" if not self._sub_agent_containers: @@ -1320,6 +1321,8 @@ def _switch_to_container(self, uuid: str, suppress_input_enable: bool = False) - coder = agent_service.foreground_coder self.enable_input({}, coder=coder) + self._refresh_footer() + def create_sub_agent_container(self, uuid: str, name: str) -> None: """Create an OutputContainer for a sub-agent.""" from cecli.helpers.agents.service import AgentService @@ -1378,10 +1381,45 @@ def remove_sub_agent_container(self, uuid: str) -> None: agent_service.foreground_uuid = None primary = self.query_one("#output", OutputContainer) primary.display = True + self._refresh_footer() # Sync border title with mode and sub-agent info self._sync_sub_agent_display() + def _project_name_for_coder(self, coder) -> str: + """Compute a display project name for a coder's root (home-shortened).""" + home = str(Path.home()) + repo = getattr(coder, "repo", None) + root = str(getattr(coder, "root", "") or "") + if not root and repo: + root = str(repo.root) + if not root: + root = str(Path.cwd()) + if root.startswith(home): + project_name = root.replace(home, "~", 1) + else: + project_name = root + if len(project_name) >= 64: + project_name = project_name.split("/")[-1] + return project_name + + def _refresh_footer(self): + """Refresh the footer with the active coder's project/root and git branch.""" + try: + footer = self.query_one(MainFooter) + coder = self._get_visible_coder() + project_name = self._project_name_for_coder(coder) + branch = "" + repo = getattr(coder, "repo", None) + if repo: + try: + branch = repo.repo.active_branch.name or "main" + except Exception: + branch = branch or "main" + footer.update_info(project_name, branch) + except Exception: + pass + def _sync_sub_agent_display(self) -> None: """Update the InputContainer border title with mode and sub-agent pills. @@ -1603,7 +1641,9 @@ def _get_path_completions(self, prefix: str) -> tuple[list[str], set[str]]: # Try FileSystemService first for efficient lookups try: - fs = FileSystemService.get_instance() + fs = getattr(coder, "fs", None) or FileSystemService.for_root( + str(root), repo=getattr(coder, "repo", None) + ) if prefix: if fs.trie: is_fuzzy = False diff --git a/cecli/tui/widgets/footer.py b/cecli/tui/widgets/footer.py index 5f77cdae230..6ea1983ddb4 100644 --- a/cecli/tui/widgets/footer.py +++ b/cecli/tui/widgets/footer.py @@ -16,7 +16,6 @@ class MainFooter(Static): # Right side info project_name = reactive("") git_branch = reactive("") - git_dirty = reactive(0) cost = reactive(0.0) # Spinner state @@ -138,8 +137,6 @@ def render(self) -> Text: if self.git_branch: right.append(self.git_branch) - # if self.git_dirty: - # right.append(f" +{self.git_dirty}") # right.append(" ") # Always show cost @@ -168,10 +165,15 @@ def update_cost(self, cost: float): self.cost = cost self.refresh() - def update_git(self, branch: str, dirty_count: int = 0): + def update_git(self, branch: str): """Update git status display.""" self.git_branch = branch - self.git_dirty = dirty_count + self.refresh() + + def update_info(self, project_name: str, branch: str): + """Update the project/root and git status display together.""" + self.project_name = project_name + self.git_branch = branch self.refresh() def update_mode(self, mode: str): diff --git a/cecli/website/docs/config.md b/cecli/website/docs/config.md index 113a324d946..19a106e8a79 100644 --- a/cecli/website/docs/config.md +++ b/cecli/website/docs/config.md @@ -36,7 +36,7 @@ CECLI_TUI=true ## Default File Locations -Cecli also checks several default locations inside `~/.cecli/` for configuration, environment variables, and agent resources. These are always included with lower precedence than project-level equivalents, so a setting in a project `.cecli.conf.yml` or `.env` file will override the `~/.cecli/` default. +Cecli also checks several default locations inside `~/.cecli/` for configuration, environment variables, and agent resources. These are always included with lower precedence than project-level equivalents, so a setting in a project `.cecli.conf.yml` or `.env` file will override the `~/.cecli/` default. The agent resource registries below each also look for a **local** default under an agent's working root, which takes precedence over the global default. ### `~/.cecli/conf.yml` @@ -48,11 +48,21 @@ An environment file loaded before any other `.env` file, so project-level `.env` ### `~/.cecli/skills/` -A directory containing skill packages (each a sub-directory with a `SKILL.md` file). Skills here are discoverable alongside those in any user-configured skills paths. +`Local: .cecli/skills/` + +A directory containing skill packages (each a sub-directory with a `SKILL.md` file). ### `~/.cecli/subagents/` -A directory containing sub-agent definition files (`.md` files with YAML front matter). Sub-agents here are registered alongside those in any user-configured sub-agent paths. +`Local: .cecli/subagents/` + +A directory containing sub-agent definition files (`.md` files with YAML front matter). + +### `~/.cecli/tools/` + +`Local: .cecli/tools/` + +A directory containing custom tool packages (`.py` files exposing a `Tool` class). > **Tip:** > See the [API key configuration docs](config/api-keys.html) for information on how to configure and store your API keys. diff --git a/cecli/website/docs/config/workspaces.md b/cecli/website/docs/config/workspaces.md index 822aead0b5a..d1ab7615851 100644 --- a/cecli/website/docs/config/workspaces.md +++ b/cecli/website/docs/config/workspaces.md @@ -1,130 +1,142 @@ --- parent: Configuration nav_order: 41 -description: Workspaces allow you to work across multiple related repositories simultaneously +description: Workspaces turn multiple git repositories into agent-ready sub-agents you can spin up --- # Workspaces -Workspaces allow you to manage multiple git repositories within a single monorepo-like folder structure, enabling development across multiple related projects. `cecli` supports two workspace modes: +Workspaces let you manage several git repositories as one unit and, crucially, turn each repository into an agent you can delegate to. A workspace is defined by a `.cecli.workspaces.yml` file that lists its projects. Each project is either: -**clone** workspaces (remote `repo:` URLs cloned into `~/.cecli/workspaces/`) +- **local** — an existing on-disk git root referenced by an absolute `path:` (used in-place, no cloning). +- **clone** — a remote `repo:` URL that is cloned into `~/.cecli/workspaces/{workspace}/{project}/main`. -**local** workspaces (existing on-disk git roots referenced by absolute `path:`) +## Workspace sub-agents -## Configuration +When a workspace is active, each project automatically becomes an **implicit `ws:{project}` sub-agent**. These agents are modelled on the built-in `worker` sub-agent but with two key differences: + +- Their `root` is **overridden** to point at the project's git root (the + in-place `path:` directory for local projects, or the cloned checkout for `repo:` projects), so the agent operates inside that repository. +- Their agent config sets `allow_nested_delegation: true`, so a `ws:*` agent can itself serve as a base for further delegations. + +Because `ws:{name}` agents are registered with the sub-agent registry, they are available through the normal mechanisms: + +- The `Delegate` tool (from a primary agent) +- `/spawn-agent ws:{name}` (interactively) -You can configure workspaces in multiple locations. `cecli` searches for configurations in the following order: +You can find the agent name reported by `/workspace` (e.g. `ws:app`). + +Each project may carry a `metadata` block that configures its `ws:{name}` agent the same way a sub-agent `.md` front-matter does: `model`, `hooks` and `auto_reap` map to the config fields, and any other key (e.g. `agent-config`) is merged into the agent's metadata. `root`, `name` and `description` are always derived from the project definition and cannot be overridden by `metadata`. + +## Configuration -1. **CLI Argument**: Via a JSON/YAML configuration or file path passed to the `--workspaces` argument. -2. **Local Workspaces File**: `.cecli.workspaces.yml` or `.cecli.workspaces.yaml` in the current directory. -3. **Global Workspaces File**: `~/.cecli/workspaces.yml` or `~/.cecli/workspaces.yaml`. +`cecli` searches for workspace config in the following order: -4. **Repo-Local Config File**: `.cecli.workspaces.yml` or `.cecli.workspaces.yaml` placed at a common ancestor of your project directories. `cecli` discovers this file by walking up from any project path, enabling a **local** workspace layout without cloning into `~/.cecli/workspaces/`. +1. **CLI argument** — a JSON/YAML string or file path passed to `--workspaces`. +2. **Local workspace file** — `.cecli.workspaces.yml` / `.cecli.workspaces.yaml` + in the current directory, or at a common ancestor of the project + directories (discovered by walking up from any project path). +3. **Global workspace file** — `~/.cecli/workspaces.yml` / `.cecli/workspaces.yaml`. ### Example Configuration ```yaml workspaces: - name: "my-workspace" + name: my-workspace projects: - - name: "frontend" - repo: "https://github.com/user/frontend.git" - branch: "main" - worktrees: - - name: "feature-auth" - branch: "feature/auth" - - name: "backend" - repo: "https://github.com/user/backend.git" - branch: "develop" - use_current_branch: true # Default: true. Set to false to force branch switching on init. - ignore: "~/.cecli/backend.ignore" # Optional: Path to a custom ignore file for this project + - name: app + path: /abs/path/to/app + primary: true # At most one project can be primary + metadata: # Optional sub-agent front-matter + model: + agent-config: + skills_paths: ["~/my-skills", "./project-skills"] + skills_includelist: ["python-refactoring", "react-components"] + - name: lib + path: /abs/path/to/lib + - name: docs + repo: https://github.com/user/docs.git + branch: main + use_current_branch: false # Force checkout of `branch` on init + ignore: ~/.cecli/docs.ignore # Optional custom ignore file ``` -### Local Workspace Configuration +### Project Fields -For **local** workspaces, place a `.cecli.workspaces.yml` file at a common ancestor of your project directories. Each project references an existing git root via `path:` instead of a remote `repo:` URL. - -```yaml -# .cecli.workspaces.yml -name: my-workspace -projects: - - name: app - path: /abs/path/to/app - primary: true # At most one project can be primary - - name: lib - path: /abs/path/to/lib - readonly: true # Prevents commits to this project -``` +| Field | Required | Description | +|---------------------|----------|-------------| +| `name` | Yes | Unique project name; also names the `ws:{name}` sub-agent | +| `path` | One of | Absolute path to an existing local git root | +| `repo` | One of | Remote clone URL (cloned under `~/.cecli/workspaces/`) | +| `primary` | No | At most one project may set `primary: true` | +| `branch` | No | Branch to check out when cloning (`repo:` projects) | +| `use_current_branch`| No | Default `true`; set `false` to force branch switching on init | +| `ignore` | No | Path to a custom ignore file for this project | +| `metadata` | No | Optional sub-agent front-matter for the `ws:{name}` agent | **Validation rules:** -- Each project must have a `name` and **exactly one** of `path` (local git root) or `repo` (clone URL). -- At most one project can be marked `primary: true`. -- Projects with `readonly: true` are excluded from commits. +- Each project must have a `name` and **exactly one** of `path` or `repo`. +- At most one project may be marked `primary: true`. +- Project names must be unique (they become `ws:{name}` agent names). ### Path Layout -The workspace layout determines how file paths are structured within the workspace: - | Layout | Prefix | Example | |--------|--------|--------| | **clone** (repo-based) | `{project}/main/{file}` | `app/main/src/main.py` | | **local** (path-based) | `{project}/{file}` | `app/src/main.py` | - ### Multiple Workspaces -You can define a list of workspaces. Use the `active: true` flag to specify which one should be used by default when running `cecli` without the `--workspace-name` argument. **Note: At most one workspace can be marked as active.** +You can define a list of workspaces and mark one `active: true` (at most one): + ```yaml workspaces: - - name: "project-a" + - name: project-a active: true projects: - - name: "app" - repo: "https://github.com/user/app.git" - - name: "project-b" + - name: app + path: /abs/path/to/app + - name: project-b projects: - - name: "api" - repo: "https://github.com/user/api.git" + - name: api + repo: https://github.com/user/api.git ``` - ## Usage -To use a workspace: - ```bash cecli --workspace-name my-workspace # OR if using a specific config file cecli --workspaces path/to/workspaces.yml --workspace-name my-workspace ``` -If the workspace does not exist, `cecli` will create the directory structure at `~/.cecli/workspaces/my-workspace/` and clone the configured repositories. For **local** workspaces, the configured `path:` directories are used in-place — no cloning occurs. - -### Clone Workspace Structure - -``` -~/.cecli/workspaces/ -└── workspace-name/ - ├── .cecli-workspace.json - └── project-name/ - ├── main/ # Main repository clone - └── worktrees/ # Additional worktrees -``` +Activating a workspace registers a `ws:{name}` sub-agent for each resolvable project. The primary agent's root is **unchanged** — multi-project work happens by delegating to the `ws:{name}` sub-agents, each rooted at its own project. -### Local Workspace Structure +- For **local** workspaces, the configured `path:` directories are used + in-place — no cloning occurs. +- For **clone** workspaces, `cecli` creates `~/.cecli/workspaces/{workspace}/` and clones each `repo:` project into `{workspace}/{project}/main`. -Local workspaces do **not** create a `~/.cecli/workspaces/` directory. Instead, the config file directory itself serves as the workspace root, with metadata stored at: +Metadata is stored at the workspace root: ``` .cecli/ └── .workspace-meta.json ``` -The project directories exist at their configured `path:` locations on disk. +Clone workspaces materialise under `~/.cecli/workspaces/`: -## Arguments +``` +~/.cecli/workspaces/ +└── my-workspace/ + ├── .cecli/ + │ └── .workspace-meta.json + └── app/ + └── main/ # git clone of `repo:` +``` -`--workspaces `: Provide a JSON/YAML configuration or file path for workspace initialization. +## Arguments -`--workspace-name `: Specify the workspace name to activate from the configuration. +- `--workspaces `: Provide a JSON/YAML configuration or file path for + workspace initialization. +- `--workspace-name `: Specify the workspace name to activate. diff --git a/tests/basic/test_coder.py b/tests/basic/test_coder.py index 216ef6e1585..07b02a92df1 100644 --- a/tests/basic/test_coder.py +++ b/tests/basic/test_coder.py @@ -17,7 +17,7 @@ from cecli.io import InputOutput from cecli.mcp import McpServerManager from cecli.models import Model -from cecli.repo import GitRepo +from cecli.repo import GitRepo, GitRepoProxy from cecli.sendchat import sanity_check_messages from cecli.utils import GitTemporaryDirectory @@ -38,10 +38,11 @@ def setup(self, gpt35_model): self.mock_webbrowser = self.webbrowser_patcher.start() # Reset conversation system before each test ConversationService.get_chunks(self).reset() - # Reset FileSystemService singleton for test isolation + # Reset per-root singletons for test isolation from cecli.helpers.file_system import FileSystemService FileSystemService.reset_instance() + GitRepoProxy.reset_instances() yield # Cleanup after each test self.webbrowser_patcher.stop() diff --git a/tests/basic/test_file_system_service.py b/tests/basic/test_file_system_service.py new file mode 100644 index 00000000000..a154dab43e9 --- /dev/null +++ b/tests/basic/test_file_system_service.py @@ -0,0 +1,206 @@ +import gc +import os +import weakref + +import pytest + +from cecli.coders import Coder +from cecli.helpers.file_system import FileSystemService +from cecli.io import InputOutput +from cecli.repo import GitRepoProxy +from cecli.utils import GitTemporaryDirectory + + +@pytest.fixture(autouse=True) +def reset_registries(): + FileSystemService.reset_instance() + GitRepoProxy.reset_instances() + yield + FileSystemService.reset_instance() + GitRepoProxy.reset_instances() + + +class TestFileSystemServicePerRoot: + def test_for_root_caches_per_base_path(self): + with GitTemporaryDirectory() as root: + first = FileSystemService.for_root(root) + again = FileSystemService.for_root(root) + + assert first is again + assert FileSystemService._normalize_root(root) in FileSystemService._instances + + def test_for_root_distinct_for_distinct_base_paths(self): + with GitTemporaryDirectory() as root_a, GitTemporaryDirectory() as root_b: + service_a = FileSystemService.for_root(root_a) + service_b = FileSystemService.for_root(root_b) + + assert service_a is not service_b + assert service_a.root.rstrip("/") == root_a.rstrip("/") + assert service_b.root.rstrip("/") == root_b.rstrip("/") + + def test_get_instance_returns_active_root(self): + with GitTemporaryDirectory() as root_a, GitTemporaryDirectory() as root_b: + service_a = FileSystemService.for_root(root_a) + service_b = FileSystemService.for_root(root_b) + + assert FileSystemService.get_instance() is service_b + assert FileSystemService.get_instance() is not service_a + + def test_evict_removes_root(self): + with GitTemporaryDirectory() as root: + FileSystemService.for_root(root) + key = FileSystemService._normalize_root(root) + assert key in FileSystemService._instances + + FileSystemService.evict(root) + assert key not in FileSystemService._instances + + +class TestGitRepoProxyPerRoot: + def test_for_root_caches_per_base_path(self): + with GitTemporaryDirectory() as root: + io = InputOutput(pretty=False, yes=True) + proxy_a = GitRepoProxy.for_root(None, io, fnames=[root]) + proxy_b = GitRepoProxy.for_root(None, io, fnames=[root]) + + assert proxy_a is proxy_b + + def test_for_root_distinct_for_distinct_base_paths(self): + with GitTemporaryDirectory() as root_a, GitTemporaryDirectory() as root_b: + io = InputOutput(pretty=False, yes=True) + proxy_a = GitRepoProxy.for_root(None, io, fnames=[root_a]) + proxy_b = GitRepoProxy.for_root(None, io, fnames=[root_b]) + + assert proxy_a is not proxy_b + assert proxy_a.root.rstrip("/") == root_a.rstrip("/") + assert proxy_b.root.rstrip("/") == root_b.rstrip("/") + + def test_for_root_with_explicit_root_caches(self): + with GitTemporaryDirectory() as root: + io = InputOutput(pretty=False, yes=True) + proxy = GitRepoProxy.for_root(root, io) + + assert proxy.root.rstrip("/") == root.rstrip("/") + assert GitRepoProxy.for_root(root, io) is proxy + + +class TestAutomaticCleanup: + """The per-root service is released when the last coder sharing it dies.""" + + class _FakeCoder: + def __init__(self, root): + self.root = root + self.repo = GitRepoProxy.for_root( + None, InputOutput(pretty=False, yes=True), fnames=[root] + ) + self.fs = FileSystemService.for_root(root, repo=self.repo) + self._fs_key = FileSystemService._normalize_root(root) + FileSystemService._inc_ref(self._fs_key) + weakref.finalize(self, FileSystemService._release, self._fs_key) + + def test_last_coder_gc_evicts_service_and_repo(self): + with GitTemporaryDirectory() as root: + key = FileSystemService._normalize_root(root) + coder = self._FakeCoder(root) + + assert FileSystemService._refcounts.get(key) == 1 + assert key in FileSystemService._instances + assert key in GitRepoProxy._instances + + del coder + gc.collect() + + assert key not in FileSystemService._instances + assert key not in GitRepoProxy._instances + assert FileSystemService._refcounts.get(key, 0) == 0 + + def test_shared_root_kept_alive_until_last_coder_dies(self): + with GitTemporaryDirectory() as root: + key = FileSystemService._normalize_root(root) + coder_a = self._FakeCoder(root) + coder_b = self._FakeCoder(root) + + assert coder_a.fs is coder_b.fs + assert coder_a.repo is coder_b.repo + assert FileSystemService._refcounts.get(key) == 2 + + del coder_b + gc.collect() + assert key in FileSystemService._instances + assert key in GitRepoProxy._instances + assert FileSystemService._refcounts.get(key) == 1 + + del coder_a + gc.collect() + assert key not in FileSystemService._instances + assert key not in GitRepoProxy._instances + assert FileSystemService._refcounts.get(key, 0) == 0 + + +class TestCoderIntegration: + async def test_coder_gets_per_root_fs_and_repo(self, gpt35_model): + with GitTemporaryDirectory() as root_a: + io = InputOutput(pretty=False, yes=True) + coder_a = await Coder.create(gpt35_model, None, io) + with GitTemporaryDirectory() as root_b: + coder_b = await Coder.create(gpt35_model, None, io) + + assert coder_a.fs is not coder_b.fs + assert coder_a.repo is not coder_b.repo + assert coder_a.fs.repo is coder_a.repo + assert coder_b.fs.repo is coder_b.repo + assert coder_a.root.rstrip("/") == root_a.rstrip("/") + assert coder_b.root.rstrip("/") == root_b.rstrip("/") + + +class TestRootOverride: + """Validates the sub-agent root override and primary_root retention.""" + + async def test_root_kwarg_overrides_working_dir(self, gpt35_model): + with GitTemporaryDirectory() as root_a: + os.chdir(root_a) + nested = os.path.join(root_a, "nested") + os.makedirs(nested, exist_ok=True) + io = InputOutput(pretty=False, yes=True) + + coder = await Coder.create(gpt35_model, None, io, root=nested) + + assert os.path.normpath(coder.root) == os.path.normpath(nested) + assert coder.primary_root == coder.root + assert coder.fs is FileSystemService.for_root(nested, repo=coder.repo) + + async def test_primary_root_propagated_from_parent(self, gpt35_model): + with GitTemporaryDirectory(): + io = InputOutput(pretty=False, yes=True) + parent = await Coder.create(gpt35_model, None, io) + with GitTemporaryDirectory() as root_b: + child = await Coder.create(from_coder=parent, root=root_b, io=io) + + assert child.primary_root.rstrip("/") == parent.root.rstrip("/") + assert child.root.rstrip("/") == root_b.rstrip("/") + assert child.fs is not parent.fs + + async def test_sub_agent_repo_scoped_to_its_root(self, gpt35_model): + with GitTemporaryDirectory(): + io = InputOutput(pretty=False, yes=True) + parent = await Coder.create(gpt35_model, None, io) + with GitTemporaryDirectory() as root_b: + child = await Coder.create(from_coder=parent, root=root_b, io=io) + + assert child.root.rstrip("/") == root_b.rstrip("/") + assert child.repo.root.rstrip("/") == root_b.rstrip("/") + assert child.fs.repo.root.rstrip("/") == root_b.rstrip("/") + + async def test_resolve_relative_to_primary_root(self, gpt35_model): + with GitTemporaryDirectory() as root_a: + os.chdir(root_a) + io = InputOutput(pretty=False, yes=True) + coder = await Coder.create(gpt35_model, None, io) + base = coder.primary_root or coder.root + + resolved = coder.resolve_relative_to_primary_root("skills/mine") + assert resolved == os.path.normpath(os.path.join(base, "skills/mine")) + + abs_path = "/tmp/abs-skill" + assert coder.resolve_relative_to_primary_root(abs_path) == abs_path + assert coder.resolve_relative_to_primary_root("") == "" diff --git a/tests/basic/test_skills.py b/tests/basic/test_skills.py index b7e24f5d082..cd272a1399b 100644 --- a/tests/basic/test_skills.py +++ b/tests/basic/test_skills.py @@ -35,7 +35,7 @@ def test_skills_manager_initialization(self): assert manager.directory_paths == [Path.home() / ".cecli" / "skills"] assert manager.include_list is None assert manager.exclude_list == set() - assert manager.git_root is None + assert manager.root is None # Test _loaded_skills is initialized as empty set assert manager._loaded_skills == set() @@ -51,15 +51,25 @@ def test_skills_manager_initialization(self): ["/tmp/test"], include_list=["skill1", "skill2"], exclude_list=["skill3"], - git_root="/tmp", + root="/tmp", ) - # "/tmp/test" + default home dir = 2 paths - assert len(manager.directory_paths) == 2 + # "/tmp/test" + local default + default home dir = 3 paths + assert len(manager.directory_paths) == 3 + assert "/tmp/test" in [str(p) for p in manager.directory_paths] + assert Path("/tmp") / ".cecli" / "skills" in manager.directory_paths assert manager.include_list == {"skill1", "skill2"} assert manager.exclude_list == {"skill3"} - assert manager.git_root == Path("/tmp").expanduser().resolve() + assert manager.root == Path("/tmp").expanduser().resolve() assert manager._loaded_skills == set() + def test_local_default_skills_dir(self): + """Local default {root}/.cecli/skills is included alongside global default.""" + manager = SkillsManager([], root="/tmp/local-root") + + paths = [str(p) for p in manager.directory_paths] + assert str(Path("/tmp/local-root") / ".cecli" / "skills") in paths + assert str(Path.home() / ".cecli" / "skills") in paths + def test_create_and_parse_skill(self): """Test creating a skill and parsing its metadata.""" # Create a skill directory structure @@ -164,15 +174,15 @@ def test_resolve_skill_directories(self): assert len(paths) == 1 assert paths[0] == Path(self.temp_dir).resolve() - # Test with relative path and git root - paths = SkillsManager.resolve_skill_directories(["./test-dir"], git_root=self.temp_dir) + # Test with relative path and root + paths = SkillsManager.resolve_skill_directories(["./test-dir"], root=self.temp_dir) # Should not resolve because directory doesn't exist assert len(paths) == 0 # Create the directory and test again test_dir = Path(self.temp_dir) / "test-dir" test_dir.mkdir() - paths = SkillsManager.resolve_skill_directories(["./test-dir"], git_root=self.temp_dir) + paths = SkillsManager.resolve_skill_directories(["./test-dir"], root=self.temp_dir) assert len(paths) == 1 assert paths[0] == test_dir.resolve() diff --git a/tests/helpers/monorepo/LOCAL_WORKSPACE.md b/tests/helpers/monorepo/LOCAL_WORKSPACE.md deleted file mode 100644 index dd4311195cb..00000000000 --- a/tests/helpers/monorepo/LOCAL_WORKSPACE.md +++ /dev/null @@ -1,66 +0,0 @@ -# PR: Local `path:` projects for cecli workspaces - -## Summary - -Extends cecli’s existing **clone** workspace mode (`repo:` URLs under `~/.cecli/workspaces/`, paths like `project/main/file.py`) with **local** layout: multiple git roots on disk referenced by absolute `path:` in a repo-local config file. - -## Motivation - -IDE clients (e.g. BrightVision) open a **primary git repo** but need agent context across **sibling repos** without cloning into `~/.cecli/workspaces/`. Submodule-only setups are a different layout; this PR adds an explicit, reviewable config surface. - -## Config - -Place at the workspace root (walked up from any listed project path): - -```yaml -# .cecli.workspaces.yml -name: my-workspace -projects: - - name: app - path: /abs/path/to/app - primary: true - - name: lib - path: /abs/path/to/lib - readonly: true -``` - -Rules (enforced in `validate_config`): - -- Each project: `name` + **exactly one** of `path` or `repo` -- At most one `primary: true` - -## Path layout - -| Layout | Prefix | Example | -|--------|--------|---------| -| **local** (this PR) | `{project}/{file}` | `app/src/main.py` | -| **clone** (existing) | `{project}/main/{file}` | `app/main/src/main.py` | - -## Behavior changes - -| Area | Change | -|------|--------| -| `GitRepo.__init__` | Multiple git roots allowed when `.cecli.workspaces.yml` is found on a common ancestor | -| `get_workspace_files` | Local layout unions `git ls-files` from each `path:` root | -| `commit` | Local layout commits per underlying repo (`_commit_local_workspace`) | -| `abs_root_path` | Resolves prefixed paths to the correct project root | - -Clone workspaces and `.cecli-workspace.json` metadata are unchanged. - -## Tests - -- `tests/helpers/monorepo/test_config.py` — validation (`path` / `repo` XOR) -- `tests/helpers/monorepo/test_local_workspace.py` — helpers + `GitRepo` integration -- Existing `test_repomap_workspace.py`, `test_workspace.py`, etc. — still pass (clone layout) - -Run: - -```bash -pytest tests/helpers/monorepo -q -``` - -## Non-goals (follow-up PRs) - -- Auto-registering git submodules into the workspace registry -- Combining submodule `RepoSet` with local YAML in one facade -- New global config file formats (reuse `.cecli.workspaces.yml` only) diff --git a/tests/helpers/monorepo/test_config.py b/tests/helpers/monorepo/test_config.py deleted file mode 100644 index 0d85669753c..00000000000 --- a/tests/helpers/monorepo/test_config.py +++ /dev/null @@ -1,75 +0,0 @@ -import pytest - -from cecli.helpers.monorepo.config import validate_config - - -def test_validate_config_empty(): - # Should not raise - validate_config({}) - - -def test_validate_config_no_name(): - with pytest.raises(ValueError, match="Workspace configuration must include a 'name'"): - validate_config({"projects": []}) - - -def test_validate_config_invalid_project_missing_source(): - with pytest.raises(ValueError, match="exactly one of 'path' or 'repo'"): - validate_config({"name": "test", "projects": [{"name": "p1"}]}) - - -def test_validate_config_invalid_project_both_sources(): - with pytest.raises(ValueError, match="exactly one of 'path' or 'repo'"): - validate_config( - { - "name": "test", - "projects": [ - { - "name": "p1", - "path": "/tmp/p1", - "repo": "https://github.com/org/r.git", - } - ], - } - ) - - -def test_validate_config_path_project(): - validate_config( - { - "name": "local", - "projects": [{"name": "app", "path": "/abs/app", "primary": True}], - } - ) - - -def test_validate_config_duplicate_project(): - with pytest.raises(ValueError, match="Duplicate project name: p1"): - validate_config( - { - "name": "test", - "projects": [{"name": "p1", "repo": "url1"}, {"name": "p1", "repo": "url2"}], - } - ) - - -def test_validate_config_valid(): - config = {"name": "test", "projects": [{"name": "p1", "repo": "url1"}]} - validate_config(config) - assert config["projects"][0]["name"] == "p1" - - -def test_load_workspace_config_json_string(): - from cecli.helpers.monorepo.config import load_workspace_config - - config_str = '{"name": "json-ws", "projects": []}' - config = load_workspace_config(config_str) - assert config["name"] == "json-ws" - - -def test_load_workspace_config_yaml_string(): - from cecli.helpers.monorepo.config import load_workspace_config - - config_str = "name: yaml-ws\nprojects: []" - config = load_workspace_config(config_str) - assert config["name"] == "yaml-ws" diff --git a/tests/helpers/monorepo/test_config_active.py b/tests/helpers/monorepo/test_config_active.py deleted file mode 100644 index 95c27944e93..00000000000 --- a/tests/helpers/monorepo/test_config_active.py +++ /dev/null @@ -1,79 +0,0 @@ -import pytest - -from cecli.helpers.monorepo.config import load_workspace_config - - -def test_load_workspace_config_multiple_active_error(): - config_list = [ - {"name": "ws1", "active": True, "projects": []}, - {"name": "ws2", "active": True, "projects": []}, - ] - - # Mocking what would be in the config file/arg - with pytest.raises(ValueError, match="Multiple workspaces marked as active: ws1, ws2"): - # We simulate the loaded config being a list - from unittest.mock import mock_open, patch - - import yaml - - with patch("pathlib.Path.exists", return_value=True): - with patch("builtins.open", mock_open(read_data=yaml.dump({"workspace": config_list}))): - load_workspace_config() - - -def test_load_workspace_config_select_by_name(): - config_list = [ - {"name": "ws1", "active": True, "projects": []}, - {"name": "ws2", "active": False, "projects": []}, - ] - - from unittest.mock import mock_open, patch - - import yaml - - with patch("pathlib.Path.exists", return_value=True): - with patch("builtins.open", mock_open(read_data=yaml.dump({"workspace": config_list}))): - # Should select ws2 even if ws1 is active - config = load_workspace_config(name="ws2") - assert config["name"] == "ws2" - - -def test_load_workspace_config_no_active_uses_first_if_only_one(): - config_list = [{"name": "ws1", "projects": []}] - - from unittest.mock import mock_open, patch - - import yaml - - with patch("pathlib.Path.exists", return_value=True): - with patch("builtins.open", mock_open(read_data=yaml.dump({"workspace": config_list}))): - config = load_workspace_config() - assert config["name"] == "ws1" - - -def test_load_workspace_config_single_dict_is_active_by_default(): - config_dict = {"name": "single-ws", "projects": []} - - from unittest.mock import mock_open, patch - - import yaml - - with patch("pathlib.Path.exists", return_value=True): - with patch("builtins.open", mock_open(read_data=yaml.dump({"workspace": config_dict}))): - # Should work even if no name is passed and active is not set - config = load_workspace_config() - assert config["name"] == "single-ws" - - -def test_load_workspace_config_multiple_in_list_none_active_picks_none(): - config_list = [{"name": "ws1", "projects": []}, {"name": "ws2", "projects": []}] - - from unittest.mock import mock_open, patch - - import yaml - - with patch("pathlib.Path.exists", return_value=True): - with patch("builtins.open", mock_open(read_data=yaml.dump({"workspace": config_list}))): - # With multiple and none active, it should return empty if no name provided - config = load_workspace_config() - assert config == {} diff --git a/tests/helpers/monorepo/test_ignore_logic.py b/tests/helpers/monorepo/test_ignore_logic.py deleted file mode 100644 index d7d7393fc38..00000000000 --- a/tests/helpers/monorepo/test_ignore_logic.py +++ /dev/null @@ -1,103 +0,0 @@ -import os -import shutil -import tempfile -import unittest -from pathlib import Path -from unittest.mock import MagicMock, patch - -import git - -from cecli.helpers.monorepo.workspace import WorkspaceManager -from cecli.io import InputOutput -from cecli.repo import GitRepo - - -class TestIgnoreLogic(unittest.TestCase): - def setUp(self): - self.test_dir = Path(tempfile.mkdtemp()).resolve() - self.old_cwd = os.getcwd() - os.chdir(self.test_dir) - - # Setup a dummy source ignore file - self.src_ignore = self.test_dir / "my_proj.ignore_src" - self.src_ignore.write_text("ignored_file.txt\n*.log\n") - - self.workspace_name = "test_ws" - # Use a local path for testing instead of ~/.cecli - self.workspace_root = (self.test_dir / "workspaces").resolve() - self.workspace_root.mkdir(parents=True, exist_ok=True) - self.ws_path = self.workspace_root / self.workspace_name - - self.config = { - "name": self.workspace_name, - "projects": [ - { - "name": "my_proj", - "repo": "https://github.com/example/repo", - "ignore": str(self.src_ignore), - } - ], - } - - def tearDown(self): - os.chdir(self.old_cwd) - if hasattr(self, "test_dir") and self.test_dir.exists(): - shutil.rmtree(self.test_dir) - - def test_ignore_file_copying(self): - # Test that WorkspaceManager.initialize copies the ignore file - wm = WorkspaceManager(self.workspace_name, self.config) - # Use our test ws_path - wm.path = self.ws_path - - with patch("cecli.helpers.monorepo.project.Project.initialize"): - wm.initialize() - - dest_ignore = self.ws_path / "my_proj.ignore" - self.assertTrue(dest_ignore.exists(), "Ignore file should be copied to workspace root") - self.assertEqual(dest_ignore.read_text(), self.src_ignore.read_text()) - - def test_repo_ignore_loading(self): - # Test that GitRepo loads the copied ignore file - wm = WorkspaceManager(self.workspace_name, self.config) - wm.path = self.ws_path - - with patch("cecli.helpers.monorepo.project.Project.initialize"): - wm.initialize() - - io = InputOutput() - # Create a dummy file in the workspace to trigger detection - dummy_file = self.ws_path / "my_proj" / "main" / "some_file.txt" - dummy_file.parent.mkdir(parents=True, exist_ok=True) - dummy_file.touch() - - # Mock git.Repo to avoid FileNotFoundError in GitRepo.__init__ - mock_repo = MagicMock(spec=git.Repo) - mock_repo.working_dir = str(self.ws_path) - mock_repo.__enter__.return_value = mock_repo - - # Patch _detect_workspace_path to return our test workspace path - with patch("git.Repo", return_value=mock_repo): - with patch("cecli.repo.GitRepo._detect_workspace_path", return_value=self.ws_path): - with patch("cecli.repo.GitRepo.init_repo"): - with patch( - "cecli.helpers.monorepo.config.load_workspace_config", - return_value=self.config, - ): - repo = GitRepo(io, fnames=[str(dummy_file)], git_dname=None) - - self.assertTrue(repo.is_workspace) - self.assertEqual(Path(repo.workspace_path), self.ws_path) - - # Verify ignore spec is loaded - repo._refresh_workspace_ignores() - self.assertIn("my_proj", repo.workspace_ignore_specs) - - spec = repo.workspace_ignore_specs["my_proj"] - self.assertTrue(spec.match_file("ignored_file.txt")) - self.assertTrue(spec.match_file("test.log")) - self.assertFalse(spec.match_file("keep.txt")) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/helpers/monorepo/test_local_workspace.py b/tests/helpers/monorepo/test_local_workspace.py deleted file mode 100644 index db29df4b06d..00000000000 --- a/tests/helpers/monorepo/test_local_workspace.py +++ /dev/null @@ -1,235 +0,0 @@ -"""Tests for repo-local workspaces (``path:`` git roots, ``.cecli.workspaces.yml``).""" - -from __future__ import annotations - -import json -import subprocess -from pathlib import Path - -import pytest -import yaml - -from cecli.helpers.monorepo.config import load_workspace_config_file, validate_config -from cecli.helpers.monorepo.local_workspace import ( - find_workspace_config_file, - load_workspace_file, - primary_project, - project_path_prefix, - read_workspace_metadata, - resolve_workspace_file_path, - union_tracked_files, - write_workspace_metadata, -) -from cecli.io import InputOutput -from cecli.repo import GitRepo -from cecli.utils import make_repo - - -def _init_git_repo(path: Path, readme: str = "# repo\n") -> None: - make_repo(path) - readme_path = path / "README.md" - readme_path.write_text(readme, encoding="utf-8") - subprocess.run(["git", "add", "README.md"], cwd=path, check=True, capture_output=True) - subprocess.run( - ["git", "commit", "-m", "init", "--no-gpg-sign"], - cwd=path, - check=True, - capture_output=True, - ) - - -@pytest.fixture -def two_path_projects(tmp_path: Path): - """ - Workspace root with ``.cecli.workspaces.yml`` and two sibling git checkouts. - - Layout:: - - ws/ - .cecli.workspaces.yml - app/ (git) - lib/ (git) - """ - ws = tmp_path / "ws" - app = ws / "app" - lib = ws / "lib" - app.mkdir(parents=True) - lib.mkdir(parents=True) - _init_git_repo(app, "# app\n") - _init_git_repo(lib, "# lib\n") - - config = { - "name": "pair", - "projects": [ - {"name": "app", "path": str(app.resolve()), "primary": True}, - {"name": "lib", "path": str(lib.resolve())}, - ], - } - (ws / ".cecli.workspaces.yml").write_text( - yaml.dump(config, sort_keys=False), - encoding="utf-8", - ) - return ws, config, app, lib - - -class TestValidateConfigPathProjects: - def test_path_only_project_valid(self): - validate_config( - { - "name": "local", - "projects": [{"name": "app", "path": "/tmp/app", "primary": True}], - } - ) - - def test_repo_only_project_valid(self): - validate_config( - { - "name": "clone", - "projects": [{"name": "p1", "repo": "https://github.com/org/r.git"}], - } - ) - - def test_missing_path_and_repo(self): - with pytest.raises(ValueError, match="exactly one of 'path' or 'repo'"): - validate_config({"name": "test", "projects": [{"name": "p1"}]}) - - def test_both_path_and_repo(self): - with pytest.raises(ValueError, match="exactly one of 'path' or 'repo'"): - validate_config( - { - "name": "test", - "projects": [ - { - "name": "p1", - "path": "/tmp/a", - "repo": "https://github.com/org/r.git", - } - ], - } - ) - - def test_multiple_primary(self): - with pytest.raises(ValueError, match="Only one project may be marked primary"): - validate_config( - { - "name": "test", - "projects": [ - {"name": "a", "path": "/a", "primary": True}, - {"name": "b", "path": "/b", "primary": True}, - ], - } - ) - - -class TestLocalWorkspaceHelpers: - def test_find_workspace_config_file_walks_up(self, two_path_projects): - ws, _config, app, _lib = two_path_projects - expected = (ws / ".cecli.workspaces.yml").resolve() - assert find_workspace_config_file(ws).resolve() == expected - # YAML lives at workspace root; project checkout is a subdirectory. - assert find_workspace_config_file(app).resolve() == expected - assert find_workspace_config_file(app / "README.md").resolve() == expected - - def test_load_workspace_config_file(self, two_path_projects): - ws, _config, _app, _lib = two_path_projects - loaded = load_workspace_config_file(ws / ".cecli.workspaces.yml") - assert loaded["name"] == "pair" - assert len(loaded["projects"]) == 2 - - def test_union_tracked_files(self, two_path_projects): - ws, config, _app, _lib = two_path_projects - files = union_tracked_files(ws, config, layout="local") - assert "app/README.md" in files - assert "lib/README.md" in files - - def test_resolve_workspace_file_path_prefixed(self, two_path_projects): - ws, config, _app, _lib = two_path_projects - resolved = resolve_workspace_file_path(ws, "lib/README.md", config, layout="local") - assert resolved is not None - git_root, abs_path, in_repo = resolved - assert in_repo == "README.md" - assert abs_path.name == "README.md" - assert git_root.name == "lib" - - def test_project_path_prefix_local_vs_clone(self): - proj = {"name": "app"} - assert project_path_prefix(proj, layout="local") == "app" - assert project_path_prefix(proj, layout="clone") == "app/main" - - def test_primary_project_explicit_and_implicit(self): - cfg = { - "projects": [ - {"name": "a", "path": "/a"}, - {"name": "b", "path": "/b", "primary": True}, - ] - } - assert primary_project(cfg)["name"] == "b" - - single = {"projects": [{"name": "only", "path": "/only"}]} - assert primary_project(single)["name"] == "only" - - def test_workspace_metadata_roundtrip(self, two_path_projects): - ws, config, _app, _lib = two_path_projects - write_workspace_metadata(ws, config, layout="local") - meta = read_workspace_metadata(ws) - assert meta is not None - loaded, layout = meta - assert layout == "local" - assert loaded["name"] == config["name"] - meta_path = ws / ".cecli" / ".workspace-meta.json" - assert meta_path.is_file() - on_disk = json.loads(meta_path.read_text(encoding="utf-8")) - assert on_disk.get("_layout") == "local" - - -class TestGitRepoLocalWorkspace: - def test_detects_local_workspace_and_unions_files(self, two_path_projects): - ws, _config, app, lib = two_path_projects - io = InputOutput(yes=True) - repo = GitRepo(io, [str(app / "README.md"), str(lib / "README.md")], None) - - assert repo.is_workspace - assert repo.workspace_layout == "local" - assert repo.workspace_path == ws.resolve() - - files = repo.get_workspace_files() - assert "app/README.md" in files - assert "lib/README.md" in files - - def test_abs_root_path_resolves_prefixed_path(self, two_path_projects): - _ws, _config, app, _lib = two_path_projects - io = InputOutput(yes=True) - repo = GitRepo(io, [str(app)], None) - - abs_path = Path(repo.abs_root_path("app/README.md")) - assert abs_path == (app / "README.md").resolve() - - def test_without_workspace_file_multi_repo_fails(self, tmp_path: Path): - root = tmp_path / "orphan" - a = root / "a" - b = root / "b" - a.mkdir(parents=True) - b.mkdir(parents=True) - _init_git_repo(a) - _init_git_repo(b) - io = InputOutput(yes=True) - with pytest.raises(FileNotFoundError): - GitRepo(io, [str(a / "README.md"), str(b / "README.md")], None) - - def test_load_workspace_file_defaults(self, tmp_path: Path): - path = tmp_path / ".cecli.workspaces.yml" - path.write_text("projects: []\n", encoding="utf-8") - loaded = load_workspace_file(path) - assert "name" in loaded - assert loaded["projects"] == [] - - -class TestGitRepoLocalWorkspaceNoYaml: - def test_single_repo_without_yaml_is_not_local_workspace(self, tmp_path: Path): - repo_dir = tmp_path / "solo" - repo_dir.mkdir() - _init_git_repo(repo_dir) - io = InputOutput(yes=True) - repo = GitRepo(io, [str(repo_dir)], None) - assert find_workspace_config_file(repo_dir) is None - assert getattr(repo, "workspace_layout", "clone") != "local" or not repo.is_workspace diff --git a/tests/helpers/monorepo/test_repomap_workspace.py b/tests/helpers/monorepo/test_repomap_workspace.py deleted file mode 100644 index 5649889e535..00000000000 --- a/tests/helpers/monorepo/test_repomap_workspace.py +++ /dev/null @@ -1,231 +0,0 @@ -import json -import subprocess -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest - -from cecli.io import InputOutput -from cecli.repo import GitRepo - - -@pytest.fixture -def mock_workspace(tmp_path): - workspace_root = tmp_path / "workspace" - workspace_root.mkdir() - - # Project 1 - p1_dir = workspace_root / "p1" / "main" - p1_dir.mkdir(parents=True) - subprocess.run(["git", "init", "-b", "main"], cwd=p1_dir, check=True) - subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=p1_dir, check=True) - subprocess.run(["git", "config", "user.name", "Test"], cwd=p1_dir, check=True) - (p1_dir / "file1.py").write_text("def func1(): pass") - subprocess.run(["git", "add", "file1.py"], cwd=p1_dir, check=True) - subprocess.run(["git", "commit", "-m", "p1 init"], cwd=p1_dir, check=True) - - # Project 2 - p2_dir = workspace_root / "p2" / "main" - p2_dir.mkdir(parents=True) - subprocess.run(["git", "init", "-b", "main"], cwd=p2_dir, check=True) - subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=p2_dir, check=True) - subprocess.run(["git", "config", "user.name", "Test"], cwd=p2_dir, check=True) - (p2_dir / "file2.py").write_text("def func2(): pass") - subprocess.run(["git", "add", "file2.py"], cwd=p2_dir, check=True) - subprocess.run(["git", "commit", "-m", "p2 init"], cwd=p2_dir, check=True) - - # Workspace metadata - config = { - "name": "test-ws", - "projects": [{"name": "p1", "repo": "url1"}, {"name": "p2", "repo": "url2"}], - } - (workspace_root / ".cecli-workspace.json").write_text(json.dumps(config)) - - return workspace_root - - -def test_get_workspace_files(mock_workspace): - io = MagicMock(spec=InputOutput) - # Initialize GitRepo in p1 but it should detect the workspace - repo = GitRepo(io, [], str(mock_workspace / "p1" / "main")) - - # Force workspace_path for the test since _detect_workspace_path looks in ~/.cecli/workspaces - repo.workspace_path = mock_workspace - - files = repo.get_workspace_files() - assert "p1/main/file1.py" in files - assert "p2/main/file2.py" in files - assert len(files) == 2 - - -@pytest.mark.asyncio -async def test_coder_get_all_relative_files_workspace_integration(mock_workspace): - io = MagicMock() - repo = GitRepo(io, [], str(mock_workspace / "p1" / "main")) - repo.workspace_path = mock_workspace - - # Create a Coder-like object without full __init__ - class SimpleCoder: - def __init__(self, repo): - self.repo = repo - self.in_chat_files = [] - - def get_inchat_relative_files(self): - return self.in_chat_files - - def get_all_relative_files(self): - # Verify the logic we implemented in base_coder.py - if self.repo: - if hasattr(self.repo, "workspace_path") and self.repo.workspace_path: - files = self.repo.get_workspace_files() - elif not self.repo.cecli_ignore_file or not self.repo.cecli_ignore_file.is_file(): - files = self.repo.get_tracked_files() - else: - files = self.repo.get_non_ignored_files_from_root() - else: - files = self.get_inchat_relative_files() - return files - - coder = SimpleCoder(repo) - files = coder.get_all_relative_files() - - assert "p1/main/file1.py" in files - assert "p2/main/file2.py" in files - assert len(files) == 2 - - -def test_repo_root_detection_for_repomap(mock_workspace): - io = MagicMock() - repo = GitRepo(io, [], str(mock_workspace / "p1" / "main")) - repo.workspace_path = mock_workspace - - # Verify that the logic we added to base_coder.py picks the workspace root - repo_root = ( - repo.workspace_path if (repo and getattr(repo, "workspace_path", None)) else Path(repo.root) - ) - - assert repo_root == mock_workspace - assert (repo_root / ".cecli-workspace.json").exists() - - -def test_project_branch_switching(mock_workspace): - # Setup: Create a new branch in p1 - p1_dir = mock_workspace / "p1" / "main" - subprocess.run(["git", "checkout", "-b", "feature/xyz"], cwd=p1_dir, check=True) - (p1_dir / "feature.py").write_text("print('feature')") - subprocess.run(["git", "add", "feature.py"], cwd=p1_dir, check=True) - subprocess.run(["git", "commit", "-m", "feature commit"], cwd=p1_dir, check=True) - - # Go back to master - subprocess.run(["git", "checkout", "main"], cwd=p1_dir, check=True) - - # Define config with the new branch - config = { - "name": "test-ws", - "projects": [ - {"name": "p1", "repo": "url1", "branch": "feature/xyz", "use_current_branch": False} - ], - } - - from cecli.helpers.monorepo.workspace import WorkspaceManager - - wm = WorkspaceManager("test-ws", config) - wm.path = mock_workspace # Override path for test - - # Re-initialize should trigger checkout - wm.initialize() - - # Verify p1 is now on feature/xyz - current_branch = subprocess.check_output( - ["git", "-C", str(p1_dir), "rev-parse", "--abbrev-ref", "HEAD"], encoding="utf-8" - ).strip() - - assert current_branch == "feature/xyz" - assert (p1_dir / "feature.py").exists() - - -def test_project_use_current_branch_flag(mock_workspace): - # Setup: p1 is on master - p1_dir = mock_workspace / "p1" / "main" - subprocess.run(["git", "checkout", "main"], cwd=p1_dir, check=True) - - # Define config with a different branch but use_current_branch=True - config = { - "name": "test-ws", - "projects": [ - {"name": "p1", "repo": "url1", "branch": "feature/xyz", "use_current_branch": True} - ], - } - - from cecli.helpers.monorepo.workspace import WorkspaceManager - - wm = WorkspaceManager("test-ws", config) - wm.path = mock_workspace - - # Re-initialize should NOT trigger checkout - wm.initialize() - - # Verify p1 is still on main - current_branch = subprocess.check_output( - ["git", "-C", str(p1_dir), "rev-parse", "--abbrev-ref", "HEAD"], encoding="utf-8" - ).strip() - - assert current_branch == "main" - - -def test_get_workspace_files_caching(mock_workspace): - io = MagicMock() - repo = GitRepo(io, [], str(mock_workspace / "p1" / "main")) - repo.workspace_path = mock_workspace - - # First call - should populate cache - with patch("subprocess.check_output", wraps=subprocess.check_output) as mock_run: - files1 = repo.get_workspace_files() - # Should have called rev-parse (2x) and ls-files (2x) - assert mock_run.call_count >= 4 - - # Second call - should use cache - with patch("subprocess.check_output", wraps=subprocess.check_output) as mock_run: - files2 = repo.get_workspace_files() - # Should only call rev-parse to check SHAs (2x), NOT ls-files - # Total calls = number of projects (2) - assert mock_run.call_count == 2 - assert files1 == files2 - - # Modify a project - should invalidate cache - p1_dir = mock_workspace / "p1" / "main" - (p1_dir / "new_file.py").write_text("test") - subprocess.run(["git", "add", "new_file.py"], cwd=p1_dir, check=True) - subprocess.run(["git", "commit", "-m", "new file"], cwd=p1_dir, check=True) - - with patch("subprocess.check_output", wraps=subprocess.check_output) as mock_run: - files3 = repo.get_workspace_files() - # Should call rev-parse (2x) and then ls-files (2x) because SHAs changed - assert mock_run.call_count >= 4 - assert "p1/main/new_file.py" in files3 - - -@pytest.mark.asyncio -async def test_workspace_command(mock_workspace): - from cecli.commands.workspace import WorkspaceCommand - from cecli.io import InputOutput - - io = MagicMock(spec=InputOutput) - repo = MagicMock() - repo.workspace_path = mock_workspace - repo.root = str(mock_workspace / "p1" / "main") - - coder = MagicMock() - coder.repo = repo - - await WorkspaceCommand.execute(io, coder, None) - - # Check if io.print was called with workspace info - # WorkspaceCommand prints name, root, then projects - io.print.assert_any_call("Current Workspace: test-ws") - io.print.assert_any_call(f"Root Directory: {mock_workspace}") - - # Verify project details were printed - # The output includes project name, branch, remote, path - io.print.assert_any_call(" - p1:") - io.print.assert_any_call(" - p2:") diff --git a/tests/helpers/monorepo/test_workspace.py b/tests/helpers/monorepo/test_workspace.py deleted file mode 100644 index b1db56247b9..00000000000 --- a/tests/helpers/monorepo/test_workspace.py +++ /dev/null @@ -1,52 +0,0 @@ -import json -from unittest.mock import patch - -import pytest - -from cecli.helpers.monorepo.workspace import WorkspaceManager - - -@pytest.fixture -def temp_workspace_root(tmp_path): - workspace_root = tmp_path / ".cecli" / "workspaces" - workspace_root.mkdir(parents=True) - - def mock_expand(path): - if path.startswith("~/.cecli/workspaces"): - return str(workspace_root / path.replace("~/.cecli/workspaces", "").lstrip("/")) - return path - - with patch("os.path.expanduser", side_effect=mock_expand): - yield workspace_root - - -def test_workspace_manager_exists(temp_workspace_root): - config = {"name": "test-ws", "projects": []} - wm = WorkspaceManager("test-ws", config) - assert not wm.exists() - - wm.path.mkdir(parents=True) - assert wm.exists() - - -@patch("cecli.helpers.monorepo.project.Project.initialize") -def test_workspace_manager_initialize(mock_proj_init, temp_workspace_root): - config = { - "name": "test-ws", - "projects": [{"name": "p1", "repo": "url1"}, {"name": "p2", "repo": "url2"}], - } - wm = WorkspaceManager("test-ws", config) - wm.initialize() - - assert wm.exists() - assert (wm.path / ".cecli-workspace.json").exists() - assert mock_proj_init.call_count == 2 - - with open(wm.path / ".cecli-workspace.json", "r") as f: - saved_config = json.load(f) - assert saved_config["name"] == "test-ws" - - -def test_workspace_manager_get_working_directory(temp_workspace_root): - wm = WorkspaceManager("test-ws", {}) - assert wm.get_working_directory() == temp_workspace_root / "test-ws" diff --git a/tests/helpers/workspaces/conftest.py b/tests/helpers/workspaces/conftest.py new file mode 100644 index 00000000000..eeb10f72386 --- /dev/null +++ b/tests/helpers/workspaces/conftest.py @@ -0,0 +1,11 @@ +import pytest + +from cecli.helpers.agents.service import AgentService + + +@pytest.fixture(autouse=True) +def reset_agent_registry(): + """Clear the global sub-agent registry before/after each test.""" + AgentService._global_registry.clear() + yield + AgentService._global_registry.clear() diff --git a/tests/helpers/workspaces/test_config.py b/tests/helpers/workspaces/test_config.py new file mode 100644 index 00000000000..93aba38d095 --- /dev/null +++ b/tests/helpers/workspaces/test_config.py @@ -0,0 +1,115 @@ +import json + +import pytest +import yaml + +from cecli.helpers.workspaces.config import ( + find_active_workspace_name, + load_workspace_config, + validate_config, + workspace_layout, +) + + +def test_validate_config_empty(): + validate_config({}) + + +def test_validate_config_no_name(): + with pytest.raises(ValueError, match="must include a 'name'"): + validate_config({"projects": []}) + + +def test_validate_config_project_missing_source(): + with pytest.raises(ValueError, match="exactly one of 'path' or 'repo'"): + validate_config({"name": "test", "projects": [{"name": "p1"}]}) + + +def test_validate_config_project_both_sources(): + with pytest.raises(ValueError, match="exactly one of 'path' or 'repo'"): + validate_config( + { + "name": "test", + "projects": [{"name": "p1", "path": "/a", "repo": "https://github.com/o/r.git"}], + } + ) + + +def test_validate_config_path_project(): + validate_config( + {"name": "local", "projects": [{"name": "app", "path": "/abs/app", "primary": True}]} + ) + + +def test_validate_config_repo_project(): + validate_config( + {"name": "clone", "projects": [{"name": "app", "repo": "https://github.com/o/r.git"}]} + ) + + +def test_validate_config_duplicate_project(): + with pytest.raises(ValueError, match="Duplicate project name: p1"): + validate_config( + { + "name": "test", + "projects": [{"name": "p1", "path": "/a"}, {"name": "p1", "path": "/b"}], + } + ) + + +def test_validate_config_multiple_primary(): + with pytest.raises(ValueError, match="Only one project may be marked primary"): + validate_config( + { + "name": "test", + "projects": [ + {"name": "a", "path": "/a", "primary": True}, + {"name": "b", "path": "/b", "primary": True}, + ], + } + ) + + +def test_workspace_layout_local(): + config = {"name": "local", "projects": [{"name": "app", "path": "/a"}]} + assert workspace_layout(config) == "local" + + +def test_workspace_layout_clone(): + config = {"name": "clone", "projects": [{"name": "app", "repo": "https://github.com/o/r.git"}]} + assert workspace_layout(config) == "clone" + + +def test_workspace_layout_explicit_field_overrides_inference(): + config = {"name": "ws", "layout": "clone", "projects": [{"name": "app", "path": "/a"}]} + assert workspace_layout(config) == "clone" + + +def test_load_workspace_config_json_string(): + config = load_workspace_config(json.dumps({"name": "json-ws", "projects": []})) + assert config["name"] == "json-ws" + + +def test_load_workspace_config_yaml_string(): + config = load_workspace_config("name: yaml-ws\nprojects: []") + assert config["name"] == "yaml-ws" + + +def test_load_workspace_config_select_by_name(): + config_list = [{"name": "ws1", "active": True, "projects": []}, {"name": "ws2", "projects": []}] + config = load_workspace_config(yaml.dump({"workspaces": config_list}), name="ws2") + assert config["name"] == "ws2" + + +def test_load_workspace_config_multiple_active_error(): + config_list = [ + {"name": "ws1", "active": True, "projects": []}, + {"name": "ws2", "active": True, "projects": []}, + ] + with pytest.raises(ValueError, match="Multiple workspaces marked as active"): + load_workspace_config(yaml.dump({"workspaces": config_list})) + + +def test_find_active_workspace_name(): + config_list = [{"name": "ws1", "active": True, "projects": []}, {"name": "ws2", "projects": []}] + assert find_active_workspace_name(yaml.dump({"workspaces": config_list})) == "ws1" diff --git a/tests/helpers/workspaces/test_paths.py b/tests/helpers/workspaces/test_paths.py new file mode 100644 index 00000000000..f0bf61634f3 --- /dev/null +++ b/tests/helpers/workspaces/test_paths.py @@ -0,0 +1,116 @@ +import os +import subprocess +import tempfile +from pathlib import Path + +from cecli.helpers.workspaces.paths import ( + project_path, + resolve_workspace_file_path, + union_tracked_files, +) +from cecli.utils import make_repo + + +def _make_git_repo(base: Path, name: str, files=None) -> Path: + root = base / name + os.makedirs(root, exist_ok=True) + make_repo(root) + if files: + for rel in files: + f = root / rel + f.parent.mkdir(parents=True, exist_ok=True) + f.write_text(rel, encoding="utf-8") + subprocess.check_call(["git", "-C", str(root), "add", rel]) + return root.resolve() + + +def test_project_path_returns_git_root_local(): + with tempfile.TemporaryDirectory() as td: + base = Path(td) + root = _make_git_repo(base, "app") + assert project_path(Path("."), {"name": "app", "path": str(root)}, layout="local") == root + + +def test_project_path_clone_returns_main_checkout(): + with tempfile.TemporaryDirectory() as td: + base = Path(td) + clone_root = _make_git_repo(base, "app/main") + assert ( + project_path(base, {"name": "app", "repo": "https://x/r.git"}, layout="clone") + == clone_root + ) + + +def test_project_path_none_for_non_git(): + with tempfile.TemporaryDirectory() as td: + plain = Path(td) / "plain" + os.makedirs(plain, exist_ok=True) + assert project_path(Path("."), {"name": "p", "path": str(plain)}, layout="local") is None + + +def test_project_path_none_for_missing(): + assert project_path(Path("."), {"name": "p", "path": "/does/not/exist"}, layout="local") is None + + +def test_resolve_workspace_file_path_local(): + with tempfile.TemporaryDirectory() as td: + base = Path(td) + app = _make_git_repo(base, "app") + config = {"name": "ws", "projects": [{"name": "app", "path": str(app)}]} + resolved = resolve_workspace_file_path(base, "app/src/main.py", config, layout="local") + assert resolved is not None + git_root, abs_path, in_repo = resolved + assert git_root == app + assert abs_path == app / "src" / "main.py" + assert in_repo == "src/main.py" + + +def test_resolve_workspace_file_path_clone(): + with tempfile.TemporaryDirectory() as td: + base = Path(td) + clone_root = _make_git_repo(base, "app/main") + config = {"name": "ws", "projects": [{"name": "app", "repo": "https://x/r.git"}]} + resolved = resolve_workspace_file_path(base, "app/main/src/main.py", config, layout="clone") + assert resolved is not None + git_root, abs_path, in_repo = resolved + assert git_root == clone_root + assert abs_path == clone_root / "src" / "main.py" + assert in_repo == "src/main.py" + + +def test_resolve_workspace_file_path_primary_fallback(): + with tempfile.TemporaryDirectory() as td: + base = Path(td) + app = _make_git_repo(base, "app") + config = {"name": "ws", "projects": [{"name": "app", "path": str(app), "primary": True}]} + resolved = resolve_workspace_file_path(base, "some/file.txt", config, layout="local") + assert resolved is not None + git_root, abs_path, in_repo = resolved + assert git_root == app + assert in_repo == "some/file.txt" + + +def test_union_tracked_files_local(): + with tempfile.TemporaryDirectory() as td: + base = Path(td) + app = _make_git_repo(base, "app", files=["src/main.py", "README.md"]) + lib = _make_git_repo(base, "lib", files=["lib.py"]) + config = { + "name": "ws", + "projects": [{"name": "app", "path": str(app)}, {"name": "lib", "path": str(lib)}], + } + + files = union_tracked_files(base, config, layout="local") + assert "app/src/main.py" in files + assert "app/README.md" in files + assert "lib/lib.py" in files + + +def test_union_tracked_files_clone(): + with tempfile.TemporaryDirectory() as td: + base = Path(td) + _make_git_repo(base, "app/main", files=["src/main.py"]) + config = {"name": "ws", "projects": [{"name": "app", "repo": "https://x/r.git"}]} + + files = union_tracked_files(base, config, layout="clone") + assert "app/main/src/main.py" in files diff --git a/tests/helpers/workspaces/test_subagents.py b/tests/helpers/workspaces/test_subagents.py new file mode 100644 index 00000000000..11b1b632a63 --- /dev/null +++ b/tests/helpers/workspaces/test_subagents.py @@ -0,0 +1,106 @@ +import os +import tempfile +from pathlib import Path + +from cecli.helpers.agents.service import AgentService +from cecli.helpers.workspaces.subagents import register_workspace_subagents +from cecli.utils import make_repo + + +def _make_git_repo(base: Path, name: str) -> Path: + root = base / name + os.makedirs(root, exist_ok=True) + make_repo(root) + return root.resolve() + + +def test_register_workspace_subagents_creates_ws_agents_local(): + with tempfile.TemporaryDirectory() as td: + base = Path(td) + app = _make_git_repo(base, "app") + lib = _make_git_repo(base, "lib") + + config = { + "name": "ws", + "projects": [ + {"name": "app", "path": str(app), "primary": True}, + {"name": "lib", "path": str(lib)}, + ], + } + + registered = register_workspace_subagents(config) + assert "ws:app" in registered + assert "ws:lib" in registered + + ws_app = AgentService.get_registry()["ws:app"] + assert ws_app.name == "ws:app" + assert ws_app.metadata["root"] == str(app) + assert ws_app.metadata["agent-config"]["allow_nested_delegation"] is True + + ws_lib = AgentService.get_registry()["ws:lib"] + assert ws_lib.metadata["root"] == str(lib) + assert ws_lib.metadata["agent-config"]["allow_nested_delegation"] is True + + +def test_register_workspace_subagents_clone_root(): + with tempfile.TemporaryDirectory() as td: + base = Path(td) + clone_root = _make_git_repo(base, "app/main") + + config = {"name": "ws", "projects": [{"name": "app", "repo": "https://x/r.git"}]} + registered = register_workspace_subagents(config, workspace_root=base) + assert "ws:app" in registered + + ws_app = AgentService.get_registry()["ws:app"] + assert ws_app.metadata["root"] == str(clone_root) + assert ws_app.metadata["layout"] == "clone" + assert ws_app.metadata["agent-config"]["allow_nested_delegation"] is True + + +def test_register_workspace_subagents_project_metadata_frontmatter(): + with tempfile.TemporaryDirectory() as td: + base = Path(td) + app = _make_git_repo(base, "app") + + config = { + "name": "ws", + "projects": [ + { + "name": "app", + "path": str(app), + "metadata": { + "model": "", + "auto_reap": False, + "name": "hijack", + "root": "/somewhere/else", + "description": "Custom ws agent", + "tools_includelist": ["readfile", "grep", "yield"], + }, + } + ], + } + + registered = register_workspace_subagents(config) + assert "ws:app" in registered + + ws_app = AgentService.get_registry()["ws:app"] + assert ws_app.name == "ws:app" + assert ws_app.model == "" + assert ws_app.auto_reap is False + # ``root``, ``name`` and ``description`` are always derived from the + # workspace/project definition and cannot be overridden by ``metadata``. + assert ws_app.metadata["root"] == str(app) + assert ws_app.metadata.get("name") != "hijack" + assert ws_app.metadata["description"].startswith("Workspace sub-agent for project 'app'") + assert ws_app.metadata["tools_includelist"] == ["readfile", "grep", "yield"] + assert ws_app.metadata["agent-config"]["allow_nested_delegation"] is True + + +def test_register_workspace_subagents_skips_non_git_project(): + with tempfile.TemporaryDirectory() as td: + base = Path(td) + plain = base / "plain" + os.makedirs(plain, exist_ok=True) + config = {"name": "ws", "projects": [{"name": "plain", "path": str(plain)}]} + registered = register_workspace_subagents(config) + assert registered == [] diff --git a/tests/helpers/workspaces/test_workspace.py b/tests/helpers/workspaces/test_workspace.py new file mode 100644 index 00000000000..05334b5efa8 --- /dev/null +++ b/tests/helpers/workspaces/test_workspace.py @@ -0,0 +1,58 @@ +import os +import tempfile +from pathlib import Path + +from cecli.helpers.workspaces.workspace import WorkspaceManager +from cecli.utils import make_repo + + +def _make_git_repo(base: Path, name: str) -> Path: + root = base / name + os.makedirs(root, exist_ok=True) + make_repo(root) + return root.resolve() + + +def test_get_working_directory_returns_primary_project(): + with tempfile.TemporaryDirectory() as td: + base = Path(td) + app = _make_git_repo(base, "app") + lib = _make_git_repo(base, "lib") + config = { + "name": "ws", + "projects": [ + {"name": "app", "path": str(app), "primary": True}, + {"name": "lib", "path": str(lib)}, + ], + } + + manager = WorkspaceManager("ws", config, root=base) + assert manager.path == base.resolve() + assert manager.get_working_directory() == app + + +def test_default_root_falls_back_to_primary_project(): + with tempfile.TemporaryDirectory() as td: + base = Path(td) + app = _make_git_repo(base, "app") + config = {"name": "ws", "projects": [{"name": "app", "path": str(app), "primary": True}]} + + manager = WorkspaceManager("ws", config) + assert manager.path == app + + +def test_exists_and_initialize_local(): + with tempfile.TemporaryDirectory() as td: + base = Path(td) + manager = WorkspaceManager("ws", {"name": "ws", "projects": []}, root=base) + manager.initialize() + assert manager.exists() + assert (base / ".cecli" / ".workspace-meta.json").is_file() + + +def test_clone_workspace_uses_cecli_workspaces_dir(): + config = {"name": "my-ws", "projects": [{"name": "app", "repo": "https://x/r.git"}]} + manager = WorkspaceManager("my-ws", config) + assert manager.layout == "clone" + assert manager.path == Path(os.path.expanduser("~/.cecli/workspaces/my-ws")) + assert manager.get_working_directory() == manager.path diff --git a/tests/subagents/test_service.py b/tests/subagents/test_service.py index 29026791e80..2084906b5a1 100644 --- a/tests/subagents/test_service.py +++ b/tests/subagents/test_service.py @@ -130,6 +130,20 @@ def test_build_registry(self, temp_dir): assert "reviewer" in AgentService._global_registry AgentService._global_registry = {} + def test_build_registry_resolves_relative_path_against_root(self, temp_dir): + """Relative sub-agent directories resolve against the provided ``root``.""" + AgentService._global_registry = {} + + subagents_dir = temp_dir / "subagents" + subagents_dir.mkdir() + md_file = subagents_dir / "reviewer.md" + md_file.write_text("---\n" "name: reviewer\n" "---\n" "Review code.") + + # ``subagents`` is relative — it must be resolved against ``root``. + AgentService.build_registry(["subagents"], root=str(temp_dir)) + assert "reviewer" in AgentService._global_registry + AgentService._global_registry = {} + def test_build_registry_skips_missing_dir(self): """Non-existent directories are skipped silently (default dirs still scanned).""" AgentService._global_registry = {} @@ -144,6 +158,19 @@ def test_build_registry_skips_missing_dir(self): # Verify the nonexistent path didn't cause an error - default entries are fine assert AgentService._global_registry is not None + def test_build_registry_includes_local_default_dir(self, temp_dir): + """build_registry scans {root}/.cecli/subagents as a local default.""" + AgentService._global_registry = {} + + local_dir = temp_dir / ".cecli" / "subagents" + local_dir.mkdir(parents=True) + md_file = local_dir / "localagent.md" + md_file.write_text("---\nname: localagent\n---\nLocal agent.") + + AgentService.build_registry([], root=str(temp_dir)) + assert "localagent" in AgentService._global_registry + AgentService._global_registry = {} + # ================================================================== # # Instance initialization diff --git a/tests/tools/test_registry.py b/tests/tools/test_registry.py index f9ba9e49952..dd926e9df68 100644 --- a/tests/tools/test_registry.py +++ b/tests/tools/test_registry.py @@ -184,6 +184,19 @@ def test_skill_and_mcp_tools_in_context_manager(self): assert "load_mcp" in params, "context_manager should have load_mcp param" assert "remove_mcp" in params, "context_manager should have remove_mcp param" + def test_build_registry_scans_default_tools_dirs(self, tmp_path): + """build_registry scans the global and local default tools directories.""" + local_dir = tmp_path / ".cecli" / "tools" + local_dir.mkdir(parents=True) + (local_dir / "custom_tool.py").write_text( + "class Tool:\n" + " NORM_NAME = 'custom_tool'\n" + " SCHEMA = {'function': {'name': 'custom_tool', 'description': 'desc'}}\n" + ) + + registry = ToolRegistry.build_registry({}, root=str(tmp_path)) + assert "custom_tool" in registry + if __name__ == "__main__": # Run tests if executed directly From 0a3d25e46d14feb246a48eb0a16fcb89c0e12ba5 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Sun, 30 Aug 2026 15:44:31 -0400 Subject: [PATCH 12/32] Add `/open` command to open a new workspace subagent to local folder --- cecli/commands/__init__.py | 3 + cecli/commands/open.py | 76 ++++++++++++++++ cecli/website/docs/config/subagents.md | 1 + cecli/website/docs/config/workspaces.md | 3 + tests/commands/test_open.py | 116 ++++++++++++++++++++++++ 5 files changed, 199 insertions(+) create mode 100644 cecli/commands/open.py create mode 100644 tests/commands/test_open.py diff --git a/cecli/commands/__init__.py b/cecli/commands/__init__.py index 0905610dc01..9f240a549ba 100644 --- a/cecli/commands/__init__.py +++ b/cecli/commands/__init__.py @@ -55,6 +55,7 @@ from .model import ModelCommand from .models import ModelsCommand from .multiline_mode import MultilineModeCommand +from .open import OpenCommand from .paste import PasteCommand from .queue import QueueCommand from .quit import QuitCommand @@ -151,6 +152,7 @@ CommandRegistry.register(ModelCommand) CommandRegistry.register(ModelsCommand) CommandRegistry.register(MultilineModeCommand) +CommandRegistry.register(OpenCommand) CommandRegistry.register(PasteCommand) CommandRegistry.register(QueueCommand) CommandRegistry.register(QuitCommand) @@ -240,6 +242,7 @@ "ModelCommand", "ModelsCommand", "MultilineModeCommand", + "OpenCommand", "parse_quoted_filenames", "PasteCommand", "quote_filename", diff --git a/cecli/commands/open.py b/cecli/commands/open.py new file mode 100644 index 00000000000..ee0db6c2523 --- /dev/null +++ b/cecli/commands/open.py @@ -0,0 +1,76 @@ +"""Open command - register and open a workspace sub-agent rooted at a path.""" + +from pathlib import Path + +from cecli.helpers.agents.service import AgentService +from cecli.helpers.workspaces.subagents import register_workspace_subagents + +from .utils.base_command import BaseCommand + + +class OpenCommand(BaseCommand): + NORM_NAME = "open" + DESCRIPTION = "Open a workspace sub-agent rooted at a given path" + + @classmethod + async def execute(cls, io, coder, args, **kwargs): + """Open a workspace sub-agent rooted at the given path. + + Syntax: + /open — register and open a ``ws:{name}`` sub-agent rooted at ```` + """ + parts = args.strip().split(maxsplit=1) + if len(parts) < 2: + io.tool_error("Usage: /open ") + return + + name = parts[0] + path_arg = parts[1].strip() + + project_name = name[3:] if name.startswith("ws:") else name + agent_name = f"ws:{project_name}" + path = Path(path_arg).expanduser() + + config = { + "name": project_name, + "projects": [{"name": project_name, "path": str(path)}], + } + registered = register_workspace_subagents(config) + if agent_name not in registered: + io.tool_error(f"Error: '{path}' is not a valid git repository or does not exist.") + return + + root = AgentService.get_registry()[agent_name].metadata.get("root") + + try: + agent_service = AgentService.get_instance(coder) + new_coder, info = await agent_service.spawn( + agent_name, prompt=None, parent=coder, auto_reap=False, independent=True + ) + + agent_service.foreground_uuid = info.coder.uuid + + if coder.tui and coder.tui(): + tui = coder.tui() + switch_key = tui.get_keys_for("next_agent") + io.tool_output( + f"Opened workspace sub-agent '{agent_name}' rooted at {root}. Switch with {switch_key}" + ) + + try: + tui.call_from_thread(tui._switch_to_container, info.coder.uuid) + except Exception: + pass + else: + io.tool_output(f"Opened workspace sub-agent '{agent_name}' rooted at {root}.") + except Exception as e: + io.tool_error(f"Error opening workspace sub-agent '{agent_name}': {e}") + + @classmethod + def get_help(cls) -> str: + return "Open a workspace sub-agent rooted at a path (/open )" + + @classmethod + def get_completions(cls, io, coder, args) -> list[str]: + """Return registered workspace sub-agent names for tab-completion.""" + return [name for name in AgentService.get_registry().keys() if name.startswith("ws:")] diff --git a/cecli/website/docs/config/subagents.md b/cecli/website/docs/config/subagents.md index de25d363907..7f65bc516ad 100644 --- a/cecli/website/docs/config/subagents.md +++ b/cecli/website/docs/config/subagents.md @@ -73,6 +73,7 @@ agent-config: |---------|-------------| | `/spawn-agent ` | Spawn a sub-agent without a prompt (non-blocking — waits for user input) | | `/spawn-agent ` | Spawn a sub-agent with a prompt (non-blocking — starts processing immediately) | +| `/open ` | Register and open a `ws:{name}` workspace sub-agent rooted at `` (ad-hoc, no config file required) | | `/reap-agent` | Force destroy the currently active sub-agent | > **Tip**: `/spawn-agent` supports tab completion of sub-agent names. diff --git a/cecli/website/docs/config/workspaces.md b/cecli/website/docs/config/workspaces.md index d1ab7615851..747c93c96f7 100644 --- a/cecli/website/docs/config/workspaces.md +++ b/cecli/website/docs/config/workspaces.md @@ -22,9 +22,12 @@ Because `ws:{name}` agents are registered with the sub-agent registry, they are - The `Delegate` tool (from a primary agent) - `/spawn-agent ws:{name}` (interactively) +- `/open ` (ad-hoc, no config file required) You can find the agent name reported by `/workspace` (e.g. `ws:app`). +`/open app /path/to/app` opens a `ws:app` sub-agent rooted at the given path without needing a `.cecli.workspaces.yml` file. The path must be an existing git root; the agent is registered immediately and becomes the foreground agent. + Each project may carry a `metadata` block that configures its `ws:{name}` agent the same way a sub-agent `.md` front-matter does: `model`, `hooks` and `auto_reap` map to the config fields, and any other key (e.g. `agent-config`) is merged into the agent's metadata. `root`, `name` and `description` are always derived from the project definition and cannot be overridden by `metadata`. ## Configuration diff --git a/tests/commands/test_open.py b/tests/commands/test_open.py new file mode 100644 index 00000000000..131c9f9c858 --- /dev/null +++ b/tests/commands/test_open.py @@ -0,0 +1,116 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from cecli.commands.open import OpenCommand + + +@pytest.fixture +def mock_coder(): + coder = MagicMock() + coder.uuid = "primary-uuid" + coder.tui = None + return coder + + +@pytest.fixture +def mock_io(): + io = MagicMock() + return io + + +@pytest.fixture +def mock_agent_service(): + with patch("cecli.commands.open.AgentService") as MockAgentService: + agent_service = MockAgentService.get_instance.return_value + MockAgentService.get_registry.return_value = { + "ws:app": MagicMock(metadata={"root": "/abs/app"}) + } + yield agent_service + + +class TestOpenCommand: + @pytest.mark.asyncio + async def test_execute_missing_args(self, mock_coder, mock_io): + await OpenCommand.execute(mock_io, mock_coder, "") + mock_io.tool_error.assert_called_once_with("Usage: /open ") + + @pytest.mark.asyncio + async def test_execute_invalid_path(self, mock_coder, mock_io): + with patch("cecli.commands.open.register_workspace_subagents", return_value=[]): + await OpenCommand.execute(mock_io, mock_coder, "app /no/such/path") + + mock_io.tool_error.assert_called_once_with( + "Error: '/no/such/path' is not a valid git repository or does not exist." + ) + + @pytest.mark.asyncio + async def test_execute_success_non_tui(self, mock_coder, mock_io, mock_agent_service): + info = MagicMock() + info.coder.uuid = "sub-uuid-1" + mock_agent_service.spawn = AsyncMock(return_value=(MagicMock(), info)) + + with patch("cecli.commands.open.register_workspace_subagents", return_value=["ws:app"]): + await OpenCommand.execute(mock_io, mock_coder, "app /abs/app") + + # spawn is non-blocking with no prompt; the sub-agent becomes the foreground agent. + mock_agent_service.spawn.assert_awaited_once_with( + "ws:app", prompt=None, parent=mock_coder, auto_reap=False, independent=True + ) + assert mock_agent_service.foreground_uuid == "sub-uuid-1" + mock_io.tool_output.assert_called_once_with( + "Opened workspace sub-agent 'ws:app' rooted at /abs/app." + ) + + @pytest.mark.asyncio + async def test_execute_success_tui(self, mock_coder, mock_io, mock_agent_service): + tui = MagicMock() + tui.get_keys_for.return_value = "" + mock_coder.tui = MagicMock(return_value=tui) + + info = MagicMock() + info.coder.uuid = "sub-uuid-1" + mock_agent_service.spawn = AsyncMock(return_value=(MagicMock(), info)) + + with patch("cecli.commands.open.register_workspace_subagents", return_value=["ws:app"]): + await OpenCommand.execute(mock_io, mock_coder, "app /abs/app") + + tui.call_from_thread.assert_called_once_with(tui._switch_to_container, "sub-uuid-1") + mock_io.tool_output.assert_called_once_with( + "Opened workspace sub-agent 'ws:app' rooted at /abs/app. Switch with " + ) + + @pytest.mark.asyncio + async def test_execute_ws_prefix(self, mock_coder, mock_io, mock_agent_service): + info = MagicMock() + info.coder.uuid = "sub-uuid-1" + mock_agent_service.spawn = AsyncMock(return_value=(MagicMock(), info)) + + with patch("cecli.commands.open.register_workspace_subagents", return_value=["ws:app"]): + await OpenCommand.execute(mock_io, mock_coder, "ws:app /abs/app") + + mock_agent_service.spawn.assert_awaited_once_with( + "ws:app", prompt=None, parent=mock_coder, auto_reap=False, independent=True + ) + + @pytest.mark.asyncio + async def test_execute_spawn_error(self, mock_coder, mock_io, mock_agent_service): + mock_agent_service.spawn = AsyncMock(side_effect=RuntimeError("boom")) + + with patch("cecli.commands.open.register_workspace_subagents", return_value=["ws:app"]): + await OpenCommand.execute(mock_io, mock_coder, "app /abs/app") + + mock_io.tool_error.assert_called_once_with( + "Error opening workspace sub-agent 'ws:app': boom" + ) + + def test_get_help(self): + assert "(/open )" in OpenCommand.get_help() + + def test_get_completions(self): + with patch("cecli.commands.open.AgentService") as MockAgentService: + MockAgentService.get_registry.return_value = { + "ws:app": MagicMock(), + "worker": MagicMock(), + } + assert OpenCommand.get_completions(MagicMock(), MagicMock(), "") == ["ws:app"] From 7ae33bdbe478fff93a3d9ed7303263c35583ee1e Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Sun, 30 Aug 2026 15:55:36 -0400 Subject: [PATCH 13/32] Only await dependent sub agents --- cecli/tools/_yield.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cecli/tools/_yield.py b/cecli/tools/_yield.py index c3577e15acf..d91ea599c14 100644 --- a/cecli/tools/_yield.py +++ b/cecli/tools/_yield.py @@ -61,7 +61,9 @@ async def execute(cls, coder, **kwargs): active_tasks = [ info.generate_task for info in children - if info.generate_task is not None and not info.generate_task.done() + if not info.independent + and info.generate_task is not None + and not info.generate_task.done() ] if active_tasks: From b8d30801fb82dec0d31ca30216c165f4161cf662 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Sun, 30 Aug 2026 16:08:14 -0400 Subject: [PATCH 14/32] Add "wait" parameter to yield tool --- cecli/tools/_yield.py | 55 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/cecli/tools/_yield.py b/cecli/tools/_yield.py index d91ea599c14..2efe5d5f4d6 100644 --- a/cecli/tools/_yield.py +++ b/cecli/tools/_yield.py @@ -33,6 +33,15 @@ class Tool(BaseTool): "and returned to the parent agent." ), }, + "wait": { + "type": "integer", + "description": ( + "Optional time in seconds (between 15 and 120) to wait " + "before returning. When provided, the tool simply sleeps " + "for the specified duration and returns, " + "allowing for other tasks to proceed." + ), + }, }, "required": [], }, @@ -53,6 +62,45 @@ async def execute(cls, coder, **kwargs): response = ToolResponse(cls.NORM_NAME) if coder: + wait = kwargs.get("wait") + if wait is not None and wait != "": + if isinstance(wait, bool): + wait_seconds = 30 + else: + try: + wait_seconds = int(wait) + except (ValueError, TypeError): + wait_seconds = 30 + + wait_seconds = max(15, min(120, wait_seconds)) + + # Plain wait — sleep for the requested duration without checking + # sub-agents or marking the task as finished. + interrupt_event = coder.interrupt_event + if interrupt_event is None: + interrupt_event = ThreadSafeEvent() + + interrupt_task = asyncio.create_task(interrupt_event.wait()) + sleep_task = asyncio.create_task(asyncio.sleep(wait_seconds)) + done, pending = await asyncio.wait( + {sleep_task, interrupt_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + + for task in pending: + task.cancel() + try: + await task + except (asyncio.CancelledError, Exception): + pass + + if interrupt_task in done: + response.append_result(f"Wait interrupted after {wait_seconds} seconds.") + else: + response.append_result(f"Waited for {wait_seconds} seconds.") + + return response + waited_for_sub_agents = False # Check for active child sub-agents and await their tasks before finishing try: @@ -238,6 +286,13 @@ def format_output(cls, coder, mcp_server, tool_response): coder.io.tool_error("Invalid Tool JSON") return + wait = params.get("wait") + if wait: + coder.io.tool_output("") + coder.io.tool_output(f"{color_start}Wait:{color_end}") + coder.io.tool_output(f"{wait} seconds") + coder.io.tool_output("") + summary = params.get("summary") if summary: coder.io.tool_output("") From b9c716a08775313587f8a3c1c28e9cd25b968d6a Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 30 Aug 2026 19:09:42 -0400 Subject: [PATCH 15/32] Add powershell support for Grep tool --- cecli/tools/grep.py | 343 ++++++++++++++++++++++++++++++++------- tests/tools/test_grep.py | 100 ++++++++++++ 2 files changed, 388 insertions(+), 55 deletions(-) diff --git a/cecli/tools/grep.py b/cecli/tools/grep.py index 7c3e26059db..d481c3e8034 100644 --- a/cecli/tools/grep.py +++ b/cecli/tools/grep.py @@ -1,4 +1,5 @@ import os +import base64 import re import shutil from pathlib import Path @@ -185,6 +186,191 @@ def _flush_current(): return files +def _build_powershell_exclude_regex(exclude_dirs=None): + """Build a PowerShell -notmatch regex that skips default build/artifact dirs.""" + if exclude_dirs is None: + exclude_dirs = DEFAULT_EXCLUDE_DIRS + fragments = [] + for entry in exclude_dirs: + # Escape the literal text, then turn * globs into path-segment wildcards. + frag = re.escape(entry).replace(r"\*", r"[^\\/]*") + fragments.append(frag) + if not fragments: + return None + return r"(?:\\|/)(?:" + "|".join(fragments) + r")(?:\\|/|$)" + + +def _build_powershell_search_script( + search_dir_path, + pattern, + file_pattern, + use_regex, + case_insensitive, + context_before, + context_after, + count_only, +): + """Build a PowerShell pipeline that mimics rg/ag/grep via Select-String.""" + + def _ps_quote(value): + # Embed a value in a PowerShell single-quoted string ('' escapes a quote). + return "'" + str(value).replace("'", "''") + "'" + + parts = ["Get-ChildItem", "-LiteralPath", _ps_quote(search_dir_path), "-Recurse", "-File"] + + if file_pattern != "*": + parts.extend(["-Filter", _ps_quote(file_pattern)]) + + exclude_regex = _build_powershell_exclude_regex() + if exclude_regex: + parts.extend( + [ + "|", + "Where-Object", + "{", + "$_.FullName", + "-notmatch", + _ps_quote(exclude_regex), + "}", + ] + ) + + parts.extend(["|", "Select-String", "-Pattern", _ps_quote(pattern)]) + + if not use_regex: + parts.append("-SimpleMatch") + if not case_insensitive: + parts.append("-CaseSensitive") + + if count_only: + parts.extend( + [ + "|", + "Group-Object", + "Path", + "|", + "ForEach-Object", + "{", + '"$($_.Name):$($_.Count)"', + "}", + ] + ) + else: + parts.extend(["-Context", f"{context_before},{context_after}"]) + + return " ".join(parts) + + +def _encode_powershell_command(script, powershell_path): + """Encode a PowerShell script for -EncodedCommand and return the full command.""" + encoded = base64.b64encode(script.encode("utf-16-le")).decode("ascii") + return ( + f"{powershell_path} -NoProfile -NonInteractive -OutputFormat Text " + f"-EncodedCommand {encoded}" + ) + + +def _parse_select_string_output(output, repo_root): + """Parse Select-String (PowerShell) output into per-file groups. + + With -OutputFormat Text, each match is rendered as: + :: (no context) + and with context as: + > :: (match line) + :: (context line, leading spaces) + + PowerShell renders paths relative to the current directory, so each path is + re-resolved against *repo_root* to an absolute path. This keeps the parsed + groups consistent with the count pass (Group-Object Path -> absolute paths) + and with the downstream truncation/relpath logic. + """ + if not output: + return [] + + file_groups = {} + file_order = [] + + def _flush(current_file, current_lines, match_count): + if current_file is None or not current_lines: + return + if current_file in file_groups: + existing = file_groups[current_file] + existing["match_count"] += match_count + existing["content_parts"].append("\n".join(current_lines)) + else: + file_groups[current_file] = { + "path": current_file, + "match_count": match_count, + "content_parts": ["\n".join(current_lines)], + } + file_order.append(current_file) + + entry_re = re.compile(r"^(.+?)[:-](\d+)[:-](.*)$") + + current_file = None + current_lines = [] + match_count = 0 + + for raw_line in output.splitlines(): + line = raw_line.rstrip("\r") + if not line.strip(): + continue + + # Detect Select-String prefix markers: '> ' for matches, spaces for context. + prefix = "" + is_match = True + rest = line + if line.startswith("> "): + prefix = "> " + rest = line[2:] + elif line.startswith(" "): + prefix = " " + rest = line[2:] + is_match = False + elif line.startswith(" "): + prefix = " " + rest = line.lstrip(" ") + is_match = False + + m = entry_re.match(rest) + if not m: + if current_file is not None: + current_lines.append(line) + continue + + filepath = m.group(1) + abs_path = filepath if os.path.isabs(filepath) else os.path.normpath(os.path.join(repo_root, filepath)) + rebuilt = prefix + abs_path + rest[len(filepath):] + + if current_file is None: + current_file = abs_path + match_count = 1 if is_match else 0 + current_lines = [rebuilt] + elif abs_path == current_file: + current_lines.append(rebuilt) + if is_match: + match_count += 1 + else: + _flush(current_file, current_lines, match_count) + current_file = abs_path + match_count = 1 if is_match else 0 + current_lines = [rebuilt] + + _flush(current_file, current_lines, match_count) + + files = [] + for fpath in file_order: + group = file_groups[fpath] + files.append( + { + "path": group["path"], + "match_count": group["match_count"], + "content": "\n".join(group["content_parts"]), + } + ) + return files + + class Tool(BaseTool): NORM_NAME = "grep" RESULT_TYPE = "list" @@ -258,6 +444,26 @@ def _validate_backend(cls, tool_name, tool_path): """Test if a search backend actually works by running a quick check.""" import subprocess + if tool_name == "powershell": + # PowerShell backend: verify the Select-String cmdlet is available. + test_cmd = [ + tool_path, + "-NoProfile", + "-NonInteractive", + "-Command", + "if (Get-Command Select-String -ErrorAction SilentlyContinue) { exit 0 } else { exit 1 }", + ] + try: + result = subprocess.run( + test_cmd, + capture_output=True, + timeout=10, + text=True, + ) + return result.returncode == 0 + except (subprocess.TimeoutExpired, OSError): + return False + try: # Test with a simple pattern on a small known file test_cmd = [tool_path, "--version"] @@ -305,10 +511,14 @@ def _validate_backend(cls, tool_name, tool_path): @classmethod def _find_search_tool(self): - """Find the best available command-line search tool (rg, ag, grep).""" - candidates = ["rg", "ag", "grep"] + """Find the best available command-line search tool (rg, ag, grep, or PowerShell Select-String).""" + candidates = ["rg", "ag", "grep", "powershell"] for name in candidates: - path = shutil.which(name) + if name == "powershell": + # PowerShell (Select-String) is the Windows-native fallback. + path = shutil.which("powershell") or shutil.which("pwsh") + else: + path = shutil.which(name) if not path: continue if self._validate_backend(name, path): @@ -325,6 +535,7 @@ def execute( """ Search for lines matching patterns in files within the project repository. Uses rg (ripgrep), ag (the silver searcher), or grep, whichever is available. + On Windows these may be absent, so PowerShell's Select-String is used as a fallback. Returns a JSON string with structured results including per-file groupings, match counts, and summary metadata. """ @@ -344,9 +555,9 @@ def execute( tool_name, tool_path = cls._find_search_tool() if not tool_path: - coder.io.tool_error("No search tool (rg, ag, grep) found in PATH.") + coder.io.tool_error("No search tool (rg, ag, grep, powershell) found in PATH.") response = ToolResponse(cls.NORM_NAME, result_type=cls.RESULT_TYPE) - response.append_error("No search tool (rg, ag, grep) found.") + response.append_error("No search tool (rg, ag, grep, powershell) found.") return response all_operation_results = [] @@ -385,48 +596,61 @@ def execute( try: search_dir_path = Path(repo.root) / directory - # Build base content command - base_cmd = [tool_path, "-n"] - if tool_name == "rg": - base_cmd.append("--with-filename") - - # Pattern type - pattern_flag = [] - if use_regex: - if tool_name == "grep": - pattern_flag = ["-E"] + if tool_name == "powershell": + # PowerShell (Select-String) backend: build a pipeline script and + # invoke it via -EncodedCommand to avoid shell quoting issues. + content_string = _encode_powershell_command( + _build_powershell_search_script( + search_dir_path=search_dir_path, + pattern=pattern, + file_pattern=file_pattern, + use_regex=use_regex, + case_insensitive=case_insensitive, + context_before=context_before, + context_after=context_after, + count_only=False, + ), + tool_path, + ) else: + # Build base content command + base_cmd = [tool_path, "-n"] if tool_name == "rg": - pattern_flag = ["-F"] - elif tool_name == "ag": - pattern_flag = ["-Q"] - elif tool_name == "grep": - pattern_flag = ["-F"] - - # Case sensitivity - case_flag = ["-i"] if case_insensitive else [] + base_cmd.append("--with-filename") - # File filtering - file_filter = [] - if file_pattern != "*": - if tool_name == "rg": - file_filter = ["-g", file_pattern] - elif tool_name == "ag": - file_filter = ["-G", file_pattern] + # Pattern type + pattern_flag = [] + if use_regex: + if tool_name == "grep": + pattern_flag = ["-E"] + else: + if tool_name == "rg": + pattern_flag = ["-F"] + elif tool_name == "ag": + pattern_flag = ["-Q"] + elif tool_name == "grep": + pattern_flag = ["-F"] + + # Case sensitivity + case_flag = ["-i"] if case_insensitive else [] + + # File filtering + file_filter = [] + if file_pattern != "*": + if tool_name == "rg": + file_filter = ["-g", file_pattern] + elif tool_name == "ag": + file_filter = ["-G", file_pattern] + elif tool_name == "grep": + file_filter = ["-r", f"--include={file_pattern}"] elif tool_name == "grep": - file_filter = ["-r", f"--include={file_pattern}"] - elif tool_name == "grep": - file_filter = ["-r"] + file_filter = ["-r"] - # Exclusions - exclude_args = [] - _build_exclude_args(tool_name, exclude_args) + # Exclusions + exclude_args = [] + _build_exclude_args(tool_name, exclude_args) - # --- PASS 1: Get match counts (fast, no context) --- - counts = {} - if count_enabled: - # Build count command (separate flags to avoid -r confusion) - # NOTE: rg -r = --replace (takes arg), not recursive. rg is recursive by default. + # --- PASS1: Build count command (fast, no context) --- count_cmd_parts = [tool_path] if tool_name == "rg": count_cmd_parts.append("-c") @@ -443,6 +667,23 @@ def execute( + ["--", pattern, str(search_dir_path)] ) count_string = oslex.join(count_cmd) + + # --- PASS2: Build content with context --- + content_cmd = ( + base_cmd + + (["-B", str(context_before)] if context_before >= 0 else []) + + (["-A", str(context_after)] if context_after >= 0 else []) + + case_flag + + pattern_flag + + exclude_args + + file_filter + + ["--", pattern, str(search_dir_path)] + ) + content_string = oslex.join(content_cmd) + + # --- PASS1: Get match counts (fast, no context) --- + counts = {} + if count_enabled and tool_name != "powershell": coder.io.tool_output( f"⛭ Counting matches with {tool_name}: '{pattern}' in {directory}", type="tool-result", @@ -456,18 +697,7 @@ def execute( if count_status == 0: counts = _parse_count_output(count_output) - # --- PASS 2: Get content with context --- - content_cmd = ( - base_cmd - + (["-B", str(context_before)] if context_before >= 0 else []) - + (["-A", str(context_after)] if context_after >= 0 else []) - + case_flag - + pattern_flag - + exclude_args - + file_filter - + ["--", pattern, str(search_dir_path)] - ) - content_string = oslex.join(content_cmd) + # --- PASS2: Get content with context --- coder.io.tool_output( f"⛭ Executing {tool_name}: '{pattern}' in {directory}", type="tool-result", @@ -482,7 +712,10 @@ def execute( output_content = content_output or "" if content_status == 0 and output_content: - parsed_files = _parse_content_into_files(output_content) + if tool_name == "powershell": + parsed_files = _parse_select_string_output(output_content, repo.root) + else: + parsed_files = _parse_content_into_files(output_content) # Merge in counts from pass 1 if available if counts: @@ -510,7 +743,7 @@ def execute( file_lines = pf["content"].splitlines() # Find actual match lines (lines with `:LINE:` pattern) filepath_escaped = re.escape(pf["path"]) - match_line_re = re.compile(r"^" + filepath_escaped + r":(\d+):") + match_line_re = re.compile(r"^(?:> )?" + filepath_escaped + r":(\d+):") match_lines_found = [ln for ln in file_lines if match_line_re.match(ln)] # Normalize path to be relative to repo root diff --git a/tests/tools/test_grep.py b/tests/tools/test_grep.py index 9030f883468..4a630b6eb04 100644 --- a/tests/tools/test_grep.py +++ b/tests/tools/test_grep.py @@ -60,3 +60,103 @@ def test_dash_prefixed_pattern_is_searched_literally(search_term, tmp_path, monk assert "total_files" in op["_"] assert isinstance(op["_"]["total_files"], int) coder.io.tool_error.assert_not_called() + + +@pytest.mark.skipif(shutil.which("powershell") is None, reason="powershell is required") +@pytest.mark.parametrize( + "search_term", + [ + "--pattern", + "-pattern", + ], +) +def test_powershell_dash_prefixed_pattern_is_searched_literally(search_term, tmp_path, monkeypatch): + sample = tmp_path / "example.txt" + sample.write_text(f"flag {search_term} should be found\n") + + coder = SimpleNamespace( + repo=SimpleNamespace(root=str(tmp_path)), + io=SimpleNamespace( + tool_error=Mock(), + tool_output=Mock(), + tool_warning=Mock(), + ), + verbose=False, + root=str(tmp_path), + tui=lambda: None, + ) + + monkeypatch.setattr( + grep.Tool, "_find_search_tool", lambda: ("powershell", shutil.which("powershell")) + ) + + result = grep.Tool.execute( + coder, + searches=[ + { + "pattern": search_term, + "file_glob": "*.txt", + "directory": ".", + "use_regex": False, + "case_insensitive": False, + "context_before": 0, + "context_after": 0, + } + ], + ) + + response_dict = result.to_dict() + operations = response_dict["result"] + assert len(operations) == 1 + op = operations[0] + assert op["_"]["pattern"] == search_term + assert op["_"]["error"] is None + assert op["_"]["total_files"] >= 1 + assert op["_"]["total_matches"] >= 1 + assert any("example.txt" in f["file"] for f in op["_"]["files"]) + coder.io.tool_error.assert_not_called() + + +@pytest.mark.skipif(shutil.which("powershell") is None, reason="powershell is required") +def test_powershell_counts_and_context(tmp_path, monkeypatch): + sample = tmp_path / "sample.txt" + sample.write_text("alpha\nbeta\nalpha\ngamma alpha\n") + + coder = SimpleNamespace( + repo=SimpleNamespace(root=str(tmp_path)), + io=SimpleNamespace( + tool_error=Mock(), + tool_output=Mock(), + tool_warning=Mock(), + ), + verbose=False, + root=str(tmp_path), + tui=lambda: None, + ) + + monkeypatch.setattr( + grep.Tool, "_find_search_tool", lambda: ("powershell", shutil.which("powershell")) + ) + + result = grep.Tool.execute( + coder, + searches=[ + { + "pattern": "alpha", + "file_glob": "*.txt", + "directory": ".", + "use_regex": False, + "case_insensitive": True, + "context_before": 1, + "context_after": 1, + } + ], + ) + + response_dict = result.to_dict() + op = response_dict["result"][0] + assert op["_"]["error"] is None + assert op["_"]["total_matches"] >= 3 + file_entry = next(f for f in op["_"]["files"] if f["file"] == "sample.txt") + assert file_entry["match_count"] >= 3 + coder.io.tool_error.assert_not_called() From c017df93e88443d27f219abc903f134e682becd5 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Sun, 30 Aug 2026 19:30:25 -0400 Subject: [PATCH 16/32] Update Orchestrate tool call guidelines --- cecli/helpers/orchestration/environment.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/cecli/helpers/orchestration/environment.py b/cecli/helpers/orchestration/environment.py index 0d301da54d5..0926c11665f 100644 --- a/cecli/helpers/orchestration/environment.py +++ b/cecli/helpers/orchestration/environment.py @@ -545,9 +545,10 @@ def build_orchestration_context_block(agent_config: dict[str, Any]) -> str | Non orchestration_config = agent_config.get("orchestration", {}) context = """ -The `Orchestrate` tool runs Python in a sandbox where you can call other tools programmatically. -Use it for batch or loop-heavy workflows. +The `Orchestrate` tool runs Python in a sandbox where you can script other tools programmatically. +Use it for batch, loop-heavy, and repeat-able workflows. Variables and helpers persist across calls; `state` persists across all Orchestrate calls in the session. +You may need to explore the below primitives to understand how to use the sandbox effectively. ### Primitives @@ -556,7 +557,7 @@ def build_orchestration_context_block(agent_config: dict[str, Any]) -> str | Non | `Agent.allowed_methods()` / `Agent.allowed_tools()` | List helper methods and available tools | | `Agent.get_tool(name)` | Get a tool proxy (case-insensitive; `Local--` / `{{Server Name}}--` prefixes ok) | | `await tool.call(**params)` | Run a tool; returns `{"result": [...], "errors": [...], "details": [...]}`, items with shape `{"content", "_"}` | -| `Agent.peek(result)` / `Agent.get_value(result, "path", default?)` | Inspect / extract values from tool results | +| `Agent.peek(result)` / `Agent.get_value(result, path, default?)` | Inspect / extract values from tool results. path is dot-separated string | | `Agent.resolve_regions(path, specs)` / `Agent.edit_region(path, edits)` | Resolve text boundaries once, then apply edits | | `gather(**tasks)` | Run tasks concurrently; results expose `.key` and `["key"]` | | `state` / `shared_state` | Persistent dicts; `state.get(k)` falls back to `shared_state` | From a9054169d47c9b3b57cf20d686433cdb349af477 Mon Sep 17 00:00:00 2001 From: DinoChiesa Date: Sun, 30 Aug 2026 15:16:06 -0700 Subject: [PATCH 17/32] feat(gemini): authenticate using x-goog-api-key header Switch Gemini provider authentication from query parameters (`?key=...`) to the `x-goog-api-key` HTTP header in alignment with Google Gemini API guidelines. - Override `build_headers` in `GeminiProvider` to populate `x-goog-api-key` and suppress `Authorization: Bearer`. - Remove `key` query parameter construction in `gemini_complete` and `gemini_stream` domain adapters. - Add test coverage in `tests/helpers/test_llms_gemini_auth.py`. Co-authored-by: cecli (gemini/gemini-3.6-flash) --- cecli/helpers/llms/domains/gemini.py | 4 +- cecli/helpers/llms/providers/gemini.py | 17 +++--- tests/helpers/test_llms_gemini_auth.py | 80 ++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 10 deletions(-) create mode 100644 tests/helpers/test_llms_gemini_auth.py diff --git a/cecli/helpers/llms/domains/gemini.py b/cecli/helpers/llms/domains/gemini.py index b267ced2dcc..2e2aed1c07d 100644 --- a/cecli/helpers/llms/domains/gemini.py +++ b/cecli/helpers/llms/domains/gemini.py @@ -194,7 +194,7 @@ async def gemini_complete( url = f"{resolved['api_base']}/v1beta/models/{resolved['route']}:generateContent" payload = gemini_payload(resolved, messages, tools, kwargs) hdrs = {"Content-Type": "application/json", **headers} - params = {"key": key} if key else {} + params: Dict[str, str] = {} async with make_client(timeout=DEFAULT_TIMEOUT, verify=VERIFY_SSL) as client: resp = await client.post(url, json=payload, headers=hdrs, params=params) @@ -215,7 +215,7 @@ async def gemini_stream( url = f"{resolved['api_base']}/v1beta/models/{resolved['route']}:streamGenerateContent" payload = gemini_payload(resolved, messages, tools, kwargs) hdrs = {"Content-Type": "application/json", **headers} - params = {"key": key, "alt": "sse"} if key else {"alt": "sse"} + params = {"alt": "sse"} has_seen_tool_calls = False _stream_state["tool_indices"] = {} diff --git a/cecli/helpers/llms/providers/gemini.py b/cecli/helpers/llms/providers/gemini.py index a1515153b0c..cbe49c2a4b5 100644 --- a/cecli/helpers/llms/providers/gemini.py +++ b/cecli/helpers/llms/providers/gemini.py @@ -1,11 +1,10 @@ """Gemini provider adapter for the llms package. -Gemini authenticates via the ``key`` query parameter (or ``X-Goog-Api-Key`` -header), NOT via an ``Authorization: Bearer`` header. The base -:class:`ProviderAdapter` adds a Bearer header whenever a key is present, which -Google rejects with 401 for API keys, so this adapter overrides -:meth:`build_headers` to skip it (the domain adapter passes ``key`` as a query -param itself). +Gemini authenticates via the ``x-goog-api-key`` header, NOT via an +``Authorization: Bearer`` header. The base :class:`ProviderAdapter` adds a +Bearer header whenever a key is present, which Google rejects with 401 for API +keys, so this adapter overrides :meth:`build_headers` to set ``x-goog-api-key`` +instead. """ from __future__ import annotations @@ -16,7 +15,7 @@ class GeminiProvider(ProviderAdapter): - """Gemini: key via query param; no Authorization header.""" + """Gemini: key via x-goog-api-key header; no Authorization header.""" provider: str = "gemini" @@ -27,10 +26,12 @@ def build_headers( family: str, headers: Dict[str, str], ) -> Dict[str, str]: - """Return headers without an Authorization header (key is a query param).""" + """Return headers with x-goog-api-key set instead of Authorization.""" merged = dict(headers) merged.setdefault("Content-Type", "application/json") + if key: + merged["x-goog-api-key"] = key return merged diff --git a/tests/helpers/test_llms_gemini_auth.py b/tests/helpers/test_llms_gemini_auth.py new file mode 100644 index 00000000000..53d5436bfcf --- /dev/null +++ b/tests/helpers/test_llms_gemini_auth.py @@ -0,0 +1,80 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from cecli.helpers.llms.domains.gemini import gemini_complete +from cecli.helpers.llms.providers.gemini import GeminiProvider + + +def test_gemini_provider_build_headers(): + provider = GeminiProvider() + headers = provider.build_headers( + resolved={"provider": "gemini"}, + key="my-secret-gemini-key", + family="gemini", + headers={"Custom-Header": "value"}, + ) + + assert headers["x-goog-api-key"] == "my-secret-gemini-key" + assert headers["Content-Type"] == "application/json" + assert headers["Custom-Header"] == "value" + assert "Authorization" not in headers + + +def test_gemini_provider_build_headers_no_key(): + provider = GeminiProvider() + headers = provider.build_headers( + resolved={"provider": "gemini"}, + key=None, + family="gemini", + headers={}, + ) + + assert "x-goog-api-key" not in headers + assert "Authorization" not in headers + + +@pytest.mark.asyncio +async def test_gemini_complete_sends_x_goog_api_key_header(): + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_response.json.return_value = { + "responseId": "resp-123", + "candidates": [ + { + "content": {"parts": [{"text": "Hello world"}]}, + "finishReason": "STOP", + } + ], + } + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + + mock_make_client = MagicMock() + mock_make_client.return_value.__aenter__.return_value = mock_client + + with patch("cecli.helpers.llms.domains.gemini.make_client", mock_make_client): + resolved = { + "api_base": "https://generativelanguage.googleapis.com", + "route": "gemini-2.0-flash", + "model": "gemini/gemini-2.0-flash", + } + messages = [{"role": "user", "content": "Hi"}] + headers = {"x-goog-api-key": "my-secret-gemini-key"} + + resp = await gemini_complete( + resolved=resolved, + messages=messages, + tools=None, + key="my-secret-gemini-key", + headers=headers, + kwargs={}, + ) + + assert resp.choices[0].message.content == "Hello world" + mock_client.post.assert_called_once() + _, kwargs = mock_client.post.call_args + assert kwargs["headers"]["x-goog-api-key"] == "my-secret-gemini-key" + assert "Authorization" not in kwargs["headers"] + assert kwargs["params"] == {} From 578db5d0ad49da228653b5fbb62c2019b4f51838 Mon Sep 17 00:00:00 2001 From: DinoChiesa Date: Sun, 30 Aug 2026 17:33:50 -0700 Subject: [PATCH 18/32] feat: extract and use retry delay from Gemini 429 error payloads When using Gemini models a 429 response often includes a retryDelay in the JSON. This is typically o(10) seconds, sometimes more. cecli uses a blind backoff computation. This can result in denial of service when using Gemini models. To correct this, respect the retryDelay delivered in Gemini model responses. - Add `_extract_gemini_retry_delay` helper to `Model` class in `cecli/models.py` to extract suggested `retryDelay` (in seconds) from 429 error response payloads. - Update `send_completion()` and `simple_send_with_retries()` to prioritize Gemini's suggested retry delay over blind unilateral backoff multipliers when available. - Add unit tests in `tests/unit/test_gemini_retry_backoff.py` covering valid retryDelay extraction, JSON string payloads, non-429 responses, and missing details fields. Co-authored-by: cecli (gemini/gemini-3.6-flash) --- cecli/models.py | 99 ++++++++++++++++- dinoprompts/commit-desc-2.txt | 5 + tests/unit/test_gemini_retry_backoff.py | 140 ++++++++++++++++++++++++ 3 files changed, 240 insertions(+), 4 deletions(-) create mode 100644 dinoprompts/commit-desc-2.txt create mode 100644 tests/unit/test_gemini_retry_backoff.py diff --git a/cecli/models.py b/cecli/models.py index eb48c352c69..864ed1e1c33 100644 --- a/cecli/models.py +++ b/cecli/models.py @@ -1456,10 +1456,15 @@ async def send_completion( if ex_info.name == "ServiceUnavailableError": should_retry = should_retry or self.retry_on_unavailable - if should_retry: + custom_retry_delay = self._extract_gemini_retry_delay(err) + if custom_retry_delay is not None: + retry_delay = custom_retry_delay + should_retry = True + elif should_retry: retry_delay *= self.retry_backoff_factor - if retry_delay > self.retry_timeout: - should_retry = False + + if retry_delay > self.retry_timeout: + should_retry = False # Check for non-retryable RateLimitError within ServiceUnavailableError if ( @@ -1550,7 +1555,11 @@ async def simple_send_with_retries( if ex_info.description: print(ex_info.description) should_retry = ex_info.retry - if should_retry: + custom_retry_delay = self._extract_gemini_retry_delay(err) + if custom_retry_delay is not None: + retry_delay = custom_retry_delay + should_retry = True + elif should_retry: retry_delay *= 2 if retry_delay > RETRY_TIMEOUT: should_retry = False @@ -1584,6 +1593,88 @@ def model_error_response(self): async def model_error_response_stream(self): yield self.model_error_response() + def _extract_gemini_retry_delay(self, err): + """ + Extract retry delay in seconds from a 429 error response payload (e.g., Gemini), if present. + """ + status_code = getattr(err, "status_code", None) + + payload = None + response = getattr(err, "response", None) + if response is not None: + if hasattr(response, "json") and callable(response.json): + try: + payload = response.json() + except Exception: + pass + if payload is None and hasattr(response, "text") and isinstance(response.text, str): + try: + payload = json.loads(response.text) + except Exception: + pass + if ( + payload is None + and hasattr(response, "content") + and isinstance(response.content, (str, bytes)) + ): + try: + payload = json.loads(response.content) + except Exception: + pass + + if payload is None: + for attr in ("message", "body", "error", "raw_response"): + val = getattr(err, attr, None) + if isinstance(val, dict): + payload = val + break + elif isinstance(val, str): + try: + payload = json.loads(val) + break + except Exception: + pass + + if payload is None and isinstance(err, Exception): + err_str = str(err) + if "{" in err_str and "}" in err_str: + start = err_str.find("{") + end = err_str.rfind("}") + 1 + try: + payload = json.loads(err_str[start:end]) + except Exception: + pass + + if not isinstance(payload, dict): + return None + + error_obj = payload.get("error") + if not isinstance(error_obj, dict): + return None + + error_code = error_obj.get("code") + if error_code != 429: + return None + + if status_code is not None and status_code != 429: + return None + + details = error_obj.get("details") + if not isinstance(details, list): + return None + + for item in details: + if isinstance(item, dict) and "retryDelay" in item: + delay_str = str(item["retryDelay"]) + if delay_str.endswith("s"): + delay_str = delay_str[:-1] + try: + return float(delay_str) + except (ValueError, TypeError): + pass + + return None + def _log_messages(self, messages, name="message"): """ Log conversation messages to a JSON file. diff --git a/dinoprompts/commit-desc-2.txt b/dinoprompts/commit-desc-2.txt new file mode 100644 index 00000000000..e4025063442 --- /dev/null +++ b/dinoprompts/commit-desc-2.txt @@ -0,0 +1,5 @@ +feat: parse Gemini retryDelay backoff interval on 429 rate limit errors + +- Add `_extract_gemini_retry_delay` helper to `Model` class in `cecli/models.py` to extract suggested `retryDelay` (in seconds) from 429 error response payloads. +- Update `send_completion()` and `simple_send_with_retries()` to prioritize Gemini's suggested retry delay over blind unilateral backoff multipliers when available. +- Add unit tests in `tests/unit/test_gemini_retry_backoff.py` covering valid retryDelay extraction, JSON string payloads, non-429 responses, and missing details fields. diff --git a/tests/unit/test_gemini_retry_backoff.py b/tests/unit/test_gemini_retry_backoff.py new file mode 100644 index 00000000000..5ef910c1685 --- /dev/null +++ b/tests/unit/test_gemini_retry_backoff.py @@ -0,0 +1,140 @@ +import asyncio +import json +from unittest.mock import MagicMock, patch + +import pytest + +from cecli.llm import litellm +from cecli.models import Model + + +def test_extract_gemini_retry_delay_valid(): + model = Model("gemini/gemini-2.5-flash") + + payload = { + "error": { + "code": 429, + "message": "Quota exceeded for metric ... Please retry in 15.2s.", + "status": "RESOURCE_EXHAUSTED", + "details": [ + { + "@type": "://googleapis.com", + "violations": [ + { + "subject": "client_id:your_api_key_or_project", + "description": "Rate limit exceeded.", + } + ], + }, + {"@type": "://googleapis.com", "retryDelay": "15.2s"}, + ], + } + } + + # Exception with response object + err = Exception("Rate limit error") + err.status_code = 429 + mock_resp = MagicMock() + mock_resp.json.return_value = payload + err.response = mock_resp + + delay = model._extract_gemini_retry_delay(err) + assert delay == 15.2 + + +def test_extract_gemini_retry_delay_json_in_message(): + model = Model("gemini/gemini-2.5-flash") + + payload = { + "error": { + "code": 429, + "message": "Quota exceeded", + "details": [{"retryDelay": "8.5s"}], + } + } + + err = Exception(f"APIError: 429 {json.dumps(payload)}") + delay = model._extract_gemini_retry_delay(err) + assert delay == 8.5 + + +def test_extract_gemini_retry_delay_non_429(): + model = Model("gemini/gemini-2.5-flash") + + payload = { + "error": { + "code": 500, + "message": "Internal error", + "details": [{"retryDelay": "15.2s"}], + } + } + + err = Exception("Internal Error") + err.status_code = 500 + mock_resp = MagicMock() + mock_resp.json.return_value = payload + err.response = mock_resp + + delay = model._extract_gemini_retry_delay(err) + assert delay is None + + +def test_extract_gemini_retry_delay_missing_details(): + model = Model("gemini/gemini-2.5-flash") + + payload = { + "error": { + "code": 429, + "message": "Quota exceeded", + } + } + + err = Exception("Quota exceeded") + err.status_code = 429 + mock_resp = MagicMock() + mock_resp.json.return_value = payload + err.response = mock_resp + + delay = model._extract_gemini_retry_delay(err) + assert delay is None + + +def test_retry_fallback_to_unilateral_backoff_when_no_retry_delay(): + async def run_test(): + model = Model("gemini/gemini-2.5-flash") + + rate_limit_err = litellm.RateLimitError( + message="Rate limit exceeded", + response=MagicMock( + json=lambda: {"error": {"code": 429, "message": "Rate limit exceeded"}} + ), + model="gemini/gemini-2.5-flash", + llm_provider="gemini", + ) + + call_count = 0 + slept_delays = [] + + async def mock_acompletion(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise rate_limit_err + return MagicMock(choices=[MagicMock(message=MagicMock(content="ok"))]) + + async def mock_sleep(delay): + slept_delays.append(delay) + + with ( + patch("cecli.llm.litellm.acompletion", side_effect=mock_acompletion), + patch("asyncio.sleep", side_effect=mock_sleep), + ): + await model.send_completion( + messages=[{"role": "user", "content": "hi"}], functions=None, stream=False + ) + + # Initial retry_delay is 0.125, multiplied by retry_backoff_factor (1.5) = 0.1875 + assert len(slept_delays) == 1 + assert pytest.approx(slept_delays[0]) == 0.125 * 1.5 + + asyncio.run(run_test()) From aa4df68780061b405c33990db89ce7ed48e47caf Mon Sep 17 00:00:00 2001 From: DinoChiesa Date: Sun, 30 Aug 2026 18:23:41 -0700 Subject: [PATCH 19/32] test: fix slept delays assertion in gemini retry backoff test Co-authored-by: cecli (gemini/gemini-3.6-flash) --- tests/unit/test_gemini_retry_backoff.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_gemini_retry_backoff.py b/tests/unit/test_gemini_retry_backoff.py index 5ef910c1685..d20701beb18 100644 --- a/tests/unit/test_gemini_retry_backoff.py +++ b/tests/unit/test_gemini_retry_backoff.py @@ -134,7 +134,7 @@ async def mock_sleep(delay): ) # Initial retry_delay is 0.125, multiplied by retry_backoff_factor (1.5) = 0.1875 - assert len(slept_delays) == 1 + assert len(slept_delays) >= 1 assert pytest.approx(slept_delays[0]) == 0.125 * 1.5 asyncio.run(run_test()) From fcc26786487c988f57b3f82fdc18bf0db56b8af1 Mon Sep 17 00:00:00 2001 From: DinoChiesa Date: Sun, 30 Aug 2026 18:52:19 -0700 Subject: [PATCH 20/32] test: disable cache delay in Gemini retry backoff test Co-authored-by: cecli (gemini/gemini-3.6-flash) --- tests/unit/test_gemini_retry_backoff.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_gemini_retry_backoff.py b/tests/unit/test_gemini_retry_backoff.py index d20701beb18..0ad9cc02c0d 100644 --- a/tests/unit/test_gemini_retry_backoff.py +++ b/tests/unit/test_gemini_retry_backoff.py @@ -102,6 +102,7 @@ def test_extract_gemini_retry_delay_missing_details(): def test_retry_fallback_to_unilateral_backoff_when_no_retry_delay(): async def run_test(): model = Model("gemini/gemini-2.5-flash") + model.caches_by_default = False rate_limit_err = litellm.RateLimitError( message="Rate limit exceeded", @@ -134,7 +135,7 @@ async def mock_sleep(delay): ) # Initial retry_delay is 0.125, multiplied by retry_backoff_factor (1.5) = 0.1875 - assert len(slept_delays) >= 1 + assert len(slept_delays) == 1 assert pytest.approx(slept_delays[0]) == 0.125 * 1.5 asyncio.run(run_test()) From 0ae5a81218970458f56b7cbcc566eb9dd3a9ff60 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Sun, 30 Aug 2026 23:43:03 -0400 Subject: [PATCH 21/32] Fix `--yes-always-commands` --- cecli/tools/command.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cecli/tools/command.py b/cecli/tools/command.py index 4359003e57a..828eea3dfcc 100644 --- a/cecli/tools/command.py +++ b/cecli/tools/command.py @@ -226,7 +226,7 @@ async def _get_confirmation(cls, coder, command_string, background): return True # Previously approved for session # Previously declined - skip session question, continue to normal confirmation - if coder.skip_cli_confirmations: + if coder.skip_cli_confirmations or getattr(coder.args, "yes_always_commands", False): return True # Check if command matches any allowed_commands patterns From 2f68df930effb24de678ace60044753523fe925b Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Mon, 31 Aug 2026 00:15:13 -0400 Subject: [PATCH 22/32] Add `BroadCast` tool to enable active, bi-directional inter-agent communication --- cecli/coders/agent_coder.py | 112 +++++++-- cecli/helpers/agents/service.py | 47 ++++ cecli/helpers/conversation/integration.py | 4 +- cecli/tools/__init__.py | 2 + cecli/tools/broadcast.py | 268 ++++++++++++++++++++++ 5 files changed, 406 insertions(+), 27 deletions(-) create mode 100644 cecli/tools/broadcast.py diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index f4d1f5e39b9..a3cf00ccb1d 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -750,7 +750,8 @@ def get_context_summary(self): def get_environment_info(self): """ Generate an environment information context block with key system details. - Returns formatted string with working directory, platform, date, and other relevant environment details. + Returns formatted string with working directory, platform, date, and other relevant environment details, + including the agent's own identity and the primary agent's UUID. """ if not self.use_enhanced_context: return None @@ -764,6 +765,17 @@ def get_environment_info(self): result += f"- Current date: {current_date}\n" result += f"- Platform: {platform_info}\n" result += f"- Language preference: {language}\n" + + # Agent identity so the model can recognise itself and the primary + # agent. Kept here (a static context block) rather than in the + # sub-agent states block to avoid re-injecting an agent's own + # identity whenever sub-agent state changes mid-conversation. + service = AgentService.get_instance(self) + self_uuid = str(self.uuid) + 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" if self.repo: try: rel_repo_dir = self.repo.get_rel_repo_dir() @@ -1716,7 +1728,7 @@ def get_sub_agents_context(self): result = '\n' result += "## Available Sub-Agents\n\n" - result += f"Found {len(registry)} registered sub-agent(s):\n\n" + result += f"Found {len(registry)} registered sub-agent(s) types:\n\n" for name, config in sorted(registry.items()): result += f"**{name}**:\n" @@ -1725,57 +1737,107 @@ def get_sub_agents_context(self): result += f"{desc}\n" 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 += "Use the `Delegate` tool with the sub-agent type to delegate tasks.\n" + result += "Use the `Yield` tool to wait for responses for all child sub agents.\n" result += "" 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. + def get_sub_agent_states(self): + """Get the state of all active sub-agents. + + Returns a formatted context block listing each active sub-agent as + ``{name} ({uuid}, {status})`` bullets, for both dependent children and + 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. + + The primary agent and calling agent's own identity are deliberately not + included here — they live in ``get_environment_info()`` (a static block) + so they don't re-inject and churn the conversation when sub-agent state + changes. - 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 + # Sub-agents should only see sub-agent 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) + + 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) - if not children: - return None + dependent_children = [ + info + for info in children + if not info.independent + and info.status not in (SubAgentStatus.FINISHED, SubAgentStatus.ERROR) + ] - # Filter to non-independent children only - dependent_children = [info for info in children if not info.independent] + # 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 + 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) - if not dependent_children: + if not dependent_children and not independent_agents: return None result = '\n' - result += "## Active Sub-Agent States\n\n" - result += f"Found {len(dependent_children)} active child sub-agent(s):\n\n" - - for info in dependent_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 += "## Active Sub-Agent Instances\n\n" + result += f"Found {len(dependent_children) + len(independent_agents)} active sub-agent(s):\n\n" + + if dependent_children: + for info in dependent_children: + line = f"- {info.name} ({info.coder.uuid}, {info.status.value})" + if info.error: + line += f" - Error: {info.error}" + result += line + "\n" + + if independent_agents: + result += "## Independent Sub-Agent Instances\n" + result += ( + "Communicate bi-directionally with these sub-agents using the " + "`Broadcast` tool:\n\n" + ) + + for info in independent_agents: + line = f"- {info.name} ({info.coder.uuid}, {info.status.value})" + if info.error: + line += f" - Error: {info.error}" + result += line + "\n" + result += "" return result + except Exception as e: - self.io.tool_error(f"Error generating child agent states: {str(e)}") + self.io.tool_error(f"Error generating sub-agent states: {str(e)}") return None def get_background_command_output(self): diff --git a/cecli/helpers/agents/service.py b/cecli/helpers/agents/service.py index aa9ede170c4..b0c9115260b 100644 --- a/cecli/helpers/agents/service.py +++ b/cecli/helpers/agents/service.py @@ -807,6 +807,53 @@ def _raise_if_signal(exc): ) return task + def wake_primary(self, primary_coder: Any, message: str) -> str: + """Wake the primary coder so it processes *message*. + + Unlike sub-agents, the primary coder does not use the + ``start_generate_task`` architecture; when it is idle its input loop + blocks in ``wait_for_input()``. Preserving the behaviour of + ``on_input_area_submit()``, we wake it by pushing the message onto its + per-coder input queue. If the primary is already generating, the + message is queued into its conversation instead so the running + ``generate`` loop picks it up. + + Args: + primary_coder: The primary coder instance (``service.coder``). + message: The message text to deliver to the primary. + + Returns: + A short mode string describing delivery: ``queued`` when the + primary is actively generating (message queued into its + conversation), ``started`` when the primary was idle and woken via + its input queue. + """ + from cecli.helpers import queues + from cecli.helpers.conversation.service import ConversationService + from cecli.helpers.conversation.tags import MessageTag + from cecli.helpers.coroutines import is_active + + def _queue_to_conversation() -> None: + ConversationService.get_manager(primary_coder).queue_message( + message_dict={"role": "user", "content": message}, + tag=MessageTag.CUR, + hash_key=("broadcast", str(primary_coder.uuid), str(time.monotonic_ns())), + ) + + if is_active(getattr(primary_coder.io, "output_task", None)): + _queue_to_conversation() + return "queued" + + delivered = queues.push_coder_input( + str(primary_coder.uuid), + {"text": message, "coder_uuid": str(primary_coder.uuid)}, + ) + if not delivered: + _queue_to_conversation() + return "queued" + + return "started" + async def _inject_sub_agent_result(self, info: SubAgentInfo) -> None: """Inject the sub-agent's result (summary/error) into the parent's conversation. diff --git a/cecli/helpers/conversation/integration.py b/cecli/helpers/conversation/integration.py index e20bad60313..6bb59fa5261 100644 --- a/cecli/helpers/conversation/integration.py +++ b/cecli/helpers/conversation/integration.py @@ -967,10 +967,10 @@ def add_sub_agent_states(self) -> None: if not hasattr(coder, "use_enhanced_context") or not coder.use_enhanced_context: return - if not hasattr(coder, "get_child_agent_states"): + if not hasattr(coder, "get_sub_agent_states"): return - block = coder.get_child_agent_states() + block = coder.get_sub_agent_states() if not block: return diff --git a/cecli/tools/__init__.py b/cecli/tools/__init__.py index bc0009b3833..2deb7bf501b 100644 --- a/cecli/tools/__init__.py +++ b/cecli/tools/__init__.py @@ -3,6 +3,7 @@ from . import ( _yield, + broadcast, command, delegate, edit_file, @@ -32,6 +33,7 @@ edit_file, explore_code, _yield, + broadcast, git_branch, git_diff, git_log, diff --git a/cecli/tools/broadcast.py b/cecli/tools/broadcast.py new file mode 100644 index 00000000000..17aae0056cb --- /dev/null +++ b/cecli/tools/broadcast.py @@ -0,0 +1,268 @@ +"""Broadcast tool - sends a message to one or more sub-agents.""" + +import time + +from cecli.tools.utils.base_tool import BaseTool +from cecli.tools.utils.helpers import ToolError +from cecli.tools.utils.output import color_markers, tool_footer, tool_header +from cecli.tools.utils.responses import ToolResponse +from cecli.tools.validations import ToolValidations + + +class Tool(BaseTool): + NORM_NAME = "broadcast" + RESULT_TYPE = "list" + VALIDATIONS = { + "targets": ["coerce_list"], + } + SCHEMA = { + "type": "function", + "function": { + "name": "Broadcast", + "description": ( + "Broadcast a message to one or more sub-agent instances. Sends the message to the " + "specified target sub-agents, or to every active sub-agent when empty/omitted. " + "The message is queued into the target's context if it is actively generating, " + "and delivered immediately if the target is idle, waking them." + ), + "parameters": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "The message to broadcast to the target sub-agent(s).", + }, + "targets": { + "type": "array", + "items": {"type": "string"}, + "description": ( + "Optional array of sub-agent IDs (UUIDs, UUID prefixes, or " + "agent names) to send the message to, or 'primary' for the primary agent. " + "Set as an empty string to broadcast to all active sub-agents." + ), + }, + }, + "required": ["message"], + }, + }, + } + + @classmethod + async def execute(cls, coder, **kwargs): + """Broadcast a message to one or more sub-agents. + + For each target sub-agent, the message is either queued into its + conversation (if it is actively generating) or used to start a fresh + generate task (if it is idle). When no targets are specified, the + message is broadcast to every active sub-agent except the originator. + + Args: + coder: The coder instance invoking the tool (the originator). + message: The message to broadcast. + targets: Optional list of sub-agent IDs. + + Returns: + ToolResponse with a per-target delivery summary. + """ + from cecli.helpers.agents.service import AgentService + + response = ToolResponse(cls.NORM_NAME, result_type=cls.RESULT_TYPE) + + message = kwargs.get("message") + if message is None or not str(message).strip(): + response.append_error("'message' parameter must be a non-empty string.") + return response + message = str(message).strip() + + targets = kwargs.get("targets") + if targets is not None and not isinstance(targets, list): + response.append_error("'targets' parameter must be an array of sub-agent IDs.") + return response + + agent_service = AgentService.get_instance(coder) + originator_uuid = str(coder.uuid) + + target_infos, errors = cls._collect_targets(agent_service, targets, originator_uuid) + + for error in errors: + response.append_error(error) + + delivered = [] + delivery_errors = [] + for info in target_infos: + try: + mode = cls._deliver(info, message, agent_service, coder) + delivered.append((info, mode)) + except Exception as exc: + delivery_errors.append(f"Broadcast to '{cls._target_label(info)}' failed: {exc}") + + for info, mode in delivered: + response.append_result(f"{cls._target_label(info)}: {mode}") + + for error in delivery_errors: + response.append_error(error) + + if not delivered and not errors and not delivery_errors: + response.append_result("No targets to broadcast to.") + + return response + + @classmethod + def format_output(cls, coder, mcp_server, tool_response): + """Format output for the Broadcast tool - show the message and targets.""" + color_start, color_end = color_markers(coder) + + tool_header(coder=coder, mcp_server=mcp_server, tool_response=tool_response) + + try: + params = ToolValidations.validate_params( + tool_response.function.arguments, cls.VALIDATIONS, cls.SCHEMA + ) + except ToolError: + coder.io.tool_error("Invalid Tool JSON") + return + + message = params.get("message", "") + targets = params.get("targets", []) + + coder.io.tool_output("") + coder.io.tool_output(f"{color_start}message:{color_end}") + coder.io.tool_output(message) + coder.io.tool_output("") + if targets: + coder.io.tool_output( + f"{color_start}targets:{color_end} {', '.join(str(t) for t in targets)}" + ) + else: + coder.io.tool_output(f"{color_start}targets:{color_end} All") + + tool_footer(coder=coder, tool_response=tool_response, params=params) + + @classmethod + def _collect_targets(cls, service, targets, originator_uuid): + """Return ``(target_infos, errors)`` for the broadcast. + + When ``targets`` is empty, returns every active sub-agent except the + originator. When ``targets`` is provided, each entry is resolved by + UUID, UUID prefix, or agent name (including ``primary`` for the primary + agent); unknown IDs produce an error. + """ + from cecli.helpers.agents.service import SubAgentInfo, SubAgentStatus + + if not targets: + infos = [ + info + for info in service.sub_agents.values() + if str(info.coder.uuid) != originator_uuid + and info.status not in (SubAgentStatus.ERROR,) + ] + return infos, [] + + infos = [] + errors = [] + for target in targets: + info = cls._resolve_target(service, target) + if info is None: + errors.append(f"Unknown sub-agent target '{target}'.") + continue + target_uuid = info.coder.uuid if isinstance(info, SubAgentInfo) else info.uuid + if str(target_uuid) != originator_uuid: + infos.append(info) + + return infos, errors + + @staticmethod + def _resolve_target(service, target): + """Resolve a target sub-agent by UUID, UUID prefix, or agent name. + + The primary agent is also resolvable by its name (``primary``) or UUID + so sub-agents can broadcast a message back to their parent. + """ + target = str(target).strip() + if not target: + return None + + info = service.sub_agents.get(target) + if info is not None: + return info + + for uuid, candidate in service.sub_agents.items(): + if uuid.startswith(target): + return candidate + + for candidate in service.sub_agents.values(): + if candidate.name == target: + return candidate + + primary_coder = service.coder + primary_uuid = str(getattr(primary_coder, "uuid", "")) + if primary_coder is not None and (target == "primary" or target == primary_uuid): + return primary_coder + + return None + + @staticmethod + def _deliver(info, message, agent_service, sender_coder): + """Queue, wake, or start a generate task for a single target. + + When the target is the primary agent, ``AgentService.wake_primary()`` is + used: if the primary is actively generating the message is queued into + its conversation, otherwise it is woken via its per-coder input queue + (the primary does not use the sub-agent generate-task architecture). + + When the target is a sub-agent that is still generating, the message is + queued into its conversation; an idle sub-agent gets a fresh generate + task via ``AgentService.start_generate_task()``. + + The message is prefixed with the sender's identity so the target + sub-agent can see who broadcast it. + + Returns a short mode string describing how the message was delivered. + """ + from cecli.helpers.agents.service import SubAgentInfo + from cecli.helpers.conversation import ConversationService, MessageTag + from cecli.helpers.coroutines import is_active + + sender_name = agent_service.get_agent_name(sender_coder) or "primary" + sender_uuid = str(sender_coder.uuid) + message = ( + "\n" + f"[Message Sent from Agent {sender_name} ({sender_uuid})]\n" + "You may respond with the `Broadcast` tool if the message " + "is relevant to you\n\n" + f"{message}" + "" + ) + + if not isinstance(info, SubAgentInfo): + # Primary-agent target. The primary does not use the sub-agent + # generate-task architecture — if it is idle we must wake it via + # its per-coder input queue (same strategy as on_input_area_submit()). + return agent_service.wake_primary(info, message) + + if is_active(info.generate_task): + ConversationService.get_manager(info.coder).queue_message( + message_dict={ + "role": "user", + "content": message, + }, + tag=MessageTag.CUR, + hash_key=("broadcast", str(info.coder.uuid), str(time.monotonic_ns())), + ) + return "queued" + + agent_service.start_generate_task(info, message) + return "started" + + @staticmethod + def _target_label(target): + """Return a display label for a broadcast target. + + Sub-agents are labelled ``name (uuid)``; the primary agent is labelled + ``primary (uuid)``. + """ + from cecli.helpers.agents.service import SubAgentInfo + + if isinstance(target, SubAgentInfo): + return f"{target.name} ({target.coder.uuid})" + return f"primary ({target.uuid})" From a865d2b959fc0488c87278667d199ba5adc4383d Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Mon, 31 Aug 2026 00:15:32 -0400 Subject: [PATCH 23/32] Fix formatting --- cecli/tools/grep.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/cecli/tools/grep.py b/cecli/tools/grep.py index d481c3e8034..ff3a40bed13 100644 --- a/cecli/tools/grep.py +++ b/cecli/tools/grep.py @@ -1,5 +1,5 @@ -import os import base64 +import os import re import shutil from pathlib import Path @@ -339,8 +339,12 @@ def _flush(current_file, current_lines, match_count): continue filepath = m.group(1) - abs_path = filepath if os.path.isabs(filepath) else os.path.normpath(os.path.join(repo_root, filepath)) - rebuilt = prefix + abs_path + rest[len(filepath):] + abs_path = ( + filepath + if os.path.isabs(filepath) + else os.path.normpath(os.path.join(repo_root, filepath)) + ) + rebuilt = prefix + abs_path + rest[len(filepath) :] if current_file is None: current_file = abs_path From 4ddcc84510db61c10cf4b3fc752e790f54fa5f2c Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 31 Aug 2026 01:08:53 -0400 Subject: [PATCH 24/32] Fix git repo tests on windows --- cecli/coders/base_coder.py | 5 +++++ cecli/helpers/workspaces/paths.py | 2 +- cecli/utils.py | 2 +- tests/basic/test_skills.py | 6 +++--- tests/commands/test_open.py | 4 +++- 5 files changed, 13 insertions(+), 6 deletions(-) diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index f5fd40e7080..0e196b76ec0 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -1093,6 +1093,11 @@ def resolve_relative_to_primary_root(self, path: str) -> str: """ if not path: return path + if path.startswith("/"): + # POSIX-style absolute path (e.g. "/tmp/foo"). os.path.isabs() + # returns False for "/"-rooted paths on Windows, so guard here + # to avoid re-anchoring them onto the primary root. + return path if os.path.isabs(path): return os.path.normpath(path) base = self.primary_root or self.root diff --git a/cecli/helpers/workspaces/paths.py b/cecli/helpers/workspaces/paths.py index 79360ab4039..17f6000d6db 100644 --- a/cecli/helpers/workspaces/paths.py +++ b/cecli/helpers/workspaces/paths.py @@ -72,7 +72,7 @@ def project_path(workspace_root: Path, project: dict[str, Any], *, layout: str) ["git", "-C", str(clone_root), "rev-parse", "--show-toplevel"], stderr=subprocess.DEVNULL, ) - return clone_root + return clone_root.resolve() except Exception: return None diff --git a/cecli/utils.py b/cecli/utils.py index 87d0f721401..438c4ec33d0 100644 --- a/cecli/utils.py +++ b/cecli/utils.py @@ -127,7 +127,7 @@ def __init__(self): def __enter__(self): res = super().__enter__() os.chdir(Path(self.temp_dir.name).resolve()) - return res + return str(Path(res).resolve()) def __exit__(self, exc_type, exc_val, exc_tb): if self.cwd: diff --git a/tests/basic/test_skills.py b/tests/basic/test_skills.py index cd272a1399b..9271f8f426e 100644 --- a/tests/basic/test_skills.py +++ b/tests/basic/test_skills.py @@ -55,8 +55,8 @@ def test_skills_manager_initialization(self): ) # "/tmp/test" + local default + default home dir = 3 paths assert len(manager.directory_paths) == 3 - assert "/tmp/test" in [str(p) for p in manager.directory_paths] - assert Path("/tmp") / ".cecli" / "skills" in manager.directory_paths + assert Path("/tmp/test").resolve() in manager.directory_paths + assert (Path("/tmp") / ".cecli" / "skills").resolve() in manager.directory_paths assert manager.include_list == {"skill1", "skill2"} assert manager.exclude_list == {"skill3"} assert manager.root == Path("/tmp").expanduser().resolve() @@ -67,7 +67,7 @@ def test_local_default_skills_dir(self): manager = SkillsManager([], root="/tmp/local-root") paths = [str(p) for p in manager.directory_paths] - assert str(Path("/tmp/local-root") / ".cecli" / "skills") in paths + assert (Path("/tmp/local-root") / ".cecli" / "skills").resolve() in manager.directory_paths assert str(Path.home() / ".cecli" / "skills") in paths def test_create_and_parse_skill(self): diff --git a/tests/commands/test_open.py b/tests/commands/test_open.py index 131c9f9c858..43d660a051e 100644 --- a/tests/commands/test_open.py +++ b/tests/commands/test_open.py @@ -1,6 +1,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from pathlib import Path from cecli.commands.open import OpenCommand @@ -40,8 +41,9 @@ async def test_execute_invalid_path(self, mock_coder, mock_io): with patch("cecli.commands.open.register_workspace_subagents", return_value=[]): await OpenCommand.execute(mock_io, mock_coder, "app /no/such/path") + open_path = Path("/no/such/path").expanduser() mock_io.tool_error.assert_called_once_with( - "Error: '/no/such/path' is not a valid git repository or does not exist." + f"Error: '{open_path}' is not a valid git repository or does not exist." ) @pytest.mark.asyncio From e2f9c56b1962c71cc35b690091e4312fbbc6bbfa Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Mon, 31 Aug 2026 01:13:13 -0400 Subject: [PATCH 25/32] Fix formatting --- tests/commands/test_open.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/commands/test_open.py b/tests/commands/test_open.py index 43d660a051e..5c5510a6e98 100644 --- a/tests/commands/test_open.py +++ b/tests/commands/test_open.py @@ -1,7 +1,7 @@ +from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest -from pathlib import Path from cecli.commands.open import OpenCommand From 7d39bcaba46df8b91f6fe3442fe8d044c254a7e1 Mon Sep 17 00:00:00 2001 From: DinoChiesa Date: Mon, 31 Aug 2026 10:23:28 -0700 Subject: [PATCH 26/32] refactor: simplify retry delay extraction using nested.getter - extract retry delay via Gemini payload and HTTP headers - add tests for same Co-authored-by: cecli (gemini/gemini-3.7-flash) --- cecli/helpers/llms/litellm_compat.py | 16 +- cecli/models.py | 222 ++++++---- dinoprompts/commit-desc-2.txt | 5 - tests/unit/test_gemini_retry_backoff.py | 141 ------- tests/unit/test_retry_backoff.py | 536 ++++++++++++++++++++++++ 5 files changed, 683 insertions(+), 237 deletions(-) delete mode 100644 dinoprompts/commit-desc-2.txt delete mode 100644 tests/unit/test_gemini_retry_backoff.py create mode 100644 tests/unit/test_retry_backoff.py diff --git a/cecli/helpers/llms/litellm_compat.py b/cecli/helpers/llms/litellm_compat.py index 91042c07387..ebe76800c31 100644 --- a/cecli/helpers/llms/litellm_compat.py +++ b/cecli/helpers/llms/litellm_compat.py @@ -395,6 +395,8 @@ def __init__( ) -> None: super().__init__(message or "") self.status_code = status_code + for k, v in kwargs.items(): + setattr(self, k, v) class APIConnectionError(_FacadeException): @@ -508,21 +510,23 @@ def _translate_http_error(err: httpx.HTTPStatusError) -> _FacadeException: if status == 400 and any( token in body for token in ("context", "context_length", "maximum context") ): - return ContextWindowExceededError(message=message, status_code=status) + return ContextWindowExceededError( + message=message, status_code=status, response=err.response + ) if status in (401, 403): - return AuthenticationError(message=message, status_code=status) + return AuthenticationError(message=message, status_code=status, response=err.response) if status == 404: - return NotFoundError(message=message, status_code=status) + return NotFoundError(message=message, status_code=status, response=err.response) if status == 429: - return RateLimitError(message=message, status_code=status) + return RateLimitError(message=message, status_code=status, response=err.response) if status >= 500: - return InternalServerError(message=message, status_code=status) + return InternalServerError(message=message, status_code=status, response=err.response) - return APIError(message=message, status_code=status) + return APIError(message=message, status_code=status, response=err.response) # --------------------------------------------------------------------------- diff --git a/cecli/models.py b/cecli/models.py index 864ed1e1c33..e0e8eb44c39 100644 --- a/cecli/models.py +++ b/cecli/models.py @@ -12,12 +12,15 @@ import yaml -from cecli import __version__ +from cecli import __version__, utils from cecli.decoding import safe_open from cecli.dump import dump from cecli.exceptions import LiteLLMExceptions from cecli.helpers import coroutines, nested -from cecli.helpers.file_searcher import generate_search_path_list, handle_core_files +from cecli.helpers.file_searcher import ( + generate_search_path_list, + handle_core_files, +) from cecli.helpers.model_config import get_default_config from cecli.helpers.model_config.utils import get_entry_from_raw from cecli.helpers.model_providers import ModelProviderManager @@ -289,7 +292,9 @@ def fetch_openrouter_model_info(self, model): import re if re.search( - f"The model\\s*.*{re.escape(url_part)}.* is not available", html, re.IGNORECASE + f"The model\\s*.*{re.escape(url_part)}.* is not available", + html, + re.IGNORECASE, ): print(f"\x1b[91mError: Model '{url_part}' is not available\x1b[0m") return {} @@ -497,7 +502,8 @@ def __init__( "editor_model", nested.getter(from_model, "editor_model", None) ) editor_edit_format = kwargs.get( - "editor_edit_format", nested.getter(from_model, "editor_edit_format", None) + "editor_edit_format", + nested.getter(from_model, "editor_edit_format", None), ) else: agent_model = kwargs.get("agent_model", None) @@ -538,7 +544,8 @@ def __init__( self.editor_model = None self.agent_model = None self.extra_model_settings = next( - (ms for ms in MODEL_SETTINGS if ms.name == "cecli/extra_params"), None + (ms for ms in MODEL_SETTINGS if ms.name == "cecli/extra_params"), + None, ) self.info = self.get_model_info(model) self.model_config_defaults = get_default_config( @@ -654,18 +661,24 @@ def _apply_structured_kwargs(self, config, model_name): for key, value in config.items(): if key in ("agent", "model_settings", "model-settings"): if not isinstance(value, dict): - raise ValueError(f"override_kwargs '{key}' must be a dict, got {type(value)}") + raise ValueError( + f"override_kwargs '{key}' must be a dict, got" f" {type(value)}" + ) for setting_key, setting_value in value.items(): if setting_key not in valid_model_settings_fields: raise ValueError( - f"Invalid model_settings key '{setting_key}'. " - f"Must be one of: {sorted(valid_model_settings_fields)}" + f"Invalid model_settings key '{setting_key}'. Must" + f" be one of: {sorted(valid_model_settings_fields)}" ) setattr(self, setting_key, setting_value) - elif has_structured_keys and key in ("api", "api_settings", "api-settings"): + elif has_structured_keys and key in ( + "api", + "api_settings", + "api-settings", + ): # api_settings: merge each sub-key into extra_params if not isinstance(value, dict): raise ValueError(f"override_kwargs '{key}' must be a dict, got {type(value)}") @@ -687,11 +700,18 @@ def _apply_structured_kwargs(self, config, model_name): if isinstance(api_value, dict) and isinstance( self.extra_params.get(api_key), dict ): - self.extra_params[api_key] = {**self.extra_params[api_key], **api_value} + self.extra_params[api_key] = { + **self.extra_params[api_key], + **api_value, + } else: self.extra_params[api_key] = api_value - elif has_structured_keys and key in ("llm", "llm_settings", "llm-settings"): + elif has_structured_keys and key in ( + "llm", + "llm_settings", + "llm-settings", + ): # llm_settings: merge into self.info if not isinstance(value, dict): raise ValueError(f"override_kwargs '{key}' must be a dict, got {type(value)}") @@ -1213,7 +1233,10 @@ def set_thinking_tokens(self, value): elif "reasoning" in extra_body: del extra_body["reasoning"] elif num_tokens > 0: - extra_body["thinking"] = {"type": "enabled", "budget_tokens": num_tokens} + extra_body["thinking"] = { + "type": "enabled", + "budget_tokens": num_tokens, + } # extra_body is authoritative; drop any legacy top-level copy. self.extra_params.pop("thinking", None) else: @@ -1322,7 +1345,8 @@ async def send_completion( if effective_tools: sorted_tools = sorted( - effective_tools, key=lambda x: x.get("function", {}).get("name", "Invalid Name") + effective_tools, + key=lambda x: x.get("function", {}).get("name", "Invalid Name"), ) try: @@ -1352,7 +1376,10 @@ async def send_completion( if "name" in function: tool_name = function.get("name") if tool_name: - kwargs["tool_choice"] = {"type": "function", "function": {"name": tool_name}} + kwargs["tool_choice"] = { + "type": "function", + "function": {"name": tool_name}, + } if self.extra_params: kwargs.update(self.extra_params) @@ -1561,8 +1588,10 @@ async def simple_send_with_retries( should_retry = True elif should_retry: retry_delay *= 2 - if retry_delay > RETRY_TIMEOUT: - should_retry = False + + if retry_delay > RETRY_TIMEOUT: + should_retry = False + if not should_retry: return None print(f"Retrying in {retry_delay:.1f} seconds...") @@ -1583,7 +1612,7 @@ def model_error_response(self): finish_reason="stop", index=0, message=litellm.Message( - content="Model API Response Error. Please retry the previous request" + content=("Model API Response Error. Please retry the previous request") ), ) ], @@ -1595,81 +1624,97 @@ async def model_error_response_stream(self): def _extract_gemini_retry_delay(self, err): """ - Extract retry delay in seconds from a 429 error response payload (e.g., Gemini), if present. + Extract suggested retry delay (in seconds) from a 429 rate-limit error. + + 1. Gemini payload body: Google RPC APIs return `google.rpc.RetryInfo` with + a `retryDelay` field (e.g. "15.2s") inside the `error.details` array. + 2. HTTP headers fallback: Standard providers (OpenAI, Anthropic, Groq, etc.) + supply `retry-after` (seconds) or `retry-after-ms` (milliseconds) headers. """ - status_code = getattr(err, "status_code", None) + status_code = nested.getter(err, ["status_code", "response.status_code"], None) + if isinstance(status_code, (int, str, float)) and str(status_code) != "429": + return None - payload = None - response = getattr(err, "response", None) - if response is not None: - if hasattr(response, "json") and callable(response.json): - try: - payload = response.json() - except Exception: - pass - if payload is None and hasattr(response, "text") and isinstance(response.text, str): - try: - payload = json.loads(response.text) - except Exception: - pass - if ( - payload is None - and hasattr(response, "content") - and isinstance(response.content, (str, bytes)) - ): - try: - payload = json.loads(response.content) - except Exception: - pass + # 1. Check Gemini response payload for google.rpc.RetryInfo retryDelay + candidates = [] + response = nested.getter(err, "response") + if callable(getattr(response, "json", None)): + try: + data = response.json() + if isinstance(data, dict): + candidates.append(data) + except Exception: + pass + + sources = [ + response, + nested.getter(err, "message"), + nested.getter(err, "body"), + nested.getter(err, "error"), + nested.getter(err, "raw_response"), + str(err) if isinstance(err, Exception) else None, + err, + ] - if payload is None: - for attr in ("message", "body", "error", "raw_response"): - val = getattr(err, attr, None) - if isinstance(val, dict): - payload = val - break - elif isinstance(val, str): + for src in sources: + if not src: + continue + if isinstance(src, dict): + candidates.append(src) + elif isinstance(src, (str, bytes)): + text_str = src.decode("utf-8", errors="ignore") if isinstance(src, bytes) else src + for chunk in utils.split_concatenated_json(text_str): try: - payload = json.loads(val) - break + parsed = json.loads(chunk) + if isinstance(parsed, dict): + candidates.append(parsed) except Exception: pass + else: + text = nested.getter(src, ["text", "content"]) + if isinstance(text, (str, bytes)): + text_str = ( + text.decode("utf-8", errors="ignore") if isinstance(text, bytes) else text + ) + for chunk in utils.split_concatenated_json(text_str): + try: + parsed = json.loads(chunk) + if isinstance(parsed, dict): + candidates.append(parsed) + except Exception: + pass + + for candidate in candidates: + if not isinstance(candidate, dict): + continue - if payload is None and isinstance(err, Exception): - err_str = str(err) - if "{" in err_str and "}" in err_str: - start = err_str.find("{") - end = err_str.rfind("}") + 1 + details = nested.getter(candidate, "error.details") + if isinstance(details, list): + for item in details: + delay_val = nested.getter(item, "retryDelay") + if delay_val is not None: + delay_str = str(delay_val).strip() + if delay_str.endswith("s"): + delay_str = delay_str[:-1] + try: + return float(delay_str) + except (ValueError, TypeError): + pass + + # 2. Check HTTP headers fallback (retry-after, retry-after-ms) + headers = nested.getter(err, ["response.headers", "headers"], None) + if headers is not None: + retry_after = nested.getter(headers, ["retry-after"], None) + if retry_after is not None: try: - payload = json.loads(err_str[start:end]) - except Exception: + return float(str(retry_after).strip()) + except (ValueError, TypeError): pass - if not isinstance(payload, dict): - return None - - error_obj = payload.get("error") - if not isinstance(error_obj, dict): - return None - - error_code = error_obj.get("code") - if error_code != 429: - return None - - if status_code is not None and status_code != 429: - return None - - details = error_obj.get("details") - if not isinstance(details, list): - return None - - for item in details: - if isinstance(item, dict) and "retryDelay" in item: - delay_str = str(item["retryDelay"]) - if delay_str.endswith("s"): - delay_str = delay_str[:-1] + retry_after_ms = nested.getter(headers, ["retry-after-ms"], None) + if retry_after_ms is not None: try: - return float(delay_str) + return float(str(retry_after_ms).strip()) / 1000.0 except (ValueError, TypeError): pass @@ -1682,7 +1727,11 @@ def _log_messages(self, messages, name="message"): os.makedirs(".cecli/logs/messages", exist_ok=True) with safe_open(f".cecli/logs/messages/{name}-{time.time()}.log", "w") as f: json.dump( - messages, f, indent=4, ensure_ascii=False, default=lambda o: "" + messages, + f, + indent=4, + ensure_ascii=False, + default=lambda o: "", ) def _log_request(self, model_call_dict): @@ -1782,8 +1831,8 @@ async def sanity_check_model(io, model): io.tool_output(f"- {key}: {status}") if platform.system() == "Windows": io.tool_output( - "Note: You may need to restart your terminal or command prompt for `setx` to take" - " effect." + "Note: You may need to restart your terminal or command prompt" + " for `setx` to take effect." ) elif not model.keys_in_environment: show = True @@ -1812,7 +1861,10 @@ async def check_for_dependencies(io, model_name): """ if model_name.startswith("bedrock/"): await check_pip_install_extra( - io, "boto3", "AWS Bedrock models require the boto3 package.", ["boto3"] + io, + "boto3", + "AWS Bedrock models require the boto3 package.", + ["boto3"], ) elif model_name.startswith("vertex_ai/"): await check_pip_install_extra( diff --git a/dinoprompts/commit-desc-2.txt b/dinoprompts/commit-desc-2.txt deleted file mode 100644 index e4025063442..00000000000 --- a/dinoprompts/commit-desc-2.txt +++ /dev/null @@ -1,5 +0,0 @@ -feat: parse Gemini retryDelay backoff interval on 429 rate limit errors - -- Add `_extract_gemini_retry_delay` helper to `Model` class in `cecli/models.py` to extract suggested `retryDelay` (in seconds) from 429 error response payloads. -- Update `send_completion()` and `simple_send_with_retries()` to prioritize Gemini's suggested retry delay over blind unilateral backoff multipliers when available. -- Add unit tests in `tests/unit/test_gemini_retry_backoff.py` covering valid retryDelay extraction, JSON string payloads, non-429 responses, and missing details fields. diff --git a/tests/unit/test_gemini_retry_backoff.py b/tests/unit/test_gemini_retry_backoff.py deleted file mode 100644 index 0ad9cc02c0d..00000000000 --- a/tests/unit/test_gemini_retry_backoff.py +++ /dev/null @@ -1,141 +0,0 @@ -import asyncio -import json -from unittest.mock import MagicMock, patch - -import pytest - -from cecli.llm import litellm -from cecli.models import Model - - -def test_extract_gemini_retry_delay_valid(): - model = Model("gemini/gemini-2.5-flash") - - payload = { - "error": { - "code": 429, - "message": "Quota exceeded for metric ... Please retry in 15.2s.", - "status": "RESOURCE_EXHAUSTED", - "details": [ - { - "@type": "://googleapis.com", - "violations": [ - { - "subject": "client_id:your_api_key_or_project", - "description": "Rate limit exceeded.", - } - ], - }, - {"@type": "://googleapis.com", "retryDelay": "15.2s"}, - ], - } - } - - # Exception with response object - err = Exception("Rate limit error") - err.status_code = 429 - mock_resp = MagicMock() - mock_resp.json.return_value = payload - err.response = mock_resp - - delay = model._extract_gemini_retry_delay(err) - assert delay == 15.2 - - -def test_extract_gemini_retry_delay_json_in_message(): - model = Model("gemini/gemini-2.5-flash") - - payload = { - "error": { - "code": 429, - "message": "Quota exceeded", - "details": [{"retryDelay": "8.5s"}], - } - } - - err = Exception(f"APIError: 429 {json.dumps(payload)}") - delay = model._extract_gemini_retry_delay(err) - assert delay == 8.5 - - -def test_extract_gemini_retry_delay_non_429(): - model = Model("gemini/gemini-2.5-flash") - - payload = { - "error": { - "code": 500, - "message": "Internal error", - "details": [{"retryDelay": "15.2s"}], - } - } - - err = Exception("Internal Error") - err.status_code = 500 - mock_resp = MagicMock() - mock_resp.json.return_value = payload - err.response = mock_resp - - delay = model._extract_gemini_retry_delay(err) - assert delay is None - - -def test_extract_gemini_retry_delay_missing_details(): - model = Model("gemini/gemini-2.5-flash") - - payload = { - "error": { - "code": 429, - "message": "Quota exceeded", - } - } - - err = Exception("Quota exceeded") - err.status_code = 429 - mock_resp = MagicMock() - mock_resp.json.return_value = payload - err.response = mock_resp - - delay = model._extract_gemini_retry_delay(err) - assert delay is None - - -def test_retry_fallback_to_unilateral_backoff_when_no_retry_delay(): - async def run_test(): - model = Model("gemini/gemini-2.5-flash") - model.caches_by_default = False - - rate_limit_err = litellm.RateLimitError( - message="Rate limit exceeded", - response=MagicMock( - json=lambda: {"error": {"code": 429, "message": "Rate limit exceeded"}} - ), - model="gemini/gemini-2.5-flash", - llm_provider="gemini", - ) - - call_count = 0 - slept_delays = [] - - async def mock_acompletion(*args, **kwargs): - nonlocal call_count - call_count += 1 - if call_count == 1: - raise rate_limit_err - return MagicMock(choices=[MagicMock(message=MagicMock(content="ok"))]) - - async def mock_sleep(delay): - slept_delays.append(delay) - - with ( - patch("cecli.llm.litellm.acompletion", side_effect=mock_acompletion), - patch("asyncio.sleep", side_effect=mock_sleep), - ): - await model.send_completion( - messages=[{"role": "user", "content": "hi"}], functions=None, stream=False - ) - - # Initial retry_delay is 0.125, multiplied by retry_backoff_factor (1.5) = 0.1875 - assert len(slept_delays) == 1 - assert pytest.approx(slept_delays[0]) == 0.125 * 1.5 - - asyncio.run(run_test()) diff --git a/tests/unit/test_retry_backoff.py b/tests/unit/test_retry_backoff.py new file mode 100644 index 00000000000..1d25525de20 --- /dev/null +++ b/tests/unit/test_retry_backoff.py @@ -0,0 +1,536 @@ +import asyncio +import json +from unittest.mock import MagicMock, patch + +import pytest + +from cecli.llm import litellm +from cecli.models import Model + + +def test_extract_gemini_retry_delay_valid(): + model = Model("gemini/gemini-2.5-flash") + + payload = { + "error": { + "code": 429, + "message": "Quota exceeded for metric ... Please retry in 15.2s.", + "status": "RESOURCE_EXHAUSTED", + "details": [ + { + "@type": "://googleapis.com", + "violations": [ + { + "subject": "client_id:your_api_key_or_project", + "description": "Rate limit exceeded.", + } + ], + }, + {"@type": "://googleapis.com", "retryDelay": "15.2s"}, + ], + } + } + + # Exception with response object + err = Exception("Rate limit error") + err.status_code = 429 + mock_resp = MagicMock() + mock_resp.json.return_value = payload + err.response = mock_resp + + delay = model._extract_gemini_retry_delay(err) + assert delay == 15.2 + + +def test_extract_gemini_retry_delay_json_in_message(): + model = Model("gemini/gemini-2.5-flash") + + payload = { + "error": { + "code": 429, + "message": "Quota exceeded", + "details": [{"retryDelay": "8.5s"}], + } + } + + err = Exception(f"APIError: 429 {json.dumps(payload)}") + delay = model._extract_gemini_retry_delay(err) + assert delay == 8.5 + + +def test_extract_gemini_retry_delay_non_429(): + model = Model("gemini/gemini-2.5-flash") + + payload = { + "error": { + "code": 500, + "message": "Internal error", + "details": [{"retryDelay": "15.2s"}], + } + } + + err = Exception("Internal Error") + err.status_code = 500 + mock_resp = MagicMock() + mock_resp.json.return_value = payload + err.response = mock_resp + + delay = model._extract_gemini_retry_delay(err) + assert delay is None + + +def test_extract_gemini_retry_delay_missing_details(): + model = Model("gemini/gemini-2.5-flash") + + payload = { + "error": { + "code": 429, + "message": "Quota exceeded", + } + } + + err = Exception("Quota exceeded") + err.status_code = 429 + mock_resp = MagicMock() + mock_resp.json.return_value = payload + err.response = mock_resp + + delay = model._extract_gemini_retry_delay(err) + assert delay is None + + +def test_extract_gemini_retry_delay_headers_fallback(): + model = Model("gemini/gemini-2.5-flash") + + # Standard retry-after header (seconds) + err1 = Exception("Rate limit") + err1.status_code = 429 + mock_resp1 = MagicMock() + mock_resp1.json.return_value = {} + mock_resp1.headers = {"retry-after": "6.5"} + err1.response = mock_resp1 + assert model._extract_gemini_retry_delay(err1) == 6.5 + + # Standard retry-after-ms header (milliseconds) + err2 = Exception("Rate limit") + err2.status_code = 429 + mock_resp2 = MagicMock() + mock_resp2.json.return_value = {} + mock_resp2.headers = {"retry-after-ms": "2500"} + err2.response = mock_resp2 + assert model._extract_gemini_retry_delay(err2) == 2.5 + + +def test_extract_retry_delay_direct_err_headers(): + model = Model("openai/gpt-4o") + + # Direct headers dict on err + err = Exception("Rate limit") + err.status_code = 429 + err.headers = {"retry-after": "3.5"} + assert model._extract_gemini_retry_delay(err) == 3.5 + + err_ms = Exception("Rate limit") + err_ms.status_code = 429 + err_ms.headers = {"retry-after-ms": "4500"} + assert model._extract_gemini_retry_delay(err_ms) == 4.5 + + +def test_extract_retry_delay_malformed_headers(): + model = Model("openai/gpt-4o") + + # HTTP-date header value (non-numeric string) + err = Exception("Rate limit") + err.status_code = 429 + mock_resp = MagicMock() + mock_resp.json.return_value = {} + mock_resp.headers = {"retry-after": "Wed, 21 Oct 2026 07:28:00 GMT"} + err.response = mock_resp + assert model._extract_gemini_retry_delay(err) is None + + # Garbage string header value + mock_resp.headers = {"retry-after": "invalid"} + assert model._extract_gemini_retry_delay(err) is None + + +def test_extract_gemini_retry_delay_bytes_payload(): + model = Model("gemini/gemini-2.5-flash") + + payload_bytes = json.dumps( + { + "error": { + "code": 429, + "message": "Resource exhausted", + "details": [{"retryDelay": "4.0s"}], + } + } + ).encode("utf-8") + + err = Exception("Rate limit") + err.status_code = 429 + mock_resp = MagicMock() + mock_resp.json.side_effect = Exception("Not parsed") + mock_resp.text = payload_bytes + err.response = mock_resp + + assert model._extract_gemini_retry_delay(err) == 4.0 + + +def test_retry_fallback_to_unilateral_backoff_when_no_retry_delay(): + async def run_test(): + model = Model("gemini/gemini-2.5-flash") + model.caches_by_default = False + + rate_limit_err = litellm.RateLimitError( + message="Rate limit exceeded", + response=MagicMock( + json=lambda: {"error": {"code": 429, "message": "Rate limit exceeded"}} + ), + model="gemini/gemini-2.5-flash", + llm_provider="gemini", + ) + + call_count = 0 + slept_delays = [] + + async def mock_acompletion(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise rate_limit_err + return MagicMock(choices=[MagicMock(message=MagicMock(content="ok"))]) + + async def mock_sleep(delay): + slept_delays.append(delay) + + with ( + patch("cecli.llm.litellm.acompletion", side_effect=mock_acompletion), + patch("asyncio.sleep", side_effect=mock_sleep), + ): + await model.send_completion( + messages=[{"role": "user", "content": "hi"}], functions=None, stream=False + ) + + # Initial retry_delay is 0.125, multiplied by retry_backoff_factor (1.5) = 0.1875 + assert len(slept_delays) == 1 + assert pytest.approx(slept_delays[0]) == 0.125 * 1.5 + + asyncio.run(run_test()) + + +def test_send_completion_retry_after_header_integration(): + async def run_test(): + model = Model("openai/gpt-4o") + model.caches_by_default = False + + mock_resp = MagicMock() + mock_resp.json.return_value = {} + mock_resp.headers = {"retry-after": "2.5"} + + rate_limit_err = litellm.RateLimitError( + message="Rate limit exceeded", + response=mock_resp, + model="openai/gpt-4o", + llm_provider="openai", + ) + rate_limit_err.status_code = 429 + + call_count = 0 + slept_delays = [] + + async def mock_acompletion(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise rate_limit_err + return MagicMock(choices=[MagicMock(message=MagicMock(content="success"))]) + + async def mock_sleep(delay): + slept_delays.append(delay) + + with ( + patch("cecli.llm.litellm.acompletion", side_effect=mock_acompletion), + patch("asyncio.sleep", side_effect=mock_sleep), + ): + _hash, resp = await model.send_completion( + messages=[{"role": "user", "content": "hi"}], functions=None, stream=False + ) + + assert len(slept_delays) == 1 + assert slept_delays[0] == 2.5 + assert resp.choices[0].message.content == "success" + + asyncio.run(run_test()) + + +def test_send_completion_retry_after_ms_header_integration(): + async def run_test(): + model = Model("anthropic/claude-3-5-sonnet") + model.caches_by_default = False + + mock_resp = MagicMock() + mock_resp.json.return_value = {} + mock_resp.headers = {"retry-after-ms": "1500"} + + rate_limit_err = litellm.RateLimitError( + message="Rate limit exceeded", + response=mock_resp, + model="anthropic/claude-3-5-sonnet", + llm_provider="anthropic", + ) + rate_limit_err.status_code = 429 + + call_count = 0 + slept_delays = [] + + async def mock_acompletion(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise rate_limit_err + return MagicMock(choices=[MagicMock(message=MagicMock(content="success"))]) + + async def mock_sleep(delay): + slept_delays.append(delay) + + with ( + patch("cecli.llm.litellm.acompletion", side_effect=mock_acompletion), + patch("asyncio.sleep", side_effect=mock_sleep), + ): + _hash, resp = await model.send_completion( + messages=[{"role": "user", "content": "hi"}], functions=None, stream=False + ) + + assert len(slept_delays) == 1 + assert slept_delays[0] == 1.5 + assert resp.choices[0].message.content == "success" + + asyncio.run(run_test()) + + +def test_send_completion_gemini_payload_retry_delay_integration(): + async def run_test(): + model = Model("gemini/gemini-2.5-flash") + model.caches_by_default = False + + mock_resp = MagicMock() + mock_resp.json.return_value = { + "error": { + "code": 429, + "message": "Resource exhausted", + "details": [{"retryDelay": "3.5s"}], + } + } + + rate_limit_err = litellm.RateLimitError( + message="Resource exhausted", + response=mock_resp, + model="gemini/gemini-2.5-flash", + llm_provider="gemini", + ) + rate_limit_err.status_code = 429 + + call_count = 0 + slept_delays = [] + + async def mock_acompletion(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise rate_limit_err + return MagicMock(choices=[MagicMock(message=MagicMock(content="success"))]) + + async def mock_sleep(delay): + slept_delays.append(delay) + + with ( + patch("cecli.llm.litellm.acompletion", side_effect=mock_acompletion), + patch("asyncio.sleep", side_effect=mock_sleep), + ): + _hash, resp = await model.send_completion( + messages=[{"role": "user", "content": "hi"}], functions=None, stream=False + ) + + assert len(slept_delays) == 1 + assert slept_delays[0] == 3.5 + assert resp.choices[0].message.content == "success" + + asyncio.run(run_test()) + + +def test_send_completion_exceeds_retry_timeout(): + async def run_test(): + model = Model("openai/gpt-4o") + model.caches_by_default = False + model.retry_timeout = 10 + + mock_resp = MagicMock() + mock_resp.json.return_value = {} + mock_resp.headers = {"retry-after": "100"} + + rate_limit_err = litellm.RateLimitError( + message="Rate limit exceeded", + response=mock_resp, + model="openai/gpt-4o", + llm_provider="openai", + ) + rate_limit_err.status_code = 429 + + slept_delays = [] + + async def mock_acompletion(*args, **kwargs): + raise rate_limit_err + + async def mock_sleep(delay): + slept_delays.append(delay) + + with ( + patch("cecli.llm.litellm.acompletion", side_effect=mock_acompletion), + patch("asyncio.sleep", side_effect=mock_sleep), + ): + _hash, resp = await model.send_completion( + messages=[{"role": "user", "content": "hi"}], functions=None, stream=False + ) + + # Should not sleep and should return model error response immediately + assert len(slept_delays) == 0 + assert "Model API Response Error" in resp.choices[0].message.content + + asyncio.run(run_test()) + + +def test_simple_send_with_retries_retry_after_integration(): + async def run_test(): + model = Model("openai/gpt-4o") + + mock_resp = MagicMock() + mock_resp.json.return_value = {} + mock_resp.headers = {"retry-after": "1.5"} + + rate_limit_err = litellm.RateLimitError( + message="Rate limit exceeded", + response=mock_resp, + model="openai/gpt-4o", + llm_provider="openai", + ) + rate_limit_err.status_code = 429 + + call_count = 0 + slept_delays = [] + + async def mock_send_completion(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise rate_limit_err + return ( + "hash", + MagicMock(choices=[MagicMock(message=MagicMock(content="generated commit"))]), + ) + + def mock_sleep(delay): + slept_delays.append(delay) + + with ( + patch.object(model, "send_completion", side_effect=mock_send_completion), + patch("time.sleep", side_effect=mock_sleep), + ): + result = await model.simple_send_with_retries( + messages=[{"role": "user", "content": "generate commit"}] + ) + + assert len(slept_delays) == 1 + assert slept_delays[0] == 1.5 + assert result == "generated commit" + + asyncio.run(run_test()) + + +def test_simple_send_with_retries_gemini_payload_integration(): + async def run_test(): + model = Model("gemini/gemini-2.5-flash") + + mock_resp = MagicMock() + mock_resp.json.return_value = { + "error": { + "code": 429, + "message": "Resource exhausted", + "details": [{"retryDelay": "2.0s"}], + } + } + + rate_limit_err = litellm.RateLimitError( + message="Resource exhausted", + response=mock_resp, + model="gemini/gemini-2.5-flash", + llm_provider="gemini", + ) + rate_limit_err.status_code = 429 + + call_count = 0 + slept_delays = [] + + async def mock_send_completion(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise rate_limit_err + return ( + "hash", + MagicMock(choices=[MagicMock(message=MagicMock(content="summary output"))]), + ) + + def mock_sleep(delay): + slept_delays.append(delay) + + with ( + patch.object(model, "send_completion", side_effect=mock_send_completion), + patch("time.sleep", side_effect=mock_sleep), + ): + result = await model.simple_send_with_retries( + messages=[{"role": "user", "content": "summarize"}] + ) + + assert len(slept_delays) == 1 + assert slept_delays[0] == 2.0 + assert result == "summary output" + + asyncio.run(run_test()) + + +def test_simple_send_with_retries_exceeds_retry_timeout(): + async def run_test(): + model = Model("openai/gpt-4o") + + mock_resp = MagicMock() + mock_resp.json.return_value = {} + mock_resp.headers = {"retry-after": "100"} + + rate_limit_err = litellm.RateLimitError( + message="Rate limit exceeded", + response=mock_resp, + model="openai/gpt-4o", + llm_provider="openai", + ) + rate_limit_err.status_code = 429 + + slept_delays = [] + + async def mock_send_completion(*args, **kwargs): + raise rate_limit_err + + def mock_sleep(delay): + slept_delays.append(delay) + + with ( + patch.object(model, "send_completion", side_effect=mock_send_completion), + patch("time.sleep", side_effect=mock_sleep), + ): + result = await model.simple_send_with_retries( + messages=[{"role": "user", "content": "test"}] + ) + + assert len(slept_delays) == 0 + assert result is None + + asyncio.run(run_test()) From ba4f8f0df22f95d6da1a7bec8245e1096c98924b Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Mon, 31 Aug 2026 21:13:28 -0400 Subject: [PATCH 27/32] Rename _extract_gemini_retry_delay to _extract_retry_delay --- cecli/models.py | 6 +++--- tests/unit/test_retry_backoff.py | 34 ++++++++++++++++---------------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/cecli/models.py b/cecli/models.py index e0e8eb44c39..f3a8cea62a6 100644 --- a/cecli/models.py +++ b/cecli/models.py @@ -1483,7 +1483,7 @@ async def send_completion( if ex_info.name == "ServiceUnavailableError": should_retry = should_retry or self.retry_on_unavailable - custom_retry_delay = self._extract_gemini_retry_delay(err) + custom_retry_delay = self._extract_retry_delay(err) if custom_retry_delay is not None: retry_delay = custom_retry_delay should_retry = True @@ -1582,7 +1582,7 @@ async def simple_send_with_retries( if ex_info.description: print(ex_info.description) should_retry = ex_info.retry - custom_retry_delay = self._extract_gemini_retry_delay(err) + custom_retry_delay = self._extract_retry_delay(err) if custom_retry_delay is not None: retry_delay = custom_retry_delay should_retry = True @@ -1622,7 +1622,7 @@ def model_error_response(self): async def model_error_response_stream(self): yield self.model_error_response() - def _extract_gemini_retry_delay(self, err): + def _extract_retry_delay(self, err): """ Extract suggested retry delay (in seconds) from a 429 rate-limit error. diff --git a/tests/unit/test_retry_backoff.py b/tests/unit/test_retry_backoff.py index 1d25525de20..f81b74d1f92 100644 --- a/tests/unit/test_retry_backoff.py +++ b/tests/unit/test_retry_backoff.py @@ -8,7 +8,7 @@ from cecli.models import Model -def test_extract_gemini_retry_delay_valid(): +def test_extract_retry_delay_valid(): model = Model("gemini/gemini-2.5-flash") payload = { @@ -38,11 +38,11 @@ def test_extract_gemini_retry_delay_valid(): mock_resp.json.return_value = payload err.response = mock_resp - delay = model._extract_gemini_retry_delay(err) + delay = model._extract_retry_delay(err) assert delay == 15.2 -def test_extract_gemini_retry_delay_json_in_message(): +def test_extract_retry_delay_json_in_message(): model = Model("gemini/gemini-2.5-flash") payload = { @@ -54,11 +54,11 @@ def test_extract_gemini_retry_delay_json_in_message(): } err = Exception(f"APIError: 429 {json.dumps(payload)}") - delay = model._extract_gemini_retry_delay(err) + delay = model._extract_retry_delay(err) assert delay == 8.5 -def test_extract_gemini_retry_delay_non_429(): +def test_extract_retry_delay_non_429(): model = Model("gemini/gemini-2.5-flash") payload = { @@ -75,11 +75,11 @@ def test_extract_gemini_retry_delay_non_429(): mock_resp.json.return_value = payload err.response = mock_resp - delay = model._extract_gemini_retry_delay(err) + delay = model._extract_retry_delay(err) assert delay is None -def test_extract_gemini_retry_delay_missing_details(): +def test_extract_retry_delay_missing_details(): model = Model("gemini/gemini-2.5-flash") payload = { @@ -95,11 +95,11 @@ def test_extract_gemini_retry_delay_missing_details(): mock_resp.json.return_value = payload err.response = mock_resp - delay = model._extract_gemini_retry_delay(err) + delay = model._extract_retry_delay(err) assert delay is None -def test_extract_gemini_retry_delay_headers_fallback(): +def test_extract_retry_delay_headers_fallback(): model = Model("gemini/gemini-2.5-flash") # Standard retry-after header (seconds) @@ -109,7 +109,7 @@ def test_extract_gemini_retry_delay_headers_fallback(): mock_resp1.json.return_value = {} mock_resp1.headers = {"retry-after": "6.5"} err1.response = mock_resp1 - assert model._extract_gemini_retry_delay(err1) == 6.5 + assert model._extract_retry_delay(err1) == 6.5 # Standard retry-after-ms header (milliseconds) err2 = Exception("Rate limit") @@ -118,7 +118,7 @@ def test_extract_gemini_retry_delay_headers_fallback(): mock_resp2.json.return_value = {} mock_resp2.headers = {"retry-after-ms": "2500"} err2.response = mock_resp2 - assert model._extract_gemini_retry_delay(err2) == 2.5 + assert model._extract_retry_delay(err2) == 2.5 def test_extract_retry_delay_direct_err_headers(): @@ -128,12 +128,12 @@ def test_extract_retry_delay_direct_err_headers(): err = Exception("Rate limit") err.status_code = 429 err.headers = {"retry-after": "3.5"} - assert model._extract_gemini_retry_delay(err) == 3.5 + assert model._extract_retry_delay(err) == 3.5 err_ms = Exception("Rate limit") err_ms.status_code = 429 err_ms.headers = {"retry-after-ms": "4500"} - assert model._extract_gemini_retry_delay(err_ms) == 4.5 + assert model._extract_retry_delay(err_ms) == 4.5 def test_extract_retry_delay_malformed_headers(): @@ -146,14 +146,14 @@ def test_extract_retry_delay_malformed_headers(): mock_resp.json.return_value = {} mock_resp.headers = {"retry-after": "Wed, 21 Oct 2026 07:28:00 GMT"} err.response = mock_resp - assert model._extract_gemini_retry_delay(err) is None + assert model._extract_retry_delay(err) is None # Garbage string header value mock_resp.headers = {"retry-after": "invalid"} - assert model._extract_gemini_retry_delay(err) is None + assert model._extract_retry_delay(err) is None -def test_extract_gemini_retry_delay_bytes_payload(): +def test_extract_retry_delay_bytes_payload(): model = Model("gemini/gemini-2.5-flash") payload_bytes = json.dumps( @@ -173,7 +173,7 @@ def test_extract_gemini_retry_delay_bytes_payload(): mock_resp.text = payload_bytes err.response = mock_resp - assert model._extract_gemini_retry_delay(err) == 4.0 + assert model._extract_retry_delay(err) == 4.0 def test_retry_fallback_to_unilateral_backoff_when_no_retry_delay(): From 3d10f1e00e7c47b43842678333aead75663529e1 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Mon, 31 Aug 2026 21:18:16 -0400 Subject: [PATCH 28/32] Make sure file completions respects active coder root --- cecli/tui/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cecli/tui/app.py b/cecli/tui/app.py index 0910b788d47..cc4b884583f 100644 --- a/cecli/tui/app.py +++ b/cecli/tui/app.py @@ -1636,7 +1636,7 @@ def _get_path_completions(self, prefix: str) -> tuple[list[str], set[str]]: tuple[list[str], set[str]]: A tuple of (ordered_list, fast_lookup_set) containing the matched path completions. """ - coder = self.worker.coder + coder = AgentService.get_instance(self.worker.coder).foreground_coder root = Path(coder.root) if hasattr(coder, "root") else Path.cwd() # Try FileSystemService first for efficient lookups From 436ce719b2b94bdbf28ef32dbd92bd610aa0f277 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 31 Aug 2026 20:32:15 -0700 Subject: [PATCH 29/32] fix: Suppress first-run release notes prompt with --yes-always --- cecli/main.py | 5 ++++- tests/basic/test_main.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/cecli/main.py b/cecli/main.py index 262f7ab1646..1acc7dbf065 100644 --- a/cecli/main.py +++ b/cecli/main.py @@ -1441,7 +1441,10 @@ def get_io(pretty): pre_init_io.tool_output() webbrowser.open(urls.release_notes) return await graceful_exit(coder) - elif args.show_release_notes is None and is_first_run: + elif args.show_release_notes is None and is_first_run and not args.yes_always: + # Suppress the first-run release-notes prompt when --yes-always is set, + # so automated/headless runs don't have the browser hijacked by an + # auto-confirmed offer_url. Explicit --show-release-notes still opens. pre_init_io.tool_output() await pre_init_io.offer_url( urls.release_notes, diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index c2d7b30c186..35c5a146d46 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -372,6 +372,38 @@ def test_main_exit_calls_version_check(dummy_io, git_temp_dir, mocker): mock_input_output.assert_called_once() +def test_suppress_release_notes_prompt_with_yes_always(dummy_io, git_temp_dir, mocker): + mock_input_output = mocker.patch("cecli.io.InputOutput") + mock_input_output.return_value.confirm_ask = AsyncMock(return_value=True) + mock_input_output.return_value.offer_url = AsyncMock() + mocker.patch("cecli.main.is_first_run_of_new_version", return_value=True) + + main(["--exit", "--yes-always"], **dummy_io) + mock_input_output.return_value.offer_url.assert_not_called() + + +def test_shows_release_notes_prompt_on_first_run(dummy_io, git_temp_dir, mocker): + mock_input_output = mocker.patch("cecli.io.InputOutput") + mock_input_output.return_value.confirm_ask = AsyncMock(return_value=True) + mock_input_output.return_value.offer_url = AsyncMock() + mocker.patch("cecli.main.is_first_run_of_new_version", return_value=True) + + main(["--exit"], **dummy_io) + mock_input_output.return_value.offer_url.assert_called_once() + + +def test_explicit_show_release_notes_with_yes_always(dummy_io, git_temp_dir, mocker): + mock_input_output = mocker.patch("cecli.io.InputOutput") + mock_input_output.return_value.confirm_ask = AsyncMock(return_value=True) + mock_input_output.return_value.offer_url = AsyncMock() + mocker.patch("cecli.main.is_first_run_of_new_version", return_value=True) + mocker.patch("webbrowser.open") + + main(["--exit", "--yes-always", "--show-release-notes"], **dummy_io) + # The explicit --show-release-notes code path uses webbrowser.open directly, not offer_url + mock_input_output.return_value.offer_url.assert_not_called() + + def test_main_message_adds_to_input_history(dummy_io, mocker): mocker.patch("cecli.coders.base_coder.Coder.run") MockInputOutput = mocker.patch("cecli.io.InputOutput", autospec=True) From e2bc5cbedb7279e036a8fb2463ad1af70a5e7b86 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Tue, 1 Sep 2026 00:34:40 -0400 Subject: [PATCH 30/32] Add persists flag to the delegate tool to enable parent child inter-communication --- cecli/tools/delegate.py | 39 +++++++++++++----- cecli/website/docs/config/agent-mode.md | 2 +- cecli/website/docs/config/subagents.md | 2 +- tests/subagents/test_delegate.py | 54 ++++++++++++++++++++++++- 4 files changed, 84 insertions(+), 13 deletions(-) diff --git a/cecli/tools/delegate.py b/cecli/tools/delegate.py index bd27fef5901..cfe734f94d1 100644 --- a/cecli/tools/delegate.py +++ b/cecli/tools/delegate.py @@ -49,6 +49,13 @@ class Tool(BaseTool): " If false, wait for the result." ), }, + "persist": { + "type": "boolean", + "default": False, + "description": ( + "If true, keep the sub-agent active after the task is complete." + ), + }, }, "required": ["name", "prompt"], }, @@ -69,7 +76,7 @@ async def execute(cls, coder, **kwargs): if not delegations or not isinstance(delegations, list): response.append_error( - "'delegations' parameter must be a non-empty array of {name, prompt, async} objects." + "'delegations' parameter must be a non-empty array of {name, prompt, async, persist} objects." ) return response @@ -90,23 +97,35 @@ async def execute(cls, coder, **kwargs): agent_service = AgentService.get_instance(coder) # Separate async (fire-and-forget) and sync (blocking) delegations - async_delegations = [(d["name"], d["prompt"]) for d in delegations if d.get("async", True)] + async_delegations = [ + (d["name"], d["prompt"], d.get("persist", False)) + for d in delegations + if d.get("async", True) + ] sync_delegations = [ - (d["name"], d["prompt"]) for d in delegations if not d.get("async", True) + (d["name"], d["prompt"], d.get("persist", False)) + for d in delegations + if not d.get("async", True) ] - async def _spawn_one(name: str, prompt: str) -> tuple: + async def _spawn_one(name: str, prompt: str, persist: bool) -> tuple: """Spawn a single sub-agent (fire-and-forget). Returns (name, uuid_or_error, error).""" + auto_reap = None if not persist else False try: - new_coder, info = await agent_service.spawn(name, prompt, parent=coder) + new_coder, info = await agent_service.spawn( + name, prompt, parent=coder, auto_reap=auto_reap + ) return name, info.coder.uuid, None except Exception as e: return name, None, f"failed: {e}" - async def _invoke_one(name: str, prompt: str) -> tuple: + async def _invoke_one(name: str, prompt: str, persist: bool) -> tuple: """Invoke a single sub-agent (blocking). Returns (name, summary_or_error, error).""" + auto_reap = None if not persist else False try: - summary = await agent_service.invoke(name, prompt, parent=coder) + summary = await agent_service.invoke( + name, prompt, parent=coder, auto_reap=auto_reap + ) return name, summary or "(no summary)", None except Exception as e: return name, None, f"failed: {e}" @@ -114,13 +133,13 @@ async def _invoke_one(name: str, prompt: str) -> tuple: # Process async delegations (fire-and-forget spawn) async_results = [] if async_delegations: - tasks = [_spawn_one(n, p) for n, p in async_delegations] + tasks = [_spawn_one(n, p, persist) for n, p, persist in async_delegations] async_results = list(await asyncio.gather(*tasks)) # Process sync delegations (blocking invoke) sync_results = [] if sync_delegations: - tasks = [_invoke_one(n, p) for n, p in sync_delegations] + tasks = [_invoke_one(n, p, persist) for n, p, persist in sync_delegations] sync_results = list(await asyncio.gather(*tasks)) # Build response @@ -185,9 +204,11 @@ def format_output(cls, coder, mcp_server, tool_response): name = d.get("name", "") prompt = d.get("prompt", "") is_async = d.get("async", True) + is_persist = d.get("persist", False) coder.io.tool_output(f"{color_start}delegation_{i + 1}:{color_end}") coder.io.tool_output(f"agent: {name}") coder.io.tool_output(f"mode: {'async' if is_async else 'sync'}") + coder.io.tool_output(f"persist: {'true' if is_persist else 'false'}") coder.io.tool_output(f"task: {prompt}") if i < len(delegations) - 1: coder.io.tool_output("") diff --git a/cecli/website/docs/config/agent-mode.md b/cecli/website/docs/config/agent-mode.md index 065528c4118..c8611976872 100644 --- a/cecli/website/docs/config/agent-mode.md +++ b/cecli/website/docs/config/agent-mode.md @@ -287,7 +287,7 @@ Tools are loaded automatically when the registry is built and will be available #### Sub-agent Behavior -`Delegate` accepts one or more delegation objects. Each delegation includes a registered sub-agent `name`, a `prompt`, and an optional `async` flag. Asynchronous delegations run in the background; synchronous delegations wait for the sub-agent result. The system uses `Yield` to wait for outstanding child tasks when finishing the parent task. +`Delegate` accepts one or more delegation objects. Each delegation includes a registered sub-agent `name`, a `prompt`, and optional `async` and `persist` flags. Asynchronous delegations run in the background; synchronous delegations wait for the sub-agent result. The system uses `Yield` to wait for outstanding child tasks when finishing the parent task. Sub-agent results are reported back to the parent conversation as summaries or errors. Sub-agents use `auto_reap: true` by default, so completed agents can be removed automatically after their work and descendants finish. Independent agents may be cleaned up shortly after completion, while the service also reaps completed agents when the configured limit requires space. Set `max_sub_agents` to `-1` to remove the limit on simultaneous sub agents. diff --git a/cecli/website/docs/config/subagents.md b/cecli/website/docs/config/subagents.md index 7f65bc516ad..104b08daa4e 100644 --- a/cecli/website/docs/config/subagents.md +++ b/cecli/website/docs/config/subagents.md @@ -164,7 +164,7 @@ You are a code review specialist. ``` - **`/spawn-agent`** always spawns sub-agents with `auto_reap=false` — since these agents are created manually by the user, they should persist until explicitly reaped with `/reap-agent`. -- **`Delegate` tool** uses the sub-agent's configured `auto_reap` value from its definition. If not set in the `.md` front matter, it defaults to `true`. +- **`Delegate` tool** uses the sub-agent's configured `auto_reap` value from its definition. If not set in the `.md` front matter, it defaults to `true`. A delegation may set `persist: true` to keep that sub-agent alive by passing `auto_reap: false`; by default `persist` is `false`, deferring to the sub-agent's configured behavior. Sub-agents with `auto_reap: true` that finish their work are candidates for automatic cleanup when the agent limit is reached. Sub-agents with `auto_reap: false` are never automatically reaped and must be cleaned up manually. diff --git a/tests/subagents/test_delegate.py b/tests/subagents/test_delegate.py index 71d0bd8bcae..67097fdc1f6 100644 --- a/tests/subagents/test_delegate.py +++ b/tests/subagents/test_delegate.py @@ -62,7 +62,7 @@ async def test_valid_delegate_calls_spawn(self): MockService.get_instance.assert_called_once_with(mock_coder) mock_instance.spawn.assert_called_once_with( - "reviewer", "review this", parent=mock_coder + "reviewer", "review this", parent=mock_coder, auto_reap=None ) assert "agent started with id" in str(result) assert "child-uuid-123" in str(result) @@ -77,7 +77,7 @@ async def test_delegate_multiple_delegations(self): with patch("cecli.helpers.agents.service.AgentService") as MockService: mock_instance = MagicMock() - async def spawn_side_effect(name, prompt, parent=None): + async def spawn_side_effect(name, prompt, parent=None, auto_reap=None): mock_info = MagicMock() mock_info.coder.uuid = f"{name}-uuid" return MagicMock(), mock_info @@ -143,3 +143,53 @@ async def test_unexpected_exception_caught(self): ) errors = result.to_dict()["result"] assert errors + + @pytest.mark.asyncio + async def test_persist_true_sets_auto_reap_false_spawn(self): + """persist=True passes auto_reap=False to spawn for async delegations.""" + from cecli.tools.delegate import Tool + + mock_coder = MagicMock() + mock_coder.uuid = "parent-uuid" + + with patch("cecli.helpers.agents.service.AgentService") as MockService: + mock_instance = MagicMock() + mock_info = MagicMock() + mock_info.coder.uuid = "child-uuid-persist" + mock_instance.spawn = AsyncMock(return_value=(MagicMock(), mock_info)) + MockService.get_instance.return_value = mock_instance + + result = await Tool.execute( + mock_coder, + delegations=[{"name": "reviewer", "prompt": "keep me", "persist": True}], + ) + + mock_instance.spawn.assert_called_once_with( + "reviewer", "keep me", parent=mock_coder, auto_reap=False + ) + assert "agent started with id" in str(result) + + @pytest.mark.asyncio + async def test_persist_true_sets_auto_reap_false_invoke(self): + """persist=True passes auto_reap=False to invoke for sync delegations.""" + from cecli.tools.delegate import Tool + + mock_coder = MagicMock() + mock_coder.uuid = "parent-uuid" + + with patch("cecli.helpers.agents.service.AgentService") as MockService: + mock_instance = MagicMock() + mock_instance.invoke = AsyncMock(return_value="done") + MockService.get_instance.return_value = mock_instance + + result = await Tool.execute( + mock_coder, + delegations=[ + {"name": "reviewer", "prompt": "blocking", "persist": True, "async": False} + ], + ) + + mock_instance.invoke.assert_called_once_with( + "reviewer", "blocking", parent=mock_coder, auto_reap=False + ) + assert "agent completed" in str(result) From 922e7e07bbe0ee8bd0310a4f8387c0b13319c04a Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Tue, 1 Sep 2026 02:41:33 -0400 Subject: [PATCH 31/32] Place subagent folders underneath primary --- cecli/coders/base_coder.py | 43 ++++++++++++++++++++++++++++----- cecli/helpers/agents/service.py | 11 ++++++++- cecli/main.py | 20 ++++++++++----- 3 files changed, 61 insertions(+), 13 deletions(-) diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index 0e196b76ec0..173e6dd1875 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -474,14 +474,14 @@ def __init__( self._inherited_tools = True self.interrupt_event = ThreadSafeEvent() - self.uuid = str(generate_unique_id()) + self.uuid = str(generate_unique_id()).split("-")[0] self.reflected_message = None if uuid: - self.uuid = str(uuid) + self.uuid = str(uuid).split("-")[0] if parent_uuid: - self.parent_uuid = str(parent_uuid) + self.parent_uuid = str(parent_uuid).split("-")[0] self.map_cache_dir = map_cache_dir @@ -4962,13 +4962,44 @@ def apply_edits_dry_run(self, edits): return edits def local_agent_folder(self, path): + primary_uuid = self._resolve_primary_agent_uuid() + primary_root = os.path.abspath(self.primary_root) + + stripped = path.lstrip("/") + + if self.uuid == primary_uuid: + rel_dir = f"{primary_root}/.cecli/agents/{GLOBAL_DATE}/{primary_uuid}" + else: + rel_dir = f"{primary_root}/.cecli/agents/{GLOBAL_DATE}/{primary_uuid}/s/{self.uuid}" + os.makedirs( - self.abs_root_path(f".cecli/agents/{GLOBAL_DATE}/{self.uuid}"), + self.abs_root_path(rel_dir), exist_ok=True, ) - stripped = path.lstrip("/") - return f".cecli/agents/{GLOBAL_DATE}/{self.uuid}/{stripped}" + return f"{rel_dir}/{stripped}" + + def _resolve_primary_agent_uuid(self): + """Return the primary coder's uuid for this session (memoized). + + All agents spawned under a primary coder share the same base folder, + so sub-agent files nest under one location regardless of delegation + depth. Falls back to this coder's own uuid when the service is + unavailable (e.g. in unit tests). + """ + if getattr(self, "_primary_agent_uuid", None): + return self._primary_agent_uuid + + primary_uuid = self.uuid + try: + from cecli.helpers.agents.service import AgentService + + primary_uuid = AgentService.get_primary_uuid() or self.uuid + except Exception: + pass + + self._primary_agent_uuid = primary_uuid + return primary_uuid async def auto_save_session(self, force=False): """Automatically save the current session to {auto-save-session-name}.json.""" diff --git a/cecli/helpers/agents/service.py b/cecli/helpers/agents/service.py index b0c9115260b..a7523d8145c 100644 --- a/cecli/helpers/agents/service.py +++ b/cecli/helpers/agents/service.py @@ -137,6 +137,15 @@ def get_registry(cls) -> Dict[str, Any]: """Return the global sub-agent registry (name -> config).""" return cls._global_registry + @classmethod + def get_primary_uuid(cls) -> Optional[str]: + """Return the primary coder's uuid for the current session (memoized). + + Fixed on the first :meth:`get_instance` call, so every agent spawned + in a session resolves to the same shared base uuid. + """ + return cls._primary_agent_uuid + @classmethod def get_all_agents(cls) -> List[Any]: """Return all live coder instances tracked in the global uuid map. @@ -576,7 +585,7 @@ async def _create_sub_agent_coder( async with self._get_lock(self._spawn_locks, parent_coder.uuid): self._check_max_sub_agents() - new_uuid = str(uuid4()) + new_uuid = str(uuid4()).split("-")[0] from cecli.coders import Coder diff --git a/cecli/main.py b/cecli/main.py index dd40d83b99e..91735493882 100644 --- a/cecli/main.py +++ b/cecli/main.py @@ -1781,13 +1781,21 @@ async def graceful_exit(coder=None, exit_code=0): if (now - mtime) > week_seconds: shutil.rmtree(agent_folder, ignore_errors=True) else: - # Remove empty sub-folders in remaining folders - for sub_folder in agent_folder.iterdir(): - if sub_folder.is_dir(): - try: + # Find all nested subdirectories inside this agent folder + all_subdirs = [d for d in agent_folder.rglob("*") if d.is_dir()] + + # Sort them by depth (deepest first) so child dirs are processed before parents + all_subdirs.sort(key=lambda x: len(x.parts), reverse=True) + + for sub_folder in all_subdirs: + try: + # rmdir() safely fails if a file is present + # any() checks if the directory has any files or remaining folders + if not any(sub_folder.iterdir()): sub_folder.rmdir() - except OSError: - pass + except OSError: + pass + except (OSError, PermissionError): pass except Exception: From 938a71ab05d8852e19d556109b14fa98ff739a4c Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Tue, 1 Sep 2026 02:54:46 -0400 Subject: [PATCH 32/32] Fix formatting --- tests/basic/test_main.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index 35c5a146d46..38e2ad7284e 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -377,7 +377,7 @@ def test_suppress_release_notes_prompt_with_yes_always(dummy_io, git_temp_dir, m mock_input_output.return_value.confirm_ask = AsyncMock(return_value=True) mock_input_output.return_value.offer_url = AsyncMock() mocker.patch("cecli.main.is_first_run_of_new_version", return_value=True) - + main(["--exit", "--yes-always"], **dummy_io) mock_input_output.return_value.offer_url.assert_not_called() @@ -387,7 +387,7 @@ def test_shows_release_notes_prompt_on_first_run(dummy_io, git_temp_dir, mocker) mock_input_output.return_value.confirm_ask = AsyncMock(return_value=True) mock_input_output.return_value.offer_url = AsyncMock() mocker.patch("cecli.main.is_first_run_of_new_version", return_value=True) - + main(["--exit"], **dummy_io) mock_input_output.return_value.offer_url.assert_called_once() @@ -398,7 +398,7 @@ def test_explicit_show_release_notes_with_yes_always(dummy_io, git_temp_dir, moc mock_input_output.return_value.offer_url = AsyncMock() mocker.patch("cecli.main.is_first_run_of_new_version", return_value=True) mocker.patch("webbrowser.open") - + main(["--exit", "--yes-always", "--show-release-notes"], **dummy_io) # The explicit --show-release-notes code path uses webbrowser.open directly, not offer_url mock_input_output.return_value.offer_url.assert_not_called()