feat(runtime): sync declarative plugins at boot

This commit is contained in:
2026-06-16 11:58:01 +08:00
parent a34b1219bc
commit 54bced4251
2 changed files with 180 additions and 9 deletions

View File

@ -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

View 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() == []