Skip to content

feat: support custom agents and Gemini/Cursor backends#17

Merged
yashturkar merged 9 commits intomainfrom
claude/tender-darwin
Mar 31, 2026
Merged

feat: support custom agents and Gemini/Cursor backends#17
yashturkar merged 9 commits intomainfrom
claude/tender-darwin

Conversation

@yashturkar
Copy link
Copy Markdown
Owner

Summary

  • Add pluggable execution backends (Codex, Gemini, Cursor) so tower start / tower-run delegate can execute through any supported AI runtime
  • Support creating custom agents via tower init (custom mode) or direct config editing, with per-agent backend, model, sandbox, and prompt file settings
  • Remove hardcoded agent choices from CLI parsers — any registered agent can now be targeted by tower-run create-packet and tower-run delegate

Changes

  • backends.py (new): Dispatch layer for codex, gemini, and cursor headless execution with run_interactive() and run_exec()
  • agents.py: Added backend field to agent definitions, make_custom_agent_entry() helper, list_registered_agents() / list_enabled_agents() utilities
  • config_ui.py: Custom agent creation flow in tower init --custom — prompts for name, role, description, backend, model, sandbox, and prompt file
  • cli.py: --backend flag on tower start / tower resume, backend shown in tower status
  • runtime_cli.py: Dynamic agent names (no hardcoded choices), backend dispatch in cmd_delegate
  • prompts.py: Custom agent prompt/policy loading with graceful fallbacks for agents without template files
  • bootstrap.py: Auto-scaffold custom agent directories and prompt files on tower init

Test plan

  • All 54 existing tests pass (no regressions)
  • 20 new tests covering:
    • Custom agent entry creation and validation
    • Custom agent persistence in registry
    • Custom agent inclusion in Tower prompt
    • Subagent prompt generation with fallbacks
    • Custom agent prompt file loading
    • Bootstrap scaffolding of custom agent dirs
    • Backend argument construction (codex, gemini, cursor)
    • Backend rejection for invalid values
    • Delegate routing through agent-configured backend
    • CLI option resolution with backend
    • Status display of custom agents and backends

Closes #16

🤖 Generated with Claude Code

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>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.py dispatching run_interactive() / run_exec() across codex, gemini, and cursor.
  • Extends agent registry to support per-agent backend plus 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 delegate subcommand is no longer Codex-specific, but the --model / --sandbox help 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.

Comment on lines 23 to +26
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"]
]
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_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.

Copilot uses AI. Check for mistakes.
Comment on lines +84 to +94
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(
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_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.

Copilot uses AI. Check for mistakes.
Comment thread src/control_tower/runtime_cli.py Outdated
from datetime import datetime, timezone
from pathlib import Path

from .agents import list_registered_agents
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
from .agents import list_registered_agents

Copilot uses AI. Check for mistakes.
Comment thread src/control_tower/cli.py
Comment on lines 48 to 53
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(
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/control_tower/prompts.py Outdated
Comment on lines +141 to +142
if agent_config.get("prompt_file"):
prompt_path = project_root / str(agent_config["prompt_file"])
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_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.

Suggested change
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."
)

Copilot uses AI. Check for mistakes.
- 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>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/control_tower/config_ui.py Outdated
Comment on lines +263 to +265
name = _prompt_text("Agent display name", "", allow_blank=False)
if not name:
return None
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_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.

Copilot uses AI. Check for mistakes.
Comment thread src/control_tower/config_ui.py Outdated
Comment on lines +272 to +274
description = _prompt_text("Description", "", allow_blank=False)
if not description:
return None
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/control_tower/config_ui.py Outdated
Comment on lines 189 to 193
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)
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment thread src/control_tower/config_ui.py Outdated
Comment on lines +217 to +226
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"))
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
Comment thread src/control_tower/runtime_cli.py Outdated
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")),
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment thread src/control_tower/runtime_cli.py Outdated
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"))
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
effective_backend = str(agent_config.get("backend", "codex"))
effective_backend = agent_config.get("backend") or "codex"

Copilot uses AI. Check for mistakes.
yashturkar and others added 7 commits March 25, 2026 21:41
…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>
@yashturkar yashturkar merged commit 77c1440 into main Mar 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support custom agents and Gemini/Cursor backends

2 participants