Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,26 @@ When you run `factory ceo /path --mode ml`:
└─────────────────────┘
```

Plugin workflows can mix plugin-defined agents (`paper-reader`) with built-in agents (`strategist`, `builder`). The engine resolves each role via the [three-tier prompt lookup](architecture.md#layer-3-specialist-agents) — plugin roles ship their own prompt files, typically installed to `~/.factory/agents/prompts/` on first load.
Plugin workflows can mix plugin-defined agents (`paper-reader`) with built-in agents (`strategist`, `builder`). Registering a role via `add_agent_roles()` extends the `AgentRole` enum, so the role works inside workflow graphs (`AgentNode(role=...)`), in the CLI, and in serialization roundtrips — use the enum member (`AgentRole.PAPER_READER`) or the role string where builtins accept strings.

### Prompt resolution contract for plugin roles

Registering a role makes it *valid*; its prompt makes it *behaved*. Roles resolve through the [three-tier lookup](architecture.md#layer-3-specialist-agents), in order:

1. Project override: `<project>/.factory/agents/<role>.md`
2. User-global: `~/.factory/agents/prompts/<role>.md`
3. Factory default: `factory/agents/prompts/<role>.md` (builtins only — plugin roles have none)

A plugin role must therefore ship a prompt file and install it to tier 2 on first load (or rely on projects providing tier 1). Two safety nets catch a broken install:

- **At plugin load time**, every registered role is checked against tiers 2 and 3; a role with no resolvable prompt logs `plugin_agent_role_prompt_missing` (a project override still satisfies it, so the warning names that escape hatch).
- **At invocation time**, `resolve_prompt` raises `FileNotFoundError` naming the expected paths, with an extra hint that the role is plugin-registered when its prompt did not ship with the plugin (e.g. missing data files in the built wheel).

If no workflow exists for a plugin mode, the CEO falls back to its default improve loop using whatever agents are available.

## Collision Protection

- **Builtins always win.** A plugin cannot override a built-in command, mode, or agent role.
- **Builtins always win.** A plugin cannot override a built-in command, mode, or agent role. `add_agent_roles()` skips builtin collisions with a warning; the lower-level `factory.workflow.primitives.register_agent_role()` raises `ValueError` instead.
- **First registration wins.** If two plugins register the same name, the first one (sorted by distribution name) keeps it.
- **Three-tier error isolation.** Failures at any stage (import, validation, registration) are caught, logged, and skipped — a broken plugin never crashes the factory.

Expand Down
17 changes: 17 additions & 0 deletions factory/agents/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@ def __init__(self, failure_count: int, last_agent: str) -> None:
_USER_PROMPTS_DIR = Path.home() / ".factory" / "agents" / "prompts"


def _is_plugin_role(role: str) -> bool:
"""True if the role was registered by a plugin (not a builtin role)."""
try:
from factory.plugins import get_registry

return role in get_registry().agent_roles
except Exception:
return False


def resolve_prompt(
role: AgentRole,
project_path: Path | None = None,
Expand Down Expand Up @@ -112,9 +122,16 @@ def resolve_prompt(
override_hint = (
f" or {project_path / '.factory' / 'agents' / f'{role}.md'}" if project_path else ""
)
plugin_hint = ""
if _is_plugin_role(role):
plugin_hint = (
f" Role '{role}' is plugin-registered; its prompt file likely did "
"not ship with the plugin (check the built wheel's data files)."
)
raise FileNotFoundError(
f"No prompt found for agent role '{role}'. "
f"Expected at {default_path}, {_USER_PROMPTS_DIR / f'{role}.md'}{override_hint}"
f"{plugin_hint}"
)

prompt = default_path.read_text()
Expand Down
44 changes: 44 additions & 0 deletions factory/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ def add_modes(self, modes: list[str]) -> None:

def add_agent_roles(self, roles: list[str]) -> None:
from factory.cli._parser_groups import BUILTIN_AGENT_ROLES
from factory.workflow.primitives import register_agent_role

for role in roles:
if role in BUILTIN_AGENT_ROLES:
Expand All @@ -88,6 +89,13 @@ def add_agent_roles(self, roles: list[str]) -> None:
if role in self.agent_roles:
log.warning("plugin_agent_role_collision", role=role, action="keeping_first")
continue
try:
register_agent_role(role)
except ValueError as exc:
log.warning(
"plugin_agent_role_registration_failed", role=role, error=str(exc)
)
continue
self.agent_roles.append(role)

def add_ceo_pre_hook(self, hook: Callable[..., Any]) -> None:
Expand Down Expand Up @@ -165,9 +173,45 @@ def load_plugins(registry: PluginRegistry | None = None) -> list[PluginLoadResul

_registry = registry
_results = results
_warn_missing_role_prompts(registry)
return results


def _warn_missing_role_prompts(registry: PluginRegistry) -> None:
"""Warn when a plugin-registered agent role has no resolvable prompt.

Roles resolve through the three-tier lookup in
``factory.agents.runner.resolve_prompt``: project override
(``<project>/.factory/agents/<role>.md``), user-global
(``~/.factory/agents/prompts/<role>.md``), factory default
(``factory/agents/prompts/<role>.md``). A plugin role has no factory
default, so a broken install (e.g. prompt data files missing from the
built wheel) only surfaces as a FileNotFoundError at invocation time —
after the CEO has already started. This check catches it at load time;
a project override still satisfies the role, so the warning names that
escape hatch.
"""
if not registry.agent_roles:
return

from pathlib import Path

factory_prompts = Path(__file__).parent / "agents" / "prompts"
user_prompts = Path.home() / ".factory" / "agents" / "prompts"

for role in registry.agent_roles:
if (factory_prompts / f"{role}.md").exists():
continue
if (user_prompts / f"{role}.md").exists():
continue
log.warning(
"plugin_agent_role_prompt_missing",
role=role,
expected_user=str(user_prompts / f"{role}.md"),
hint="a project override at .factory/agents/<role>.md also works",
)


def get_registry() -> PluginRegistry:
global _registry
if _registry is None:
Expand Down
2 changes: 2 additions & 0 deletions factory/workflow/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
GateNode,
JoinNode,
SelectionNode,
register_agent_role,
Study,
SubgraphForkNode,
Verdict,
Expand All @@ -32,6 +33,7 @@
"JoinNode",
"SelectionNode",
"Study",
"register_agent_role",
"SubgraphForkNode",
"Verdict",
"VerdictType",
Expand Down
88 changes: 88 additions & 0 deletions factory/workflow/primitives.py
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,94 @@ def from_dict(cls, data: dict[str, Any]) -> Workflow:
)


# ── plugin role registration ─────────────────────────────────────


# Roles registered through register_agent_role, keyed by value. Lets
# re-registration be idempotent while builtin collisions still raise.
_registered_plugin_roles: dict[str, AgentRole] = {}


def _rebuild_role_schemas() -> None:
"""Rebuild the Pydantic core schemas of models defined in this module.

Pydantic freezes an enum's valid values into the model's core schema at
class-definition time, so a newly added ``AgentRole`` member is invisible
to validation until every model referencing it is rebuilt. Iterating in
``vars()`` order visits children before the models that embed them
(``Workflow``, ``Factory``), which is the order the schema graph needs.
"""
import sys

module = sys.modules[__name__]
for obj in vars(module).values():
if isinstance(obj, type) and issubclass(obj, BaseModel) and obj is not BaseModel:
obj.model_rebuild(force=True)


def register_agent_role(role: str, name: str | None = None) -> AgentRole:
"""Register a plugin-defined agent role, making it usable in workflow graphs.

Extends ``AgentRole`` with a new member so the role can appear in
``AgentNode.role`` and ``GateNode.evaluator_role`` — plain enum mutation
(the workaround plugins used before this API) leaves Pydantic's frozen
schema stale, and workflows containing the role then fail JSON
validation. This function performs the insertion and rebuilds the
affected schemas, so serialization roundtrips work.

``role`` is the role value used in graphs, prompts, and the CLI (e.g.
``"paper-reader"``). The enum member name is derived from it (``PAPER_READER``)
unless ``name`` is given explicitly.

Idempotent: re-registering an existing name with the same value returns
the existing member. Raises ``ValueError`` on name or value collisions
with existing members, or when the derived member name is not a valid
Python identifier.
"""
if not role or not role.strip():
raise ValueError("agent role value must be a non-empty string")
role = role.strip()
if name is None:
name = role.upper().replace("-", "_")
if not name.isidentifier():
raise ValueError(
f"agent role {role!r} does not map to a valid enum member name ({name!r})"
)

previously = _registered_plugin_roles.get(role)
if previously is not None:
if previously.name != name:
raise ValueError(
f"agent role {role!r} is already registered with member name "
f"{previously.name!r}, not {name!r}"
)
return previously

by_name = AgentRole._member_map_.get(name)
if by_name is not None:
raise ValueError(
f"agent role name {name!r} already exists with value {by_name.value!r}"
)
if role in AgentRole._value2member_map_:
raise ValueError(
f"agent role value {role!r} is already taken by member "
f"{AgentRole._value2member_map_[role].name!r}"
)

member = str.__new__(AgentRole, role)
member._name_ = name
member._value_ = role
AgentRole._member_map_[name] = member
AgentRole._value2member_map_[role] = member
type.__setattr__(AgentRole, name, member)
if name not in AgentRole._member_names_:
AgentRole._member_names_.append(name)

_registered_plugin_roles[role] = member
_rebuild_role_schemas()
return member


# ── factory ──────────────────────────────────────────────────────


Expand Down
Loading
Loading