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
28 changes: 28 additions & 0 deletions loony_dev/agents/plugins.py
Original file line number Diff line number Diff line change
@@ -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),
]
9 changes: 4 additions & 5 deletions loony_dev/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
24 changes: 8 additions & 16 deletions loony_dev/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,27 +16,25 @@

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__(
self,
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
Expand Down Expand Up @@ -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):
Expand Down
Empty file added loony_dev/plugins/__init__.py
Empty file.
54 changes: 54 additions & 0 deletions loony_dev/plugins/base.py
Original file line number Diff line number Diff line change
@@ -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.
"""
...
148 changes: 148 additions & 0 deletions loony_dev/plugins/loader.py
Original file line number Diff line number Diff line change
@@ -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
29 changes: 29 additions & 0 deletions loony_dev/tasks/plugins.py
Original file line number Diff line number Diff line change
@@ -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,
]
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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*"]