loopengt 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- loopengt/__init__.py +31 -0
- loopengt/adapters/__init__.py +1 -0
- loopengt/adapters/antigravity/__init__.py +1 -0
- loopengt/adapters/antigravity/adapter.py +55 -0
- loopengt/adapters/antigravity/commands.py +21 -0
- loopengt/adapters/base.py +51 -0
- loopengt/adapters/claude_code/__init__.py +1 -0
- loopengt/adapters/claude_code/adapter.py +55 -0
- loopengt/adapters/claude_code/commands.py +16 -0
- loopengt/adapters/codex/__init__.py +1 -0
- loopengt/adapters/codex/adapter.py +52 -0
- loopengt/adapters/codex/commands.py +16 -0
- loopengt/adapters/cursor/__init__.py +1 -0
- loopengt/adapters/cursor/adapter.py +56 -0
- loopengt/adapters/cursor/commands.py +29 -0
- loopengt/adapters/generic/__init__.py +1 -0
- loopengt/adapters/generic/terminal.py +82 -0
- loopengt/cli/__init__.py +1 -0
- loopengt/cli/commands/__init__.py +1 -0
- loopengt/cli/commands/design.py +171 -0
- loopengt/cli/commands/doctor.py +110 -0
- loopengt/cli/commands/eval.py +105 -0
- loopengt/cli/commands/init.py +131 -0
- loopengt/cli/commands/mcp_serve.py +57 -0
- loopengt/cli/commands/run.py +99 -0
- loopengt/cli/commands/template.py +145 -0
- loopengt/cli/commands/trace.py +114 -0
- loopengt/cli/formatters.py +125 -0
- loopengt/cli/main.py +66 -0
- loopengt/core/__init__.py +1 -0
- loopengt/core/evals/__init__.py +1 -0
- loopengt/core/evals/judges.py +216 -0
- loopengt/core/evals/metrics.py +119 -0
- loopengt/core/evals/regression.py +157 -0
- loopengt/core/memory/__init__.py +1 -0
- loopengt/core/memory/retrieval.py +124 -0
- loopengt/core/memory/store.py +184 -0
- loopengt/core/memory/summarizer.py +97 -0
- loopengt/core/models/__init__.py +43 -0
- loopengt/core/models/agent.py +126 -0
- loopengt/core/models/loop_spec.py +251 -0
- loopengt/core/models/policy.py +131 -0
- loopengt/core/models/state.py +271 -0
- loopengt/core/models/tool.py +105 -0
- loopengt/core/runtime/__init__.py +1 -0
- loopengt/core/runtime/checkpoint.py +152 -0
- loopengt/core/runtime/executor.py +463 -0
- loopengt/core/runtime/handoff.py +139 -0
- loopengt/core/runtime/scheduler.py +168 -0
- loopengt/core/tracing/__init__.py +1 -0
- loopengt/core/tracing/events.py +95 -0
- loopengt/core/tracing/exporters.py +158 -0
- loopengt/core/tracing/store.py +202 -0
- loopengt/mcp/__init__.py +1 -0
- loopengt/mcp/client/__init__.py +1 -0
- loopengt/mcp/client/manager.py +118 -0
- loopengt/mcp/client/tools.py +107 -0
- loopengt/mcp/server/__init__.py +1 -0
- loopengt/mcp/server/prompts.py +82 -0
- loopengt/mcp/server/resources.py +75 -0
- loopengt/mcp/server/server.py +50 -0
- loopengt/mcp/server/tools.py +214 -0
- loopengt/mcp/shared/__init__.py +1 -0
- loopengt/mcp/shared/schemas.py +91 -0
- loopengt/plugins/__init__.py +1 -0
- loopengt/plugins/base.py +90 -0
- loopengt/plugins/loader.py +130 -0
- loopengt/plugins/manifest.py +70 -0
- loopengt/plugins/registry.py +146 -0
- loopengt/prompts/LOOPENGT.md +60 -0
- loopengt/prompts/__init__.py +1 -0
- loopengt/storage/__init__.py +1 -0
- loopengt/storage/jsonl.py +84 -0
- loopengt/storage/sqlite.py +102 -0
- loopengt/templates/__init__.py +1 -0
- loopengt/templates/builtins/handoff_loop/LOOPENGS.md +10 -0
- loopengt/templates/builtins/planner_executor/LOOPENGS.md +29 -0
- loopengt/templates/builtins/research_architect/LOOPENGS.md +17 -0
- loopengt/templates/builtins/reviewer_retry/LOOPENGS.md +29 -0
- loopengt/templates/builtins/supervisor_workers/LOOPENGS.md +29 -0
- loopengt/templates/loader.py +38 -0
- loopengt/templates/registry.py +85 -0
- loopengt-0.1.0.dist-info/METADATA +275 -0
- loopengt-0.1.0.dist-info/RECORD +87 -0
- loopengt-0.1.0.dist-info/WHEEL +4 -0
- loopengt-0.1.0.dist-info/entry_points.txt +8 -0
- loopengt-0.1.0.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Shared JSON schemas for MCP tools and resources."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# JSON Schema definitions for MCP tool inputs/outputs
|
|
9
|
+
LOOP_SPEC_SCHEMA: dict[str, Any] = {
|
|
10
|
+
"type": "object",
|
|
11
|
+
"properties": {
|
|
12
|
+
"name": {"type": "string", "description": "Loop identifier"},
|
|
13
|
+
"version": {"type": "string", "default": "1.0"},
|
|
14
|
+
"goal": {"type": "string", "description": "Loop objective"},
|
|
15
|
+
"pattern": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"enum": [
|
|
18
|
+
"sequential",
|
|
19
|
+
"supervisor_worker",
|
|
20
|
+
"parallel_fan_out",
|
|
21
|
+
"handoff",
|
|
22
|
+
"evaluator_optimizer",
|
|
23
|
+
"custom",
|
|
24
|
+
],
|
|
25
|
+
"default": "sequential",
|
|
26
|
+
},
|
|
27
|
+
"agents": {
|
|
28
|
+
"type": "array",
|
|
29
|
+
"items": {"$ref": "#/$defs/AgentRole"},
|
|
30
|
+
},
|
|
31
|
+
"steps": {
|
|
32
|
+
"type": "array",
|
|
33
|
+
"items": {"$ref": "#/$defs/StepSpec"},
|
|
34
|
+
},
|
|
35
|
+
"policy": {"$ref": "#/$defs/Policy"},
|
|
36
|
+
},
|
|
37
|
+
"required": ["name", "goal", "agents", "steps"],
|
|
38
|
+
"$defs": {
|
|
39
|
+
"AgentRole": {
|
|
40
|
+
"type": "object",
|
|
41
|
+
"properties": {
|
|
42
|
+
"name": {"type": "string"},
|
|
43
|
+
"description": {"type": "string"},
|
|
44
|
+
"capabilities": {
|
|
45
|
+
"type": "array",
|
|
46
|
+
"items": {"type": "string"},
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
"required": ["name"],
|
|
50
|
+
},
|
|
51
|
+
"StepSpec": {
|
|
52
|
+
"type": "object",
|
|
53
|
+
"properties": {
|
|
54
|
+
"name": {"type": "string"},
|
|
55
|
+
"description": {"type": "string"},
|
|
56
|
+
"agent": {"type": "string"},
|
|
57
|
+
"dependencies": {
|
|
58
|
+
"type": "array",
|
|
59
|
+
"items": {
|
|
60
|
+
"type": "object",
|
|
61
|
+
"properties": {
|
|
62
|
+
"step_name": {"type": "string"},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
"required": ["name", "agent"],
|
|
68
|
+
},
|
|
69
|
+
"Policy": {
|
|
70
|
+
"type": "object",
|
|
71
|
+
"properties": {
|
|
72
|
+
"max_turns": {"type": "integer", "minimum": 1},
|
|
73
|
+
"max_total_time_seconds": {"type": "number"},
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
RUN_RESULT_SCHEMA: dict[str, Any] = {
|
|
80
|
+
"type": "object",
|
|
81
|
+
"properties": {
|
|
82
|
+
"status": {
|
|
83
|
+
"type": "string",
|
|
84
|
+
"enum": ["completed", "failed", "cancelled", "running"],
|
|
85
|
+
},
|
|
86
|
+
"run_id": {"type": "string"},
|
|
87
|
+
"turns": {"type": "integer"},
|
|
88
|
+
"steps_completed": {"type": "integer"},
|
|
89
|
+
"error": {"type": "string"},
|
|
90
|
+
},
|
|
91
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Plugin system for extensible loop engineering."""
|
loopengt/plugins/base.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Plugin base class and protocol definitions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import Any, Protocol, runtime_checkable
|
|
7
|
+
|
|
8
|
+
from loopengt.plugins.manifest import PluginManifest
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@runtime_checkable
|
|
12
|
+
class PluginProtocol(Protocol):
|
|
13
|
+
"""Protocol that all loopengt plugins must satisfy."""
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def manifest(self) -> PluginManifest: ...
|
|
17
|
+
|
|
18
|
+
def activate(self, context: dict[str, Any]) -> None: ...
|
|
19
|
+
|
|
20
|
+
def deactivate(self) -> None: ...
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Plugin(ABC):
|
|
24
|
+
"""Abstract base class for loopengt plugins.
|
|
25
|
+
|
|
26
|
+
Provides lifecycle hooks and metadata. Subclass this to create
|
|
27
|
+
new plugins.
|
|
28
|
+
|
|
29
|
+
Example::
|
|
30
|
+
|
|
31
|
+
class MyPlugin(Plugin):
|
|
32
|
+
@property
|
|
33
|
+
def manifest(self) -> PluginManifest:
|
|
34
|
+
return PluginManifest(
|
|
35
|
+
name="my-plugin",
|
|
36
|
+
version="1.0.0",
|
|
37
|
+
description="Does cool things",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def activate(self, context):
|
|
41
|
+
# Set up resources
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
def deactivate(self):
|
|
45
|
+
# Tear down resources
|
|
46
|
+
pass
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
_active: bool = False
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def manifest(self) -> PluginManifest:
|
|
54
|
+
"""Return the plugin's metadata manifest."""
|
|
55
|
+
...
|
|
56
|
+
|
|
57
|
+
@abstractmethod
|
|
58
|
+
def activate(self, context: dict[str, Any]) -> None:
|
|
59
|
+
"""Called when the plugin is loaded and activated.
|
|
60
|
+
|
|
61
|
+
Parameters
|
|
62
|
+
----------
|
|
63
|
+
context:
|
|
64
|
+
Shared context dict with config, adapters, etc.
|
|
65
|
+
"""
|
|
66
|
+
...
|
|
67
|
+
|
|
68
|
+
@abstractmethod
|
|
69
|
+
def deactivate(self) -> None:
|
|
70
|
+
"""Called when the plugin is being unloaded."""
|
|
71
|
+
...
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def is_active(self) -> bool:
|
|
75
|
+
"""Whether this plugin is currently active."""
|
|
76
|
+
return self._active
|
|
77
|
+
|
|
78
|
+
def on_loop_start(self, loop_name: str, context: dict[str, Any]) -> None:
|
|
79
|
+
"""Hook called when a loop starts. Override if needed."""
|
|
80
|
+
|
|
81
|
+
def on_loop_complete(
|
|
82
|
+
self, loop_name: str, result: Any
|
|
83
|
+
) -> None:
|
|
84
|
+
"""Hook called when a loop completes. Override if needed."""
|
|
85
|
+
|
|
86
|
+
def on_error(self, error: Exception, context: dict[str, Any]) -> None:
|
|
87
|
+
"""Hook called on errors. Override if needed."""
|
|
88
|
+
|
|
89
|
+
def __repr__(self) -> str:
|
|
90
|
+
return f"<Plugin {self.manifest.name} v{self.manifest.version}>"
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Dynamic plugin loading with validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import structlog
|
|
9
|
+
|
|
10
|
+
from loopengt.plugins.base import Plugin, PluginProtocol
|
|
11
|
+
from loopengt.plugins.manifest import PluginManifest
|
|
12
|
+
|
|
13
|
+
logger = structlog.get_logger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PluginLoadError(Exception):
|
|
17
|
+
"""Raised when a plugin cannot be loaded."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PluginLoader:
|
|
21
|
+
"""Dynamically loads plugins from dotted import paths.
|
|
22
|
+
|
|
23
|
+
Validates that loaded classes conform to the ``PluginProtocol``
|
|
24
|
+
and have valid manifests.
|
|
25
|
+
|
|
26
|
+
Usage::
|
|
27
|
+
|
|
28
|
+
loader = PluginLoader()
|
|
29
|
+
plugin = loader.load("loopengt.adapters.generic.terminal.TerminalAdapter")
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self) -> None:
|
|
33
|
+
self._log = logger.bind(component="plugin_loader")
|
|
34
|
+
self._cache: dict[str, Plugin] = {}
|
|
35
|
+
|
|
36
|
+
def load(self, import_path: str) -> Plugin:
|
|
37
|
+
"""Load a plugin class from a dotted import path.
|
|
38
|
+
|
|
39
|
+
Parameters
|
|
40
|
+
----------
|
|
41
|
+
import_path:
|
|
42
|
+
Fully-qualified dotted path, e.g.
|
|
43
|
+
``"loopengt.adapters.generic.terminal.TerminalAdapter"``
|
|
44
|
+
|
|
45
|
+
Returns
|
|
46
|
+
-------
|
|
47
|
+
Plugin
|
|
48
|
+
An *unactivated* plugin instance.
|
|
49
|
+
|
|
50
|
+
Raises
|
|
51
|
+
------
|
|
52
|
+
PluginLoadError
|
|
53
|
+
If the module/class cannot be imported or does not satisfy
|
|
54
|
+
the plugin protocol.
|
|
55
|
+
"""
|
|
56
|
+
if import_path in self._cache:
|
|
57
|
+
return self._cache[import_path]
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
module_path, class_name = import_path.rsplit(".", 1)
|
|
61
|
+
module = importlib.import_module(module_path)
|
|
62
|
+
cls = getattr(module, class_name)
|
|
63
|
+
except (ImportError, AttributeError, ValueError) as exc:
|
|
64
|
+
raise PluginLoadError(
|
|
65
|
+
f"Cannot import plugin from '{import_path}': {exc}"
|
|
66
|
+
) from exc
|
|
67
|
+
|
|
68
|
+
# Instantiate
|
|
69
|
+
try:
|
|
70
|
+
instance = cls()
|
|
71
|
+
except Exception as exc:
|
|
72
|
+
raise PluginLoadError(
|
|
73
|
+
f"Cannot instantiate plugin '{import_path}': {exc}"
|
|
74
|
+
) from exc
|
|
75
|
+
|
|
76
|
+
# Validate
|
|
77
|
+
if not isinstance(instance, (Plugin, PluginProtocol)):
|
|
78
|
+
raise PluginLoadError(
|
|
79
|
+
f"'{import_path}' does not implement the Plugin protocol"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Validate manifest
|
|
83
|
+
try:
|
|
84
|
+
manifest = instance.manifest
|
|
85
|
+
if not isinstance(manifest, PluginManifest):
|
|
86
|
+
raise PluginLoadError(
|
|
87
|
+
f"'{import_path}' manifest is not a PluginManifest"
|
|
88
|
+
)
|
|
89
|
+
except Exception as exc:
|
|
90
|
+
raise PluginLoadError(
|
|
91
|
+
f"'{import_path}' has invalid manifest: {exc}"
|
|
92
|
+
) from exc
|
|
93
|
+
|
|
94
|
+
self._cache[import_path] = instance
|
|
95
|
+
self._log.info(
|
|
96
|
+
"plugin.loaded",
|
|
97
|
+
path=import_path,
|
|
98
|
+
name=manifest.name,
|
|
99
|
+
version=manifest.version,
|
|
100
|
+
)
|
|
101
|
+
return instance
|
|
102
|
+
|
|
103
|
+
def load_many(self, import_paths: list[str]) -> list[Plugin]:
|
|
104
|
+
"""Load multiple plugins. Skips failures with warnings."""
|
|
105
|
+
plugins = []
|
|
106
|
+
for path in import_paths:
|
|
107
|
+
try:
|
|
108
|
+
plugins.append(self.load(path))
|
|
109
|
+
except PluginLoadError as exc:
|
|
110
|
+
self._log.warning("plugin.load_failed", path=path, error=str(exc))
|
|
111
|
+
return plugins
|
|
112
|
+
|
|
113
|
+
def reload(self, import_path: str) -> Plugin:
|
|
114
|
+
"""Force-reload a plugin (hot-reload for development)."""
|
|
115
|
+
self._cache.pop(import_path, None)
|
|
116
|
+
|
|
117
|
+
module_path, _ = import_path.rsplit(".", 1)
|
|
118
|
+
try:
|
|
119
|
+
module = importlib.import_module(module_path)
|
|
120
|
+
importlib.reload(module)
|
|
121
|
+
except Exception as exc:
|
|
122
|
+
raise PluginLoadError(
|
|
123
|
+
f"Cannot reload module '{module_path}': {exc}"
|
|
124
|
+
) from exc
|
|
125
|
+
|
|
126
|
+
return self.load(import_path)
|
|
127
|
+
|
|
128
|
+
def clear_cache(self) -> None:
|
|
129
|
+
"""Clear the loader cache."""
|
|
130
|
+
self._cache.clear()
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Plugin metadata manifest model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PluginManifest(BaseModel):
|
|
11
|
+
"""Metadata describing a loopengt plugin.
|
|
12
|
+
|
|
13
|
+
Every plugin must provide a manifest with at least a name, version,
|
|
14
|
+
and description.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
model_config = ConfigDict(frozen=True)
|
|
18
|
+
|
|
19
|
+
name: str = Field(
|
|
20
|
+
..., min_length=1, max_length=128, description="Plugin identifier"
|
|
21
|
+
)
|
|
22
|
+
version: str = Field(default="0.1.0", description="Semantic version")
|
|
23
|
+
description: str = Field(
|
|
24
|
+
default="", max_length=2048, description="Human-readable description"
|
|
25
|
+
)
|
|
26
|
+
author: str = Field(default="", description="Author name or organisation")
|
|
27
|
+
license: str = Field(default="", description="SPDX license identifier")
|
|
28
|
+
homepage: str = Field(default="", description="URL to project homepage")
|
|
29
|
+
|
|
30
|
+
# Capability declarations
|
|
31
|
+
capabilities: list[str] = Field(
|
|
32
|
+
default_factory=list,
|
|
33
|
+
description=(
|
|
34
|
+
"Capabilities this plugin provides. Standard values: "
|
|
35
|
+
"'adapter', 'mcp_tools', 'templates', 'skills', 'verifiers'"
|
|
36
|
+
),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Entry point groups this plugin contributes to
|
|
40
|
+
entry_points: list[str] = Field(
|
|
41
|
+
default_factory=list,
|
|
42
|
+
description="Entry-point groups: loopengt.adapters, loopengt.mcp_tools, …",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# Configuration schema
|
|
46
|
+
config_schema: dict[str, Any] | None = Field(
|
|
47
|
+
default=None,
|
|
48
|
+
description="JSON Schema for this plugin's configuration",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Version constraints
|
|
52
|
+
min_loopengt_version: str | None = Field(
|
|
53
|
+
default=None,
|
|
54
|
+
description="Minimum loopengt version required (e.g. '0.1.0')",
|
|
55
|
+
)
|
|
56
|
+
max_loopengt_version: str | None = Field(
|
|
57
|
+
default=None,
|
|
58
|
+
description="Maximum loopengt version supported",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# Dependencies on other plugins
|
|
62
|
+
dependencies: list[str] = Field(
|
|
63
|
+
default_factory=list,
|
|
64
|
+
description="Names of plugins this one depends on",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
metadata: dict[str, Any] = Field(
|
|
68
|
+
default_factory=dict,
|
|
69
|
+
description="Arbitrary plugin metadata",
|
|
70
|
+
)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Entry-point based plugin discovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import structlog
|
|
9
|
+
|
|
10
|
+
from loopengt.plugins.base import Plugin, PluginProtocol
|
|
11
|
+
from loopengt.plugins.manifest import PluginManifest
|
|
12
|
+
|
|
13
|
+
logger = structlog.get_logger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PluginRegistry:
|
|
17
|
+
"""Discovers and manages loopengt plugins via entry points.
|
|
18
|
+
|
|
19
|
+
Scans the ``loopengt.adapters``, ``loopengt.mcp_tools``,
|
|
20
|
+
``loopengt.templates``, ``loopengt.skills``, and ``loopengt.verifiers``
|
|
21
|
+
entry-point groups.
|
|
22
|
+
|
|
23
|
+
Usage::
|
|
24
|
+
|
|
25
|
+
registry = PluginRegistry()
|
|
26
|
+
registry.discover()
|
|
27
|
+
adapter = registry.get("terminal", group="loopengt.adapters")
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
ENTRY_POINT_GROUPS = [
|
|
31
|
+
"loopengt.adapters",
|
|
32
|
+
"loopengt.mcp_tools",
|
|
33
|
+
"loopengt.templates",
|
|
34
|
+
"loopengt.skills",
|
|
35
|
+
"loopengt.verifiers",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
def __init__(self) -> None:
|
|
39
|
+
self._plugins: dict[str, dict[str, Any]] = {
|
|
40
|
+
group: {} for group in self.ENTRY_POINT_GROUPS
|
|
41
|
+
}
|
|
42
|
+
self._activated: dict[str, Plugin] = {}
|
|
43
|
+
self._log = logger.bind(component="plugin_registry")
|
|
44
|
+
|
|
45
|
+
# ------------------------------------------------------------------
|
|
46
|
+
# Discovery
|
|
47
|
+
# ------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
def discover(self) -> dict[str, list[str]]:
|
|
50
|
+
"""Discover all registered plugins across entry-point groups.
|
|
51
|
+
|
|
52
|
+
Returns a dict mapping group name → list of plugin names.
|
|
53
|
+
"""
|
|
54
|
+
discovered: dict[str, list[str]] = {}
|
|
55
|
+
|
|
56
|
+
for group in self.ENTRY_POINT_GROUPS:
|
|
57
|
+
eps = self._load_entry_points(group)
|
|
58
|
+
names = []
|
|
59
|
+
for ep in eps:
|
|
60
|
+
self._plugins[group][ep.name] = ep
|
|
61
|
+
names.append(ep.name)
|
|
62
|
+
self._log.debug(
|
|
63
|
+
"plugin.discovered", group=group, name=ep.name
|
|
64
|
+
)
|
|
65
|
+
discovered[group] = names
|
|
66
|
+
|
|
67
|
+
return discovered
|
|
68
|
+
|
|
69
|
+
def get(self, name: str, group: str = "loopengt.adapters") -> Any:
|
|
70
|
+
"""Load and return a plugin class by name and group."""
|
|
71
|
+
if group not in self._plugins:
|
|
72
|
+
raise KeyError(f"Unknown plugin group: {group}")
|
|
73
|
+
|
|
74
|
+
ep = self._plugins[group].get(name)
|
|
75
|
+
if ep is None:
|
|
76
|
+
raise KeyError(
|
|
77
|
+
f"Plugin '{name}' not found in group '{group}'"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
return ep.load()
|
|
81
|
+
|
|
82
|
+
def list_plugins(
|
|
83
|
+
self, group: str | None = None
|
|
84
|
+
) -> dict[str, list[str]]:
|
|
85
|
+
"""List discovered plugin names, optionally filtered by group."""
|
|
86
|
+
if group:
|
|
87
|
+
return {group: list(self._plugins.get(group, {}).keys())}
|
|
88
|
+
return {g: list(plugins.keys()) for g, plugins in self._plugins.items()}
|
|
89
|
+
|
|
90
|
+
# ------------------------------------------------------------------
|
|
91
|
+
# Activation
|
|
92
|
+
# ------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
def activate(
|
|
95
|
+
self, name: str, group: str, context: dict[str, Any] | None = None
|
|
96
|
+
) -> Plugin:
|
|
97
|
+
"""Load, instantiate, and activate a plugin."""
|
|
98
|
+
plugin_cls = self.get(name, group)
|
|
99
|
+
instance = plugin_cls()
|
|
100
|
+
|
|
101
|
+
if isinstance(instance, Plugin):
|
|
102
|
+
instance.activate(context or {})
|
|
103
|
+
instance._active = True
|
|
104
|
+
self._activated[f"{group}:{name}"] = instance
|
|
105
|
+
self._log.info("plugin.activated", group=group, name=name)
|
|
106
|
+
else:
|
|
107
|
+
self._log.warning(
|
|
108
|
+
"plugin.not_plugin_subclass",
|
|
109
|
+
group=group,
|
|
110
|
+
name=name,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
return instance
|
|
114
|
+
|
|
115
|
+
def deactivate(self, name: str, group: str) -> None:
|
|
116
|
+
"""Deactivate a plugin."""
|
|
117
|
+
key = f"{group}:{name}"
|
|
118
|
+
instance = self._activated.pop(key, None)
|
|
119
|
+
if instance:
|
|
120
|
+
instance.deactivate()
|
|
121
|
+
instance._active = False
|
|
122
|
+
self._log.info("plugin.deactivated", group=group, name=name)
|
|
123
|
+
|
|
124
|
+
def deactivate_all(self) -> None:
|
|
125
|
+
"""Deactivate all active plugins."""
|
|
126
|
+
for key, instance in list(self._activated.items()):
|
|
127
|
+
instance.deactivate()
|
|
128
|
+
instance._active = False
|
|
129
|
+
self._activated.clear()
|
|
130
|
+
|
|
131
|
+
# ------------------------------------------------------------------
|
|
132
|
+
# Internals
|
|
133
|
+
# ------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
@staticmethod
|
|
136
|
+
def _load_entry_points(group: str) -> list[Any]:
|
|
137
|
+
"""Load entry points for a given group."""
|
|
138
|
+
if sys.version_info >= (3, 12):
|
|
139
|
+
from importlib.metadata import entry_points
|
|
140
|
+
|
|
141
|
+
return list(entry_points(group=group))
|
|
142
|
+
else:
|
|
143
|
+
from importlib.metadata import entry_points
|
|
144
|
+
|
|
145
|
+
eps = entry_points()
|
|
146
|
+
return list(eps.get(group, []))
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# LOOPENGT — Loop Engineering Architect
|
|
2
|
+
|
|
3
|
+
You are **LOOPENGT**, the Loop Engineering Architect. Your purpose is to
|
|
4
|
+
design optimal agent loop specifications given a natural-language goal.
|
|
5
|
+
|
|
6
|
+
## Core Principles
|
|
7
|
+
|
|
8
|
+
1. **Minimal agents** — each agent must justify its existence with a
|
|
9
|
+
distinct capability no other agent provides.
|
|
10
|
+
2. **Clear handoff contracts** — every step must define what it receives
|
|
11
|
+
and what it produces.
|
|
12
|
+
3. **Verification gates** — critical boundaries must have explicit pass/fail
|
|
13
|
+
checks before the loop continues.
|
|
14
|
+
4. **Graceful degradation** — failures should be retried with backoff, and
|
|
15
|
+
the loop should produce partial results rather than nothing.
|
|
16
|
+
5. **Observable by default** — every step emits structured trace events.
|
|
17
|
+
|
|
18
|
+
## Design Process
|
|
19
|
+
|
|
20
|
+
When given a goal:
|
|
21
|
+
|
|
22
|
+
1. **Classify** the task type (code generation, review, research, data
|
|
23
|
+
processing, multi-agent collaboration).
|
|
24
|
+
2. **Select pattern** — choose the orchestration pattern that best fits:
|
|
25
|
+
- `sequential` — linear step-by-step
|
|
26
|
+
- `supervisor_worker` — central coordinator with specialists
|
|
27
|
+
- `parallel_fan_out` — independent concurrent tasks
|
|
28
|
+
- `handoff` — enrichment pipeline
|
|
29
|
+
- `evaluator_optimizer` — iterative quality improvement
|
|
30
|
+
3. **Define agents** — minimum viable set with clear roles.
|
|
31
|
+
4. **Design steps** — with dependencies, prompt templates, and tool bindings.
|
|
32
|
+
5. **Set policies** — max turns, retry budgets, timeout, verification gates.
|
|
33
|
+
6. **Specify stop conditions** — when is the loop "done"?
|
|
34
|
+
|
|
35
|
+
## Output Format
|
|
36
|
+
|
|
37
|
+
Produce two artifacts:
|
|
38
|
+
|
|
39
|
+
### 1. `LOOP_DESIGN.md`
|
|
40
|
+
A human-readable design document with:
|
|
41
|
+
- Goal restatement
|
|
42
|
+
- Pattern rationale
|
|
43
|
+
- Agent descriptions
|
|
44
|
+
- Step-by-step walkthrough
|
|
45
|
+
- Verification strategy
|
|
46
|
+
- Risk assessment
|
|
47
|
+
|
|
48
|
+
### 2. `loop.yaml`
|
|
49
|
+
A valid YAML file conforming to the LoopSpec schema with all fields
|
|
50
|
+
populated.
|
|
51
|
+
|
|
52
|
+
## Quality Checklist
|
|
53
|
+
|
|
54
|
+
Before finalising, verify:
|
|
55
|
+
- [ ] Every step references an existing agent
|
|
56
|
+
- [ ] Agent tools are declared in the top-level `tools` list
|
|
57
|
+
- [ ] Dependencies form a DAG (no cycles)
|
|
58
|
+
- [ ] At least one stop condition is defined
|
|
59
|
+
- [ ] Timeout is set for the overall loop
|
|
60
|
+
- [ ] Retry policy is appropriate for the task criticality
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Prompt templates for loop engineering."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Storage backends package."""
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""JSONL streaming storage for events and logs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import structlog
|
|
10
|
+
|
|
11
|
+
logger = structlog.get_logger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class JSONLStorage:
|
|
15
|
+
"""Append-only JSONL file storage.
|
|
16
|
+
|
|
17
|
+
Each record is a single JSON object written as one line. Suitable
|
|
18
|
+
for streaming event logs and trace data.
|
|
19
|
+
|
|
20
|
+
Usage::
|
|
21
|
+
|
|
22
|
+
storage = JSONLStorage(Path(".loopengt/events.jsonl"))
|
|
23
|
+
storage.append({"event": "step.start", "step": "plan"})
|
|
24
|
+
records = storage.read_all()
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, path: Path) -> None:
|
|
28
|
+
self._path = path
|
|
29
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
self._log = logger.bind(component="jsonl", path=str(path))
|
|
31
|
+
|
|
32
|
+
def append(self, record: dict[str, Any]) -> None:
|
|
33
|
+
"""Append a single record to the JSONL file."""
|
|
34
|
+
line = json.dumps(record, default=str, ensure_ascii=False)
|
|
35
|
+
with open(self._path, "a", encoding="utf-8") as f:
|
|
36
|
+
f.write(line + "\n")
|
|
37
|
+
|
|
38
|
+
def append_many(self, records: list[dict[str, Any]]) -> None:
|
|
39
|
+
"""Append multiple records efficiently."""
|
|
40
|
+
with open(self._path, "a", encoding="utf-8") as f:
|
|
41
|
+
for record in records:
|
|
42
|
+
line = json.dumps(record, default=str, ensure_ascii=False)
|
|
43
|
+
f.write(line + "\n")
|
|
44
|
+
|
|
45
|
+
def read_all(self) -> list[dict[str, Any]]:
|
|
46
|
+
"""Read all records from the file."""
|
|
47
|
+
if not self._path.exists():
|
|
48
|
+
return []
|
|
49
|
+
|
|
50
|
+
records: list[dict[str, Any]] = []
|
|
51
|
+
with open(self._path, encoding="utf-8") as f:
|
|
52
|
+
for line_no, line in enumerate(f, 1):
|
|
53
|
+
line = line.strip()
|
|
54
|
+
if not line:
|
|
55
|
+
continue
|
|
56
|
+
try:
|
|
57
|
+
records.append(json.loads(line))
|
|
58
|
+
except json.JSONDecodeError:
|
|
59
|
+
self._log.warning(
|
|
60
|
+
"jsonl.parse_error", line=line_no, path=str(self._path)
|
|
61
|
+
)
|
|
62
|
+
return records
|
|
63
|
+
|
|
64
|
+
def read_last(self, n: int = 10) -> list[dict[str, Any]]:
|
|
65
|
+
"""Read the last *n* records (tail)."""
|
|
66
|
+
all_records = self.read_all()
|
|
67
|
+
return all_records[-n:]
|
|
68
|
+
|
|
69
|
+
def count(self) -> int:
|
|
70
|
+
"""Return the number of records in the file."""
|
|
71
|
+
if not self._path.exists():
|
|
72
|
+
return 0
|
|
73
|
+
with open(self._path, encoding="utf-8") as f:
|
|
74
|
+
return sum(1 for line in f if line.strip())
|
|
75
|
+
|
|
76
|
+
def truncate(self) -> None:
|
|
77
|
+
"""Clear all records from the file."""
|
|
78
|
+
with open(self._path, "w", encoding="utf-8"):
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def path(self) -> Path:
|
|
83
|
+
"""Return the file path."""
|
|
84
|
+
return self._path
|