From 02c19e5391bd5baaa7ab84073c38118cc30cbcc1 Mon Sep 17 00:00:00 2001 From: shiv Date: Fri, 14 Aug 2026 16:28:35 -0400 Subject: [PATCH 1/2] fix: widen CycleState.mode from Literal to str for plugin modes Plugin modes registered via add_modes() failed Pydantic validation when headless mode created a CycleState, since the Literal type only accepted hardcoded built-in mode names. Closes #1262 Co-Authored-By: Claude Opus 4.6 (1M context) --- factory/models.py | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/factory/models.py b/factory/models.py index 99304f6d0..1cdd5235d 100644 --- a/factory/models.py +++ b/factory/models.py @@ -491,24 +491,7 @@ class CycleState(BaseModel): cycle_id: str started_at: datetime - mode: Literal[ - "build", - "create", - "deep-qa", - "deep-research", - "design", - "discover", - "founder", - "improve", - "meta", - "parallel-improve", - "qa", - "refine", - "research", - "review", - "study", - "swebench", - ] + mode: str initial_prompt: str = "" respawns: int = 0 runner_name: str | None = None From 1b58e3f48843161690ac5232563e705ca01bebe5 Mon Sep 17 00:00:00 2001 From: shiv Date: Fri, 14 Aug 2026 16:29:18 -0400 Subject: [PATCH 2/2] fix: allow plugin-registered agent roles in factory agent CLI (#1260) The hardcoded choices list in argparse rejected plugin-registered roles before the agent runner could execute. Move validation to cmd_agent() where it checks both BUILTIN_AGENT_ROLES and plugin-registered roles via get_registry().agent_roles. Add add_agent_roles() to PluginRegistry following the same collision-guard pattern as add_modes(). Co-Authored-By: Claude Opus 4.6 (1M context) --- factory/cli/_main.py | 2 ++ factory/cli/_parser_groups.py | 12 +++++++----- factory/cli/agents.py | 12 ++++++++++++ factory/plugins.py | 13 +++++++++++++ 4 files changed, 34 insertions(+), 5 deletions(-) diff --git a/factory/cli/_main.py b/factory/cli/_main.py index e5929e5fe..e231c5f71 100644 --- a/factory/cli/_main.py +++ b/factory/cli/_main.py @@ -213,6 +213,8 @@ def _cmd_plugins(args: argparse.Namespace) -> int: print(f"\nRegistered commands: {', '.join(sorted(registry.commands))}") if registry.modes: print(f"Registered modes: {', '.join(registry.modes)}") + if registry.agent_roles: + print(f"Registered agent roles: {', '.join(registry.agent_roles)}") return 0 diff --git a/factory/cli/_parser_groups.py b/factory/cli/_parser_groups.py index cf1253a4a..e0efcf2f4 100644 --- a/factory/cli/_parser_groups.py +++ b/factory/cli/_parser_groups.py @@ -3,6 +3,11 @@ import argparse +BUILTIN_AGENT_ROLES: frozenset[str] = frozenset({ + "researcher", "strategist", "builder", + "health_checker", "code_reviewer", "adversarial_tester", + "archivist", "ceo", "failure_analyst", "refiner", +}) def add_project_setup_parsers(sub: argparse._SubParsersAction) -> None: # type: ignore[type-arg] @@ -334,11 +339,8 @@ def add_validation_recovery_parsers(sub: argparse._SubParsersAction) -> None: # def add_entry_point_parsers(sub: argparse._SubParsersAction) -> None: # type: ignore[type-arg] p = sub.add_parser("agent", help="Invoke a specialist agent with a task") - p.add_argument("role", choices=["researcher", "strategist", "builder", - "health_checker", "code_reviewer", "adversarial_tester", - "archivist", "ceo", - "failure_analyst", "refiner"], - help="Agent role to invoke") + p.add_argument("role", + help="Agent role to invoke (built-in or plugin-registered)") p.add_argument("--task", required=True, help="Task description for the agent") p.add_argument("--project", required=True, help="Path to the project") p.add_argument("--timeout", type=float, default=600.0, diff --git a/factory/cli/agents.py b/factory/cli/agents.py index 973c8596a..f53160c0d 100644 --- a/factory/cli/agents.py +++ b/factory/cli/agents.py @@ -158,12 +158,24 @@ def cmd_agent(args: argparse.Namespace) -> int: """Invoke a specialist agent with the given task.""" from factory.agents.plugin import load_agent_config from factory.agents.runner import invoke_agent + from factory.cli._parser_groups import BUILTIN_AGENT_ROLES + from factory.plugins import get_registry from factory.user_config import load_config profile = getattr(args, "profile", None) load_config(profile=profile) role = args.role + plugin_roles = set(get_registry().agent_roles) + valid_roles = BUILTIN_AGENT_ROLES | plugin_roles + if role not in valid_roles: + print( + f"Error: unknown agent role '{role}'. " + f"Valid roles: {', '.join(sorted(valid_roles))}", + file=sys.stderr, + ) + return 1 + task = args.task project_path = Path(args.project).resolve() timeout = getattr(args, "timeout", 600.0) diff --git a/factory/plugins.py b/factory/plugins.py index bb55294af..9fcb19815 100644 --- a/factory/plugins.py +++ b/factory/plugins.py @@ -49,6 +49,7 @@ class PluginLoadResult: class PluginRegistry: commands: dict[str, CommandSpec] = field(default_factory=dict) modes: list[str] = field(default_factory=list) + agent_roles: list[str] = field(default_factory=list) ceo_pre_hooks: list[Callable[..., Any]] = field(default_factory=list) workflow_search_paths: list[str] = field(default_factory=list) parser_extensions: dict[str, list[Callable[[argparse.ArgumentParser], None]]] = field( @@ -77,6 +78,18 @@ def add_modes(self, modes: list[str]) -> None: continue self.modes.append(mode) + def add_agent_roles(self, roles: list[str]) -> None: + from factory.cli._parser_groups import BUILTIN_AGENT_ROLES + + for role in roles: + if role in BUILTIN_AGENT_ROLES: + log.warning("plugin_agent_role_collision_builtin", role=role, action="skipped") + continue + if role in self.agent_roles: + log.warning("plugin_agent_role_collision", role=role, action="keeping_first") + continue + self.agent_roles.append(role) + def add_ceo_pre_hook(self, hook: Callable[..., Any]) -> None: self.ceo_pre_hooks.append(hook)