feat: support custom agents and Gemini/Cursor backends#17
Conversation
Add pluggable execution backends (Codex, Gemini, Cursor) and custom agent support to make Tower useful beyond the default built-in lineup and avoid coupling the system too tightly to Codex. Key changes: - New backends.py module with dispatch for codex, gemini, cursor - agents.py: backend field, custom agent helpers, list_* utilities - config_ui.py: custom agent creation in `tower init` custom flow - cli.py: --backend flag for tower start/resume - runtime_cli.py: dynamic agent names (no hardcoded choices) - prompts.py: custom agent prompt/policy loading with fallbacks - bootstrap.py: scaffold custom agent directories on init Closes #16 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a pluggable execution layer so Tower can delegate/run agents via multiple AI runtimes (Codex/Gemini/Cursor) and expands the agent registry/init flow to support project-defined custom agents.
Changes:
- Introduces
control_tower/backends.pydispatchingrun_interactive()/run_exec()acrosscodex,gemini, andcursor. - Extends agent registry to support per-agent
backendplus custom agent entries, and updates CLI flows to accept dynamic agent names. - Updates prompt generation and bootstrap/init to support custom agent prompt loading and scaffolding; adds tests covering these behaviors.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
src/control_tower/backends.py |
New backend dispatch + argument construction for codex/gemini/cursor. |
src/control_tower/agents.py |
Adds backend defaults, custom agent entry helper, and agent listing utilities. |
src/control_tower/config_ui.py |
Custom init flow now supports creating/configuring custom agents + backend selection. |
src/control_tower/cli.py |
Adds --backend option to tower start/resume, and surfaces backend in status. |
src/control_tower/runtime_cli.py |
Removes hardcoded agent choices and routes delegation through backend dispatch. |
src/control_tower/prompts.py |
Loads custom agent prompts/policies with fallbacks and annotates tower prompt with backend/custom. |
src/control_tower/bootstrap.py |
Scaffolds custom agent directories/prompt files during init. |
tests/test_bootstrap.py |
Adds/updates tests for custom agents, backend dispatch, delegation routing, and CLI option resolution. |
Comments suppressed due to low confidence (2)
src/control_tower/runtime_cli.py:136
- The
delegatesubcommand is no longer Codex-specific, but the--model/--sandboxhelp strings still say “Codex”. Please update these help messages to be backend-neutral (or describe backend-specific behavior if needed).
delegate_parser = subparsers.add_parser("delegate", help="Run a subagent through a headless backend")
delegate_parser.add_argument("agent")
delegate_parser.add_argument("--packet", required=True, help="Path to a TaskPacket JSON file")
delegate_parser.add_argument("--output", help="Path for the ResultPacket JSON output")
delegate_parser.add_argument("--model", help="Optional Codex model override")
delegate_parser.add_argument("--sandbox", help="Codex sandbox mode for the subagent")
src/control_tower/runtime_cli.py:499
- After adding per-agent backend dispatch, the error message on invalid/missing ResultPacket still says “codex exec …”. This will be misleading for Gemini/Cursor runs; please make the message backend-agnostic or interpolate the selected backend (e.g., from
effective_backend).
effective_backend = str(agent_config.get("backend", "codex"))
exit_code = run_exec(
project_root,
prompt,
output_schema=schema_path,
output_path=output_path,
backend=effective_backend,
model=effective_model,
sandbox=effective_sandbox,
dangerous=effective_dangerous,
)
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| agent_files = [".control-tower/agents/tower/prompt.md"] + [ | ||
| f".control-tower/agents/{agent_key}/prompt.md" for agent_key in enabled_agents | ||
| ] | ||
| configured_agents = [ | ||
| f"- {registry['agents'][agent_key]['name']} ({agent_key})" | ||
| _agent_prompt_path(agent_key, registry["agents"][agent_key]) | ||
| for agent_key in enabled_agents | ||
| ] or ["- No subagents enabled"] | ||
| ] |
There was a problem hiding this comment.
_agent_prompt_path() always emits a default .control-tower/agents/<agent>/prompt.md path even when a custom agent has no prompt file and no such file exists (since scaffolding only happens when prompt_file is set). This causes the Tower bootstrap prompt to reference non-existent “agent contracts”. Consider omitting missing files from agent_files, auto-scaffolding a default prompt.md for enabled custom agents, or explicitly indicating a generated fallback prompt is used.
| agent_dir = destination / "agents" / agent_key | ||
| agent_dir.mkdir(parents=True, exist_ok=True) | ||
| prompt_file = agent_config.get("prompt_file") | ||
| if prompt_file: | ||
| prompt_path = project_root / str(prompt_file) | ||
| if not prompt_path.exists(): | ||
| prompt_path.parent.mkdir(parents=True, exist_ok=True) | ||
| name = agent_config.get("name", agent_key) | ||
| role = agent_config.get("role", "custom") | ||
| description = agent_config.get("description", "") | ||
| prompt_path.write_text( |
There was a problem hiding this comment.
_scaffold_custom_agent_dirs() writes prompt_file paths from the registry using project_root / prompt_file without any path containment check. If the registry is edited (or comes from an untrusted repo), prompt_file could escape the repo (e.g., ../../...) and cause writes outside project_root during init_project(). Please resolve and validate the target path is within project_root (and ideally within .control-tower/) before creating directories/writing files.
| from datetime import datetime, timezone | ||
| from pathlib import Path | ||
|
|
||
| from .agents import list_registered_agents |
There was a problem hiding this comment.
list_registered_agents is imported but never used in this module. Please remove the unused import to avoid confusion and keep the import list accurate.
| from .agents import list_registered_agents |
| def _add_codex_options(parser: argparse.ArgumentParser) -> None: | ||
| parser.add_argument("--model", help="Optional Codex model override") | ||
| parser.add_argument("--sandbox", help="Codex sandbox mode") | ||
| parser.add_argument("--approval", help="Codex approval policy") | ||
| parser.add_argument("--model", help="Optional model override") | ||
| parser.add_argument("--sandbox", help="Sandbox mode") | ||
| parser.add_argument("--approval", help="Approval policy") | ||
| parser.add_argument("--backend", choices=list(VALID_BACKENDS), help="Execution backend (codex, gemini, cursor)") | ||
| parser.add_argument( |
There was a problem hiding this comment.
start/resume are now able to run through non-Codex backends (via --backend), but the CLI subcommand descriptions still say “Tower Codex session”. Please update the start/resume help text to avoid implying Codex-only behavior.
| if agent_config.get("prompt_file"): | ||
| prompt_path = project_root / str(agent_config["prompt_file"]) |
There was a problem hiding this comment.
_load_agent_prompt() reads prompt_file from the registry using project_root / prompt_file without constraining it to the repo directory. A malicious/accidental prompt_file like ../../.ssh/id_rsa could be read and embedded into the subagent prompt. Please validate that the resolved path stays within project_root (or within .control-tower/) before reading, and fail closed with a clear error if it does not.
| if agent_config.get("prompt_file"): | |
| prompt_path = project_root / str(agent_config["prompt_file"]) | |
| """ | |
| Load the prompt for a given agent, ensuring any configured prompt_file | |
| resides within the project root or the .control-tower directory. | |
| """ | |
| if agent_config.get("prompt_file"): | |
| # Resolve the candidate prompt path and ensure it stays within an allowed root. | |
| raw_prompt_file = str(agent_config["prompt_file"]) | |
| prompt_path = (project_root / raw_prompt_file).resolve() | |
| project_root_resolved = project_root.resolve() | |
| base = tower_dir(project_root).resolve() | |
| def _is_within_directory(path: Path, root: Path) -> bool: | |
| """ | |
| Return True if `path` is the same as or is contained within `root`. | |
| """ | |
| try: | |
| path.relative_to(root) | |
| return True | |
| except ValueError: | |
| return False | |
| if not (_is_within_directory(prompt_path, project_root_resolved) or _is_within_directory(prompt_path, base)): | |
| raise ValueError( | |
| f"Invalid prompt_file path for agent '{agent}': {raw_prompt_file!r}. " | |
| "The prompt_file must be located within the project root or the .control-tower directory." | |
| ) |
- Add path containment validation in prompt_file reads and writes to prevent directory traversal attacks (bootstrap.py, prompts.py) - Filter out non-existent prompt files from bootstrap agent contracts - Remove unused list_registered_agents import from runtime_cli.py - Update stale help text referencing "Codex" to be backend-agnostic Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated 6 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| name = _prompt_text("Agent display name", "", allow_blank=False) | ||
| if not name: | ||
| return None |
There was a problem hiding this comment.
_prompt_text(..., default="", allow_blank=False) loops forever if the user hits Enter, and _prompt_text will never return an empty string here. As a result, the subsequent if not name: return None is unreachable. Consider either (a) allowing blank input to cancel (allow_blank=True) and handling it, or (b) providing a non-empty default / an explicit error message when blank input is rejected.
| description = _prompt_text("Description", "", allow_blank=False) | ||
| if not description: | ||
| return None |
There was a problem hiding this comment.
Same issue as the name prompt: Description uses _prompt_text with default="" and allow_blank=False, which will spin until non-empty input but gives no feedback, and the later if not description check is unreachable. Either allow a blank-to-cancel path or emit a clear validation message when rejecting blanks.
| backend = str(current.get("backend", definition.default_backend)) | ||
|
|
||
| if enabled: | ||
| backend = _prompt_choice("Backend", list(VALID_BACKENDS), backend) | ||
| model = _prompt_text("Model override", model, allow_blank=True) |
There was a problem hiding this comment.
backend = str(current.get('backend', ...)) can produce a default like 'None' if the registry contains backend: null. _prompt_choice returns the default unchanged when the user presses Enter, even if it isn't in the allowed options, so this can persist an invalid backend into the saved config. Consider normalizing/validating the default before prompting (e.g., fall back to 'codex' when the current value isn't in VALID_BACKENDS).
| enabled = _prompt_yes_no("Keep this agent enabled", bool(config.get("enabled", True))) | ||
| if enabled: | ||
| backend = _prompt_choice("Backend", list(VALID_BACKENDS), str(config.get("backend", "codex"))) | ||
| model = _prompt_text("Model override", str(config.get("model") or ""), allow_blank=True) | ||
| dangerously_bypass = _prompt_yes_no("Use dangerous bypass", bool(config.get("dangerously_bypass", False))) | ||
| sandbox = config.get("sandbox", "workspace-write") | ||
| if not dangerously_bypass: | ||
| sandbox = _prompt_choice("Sandbox", SANDBOX_OPTIONS, str(sandbox)) | ||
| else: | ||
| backend = str(config.get("backend", "codex")) |
There was a problem hiding this comment.
Same backend-default issue for existing custom agents: str(config.get('backend', 'codex')) can yield 'None' (or any invalid value) and _prompt_choice will accept it if the user presses Enter. It would be safer to coerce invalid/unknown defaults back to 'codex' (or re-prompt) before calling _prompt_choice.
| enabled = _prompt_yes_no("Keep this agent enabled", bool(config.get("enabled", True))) | |
| if enabled: | |
| backend = _prompt_choice("Backend", list(VALID_BACKENDS), str(config.get("backend", "codex"))) | |
| model = _prompt_text("Model override", str(config.get("model") or ""), allow_blank=True) | |
| dangerously_bypass = _prompt_yes_no("Use dangerous bypass", bool(config.get("dangerously_bypass", False))) | |
| sandbox = config.get("sandbox", "workspace-write") | |
| if not dangerously_bypass: | |
| sandbox = _prompt_choice("Sandbox", SANDBOX_OPTIONS, str(sandbox)) | |
| else: | |
| backend = str(config.get("backend", "codex")) | |
| # Determine a safe default backend for this custom agent | |
| backend_default = str(config.get("backend", "codex")) | |
| if backend_default not in VALID_BACKENDS: | |
| backend_default = "codex" if "codex" in VALID_BACKENDS else next(iter(VALID_BACKENDS)) | |
| enabled = _prompt_yes_no("Keep this agent enabled", bool(config.get("enabled", True))) | |
| if enabled: | |
| backend = _prompt_choice("Backend", list(VALID_BACKENDS), backend_default) | |
| model = _prompt_text("Model override", str(config.get("model") or ""), allow_blank=True) | |
| dangerously_bypass = _prompt_yes_no("Use dangerous bypass", bool(config.get("dangerously_bypass", False))) | |
| sandbox = config.get("sandbox", "workspace-write") | |
| if not dangerously_bypass: | |
| sandbox = _prompt_choice("Sandbox", SANDBOX_OPTIONS, str(sandbox)) | |
| else: | |
| backend = backend_default |
| from_agent=args.from_agent, | ||
| to_agent=args.agent, | ||
| task_type=args.task_type or DEFAULT_TASK_TYPES[args.agent], | ||
| task_type=args.task_type or DEFAULT_TASK_TYPES.get(args.agent, agent_config.get("role", "custom")), |
There was a problem hiding this comment.
task_type fallback can become None if a custom agent has role: null (or an empty value) in the registry, because dict.get('role', 'custom') returns the stored None. This would emit TaskPackets with a non-string task_type. Prefer an explicit fallback like (agent_config.get('role') or 'custom') (and similarly guard other optional string fields).
| effective_model = model or agent_config.get("model") | ||
| effective_dangerous = dangerous if dangerous is not None else bool(agent_config.get("dangerously_bypass", False)) | ||
| effective_sandbox = None if effective_dangerous else (sandbox or agent_config.get("sandbox") or "workspace-write") | ||
| effective_backend = str(agent_config.get("backend", "codex")) |
There was a problem hiding this comment.
Casting the backend to str means an explicitly-null/None backend in the registry becomes the literal string 'None', which then fails dispatch with a confusing "Unknown backend" error. Treat missing/falsey backend values as the default (e.g., agent_config.get('backend') or 'codex') and optionally validate earlier for a clearer error.
| effective_backend = str(agent_config.get("backend", "codex")) | |
| effective_backend = agent_config.get("backend") or "codex" |
…null role fallback - Validate backend defaults against VALID_BACKENDS before prompting; fall back to 'codex' when value is None or invalid (config_ui.py) - Use allow_blank=True for name/description prompts in custom agent creation so users can cancel with blank input (config_ui.py) - Guard against null role with `or` fallback in task_type (runtime_cli.py) - Use `or 'codex'` instead of str() cast for backend in delegate to avoid 'None' string on null values (runtime_cli.py) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When selecting a backend during tower init (custom mode), the CLI now shows a numbered list of 4-5 recommended models for that backend instead of a free-text model override prompt. Users can pick by number, type a model name directly, or choose "custom" for free input. Applies to built-in agent config, existing custom agent config, and new custom agent creation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Cursor's headless CLI is 'agent', not 'cursor --headless'. Updated: - Interactive: agent --workspace <path> [--model] [--force|--sandbox] <prompt> - Exec: agent -p --workspace <path> --output-format json [--model] <prompt> captures stdout JSON and writes to output_path - Pass dangerous flag through dispatch as --force for Cursor Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace placeholder model names with real Cursor model slugs: - composer-2 (default, Cursor's latest in-house model) - opus-4.6-thinking - sonnet-4.6-thinking - gpt-5.4-high - auto (automatic model selection) Also fix gemini model list (2.5-flash-lite, not 2.0-flash-lite). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add feedback message when blank input rejected in _prompt_text - Fix role prompt to use default="custom" instead of spinning on blank - Guard task_type fallback against None role values in registry - Backend validation already addressed in prior commits Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Summary
tower start/tower-run delegatecan execute through any supported AI runtimetower init(custom mode) or direct config editing, with per-agent backend, model, sandbox, and prompt file settingstower-run create-packetandtower-run delegateChanges
backends.py(new): Dispatch layer forcodex,gemini, andcursorheadless execution withrun_interactive()andrun_exec()agents.py: Addedbackendfield to agent definitions,make_custom_agent_entry()helper,list_registered_agents()/list_enabled_agents()utilitiesconfig_ui.py: Custom agent creation flow intower init --custom— prompts for name, role, description, backend, model, sandbox, and prompt filecli.py:--backendflag ontower start/tower resume, backend shown intower statusruntime_cli.py: Dynamic agent names (no hardcoded choices), backend dispatch incmd_delegateprompts.py: Custom agent prompt/policy loading with graceful fallbacks for agents without template filesbootstrap.py: Auto-scaffold custom agent directories and prompt files ontower initTest plan
Closes #16
🤖 Generated with Claude Code