diff --git a/app-instance/backend/beaver/engine/loader.py b/app-instance/backend/beaver/engine/loader.py index 270cd50..d51666b 100644 --- a/app-instance/backend/beaver/engine/loader.py +++ b/app-instance/backend/beaver/engine/loader.py @@ -12,10 +12,14 @@ from beaver.coordinator.registry import AgentRegistry from beaver.engine.context import ContextBuilder from beaver.engine.session import SessionManager 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.memory.curated.store import MemoryStore from beaver.memory.runs import RunMemoryStore 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.skills.drafts import DraftService from beaver.skills.learning import EvidenceSelector, SkillDraftSynthesizer, SkillLearningPipelineService, SkillLearningService @@ -94,6 +98,8 @@ class EngineLoadResult: skill_publisher: SkillPublisher | None = None skill_learning_service: SkillLearningService | 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 task_skill_resolver: TaskSkillResolver | None = None task_service: TaskService | None = None @@ -168,6 +174,7 @@ class EngineLoader: skill_publisher: SkillPublisher | None = None, skill_learning_service: SkillLearningService | None = None, skill_learning_pipeline: SkillLearningPipelineService | None = None, + plugin_manager: PluginManager | None = None, agent_registry: AgentRegistry | None = None, task_skill_resolver: TaskSkillResolver | None = None, task_service: TaskService | None = None, @@ -193,6 +200,7 @@ class EngineLoader: self._skill_publisher = skill_publisher self._skill_learning_service = skill_learning_service self._skill_learning_pipeline = skill_learning_pipeline + self._plugin_manager = plugin_manager self._agent_registry = agent_registry self._task_skill_resolver = task_skill_resolver 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.initialize() 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() skill_spec_store = self._skill_spec_store or SkillSpecStore(workspace) @@ -264,21 +276,40 @@ class EngineLoader: evidence_selector=evidence_selector, 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( learning_store=skill_learning_store, learning_service=skill_learning_service, draft_service=draft_service, review_service=review_service, publisher=skill_publisher, - 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() - }, - ), + safety_checker=safety_checker, evaluator=SkillDraftEvaluator(run_memory_store), + publish_observer=plugin_manager.on_skill_published, ) agent_registry = self._agent_registry or AgentRegistry(workspace) task_skill_resolver = self._task_skill_resolver or TaskSkillResolver( @@ -317,6 +348,8 @@ class EngineLoader: skill_publisher=skill_publisher, skill_learning_service=skill_learning_service, skill_learning_pipeline=skill_learning_pipeline, + plugin_manager=plugin_manager, + plugins=_plugin_summaries(plugin_manager), agent_registry=agent_registry, task_skill_resolver=task_skill_resolver, task_service=task_service, @@ -336,3 +369,35 @@ def _close_mcp_manager(manager: MCPConnectionManager) -> None: asyncio.run(manager.close()) return 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 diff --git a/app-instance/backend/tests/unit/test_plugin_runtime.py b/app-instance/backend/tests/unit/test_plugin_runtime.py new file mode 100644 index 0000000..b3a5b65 --- /dev/null +++ b/app-instance/backend/tests/unit/test_plugin_runtime.py @@ -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() == []