From 8aaca7bf7448b24f5aa42bba826d9a7e6b39c930 Mon Sep 17 00:00:00 2001 From: Ari Aye Date: Thu, 10 Sep 2026 12:20:41 -0700 Subject: [PATCH 1/2] feat: sanctioned AgentRole extension for plugin agent roles (closes #1484) register_agent_role() in factory/workflow/primitives.py gives plugins a tested, collision-checked way to extend the AgentRole enum so their roles work inside workflow graphs (AgentNode.role, GateNode.evaluator_role), matching what docs/plugins.md already promised. PluginRegistry.add_agent_roles() now calls it, so a role registered at the plugin level is usable in both the CLI and graphs without enum-mutation workarounds. Beyond centralizing the insertion, this fixes a latent bug in the import-time mutation workaround: Pydantic freezes an enum's valid values into model core schemas at class-definition time, so workflows containing dynamically added roles failed JSON validation (and thus to_dict/from_dict roundtrips via json paths). register_agent_role() rebuilds the schemas of models defined in primitives.py after insertion, so serialization is roundtrip-clean. Builtin collisions raise ValueError at the register_agent_role level; add_agent_roles keeps its existing skip-with-warning behavior, consistent with the other registry methods. Co-Authored-By: Claude Code --- docs/plugins.md | 4 +- factory/plugins.py | 8 ++ factory/workflow/__init__.py | 2 + factory/workflow/primitives.py | 88 ++++++++++++ tests/test_plugin_agent_roles.py | 230 +++++++++++++++++++++++++++++++ 5 files changed, 330 insertions(+), 2 deletions(-) create mode 100644 tests/test_plugin_agent_roles.py diff --git a/docs/plugins.md b/docs/plugins.md index 0f450534f..7eca7df79 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -51,13 +51,13 @@ 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. 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. 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. diff --git a/factory/plugins.py b/factory/plugins.py index 9fcb19815..552ae340d 100644 --- a/factory/plugins.py +++ b/factory/plugins.py @@ -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: @@ -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: diff --git a/factory/workflow/__init__.py b/factory/workflow/__init__.py index 053dbbbe0..2e5d788d5 100644 --- a/factory/workflow/__init__.py +++ b/factory/workflow/__init__.py @@ -12,6 +12,7 @@ GateNode, JoinNode, SelectionNode, + register_agent_role, Study, SubgraphForkNode, Verdict, @@ -32,6 +33,7 @@ "JoinNode", "SelectionNode", "Study", + "register_agent_role", "SubgraphForkNode", "Verdict", "VerdictType", diff --git a/factory/workflow/primitives.py b/factory/workflow/primitives.py index 843bca741..b89cd0185 100644 --- a/factory/workflow/primitives.py +++ b/factory/workflow/primitives.py @@ -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 ────────────────────────────────────────────────────── diff --git a/tests/test_plugin_agent_roles.py b/tests/test_plugin_agent_roles.py new file mode 100644 index 000000000..be286e1ac --- /dev/null +++ b/tests/test_plugin_agent_roles.py @@ -0,0 +1,230 @@ +"""Tests for plugin agent role registration (sanctioned AgentRole extension).""" + +from __future__ import annotations + +import pytest + +from factory.plugins import PluginRegistry +from factory.workflow.primitives import ( + AgentConfig, + AgentNode, + AgentRole, + Factory, + GateNode, + Workflow, + _registered_plugin_roles, + _rebuild_role_schemas, + register_agent_role, +) + + +@pytest.fixture(autouse=True) +def _restore_agent_role(): + """Snapshot AgentRole state and restore it after each test. + + register_agent_role mutates a process-global enum; without this fixture + roles registered in one test would leak into every later test (and into + other suites, where e.g. the outer loop picks roles with + random.choice(list(AgentRole))). + """ + saved_members = dict(AgentRole._member_map_) + saved_values = dict(AgentRole._value2member_map_) + saved_names = list(AgentRole._member_names_) + saved_attrs = [n for n in vars(AgentRole) if n.isupper() and isinstance(getattr(AgentRole, n), AgentRole)] + saved_registered = dict(_registered_plugin_roles) + yield + + AgentRole._member_map_.clear() + AgentRole._member_map_.update(saved_members) + AgentRole._value2member_map_.clear() + AgentRole._value2member_map_.update(saved_values) + AgentRole._member_names_[:] = saved_names + for attr in saved_attrs: + type.__setattr__(AgentRole, attr, saved_members[attr]) + for name in [n for n in vars(AgentRole) if n.isupper()]: + if name not in saved_members: + type.__delattr__(AgentRole, name) + _registered_plugin_roles.clear() + _registered_plugin_roles.update(saved_registered) + _rebuild_role_schemas() + + +class TestRegisterAgentRole: + def test_returns_member_with_derived_name(self): + member = register_agent_role("paper-reader") + assert member.name == "PAPER_READER" + assert member.value == "paper-reader" + assert AgentRole.PAPER_READER is member + assert AgentRole("paper-reader") is member + assert member in list(AgentRole) + + def test_custom_member_name(self): + member = register_agent_role("cve-judge", name="CVE_JUDGE_ROLE") + assert member.name == "CVE_JUDGE_ROLE" + assert member.value == "cve-judge" + + def test_idempotent(self): + first = register_agent_role("paper-reader") + assert register_agent_role("paper-reader") is first + + def test_idempotent_with_same_custom_name(self): + first = register_agent_role("x-role", name="X_ROLE") + assert register_agent_role("x-role", name="X_ROLE") is first + + def test_builtin_value_collision_raises(self): + with pytest.raises(ValueError, match="already exists"): + register_agent_role("builder") + + def test_builtin_name_collision_via_custom_name_raises(self): + with pytest.raises(ValueError, match="already exists"): + register_agent_role("totally-new", name="BUILDER") + + def test_value_collision_with_plugin_role_raises(self): + register_agent_role("dup-role", name="DUP_A") + # same value under a different member name → rejected (name-mismatch error) + with pytest.raises(ValueError, match="member name"): + register_agent_role("dup-role", name="DUP_B") + # same value under the same name → idempotent + assert register_agent_role("dup-role", name="DUP_A") is AgentRole.DUP_A + + def test_name_mismatch_on_reregistration_raises(self): + register_agent_role("y-role") + with pytest.raises(ValueError, match="member name"): + register_agent_role("y-role", name="OTHER_NAME") + + def test_invalid_identifier_raises(self): + with pytest.raises(ValueError, match="valid enum member name"): + register_agent_role("bad role!") + + def test_empty_role_raises(self): + with pytest.raises(ValueError, match="non-empty"): + register_agent_role(" ") + + +class TestGraphUsage: + def test_agent_node_accepts_plugin_role(self): + member = register_agent_role("paper-reader") + node = AgentNode(id="read", role=member) + assert node.role is member + + def test_lax_validation_accepts_role_string(self): + """from_dict() validates node data with strict=False; plugin roles must + behave like builtins there.""" + register_agent_role("paper-reader") + node = AgentNode.model_validate( + {"id": "read", "role": "paper-reader"}, strict=False + ) + assert node.role is AgentRole.PAPER_READER + + def test_gate_node_accepts_plugin_role(self): + member = register_agent_role("paper-reader") + gate = GateNode(id="g", evaluator_role=member) + assert gate.evaluator_role is member + + def test_agent_config_accepts_plugin_role(self): + member = register_agent_role("paper-reader") + config = AgentConfig(role=member, model="opus") + assert config.role is member + + def test_workflow_json_roundtrip(self): + """Plain enum mutation leaves Pydantic's frozen schema stale, so JSON + validation of plugin roles fails. register_agent_role must fix that.""" + member = register_agent_role("paper-reader") + wf = Workflow( + name="t", + nodes={"a": AgentNode(id="a", role=member)}, + edges=[], + start_node="a", + ) + restored = Workflow.model_validate_json(wf.model_dump_json()) + assert restored.nodes["a"].role is member + + def test_workflow_to_from_dict_roundtrip(self): + member = register_agent_role("paper-reader") + wf = Workflow( + name="t", + nodes={"a": AgentNode(id="a", role=member)}, + edges=[], + start_node="a", + ) + restored = Workflow.from_dict(wf.to_dict()) + assert restored.nodes["a"].role is member + + def test_gate_node_json_roundtrip(self): + member = register_agent_role("paper-reader") + gate = GateNode(id="g", evaluator_role=member) + restored = GateNode.model_validate_json(gate.model_dump_json()) + assert restored.evaluator_role is member + + def test_factory_container_roundtrip(self): + member = register_agent_role("paper-reader") + factory = Factory( + agent_pool={"paper-reader": AgentConfig(role=member, model="sonnet")}, + workflows={}, + ) + restored = Factory.model_validate_json(factory.model_dump_json()) + assert restored.agent_pool["paper-reader"].role is member + + def test_builtin_roles_still_validate_after_registration(self): + register_agent_role("paper-reader") + wf = Workflow( + name="t", + nodes={"a": AgentNode(id="a", role=AgentRole.BUILDER)}, + edges=[], + start_node="a", + ) + restored = Workflow.model_validate_json(wf.model_dump_json()) + assert restored.nodes["a"].role is AgentRole.BUILDER + + +class TestSkillExportRendering: + def test_plugin_role_renders_agent_command(self): + from factory.workflow.skill_export import workflow_to_skill_md + + member = register_agent_role("paper-reader") + node = AgentNode(id="read", role=member) + wf = Workflow(name="t", nodes={"read": node}, edges=[], start_node="read") + content = workflow_to_skill_md(wf) + assert "factory agent paper-reader" in content + + +class TestPluginRegistryIntegration: + def test_add_agent_roles_registers_graph_usable_role(self): + registry = PluginRegistry() + registry.add_agent_roles(["paper-reader"]) + assert "paper-reader" in registry.agent_roles + assert AgentRole.PAPER_READER is not None + + node = AgentNode(id="read", role=AgentRole.PAPER_READER) + wf = Workflow(name="t", nodes={"read": node}, edges=[], start_node="read") + restored = Workflow.model_validate_json(wf.model_dump_json()) + assert restored.nodes["read"].role is AgentRole.PAPER_READER + + def test_add_agent_roles_builtin_collision_skipped(self, caplog): + import structlog + + registry = PluginRegistry() + with structlog.testing.capture_logs() as logs: + registry.add_agent_roles(["builder", "fresh-role"]) + assert "builder" not in registry.agent_roles + assert "fresh-role" in registry.agent_roles + events = [e for e in logs if e["event"] == "plugin_agent_role_collision_builtin"] + assert events and events[0]["role"] == "builder" + + def test_add_agent_roles_duplicate_skipped(self): + registry = PluginRegistry() + registry.add_agent_roles(["dup-role"]) + registry.add_agent_roles(["dup-role"]) + assert registry.agent_roles.count("dup-role") == 1 + + def test_add_agent_roles_invalid_role_skipped(self): + import structlog + + registry = PluginRegistry() + with structlog.testing.capture_logs() as logs: + registry.add_agent_roles(["bad role!"]) + assert "bad role!" not in registry.agent_roles + events = [ + e for e in logs if e["event"] == "plugin_agent_role_registration_failed" + ] + assert events and events[0]["role"] == "bad role!" From fb36b95fe47c55baadc71141b285887bc5add5d3 Mon Sep 17 00:00:00 2001 From: Ari Aye Date: Thu, 10 Sep 2026 14:05:12 -0700 Subject: [PATCH 2/2] feat: prompt-resolution contract for plugin agent roles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on #1492: registering a role makes it valid, not behaved — a plugin role still needs its prompt resolvable somewhere, and a broken install (prompt data files missing from the built wheel) only surfaced as a bare FileNotFoundError deep into a CEO cycle. Two safety nets plus docs: - Load time: _warn_missing_role_prompts() runs after plugin registration and warns for any registered role with no user-global or factory-default prompt (a project override still satisfies the role; the warning names that escape hatch). - Invocation time: resolve_prompt's FileNotFoundError now adds a hint that the role is plugin-registered when it is, pointing at wheel data files as the likely cause. - docs/plugins.md documents the three-tier resolution contract for plugin roles and both safety nets. Co-Authored-By: Claude Code --- docs/plugins.md | 15 +++++- factory/agents/runner.py | 17 +++++++ factory/plugins.py | 36 ++++++++++++++ tests/test_plugin_agent_roles.py | 83 ++++++++++++++++++++++++++++++++ 4 files changed, 150 insertions(+), 1 deletion(-) diff --git a/docs/plugins.md b/docs/plugins.md index 7eca7df79..5b634618c 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -51,7 +51,20 @@ When you run `factory ceo /path --mode ml`: └─────────────────────┘ ``` -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. 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: `/.factory/agents/.md` +2. User-global: `~/.factory/agents/prompts/.md` +3. Factory default: `factory/agents/prompts/.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. diff --git a/factory/agents/runner.py b/factory/agents/runner.py index 38d754942..ef00bad2b 100644 --- a/factory/agents/runner.py +++ b/factory/agents/runner.py @@ -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, @@ -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() diff --git a/factory/plugins.py b/factory/plugins.py index 552ae340d..2e6d9e043 100644 --- a/factory/plugins.py +++ b/factory/plugins.py @@ -173,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 + (``/.factory/agents/.md``), user-global + (``~/.factory/agents/prompts/.md``), factory default + (``factory/agents/prompts/.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/.md also works", + ) + + def get_registry() -> PluginRegistry: global _registry if _registry is None: diff --git a/tests/test_plugin_agent_roles.py b/tests/test_plugin_agent_roles.py index be286e1ac..66755f308 100644 --- a/tests/test_plugin_agent_roles.py +++ b/tests/test_plugin_agent_roles.py @@ -228,3 +228,86 @@ def test_add_agent_roles_invalid_role_skipped(self): e for e in logs if e["event"] == "plugin_agent_role_registration_failed" ] assert events and events[0]["role"] == "bad role!" + + +class TestPromptResolutionContract: + """Review feedback on #1492: registering a role makes it valid, not + behaved. A plugin role with no resolvable prompt should be caught at + load time (not deep into a CEO cycle), and the runtime error should + point at plugin packaging as the likely cause.""" + + def test_load_plugins_warns_for_role_without_prompt(self, tmp_path): + import structlog + from factory.plugins import _warn_missing_role_prompts + + registry = PluginRegistry() + registry.add_agent_roles(["ghost-role"]) + + with structlog.testing.capture_logs() as logs: + _warn_missing_role_prompts(registry) + events = [e for e in logs if e["event"] == "plugin_agent_role_prompt_missing"] + assert events and events[0]["role"] == "ghost-role" + assert "escape" not in events[0] # hint present, not an error + + def test_load_plugins_no_warning_when_user_prompt_exists(self, tmp_path, monkeypatch): + import structlog + from factory.agents import runner as runner_mod + from factory.plugins import _warn_missing_role_prompts + + registry = PluginRegistry() + registry.add_agent_roles(["settled-role"]) + + fake_user_dir = tmp_path / ".factory" / "agents" / "prompts" + fake_user_dir.mkdir(parents=True) + (fake_user_dir / "settled-role.md").write_text("# settled role prompt\n") + monkeypatch.setattr(runner_mod, "_USER_PROMPTS_DIR", fake_user_dir) + # the check re-derives the path; patch Path.home for the check + monkeypatch.setattr("pathlib.Path.home", classmethod(lambda cls: tmp_path)) + + with structlog.testing.capture_logs() as logs: + _warn_missing_role_prompts(registry) + assert not [ + e for e in logs if e["event"] == "plugin_agent_role_prompt_missing" + ] + + def test_no_roles_means_no_check_output(self): + import structlog + from factory.plugins import _warn_missing_role_prompts + + with structlog.testing.capture_logs() as logs: + _warn_missing_role_prompts(PluginRegistry()) + assert not [e for e in logs if "prompt_missing" in e["event"]] + + def test_resolve_prompt_error_mentions_plugin_registration(self, tmp_path, monkeypatch): + from unittest.mock import patch + + from factory.agents.runner import resolve_prompt + + registry = PluginRegistry() + registry.add_agent_roles(["ghost-role"]) + + # No prompt anywhere: not a factory builtin, no user-global, no project + with patch("factory.plugins.get_registry", return_value=registry): + try: + resolve_prompt("ghost-role", tmp_path) + except FileNotFoundError as exc: + assert "plugin-registered" in str(exc) + assert "wheel" in str(exc) + else: + pytest.fail("expected FileNotFoundError for role with no prompt") + + def test_resolve_prompt_error_plain_for_builtin_role(self, tmp_path): + from factory.agents.runner import resolve_prompt + + # A builtin role with no prompt file anywhere should NOT get the + # plugin hint (patch the prompts dir to a nonexistent one). + import factory.agents.runner as runner_mod + + original = runner_mod._PROMPTS_DIR + runner_mod._PROMPTS_DIR = tmp_path / "nonexistent" + try: + with pytest.raises(FileNotFoundError) as excinfo: + resolve_prompt("definitely-not-a-real-role", tmp_path) + assert "plugin-registered" not in str(excinfo.value) + finally: + runner_mod._PROMPTS_DIR = original