feat(runtime): sync declarative plugins at boot
This commit is contained in:
@ -12,10 +12,14 @@ from beaver.coordinator.registry import AgentRegistry
|
|||||||
from beaver.engine.context import ContextBuilder
|
from beaver.engine.context import ContextBuilder
|
||||||
from beaver.engine.session import SessionManager
|
from beaver.engine.session import SessionManager
|
||||||
from beaver.foundation.config import BeaverConfig, load_config
|
from beaver.foundation.config import BeaverConfig, load_config
|
||||||
|
from beaver.foundation.utils.file_lock import WorkspaceWriteLock, WorkspaceWriteLockBusy
|
||||||
from beaver.integrations.mcp import MCPConnectionManager
|
from beaver.integrations.mcp import MCPConnectionManager
|
||||||
from beaver.memory.curated.store import MemoryStore
|
from beaver.memory.curated.store import MemoryStore
|
||||||
from beaver.memory.runs import RunMemoryStore
|
from beaver.memory.runs import RunMemoryStore
|
||||||
from beaver.memory.skills import SkillLearningStore
|
from beaver.memory.skills import SkillLearningStore
|
||||||
|
from beaver.plugins.discovery import discover_plugins
|
||||||
|
from beaver.plugins.skills import PluginManager
|
||||||
|
from beaver.plugins.state import PluginStateStore
|
||||||
from beaver.services.memory_service import MemoryService
|
from beaver.services.memory_service import MemoryService
|
||||||
from beaver.skills.drafts import DraftService
|
from beaver.skills.drafts import DraftService
|
||||||
from beaver.skills.learning import EvidenceSelector, SkillDraftSynthesizer, SkillLearningPipelineService, SkillLearningService
|
from beaver.skills.learning import EvidenceSelector, SkillDraftSynthesizer, SkillLearningPipelineService, SkillLearningService
|
||||||
@ -94,6 +98,8 @@ class EngineLoadResult:
|
|||||||
skill_publisher: SkillPublisher | None = None
|
skill_publisher: SkillPublisher | None = None
|
||||||
skill_learning_service: SkillLearningService | None = None
|
skill_learning_service: SkillLearningService | None = None
|
||||||
skill_learning_pipeline: SkillLearningPipelineService | None = None
|
skill_learning_pipeline: SkillLearningPipelineService | None = None
|
||||||
|
plugin_manager: PluginManager | None = None
|
||||||
|
plugins: list[dict] = field(default_factory=list)
|
||||||
agent_registry: AgentRegistry | None = None
|
agent_registry: AgentRegistry | None = None
|
||||||
task_skill_resolver: TaskSkillResolver | None = None
|
task_skill_resolver: TaskSkillResolver | None = None
|
||||||
task_service: TaskService | None = None
|
task_service: TaskService | None = None
|
||||||
@ -168,6 +174,7 @@ class EngineLoader:
|
|||||||
skill_publisher: SkillPublisher | None = None,
|
skill_publisher: SkillPublisher | None = None,
|
||||||
skill_learning_service: SkillLearningService | None = None,
|
skill_learning_service: SkillLearningService | None = None,
|
||||||
skill_learning_pipeline: SkillLearningPipelineService | None = None,
|
skill_learning_pipeline: SkillLearningPipelineService | None = None,
|
||||||
|
plugin_manager: PluginManager | None = None,
|
||||||
agent_registry: AgentRegistry | None = None,
|
agent_registry: AgentRegistry | None = None,
|
||||||
task_skill_resolver: TaskSkillResolver | None = None,
|
task_skill_resolver: TaskSkillResolver | None = None,
|
||||||
task_service: TaskService | None = None,
|
task_service: TaskService | None = None,
|
||||||
@ -193,6 +200,7 @@ class EngineLoader:
|
|||||||
self._skill_publisher = skill_publisher
|
self._skill_publisher = skill_publisher
|
||||||
self._skill_learning_service = skill_learning_service
|
self._skill_learning_service = skill_learning_service
|
||||||
self._skill_learning_pipeline = skill_learning_pipeline
|
self._skill_learning_pipeline = skill_learning_pipeline
|
||||||
|
self._plugin_manager = plugin_manager
|
||||||
self._agent_registry = agent_registry
|
self._agent_registry = agent_registry
|
||||||
self._task_skill_resolver = task_skill_resolver
|
self._task_skill_resolver = task_skill_resolver
|
||||||
self._task_service = task_service
|
self._task_service = task_service
|
||||||
@ -209,7 +217,11 @@ class EngineLoader:
|
|||||||
memory_service = self._memory_service or MemoryService(curated_root, store=curated_memory_store)
|
memory_service = self._memory_service or MemoryService(curated_root, store=curated_memory_store)
|
||||||
memory_service.initialize()
|
memory_service.initialize()
|
||||||
run_memory_store = self._run_memory_store or RunMemoryStore(workspace / "memory" / "runs")
|
run_memory_store = self._run_memory_store or RunMemoryStore(workspace / "memory" / "runs")
|
||||||
skill_learning_store = self._skill_learning_store or SkillLearningStore(workspace / "memory" / "skills")
|
write_lock = WorkspaceWriteLock(workspace)
|
||||||
|
skill_learning_store = self._skill_learning_store or SkillLearningStore(
|
||||||
|
workspace / "memory" / "skills",
|
||||||
|
write_lock=write_lock,
|
||||||
|
)
|
||||||
|
|
||||||
tool_registry = self._tool_registry or ToolRegistry()
|
tool_registry = self._tool_registry or ToolRegistry()
|
||||||
skill_spec_store = self._skill_spec_store or SkillSpecStore(workspace)
|
skill_spec_store = self._skill_spec_store or SkillSpecStore(workspace)
|
||||||
@ -264,21 +276,40 @@ class EngineLoader:
|
|||||||
evidence_selector=evidence_selector,
|
evidence_selector=evidence_selector,
|
||||||
synthesizer=SkillDraftSynthesizer(),
|
synthesizer=SkillDraftSynthesizer(),
|
||||||
)
|
)
|
||||||
|
safety_checker = SkillDraftSafetyChecker(
|
||||||
|
allowed_tool_names={spec.name for spec in tool_registry.list_specs()},
|
||||||
|
allowed_tool_prefixes={
|
||||||
|
f"mcp_{server_id}_"
|
||||||
|
for server_id in self.config.tools.mcp_servers
|
||||||
|
if str(server_id).strip()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
discovery = discover_plugins(workspace, search_paths=self.config.plugins.search_paths)
|
||||||
|
plugin_manager = self._plugin_manager or PluginManager(
|
||||||
|
workspace=workspace,
|
||||||
|
manifests=discovery.manifests,
|
||||||
|
discovery_errors=discovery.errors,
|
||||||
|
state_store=PluginStateStore(workspace),
|
||||||
|
skill_store=skill_spec_store,
|
||||||
|
learning_store=skill_learning_store,
|
||||||
|
publisher=skill_publisher,
|
||||||
|
safety_checker=safety_checker,
|
||||||
|
write_lock=write_lock,
|
||||||
|
)
|
||||||
|
if self.config.plugins.auto_sync:
|
||||||
|
try:
|
||||||
|
plugin_manager.sync_enabled(blocking=False)
|
||||||
|
except WorkspaceWriteLockBusy:
|
||||||
|
pass
|
||||||
skill_learning_pipeline = self._skill_learning_pipeline or SkillLearningPipelineService(
|
skill_learning_pipeline = self._skill_learning_pipeline or SkillLearningPipelineService(
|
||||||
learning_store=skill_learning_store,
|
learning_store=skill_learning_store,
|
||||||
learning_service=skill_learning_service,
|
learning_service=skill_learning_service,
|
||||||
draft_service=draft_service,
|
draft_service=draft_service,
|
||||||
review_service=review_service,
|
review_service=review_service,
|
||||||
publisher=skill_publisher,
|
publisher=skill_publisher,
|
||||||
safety_checker=SkillDraftSafetyChecker(
|
safety_checker=safety_checker,
|
||||||
allowed_tool_names={spec.name for spec in tool_registry.list_specs()},
|
|
||||||
allowed_tool_prefixes={
|
|
||||||
f"mcp_{server_id}_"
|
|
||||||
for server_id in self.config.tools.mcp_servers
|
|
||||||
if str(server_id).strip()
|
|
||||||
},
|
|
||||||
),
|
|
||||||
evaluator=SkillDraftEvaluator(run_memory_store),
|
evaluator=SkillDraftEvaluator(run_memory_store),
|
||||||
|
publish_observer=plugin_manager.on_skill_published,
|
||||||
)
|
)
|
||||||
agent_registry = self._agent_registry or AgentRegistry(workspace)
|
agent_registry = self._agent_registry or AgentRegistry(workspace)
|
||||||
task_skill_resolver = self._task_skill_resolver or TaskSkillResolver(
|
task_skill_resolver = self._task_skill_resolver or TaskSkillResolver(
|
||||||
@ -317,6 +348,8 @@ class EngineLoader:
|
|||||||
skill_publisher=skill_publisher,
|
skill_publisher=skill_publisher,
|
||||||
skill_learning_service=skill_learning_service,
|
skill_learning_service=skill_learning_service,
|
||||||
skill_learning_pipeline=skill_learning_pipeline,
|
skill_learning_pipeline=skill_learning_pipeline,
|
||||||
|
plugin_manager=plugin_manager,
|
||||||
|
plugins=_plugin_summaries(plugin_manager),
|
||||||
agent_registry=agent_registry,
|
agent_registry=agent_registry,
|
||||||
task_skill_resolver=task_skill_resolver,
|
task_skill_resolver=task_skill_resolver,
|
||||||
task_service=task_service,
|
task_service=task_service,
|
||||||
@ -336,3 +369,35 @@ def _close_mcp_manager(manager: MCPConnectionManager) -> None:
|
|||||||
asyncio.run(manager.close())
|
asyncio.run(manager.close())
|
||||||
return
|
return
|
||||||
loop.create_task(manager.close())
|
loop.create_task(manager.close())
|
||||||
|
|
||||||
|
|
||||||
|
def _plugin_summaries(manager: PluginManager) -> list[dict]:
|
||||||
|
summaries: list[dict] = []
|
||||||
|
for state in manager.list_plugins():
|
||||||
|
manifest = manager.manifests.get(state.plugin_id)
|
||||||
|
summaries.append(
|
||||||
|
{
|
||||||
|
"id": state.plugin_id,
|
||||||
|
"name": manifest.name if manifest is not None else state.plugin_id,
|
||||||
|
"discovered_version": manifest.version if manifest is not None else None,
|
||||||
|
"installed_version": state.installed_version,
|
||||||
|
"enabled": state.enabled,
|
||||||
|
"status": state.status,
|
||||||
|
"last_error": state.last_error,
|
||||||
|
"manifest_path": manifest.display_path if manifest is not None else state.manifest_path,
|
||||||
|
"updates_paused": state.updates_paused,
|
||||||
|
"skills": [
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"status": binding.status,
|
||||||
|
"current_beaver_version": binding.current_beaver_version,
|
||||||
|
"accepted_upstream_tree_hash": binding.accepted_upstream_tree_hash,
|
||||||
|
"observed_upstream_tree_hash": binding.observed_upstream_tree_hash,
|
||||||
|
"accepted_beaver_version": binding.accepted_beaver_version,
|
||||||
|
"pending_candidate_id": binding.pending_candidate_id,
|
||||||
|
}
|
||||||
|
for name, binding in sorted(state.skills.items())
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return summaries
|
||||||
|
|||||||
106
app-instance/backend/tests/unit/test_plugin_runtime.py
Normal file
106
app-instance/backend/tests/unit/test_plugin_runtime.py
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from beaver.engine.loader import EngineLoader
|
||||||
|
from beaver.foundation.config import BeaverConfig, PluginsConfig
|
||||||
|
from beaver.foundation.utils.file_lock import WorkspaceWriteLock
|
||||||
|
from beaver.memory.skills import SkillLearningStore
|
||||||
|
from beaver.plugins.discovery import discover_plugins
|
||||||
|
from beaver.plugins.skills import PluginManager
|
||||||
|
from beaver.plugins.state import PluginStateStore
|
||||||
|
from beaver.skills.learning.safety import SkillDraftSafetyChecker
|
||||||
|
from beaver.skills.publisher import SkillPublisher
|
||||||
|
from beaver.skills.specs import SkillSpecStore
|
||||||
|
|
||||||
|
|
||||||
|
def _write_plugin(root: Path, *, version: str = "1.0.0", body: str = "# Plugin\n\nV1.\n") -> Path:
|
||||||
|
plugin_root = root / "baoyu-comic"
|
||||||
|
skill_root = plugin_root / "skills" / "baoyu-comic"
|
||||||
|
skill_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
(skill_root / "SKILL.md").write_text(
|
||||||
|
"---\nname: baoyu-comic\ndescription: Comic workflow\ntools: []\n---\n\n" + body,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(plugin_root / "beaver.plugin.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"id": "baoyu-comic",
|
||||||
|
"name": "Baoyu Comic",
|
||||||
|
"version": version,
|
||||||
|
"skills": [{"name": "baoyu-comic", "path": "skills/baoyu-comic"}],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return plugin_root
|
||||||
|
|
||||||
|
|
||||||
|
def _rewrite_plugin(plugin_root: Path, *, version: str, body: str) -> None:
|
||||||
|
manifest_path = plugin_root / "beaver.plugin.json"
|
||||||
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
manifest["version"] = version
|
||||||
|
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
(plugin_root / "skills" / "baoyu-comic" / "SKILL.md").write_text(
|
||||||
|
"---\nname: baoyu-comic\ndescription: Comic workflow\ntools: []\n---\n\n" + body,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _enable(workspace: Path) -> None:
|
||||||
|
discovery = discover_plugins(workspace, search_paths=[])
|
||||||
|
store = SkillSpecStore(workspace)
|
||||||
|
PluginManager(
|
||||||
|
workspace=workspace,
|
||||||
|
manifests=discovery.manifests,
|
||||||
|
discovery_errors=discovery.errors,
|
||||||
|
state_store=PluginStateStore(workspace),
|
||||||
|
skill_store=store,
|
||||||
|
learning_store=SkillLearningStore(workspace / "memory" / "skills"),
|
||||||
|
publisher=SkillPublisher(store),
|
||||||
|
safety_checker=SkillDraftSafetyChecker(),
|
||||||
|
write_lock=WorkspaceWriteLock(workspace),
|
||||||
|
).enable("baoyu-comic")
|
||||||
|
|
||||||
|
|
||||||
|
def test_engine_loader_discovers_disabled_plugin_without_mirroring(tmp_path: Path) -> None:
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
_write_plugin(workspace / "plugins")
|
||||||
|
|
||||||
|
loaded = EngineLoader(workspace=workspace).load()
|
||||||
|
|
||||||
|
assert "baoyu-comic" not in loaded.skills
|
||||||
|
assert loaded.plugin_manager is not None
|
||||||
|
assert loaded.plugins[0]["id"] == "baoyu-comic"
|
||||||
|
assert loaded.plugins[0]["enabled"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_engine_loader_syncs_enabled_plugin_updates_before_result_skills(tmp_path: Path) -> None:
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
plugin_root = _write_plugin(workspace / "plugins")
|
||||||
|
_enable(workspace)
|
||||||
|
_rewrite_plugin(plugin_root, version="1.1.0", body="# Plugin\n\nV2.\n")
|
||||||
|
|
||||||
|
loaded = EngineLoader(workspace=workspace).load()
|
||||||
|
candidates = SkillLearningStore(workspace / "memory" / "skills").list_learning_candidates()
|
||||||
|
|
||||||
|
assert "baoyu-comic" in loaded.skills
|
||||||
|
assert loaded.plugin_manager is not None
|
||||||
|
assert loaded.plugins[0]["status"] == "update_pending"
|
||||||
|
assert len(candidates) == 1
|
||||||
|
assert candidates[0].kind == "plugin_skill_update"
|
||||||
|
|
||||||
|
|
||||||
|
def test_engine_loader_respects_plugin_auto_sync_config(tmp_path: Path) -> None:
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
plugin_root = _write_plugin(workspace / "plugins")
|
||||||
|
_enable(workspace)
|
||||||
|
_rewrite_plugin(plugin_root, version="1.1.0", body="# Plugin\n\nV2.\n")
|
||||||
|
|
||||||
|
config = BeaverConfig(plugins=PluginsConfig(auto_sync=False))
|
||||||
|
loaded = EngineLoader(workspace=workspace, config=config).load()
|
||||||
|
|
||||||
|
assert loaded.plugin_manager is not None
|
||||||
|
assert SkillLearningStore(workspace / "memory" / "skills").list_learning_candidates() == []
|
||||||
Reference in New Issue
Block a user