diff --git a/loony_dev/agents/plugins.py b/loony_dev/agents/plugins.py new file mode 100644 index 0000000..7208dc4 --- /dev/null +++ b/loony_dev/agents/plugins.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +from loony_dev.agents.base import Agent +from loony_dev.agents.coding import CodingAgent +from loony_dev.agents.null_agent import NullAgent +from loony_dev.agents.planning import PlanningAgent +from loony_dev.plugins.base import AgentPlugin + +if TYPE_CHECKING: + from loony_dev.config import Settings + + +class ClaudeAgentPlugin(AgentPlugin): + """Built-in agent plugin that registers the Claude Code and planning agents.""" + + @property + def name(self) -> str: + return "claude" + + def create_agents(self, work_dir: Path, settings: Settings) -> list[Agent]: + return [ + NullAgent(), + CodingAgent(work_dir=work_dir), + PlanningAgent(work_dir=work_dir), + ] diff --git a/loony_dev/cli.py b/loony_dev/cli.py index 90ffe46..5554a6b 100644 --- a/loony_dev/cli.py +++ b/loony_dev/cli.py @@ -6,12 +6,10 @@ import click from loony_dev import config -from loony_dev.agents.coding import CodingAgent -from loony_dev.agents.null_agent import NullAgent -from loony_dev.agents.planning import PlanningAgent from loony_dev.git import GitRepo from loony_dev.github import GitHubClient from loony_dev.orchestrator import Orchestrator +from loony_dev.plugins.loader import load_agent_plugins, load_task_plugins @click.group(cls=config.ClickGroup) @@ -81,9 +79,10 @@ def worker(**_) -> None: default_branch = github.detect_default_branch() click.echo(f"Default branch: {default_branch}") git = GitRepo(work_dir=work_path, default_branch=default_branch) - agents = [NullAgent(), CodingAgent(work_dir=work_path), PlanningAgent(work_dir=work_path)] + task_classes = load_task_plugins() + agents = load_agent_plugins(work_dir=work_path, settings=config.settings) - orchestrator = Orchestrator(github=github, git=git, agents=agents) + orchestrator = Orchestrator(github=github, git=git, agents=agents, task_classes=task_classes) click.echo(f"Starting orchestrator for {repo} (polling every {config.settings.interval}s)") orchestrator.run() diff --git a/loony_dev/orchestrator.py b/loony_dev/orchestrator.py index 7c570e2..2ac1d23 100644 --- a/loony_dev/orchestrator.py +++ b/loony_dev/orchestrator.py @@ -5,15 +5,9 @@ import time from typing import TYPE_CHECKING -from loony_dev.tasks.ci_failure_task import CIFailureTask -from loony_dev.tasks.conflict_task import ConflictResolutionTask -from loony_dev.tasks.issue_task import IssueTask -from loony_dev.tasks.planning_task import PlanningTask -from loony_dev.tasks.pr_review_task import PRReviewTask -from loony_dev.tasks.stuck_item_task import StuckItemCleanupTask - from loony_dev.models import RateLimitedError + if TYPE_CHECKING: from loony_dev.agents.base import Agent from loony_dev.git import GitRepo @@ -22,14 +16,6 @@ logger = logging.getLogger(__name__) -# Task classes ordered by priority (lowest number = highest priority). -# The orchestrator iterates these in order, stopping as soon as it finds -# a task that some configured agent can handle. -TASK_CLASSES = sorted( - [StuckItemCleanupTask, ConflictResolutionTask, CIFailureTask, PRReviewTask, PlanningTask, IssueTask], - key=lambda tc: tc.priority, -) - class Orchestrator: def __init__( @@ -37,12 +23,18 @@ def __init__( github: GitHubClient, git: GitRepo, agents: list[Agent], + task_classes: list[type[Task]] | None = None, interval: int | None = None, ) -> None: from loony_dev import config self.github = github self.git = git self.agents = agents + self.task_classes: list[type[Task]] = ( + sorted(task_classes, key=lambda tc: tc.priority) + if task_classes is not None + else [] + ) self.interval = interval if interval is not None else config.settings.get("interval", 60) self._shutdown_requested: bool = False self._graceful_shutdown: bool = False @@ -113,7 +105,7 @@ def _find_work(self) -> tuple[Task, Agent] | None: Each task class's discover() is an iterator so discovery stops as soon as a handleable task is found — avoiding unnecessary GitHub API calls. """ - for task_class in TASK_CLASSES: + for task_class in self.task_classes: logger.debug("Checking %s for work...", task_class.__name__) found_in_class = 0 for task in task_class.discover(self.github): diff --git a/loony_dev/plugins/__init__.py b/loony_dev/plugins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/loony_dev/plugins/base.py b/loony_dev/plugins/base.py new file mode 100644 index 0000000..04c0ee5 --- /dev/null +++ b/loony_dev/plugins/base.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from loony_dev.agents.base import Agent + from loony_dev.config import Settings + from loony_dev.tasks.base import Task + + +class PluginConflictError(Exception): + """Raised when two plugins register conflicting task types or agent names.""" + + +class TaskPlugin(ABC): + """Bundles one or more Task subclasses for registration with the orchestrator.""" + + @property + @abstractmethod + def name(self) -> str: + """Unique identifier for this plugin. Used for conflict detection.""" + ... + + @property + @abstractmethod + def task_classes(self) -> list[type[Task]]: + """Return Task subclasses to register with the orchestrator. + + Each class must declare a ``task_type: str`` and ``priority: int``. + The orchestrator sorts all registered classes by priority across plugins. + """ + ... + + +class AgentPlugin(ABC): + """Bundles one or more Agent instances for registration with the orchestrator.""" + + @property + @abstractmethod + def name(self) -> str: + """Unique identifier for this plugin. Used for conflict detection.""" + ... + + @abstractmethod + def create_agents(self, work_dir: Path, settings: Settings) -> list[Agent]: + """Instantiate and return Agent objects. + + ``settings`` provides access to the full resolved configuration so that + plugins can read their own keys without coupling to environment variables + directly. + """ + ... diff --git a/loony_dev/plugins/loader.py b/loony_dev/plugins/loader.py new file mode 100644 index 0000000..4128374 --- /dev/null +++ b/loony_dev/plugins/loader.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +import logging +import os +from importlib import import_module +from importlib.metadata import entry_points +from pathlib import Path +from typing import TYPE_CHECKING + +from loony_dev.plugins.base import PluginConflictError + +if TYPE_CHECKING: + from loony_dev.agents.base import Agent + from loony_dev.config import Settings + from loony_dev.tasks.base import Task + +logger = logging.getLogger(__name__) + +TASK_GROUP = "loony_dev.task_plugins" +AGENT_GROUP = "loony_dev.agent_plugins" + +# Environment variable for local development overrides (comma-separated +# "module:ClassName" paths loaded in addition to entry-point plugins). +_EXTRA_PLUGINS_ENV = "LOONY_DEV_EXTRA_PLUGINS" + + +def _load_extra_task_plugins() -> list[object]: + """Load additional task plugins from LOONY_DEV_EXTRA_PLUGINS env var.""" + raw = os.environ.get(_EXTRA_PLUGINS_ENV, "").strip() + if not raw: + return [] + plugins = [] + for spec in raw.split(","): + spec = spec.strip() + if not spec: + continue + try: + module_path, class_name = spec.rsplit(":", 1) + module = import_module(module_path) + cls = getattr(module, class_name) + plugins.append(cls()) + logger.info("Loaded extra task plugin from env: %s", spec) + except Exception: + logger.exception("Failed to load extra task plugin from env: %s", spec) + return plugins + + +def _load_extra_agent_plugins() -> list[object]: + """Load additional agent plugins from LOONY_DEV_EXTRA_PLUGINS env var.""" + raw = os.environ.get(_EXTRA_PLUGINS_ENV, "").strip() + if not raw: + return [] + plugins = [] + for spec in raw.split(","): + spec = spec.strip() + if not spec: + continue + try: + module_path, class_name = spec.rsplit(":", 1) + module = import_module(module_path) + cls = getattr(module, class_name) + plugins.append(cls()) + logger.info("Loaded extra agent plugin from env: %s", spec) + except Exception: + logger.exception("Failed to load extra agent plugin from env: %s", spec) + return plugins + + +def load_task_plugins() -> list[type[Task]]: + """Discover and load all task plugins via entry points. + + Returns task classes sorted by priority (ascending — lower number = higher + priority). Raises :exc:`PluginConflictError` on name or task-type conflicts. + Logs and skips individual plugins that raise unexpected errors on load. + """ + seen_names: dict[str, str] = {} # plugin name → ep.name + seen_task_types: dict[str, str] = {} # task_type → plugin name + task_classes: list[type[Task]] = [] + + eps = list(entry_points(group=TASK_GROUP)) + logger.debug("Found %d task plugin entry point(s) in group '%s'", len(eps), TASK_GROUP) + + for ep in eps: + try: + plugin_cls = ep.load() + plugin = plugin_cls() + + if plugin.name in seen_names: + raise PluginConflictError( + f"Task plugin name '{plugin.name}' is claimed by both " + f"'{seen_names[plugin.name]}' and '{ep.name}'" + ) + seen_names[plugin.name] = ep.name + + for cls in plugin.task_classes: + task_type = cls.task_type + if task_type in seen_task_types: + raise PluginConflictError( + f"Task type '{task_type}' is registered by both " + f"'{seen_task_types[task_type]}' and '{plugin.name}'" + ) + seen_task_types[task_type] = plugin.name + task_classes.append(cls) + + logger.info("Loaded task plugin: %s (provides %d task type(s))", ep.name, len(plugin.task_classes)) + except PluginConflictError: + raise + except Exception: + logger.exception("Failed to load task plugin: %s — skipping", ep.name) + + return sorted(task_classes, key=lambda tc: tc.priority) + + +def load_agent_plugins(work_dir: Path, settings: Settings) -> list[Agent]: + """Discover and load all agent plugins via entry points. + + Returns a flat list of instantiated :class:`~loony_dev.agents.base.Agent` + objects ready for use by the orchestrator. Raises :exc:`PluginConflictError` + on plugin name conflicts. Logs and skips individual plugins that raise + unexpected errors on load. + """ + seen_names: dict[str, str] = {} # plugin name → ep.name + agents: list[Agent] = [] + + eps = list(entry_points(group=AGENT_GROUP)) + logger.debug("Found %d agent plugin entry point(s) in group '%s'", len(eps), AGENT_GROUP) + + for ep in eps: + try: + plugin_cls = ep.load() + plugin = plugin_cls() + + if plugin.name in seen_names: + raise PluginConflictError( + f"Agent plugin name '{plugin.name}' is claimed by both " + f"'{seen_names[plugin.name]}' and '{ep.name}'" + ) + seen_names[plugin.name] = ep.name + + new_agents = plugin.create_agents(work_dir, settings) + agents.extend(new_agents) + logger.info("Loaded agent plugin: %s (provides %d agent(s))", ep.name, len(new_agents)) + except PluginConflictError: + raise + except Exception: + logger.exception("Failed to load agent plugin: %s — skipping", ep.name) + + return agents diff --git a/loony_dev/tasks/plugins.py b/loony_dev/tasks/plugins.py new file mode 100644 index 0000000..7e1574b --- /dev/null +++ b/loony_dev/tasks/plugins.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from loony_dev.plugins.base import TaskPlugin +from loony_dev.tasks.base import Task +from loony_dev.tasks.ci_failure_task import CIFailureTask +from loony_dev.tasks.conflict_task import ConflictResolutionTask +from loony_dev.tasks.issue_task import IssueTask +from loony_dev.tasks.planning_task import PlanningTask +from loony_dev.tasks.pr_review_task import PRReviewTask +from loony_dev.tasks.stuck_item_task import StuckItemCleanupTask + + +class GithubTaskPlugin(TaskPlugin): + """Built-in task plugin that registers all GitHub-backed task types.""" + + @property + def name(self) -> str: + return "github" + + @property + def task_classes(self) -> list[type[Task]]: + return [ + StuckItemCleanupTask, + ConflictResolutionTask, + CIFailureTask, + PRReviewTask, + PlanningTask, + IssueTask, + ] diff --git a/pyproject.toml b/pyproject.toml index 58e7ddc..fd1e7f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,5 +10,11 @@ dependencies = [ [project.scripts] loony-dev = "loony_dev.cli:cli" +[project.entry-points."loony_dev.task_plugins"] +github = "loony_dev.tasks.plugins:GithubTaskPlugin" + +[project.entry-points."loony_dev.agent_plugins"] +claude = "loony_dev.agents.plugins:ClaudeAgentPlugin" + [tool.setuptools.packages.find] include = ["loony_dev*"]