Coverage for little_loops / extension.py: 33%
102 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-04-11 23:20 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-04-11 23:20 -0500
1"""Extension system for little-loops.
3Provides the extension Protocol, a reference implementation, and discovery/loading
4utilities for external packages to hook into little-loops via structured events.
6Public exports:
7 LLExtension: Protocol that extensions must satisfy
8 NoopLoggerExtension: Reference extension that logs events to a JSONL file
9 ExtensionLoader: Discovers and loads extensions from config and entry points
10"""
12from __future__ import annotations
14import importlib
15import json
16import logging
17from importlib.metadata import entry_points
18from pathlib import Path
19from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
21from little_loops.events import EventBus, EventCallback, LLEvent
22from little_loops.issue_parser import IssueInfo
24if TYPE_CHECKING:
25 from little_loops.fsm.executor import FSMExecutor, RouteContext, RouteDecision
26 from little_loops.fsm.persistence import PersistentExecutor
27 from little_loops.fsm.runners import ActionRunner
28 from little_loops.fsm.types import Evaluator
30logger = logging.getLogger(__name__)
32ENTRY_POINT_GROUP = "little_loops.extensions"
35@runtime_checkable
36class LLExtension(Protocol):
37 """Protocol for little-loops extensions.
39 Any class with an ``on_event`` method matching this signature is a valid
40 extension. Extensions receive structured events from the EventBus.
42 Optionally, an extension may declare ``event_filter`` to subscribe only to
43 specific event namespaces via glob patterns (e.g. ``"issue.*"`` or
44 ``["issue.*", "parallel.*"]``). ``None`` (the default) means the extension
45 receives every event.
46 """
48 event_filter: str | list[str] | None
50 def on_event(self, event: LLEvent) -> None:
51 """Handle an event from little-loops.
53 Args:
54 event: Structured event with type, timestamp, and payload
55 """
56 ...
59class InterceptorExtension(Protocol):
60 """Protocol for extensions that intercept FSM routing decisions.
62 Detected via hasattr() in wire_extensions() — no @runtime_checkable needed.
63 All methods are optional to implement individually; detection is per-method.
64 """
66 def before_route(self, context: RouteContext) -> RouteDecision | None:
67 """Called before routing; return RouteDecision to redirect or veto, None to pass through."""
68 ...
70 def after_route(self, context: RouteContext) -> None:
71 """Called after routing is resolved (observational only)."""
72 ...
74 def before_issue_close(self, info: IssueInfo) -> bool | None:
75 """Called before an issue is closed; return False to veto, None to pass through."""
76 ...
79class ActionProviderExtension(Protocol):
80 """Protocol for extensions that contribute custom actions to FSM loops.
82 Detected via hasattr() in wire_extensions() — no @runtime_checkable needed.
83 """
85 def provided_actions(self) -> dict[str, ActionRunner]:
86 """Return a mapping of action name → ActionRunner for injection into the executor."""
87 ...
90class EvaluatorProviderExtension(Protocol):
91 """Protocol for extensions that contribute custom evaluators to FSM loops.
93 Detected via hasattr() in wire_extensions() — no @runtime_checkable needed.
94 """
96 def provided_evaluators(self) -> dict[str, Evaluator]:
97 """Return a mapping of evaluator type name → Evaluator callable."""
98 ...
101class NoopLoggerExtension:
102 """Reference extension that logs events to a JSONL file.
104 Demonstrates the extension API by writing each event as a JSON line
105 to a specified log file. Useful for debugging and as a template for
106 building custom extensions.
107 """
109 def __init__(self, log_path: Path | None = None) -> None:
110 self._log_path = log_path or Path(".ll/extension-events.jsonl")
111 self._log_path.parent.mkdir(parents=True, exist_ok=True)
113 def on_event(self, event: LLEvent) -> None:
114 """Append event to JSONL log file."""
115 with open(self._log_path, "a", encoding="utf-8") as f:
116 f.write(json.dumps(event.to_dict()) + "\n")
119class ExtensionLoader:
120 """Discover and load extensions from config paths and entry points."""
122 @staticmethod
123 def from_config(extension_paths: list[str]) -> list[LLExtension]:
124 """Load extensions from dotted module paths.
126 Each path should be in the format "module.path:ClassName".
127 Extensions that fail to load are skipped with a warning.
129 Args:
130 extension_paths: List of "module:Class" strings
132 Returns:
133 List of successfully loaded extension instances
134 """
135 extensions: list[Any] = []
136 for path in extension_paths:
137 try:
138 module_path, class_name = path.rsplit(":", 1)
139 module = importlib.import_module(module_path)
140 cls = getattr(module, class_name)
141 extensions.append(cls())
142 except Exception:
143 logger.warning("Failed to load extension from config: %s", path, exc_info=True)
144 return extensions
146 @staticmethod
147 def from_entry_points() -> list[LLExtension]:
148 """Discover extensions via importlib.metadata entry points.
150 Looks for entry points in the "little_loops.extensions" group.
152 Returns:
153 List of successfully loaded extension instances
154 """
155 extensions: list[Any] = []
156 try:
157 eps = entry_points(group=ENTRY_POINT_GROUP)
158 except TypeError:
159 # Python 3.11 compatibility: entry_points() may not support group kwarg
160 eps = entry_points().get(ENTRY_POINT_GROUP, []) # type: ignore[attr-defined, assignment]
161 for ep in eps:
162 try:
163 cls = ep.load()
164 extensions.append(cls())
165 except Exception:
166 logger.warning("Failed to load extension entry point: %s", ep.name, exc_info=True)
167 return extensions
169 @staticmethod
170 def load_all(config_paths: list[str] | None = None) -> list[LLExtension]:
171 """Load extensions from all discovery sources.
173 Combines extensions from config paths and entry points.
175 Args:
176 config_paths: Optional list of "module:Class" strings from config
178 Returns:
179 Combined list of all loaded extensions
180 """
181 extensions: list[Any] = []
182 if config_paths:
183 extensions.extend(ExtensionLoader.from_config(config_paths))
184 extensions.extend(ExtensionLoader.from_entry_points())
185 return extensions
188def wire_extensions(
189 bus: EventBus,
190 config_paths: list[str] | None = None,
191 executor: FSMExecutor | PersistentExecutor | None = None,
192) -> list[LLExtension]:
193 """Load extensions and register them on an EventBus and optional FSMExecutor.
195 Each extension's ``on_event`` callback is wrapped to convert the raw
196 ``dict[str, Any]`` dispatched by ``EventBus.emit()`` into an ``LLEvent``
197 using ``from_raw_event()`` (which copies the dict to avoid mutation).
199 When ``executor`` is provided, a second pass populates
200 ``executor._contributed_actions``, ``executor._contributed_evaluators``,
201 and ``executor._interceptors`` from each extension that implements the
202 corresponding protocols.
204 Args:
205 bus: EventBus to register extension callbacks on
206 config_paths: Optional list of "module:Class" strings from config
207 executor: Optional FSMExecutor to populate with contributed types
209 Returns:
210 List of loaded extension instances
211 """
212 extensions = ExtensionLoader.load_all(config_paths)
213 extensions = sorted(extensions, key=lambda e: getattr(e, "priority", 0))
215 def _make_callback(e: LLExtension) -> EventCallback:
216 def _cb(event: dict[str, Any]) -> None:
217 e.on_event(LLEvent.from_raw_event(event))
219 return _cb
221 for ext in extensions:
222 if hasattr(ext, "on_event"):
223 bus.register(_make_callback(ext), filter=getattr(ext, "event_filter", None))
225 fsm_executor: FSMExecutor | None
226 if executor is None:
227 fsm_executor = None
228 elif hasattr(executor, "_executor"):
229 fsm_executor = executor._executor
230 else:
231 fsm_executor = executor
233 if fsm_executor is not None:
234 for ext in extensions:
235 if hasattr(ext, "provided_actions"):
236 for name in ext.provided_actions():
237 if name in fsm_executor._contributed_actions:
238 raise ValueError(
239 f"Extension conflict: action '{name}' already registered by another extension"
240 )
241 fsm_executor._contributed_actions.update(ext.provided_actions())
242 if hasattr(ext, "provided_evaluators"):
243 for name in ext.provided_evaluators():
244 if name in fsm_executor._contributed_evaluators:
245 raise ValueError(
246 f"Extension conflict: evaluator '{name}' already registered by another extension"
247 )
248 fsm_executor._contributed_evaluators.update(ext.provided_evaluators())
249 if (
250 hasattr(ext, "before_route")
251 or hasattr(ext, "after_route")
252 or hasattr(ext, "before_issue_close")
253 ):
254 fsm_executor._interceptors.append(ext)
256 if extensions:
257 logger.info("Wired %d extension(s) to EventBus", len(extensions))
258 return extensions