From 1b1c78d784b949602bdc8c7b644ef449d3a1e9ec Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Fri, 14 Aug 2026 22:52:36 +0800 Subject: [PATCH] refactor: bind meme to narrow plugin capabilities --- .github/workflows/plugin-api-v2.yml | 28 -------- .github/workflows/plugin-api-v3.yml | 57 +++++++++++++++ README.md | 9 +-- plugin.py | 26 +++---- tests/test_plugin.py | 105 +++++++++++++++++++--------- 5 files changed, 147 insertions(+), 78 deletions(-) delete mode 100644 .github/workflows/plugin-api-v2.yml create mode 100644 .github/workflows/plugin-api-v3.yml diff --git a/.github/workflows/plugin-api-v2.yml b/.github/workflows/plugin-api-v2.yml deleted file mode 100644 index 7c1876b..0000000 --- a/.github/workflows/plugin-api-v2.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: plugin-api-v2 - -on: - pull_request: - push: - branches: - - main - -permissions: - contents: read - -jobs: - contract: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/checkout@v4 - with: - repository: akashic-plugins/plugin-contracts - ref: 24543445c7b99ca63fcd90b5828f754a148b184c - path: .plugin-contracts - - uses: actions/setup-python@v5 - with: - python-version: "3.13" - - name: Check Plugin API v2 - env: - PYTHONPATH: .plugin-contracts - run: python -m akashic_plugin_contracts check plugin.py diff --git a/.github/workflows/plugin-api-v3.yml b/.github/workflows/plugin-api-v3.yml new file mode 100644 index 0000000..603eb24 --- /dev/null +++ b/.github/workflows/plugin-api-v3.yml @@ -0,0 +1,57 @@ +name: plugin-api-v3 + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + contract: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + with: + repository: akashic-plugins/plugin-contracts + ref: 4dd69dd621e029e51e99aa428443fa3a4ec1f6cf + path: .plugin-contracts + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Check Plugin API v3 + env: + PYTHONPATH: .plugin-contracts + run: python -m akashic_plugin_contracts check plugin.py + + composition-parity: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + with: + repository: kachofugetsu09/akashic-agent + ref: 435bbaf1e6f777951b4622702d7a7796665ed67a + path: .akashic-core + - uses: actions/checkout@v4 + with: + repository: akashic-plugins/citation + ref: b2941ee472d73ac40547dde8f16c98f0dfbbcdba + path: .citation + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + cache: pip + cache-dependency-path: .akashic-core/requirements.txt + - name: Install pinned Core dependencies + run: python -m pip install -r .akashic-core/requirements.txt pytest pytest-asyncio + - name: Compare v2 and v3 Meme receipts + env: + AKASHIC_AGENT_ROOT: .akashic-core + AKASHIC_CITATION_ROOT: .citation + PYTHONPATH: .akashic-core + run: python -m pytest -q tests/ diff --git a/README.md b/README.md index 2190826..1fc24b7 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,12 @@ | 接入方式 | 阶段 | |---|---| -| v3 `PROMPT_RENDER_EVENT` | 注入表情包目录说明 | -| v3 `AFTER_REASONING_PREPROCESS_EVENT` | 解析 meme 标签,附加媒体 | -| `PLUGIN_ASSETS` Service | 注册 `skills/` 与 `dashboard.py` | +| v3 `PROMPT_RENDER_AFTER_EVENT_BUS` | legacy Prompt 事件后注入表情包目录说明 | +| v3 `AFTER_REASONING_BEFORE_EVENT_BUS` | legacy AfterReasoning 事件前解析 meme 标签 | +| v3 `SKILLS` Service | 注册 `skills/` catalog | +| v3 `UI_SLOTS` Service | 注册 `dashboard.py` backend | -插件通过模块命名导出 `api_version = 3` 与 `apply(ctx, config)` 自行构建 `MemeCatalog`/`MemeDecorator` 并注册接入点。`citation.protocol` 是硬依赖:Citation 先剥离 cited metadata 并保留 meme tag,Meme 再完成媒体装饰,Citation cleanup 最后清除残留协议标签。旧 `MemePlugin` 暂时保留,只用于迁移期差分验证。 +插件通过模块命名导出 `api_version = 3` 与 `apply(ctx, config)` 自行构建 `MemeCatalog`/`MemeDecorator`,再从独立的 Skills、UI Slots 与 typed event 能力登记接入点。`citation.protocol` 是硬依赖:Citation 先剥离 cited metadata 并保留 meme tag,Meme 再完成媒体装饰,Citation cleanup 最后清除残留协议标签。旧 `MemePlugin` 暂时保留,只用于迁移期差分验证。 --- diff --git a/plugin.py b/plugin.py index 3541bd5..a8fdda7 100644 --- a/plugin.py +++ b/plugin.py @@ -4,18 +4,17 @@ from pathlib import Path from typing import Any, cast -from agent.lifecycle.composition import ( - AFTER_REASONING_PREPROCESS_EVENT, - PROMPT_RENDER_EVENT, -) from agent.lifecycle.types import AfterReasoningCtx, PromptRenderCtx from agent.plugin_composition import ( - PLUGIN_ASSETS, + SKILLS, + UI_SLOTS, Context, ServiceKey, ) from agent.plugins import Plugin, on_after_reasoning from agent.prompting import PromptSectionRender +from agent.turn_events.after_reasoning import AFTER_REASONING_BEFORE_EVENT_BUS +from agent.turn_events.prompt_render import PROMPT_RENDER_AFTER_EVENT_BUS from .runtime import MemeCatalog, MemeDecorator _CTX_SLOT = "prompt:ctx" @@ -69,7 +68,8 @@ async def run(self, frame: Any) -> Any: version = "1.0.0" inject: tuple[ServiceKey[object], ...] = ( CITATION_PROTOCOL_SERVICE, - PLUGIN_ASSETS, + SKILLS, + UI_SLOTS, ) @@ -81,19 +81,21 @@ async def apply(ctx: Context, config: object) -> None: catalog = MemeCatalog(ctx.runtime.workspace / "memes") decorator = MemeDecorator(catalog) - # 2. Assets and lifecycle behavior are reversible Fiber effects. - assets = ctx.require(PLUGIN_ASSETS) - await assets.register_skill(ctx, "skills") - await assets.register_dashboard(ctx, "dashboard.py") + # 2. Static capabilities remain separate reversible Fiber effects. + skills = ctx.require(SKILLS) + _ = await skills.register(ctx, "skills") + ui_slots = ctx.require(UI_SLOTS) + _ = await ui_slots.register_dashboard(ctx, "dashboard.py") + # 3. Turn behavior remains plugin-owned and ordered by typed events. def prompt_listener(prompt: PromptRenderCtx) -> None: append_meme_prompt(prompt, catalog) def answer_listener(answer: AfterReasoningCtx) -> None: decorate_meme_ctx(answer, decorator) - await ctx.on(PROMPT_RENDER_EVENT, prompt_listener) - await ctx.on(AFTER_REASONING_PREPROCESS_EVENT, answer_listener) + _ = await ctx.on(PROMPT_RENDER_AFTER_EVENT_BUS, prompt_listener) + _ = await ctx.on(AFTER_REASONING_BEFORE_EVENT_BUS, answer_listener) class MemePlugin(Plugin): diff --git a/tests/test_plugin.py b/tests/test_plugin.py index da696b8..35c64bd 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -12,22 +12,26 @@ import pytest from agent.core.response_parser import ResponseMetadata -from agent.lifecycle.composition import ( - AFTER_REASONING_CLEANUP_EVENT, - AFTER_REASONING_PREPROCESS_EVENT, - PROMPT_RENDER_EVENT, -) from agent.lifecycle.types import AfterReasoningCtx, PromptRenderCtx from agent.plugin_composition import ( - PLUGIN_ASSETS, + SKILLS, + UI_SLOTS, CompositionRoot, - PluginAssets, + Context, PluginRuntime, + PluginSkills, + PluginUiSlots, ) from agent.plugins.composable import ComposablePlugin from agent.plugins.context import PluginContext, PluginKVStore -from agent.plugins.dashboard_host import PluginDashboardHost +from agent.plugins.dashboard_host import DashboardBinding, PluginDashboardHost from agent.plugins.manager import PluginManager +from agent.plugins.scope import PluginScope, ScopedEventBus +from agent.turn_events.after_reasoning import ( + AFTER_REASONING_BEFORE_EVENT_BUS, + AFTER_REASONING_BEFORE_PERSIST, +) +from agent.turn_events.prompt_render import PROMPT_RENDER_AFTER_EVENT_BUS from bus.event_bus import EventBus from runtime import MemeCatalog, MemeDecorator @@ -55,6 +59,17 @@ def _load_meme_plugin_module(): inject = _meme_plugin_module.inject +def _copy_ignore(): + return shutil.ignore_patterns( + ".akashic-core", + ".citation", + ".git", + ".plugin-contracts", + ".pytest_cache", + "__pycache__", + ) + + def _write_meme_workspace(workspace: Path) -> Path: memes = workspace / "memes" (memes / "shy").mkdir(parents=True) @@ -73,15 +88,17 @@ def _write_meme_workspace(workspace: Path) -> Path: async def _make_plugin(tmp_path: Path) -> MemePlugin: plugin_dir = tmp_path / "plugin" plugin_dir.mkdir(parents=True) + scope = PluginScope("meme") plugin = MemePlugin() plugin.context = PluginContext( - event_bus=None, + event_bus=ScopedEventBus(EventBus(), scope), tool_registry=None, plugin_id="meme", plugin_dir=plugin_dir, data_dir=tmp_path, kv_store=PluginKVStore(plugin_dir / ".kv.json"), workspace=tmp_path, + scope=scope, ) await plugin.prepare() return plugin @@ -201,13 +218,15 @@ async def test_meme_plugin_ignores_code_tag(tmp_path: Path) -> None: async def test_v3_named_exports_match_legacy_behavior(tmp_path: Path) -> None: image = _write_meme_workspace(tmp_path) legacy = await _make_plugin(tmp_path) - ComposablePlugin.from_module(_meme_plugin_module) + _ = ComposablePlugin.from_module(_meme_plugin_module) root = CompositionRoot("meme-parity") - assets = PluginAssets() - _ = await root.context.provide(PLUGIN_ASSETS, assets) + skills = PluginSkills() + ui_slots = PluginUiSlots() + _ = await root.context.provide(SKILLS, skills) + _ = await root.context.provide(UI_SLOTS, ui_slots) _ = await root.context.provide(CITATION_PROTOCOL_SERVICE, object()) - async def mount(ctx) -> None: + async def mount(ctx: Context) -> None: await apply(ctx, object()) plugin_dir = Path(__file__).parents[1] @@ -223,10 +242,12 @@ async def mount(ctx) -> None: config=object(), ), ) - assert root.receipt().ready is True - declared = assets.freeze()["meme"] - assert declared.skill_roots == (plugin_dir / "skills",) - assert declared.dashboard_module == plugin_dir / "dashboard.py" + receipt = root.receipt() + assert receipt.ready is True + assert receipt.writes == () + assert receipt.external_effects == () + assert skills.freeze()["meme"].skill_roots == (plugin_dir / "skills",) + assert ui_slots.freeze()["meme"].dashboard_module == plugin_dir / "dashboard.py" legacy_prompt = PromptRenderCtx( session_key="telegram:1", @@ -257,7 +278,7 @@ async def mount(ctx) -> None: disabled_sections=set(), turn_injection_prompt="", ) - await root.context.serial(PROMPT_RENDER_EVENT, v3_prompt) + _ = await root.context.serial(PROMPT_RENDER_AFTER_EVENT_BUS, v3_prompt) assert v3_prompt.system_sections_bottom == legacy_prompt.system_sections_bottom legacy_answer = AfterReasoningCtx( @@ -285,16 +306,20 @@ async def mount(ctx) -> None: context_retry={}, reply="好的 ", ) - await root.context.serial(AFTER_REASONING_PREPROCESS_EVENT, v3_answer) + _ = await root.context.serial(AFTER_REASONING_BEFORE_EVENT_BUS, v3_answer) assert v3_answer.reply == legacy_answer.reply == "好的" assert v3_answer.media == legacy_answer.media == [str(image)] assert v3_answer.meme_tag == legacy_answer.meme_tag == "shy" await root.dispose() + assert root.receipt().services == () + assert root.receipt().effects == () @pytest.mark.asyncio -async def test_v3_plugin_loads_assets_through_real_manager(tmp_path: Path) -> None: +async def test_v3_plugin_loads_capabilities_through_real_manager( + tmp_path: Path, +) -> None: _write_meme_workspace(tmp_path / "workspace") plugin_home = tmp_path / "plugins" citation_dir = plugin_home / "citation" @@ -309,10 +334,10 @@ async def test_v3_plugin_loads_assets_through_real_manager(tmp_path: Path) -> No " await ctx.provide(SERVICE, object())\n", encoding="utf-8", ) - shutil.copytree( + _ = shutil.copytree( Path(__file__).parents[1], plugin_home / "meme", - ignore=shutil.ignore_patterns(".git", ".pytest_cache", "__pycache__"), + ignore=_copy_ignore(), ) workspace = tmp_path / "workspace" manager = PluginManager( @@ -335,6 +360,10 @@ async def test_v3_plugin_loads_assets_through_real_manager(tmp_path: Path) -> No ) assert snapshot.plugin_skill_index is not None assert "meme-manage" in snapshot.plugin_skill_index.records + assert snapshot.composition_topology is not None + assert "core.skills" in snapshot.composition_topology.services + assert "core.ui_slots" in snapshot.composition_topology.services + assert "core.plugin_assets" not in snapshot.composition_topology.services dashboard = PluginDashboardHost( workspace=workspace, memory_admin=object(), @@ -342,10 +371,15 @@ async def test_v3_plugin_loads_assets_through_real_manager(tmp_path: Path) -> No core_routes=(), ) dashboard.prepare_snapshot(snapshot) - assert tuple(binding.plugin_id for binding in snapshot.dashboard_bindings) == ( - "meme", - ) + assert len(snapshot.dashboard_bindings) == 1 + binding = snapshot.dashboard_bindings[0] + assert isinstance(binding, DashboardBinding) + assert binding.plugin_id == "meme" + root = snapshot.composition_root + assert root is not None await manager.terminate_all() + assert root.receipt().services == () + assert root.receipt().effects == () @pytest.mark.asyncio @@ -405,15 +439,15 @@ async def test_citation_meme_cross_repository_parity(tmp_path: Path) -> None: await citation_module.ProtocolTagCleanupModule().run(legacy_frame) plugin_home = tmp_path / "plugins" - shutil.copytree( + _ = shutil.copytree( citation_root, plugin_home / "citation", - ignore=shutil.ignore_patterns(".git", ".pytest_cache", "__pycache__"), + ignore=_copy_ignore(), ) - shutil.copytree( + _ = shutil.copytree( Path(__file__).parents[1], plugin_home / "meme", - ignore=shutil.ignore_patterns(".git", ".pytest_cache", "__pycache__"), + ignore=_copy_ignore(), ) manager = PluginManager( plugin_dirs=[plugin_home], @@ -439,7 +473,10 @@ async def test_citation_meme_cross_repository_parity(tmp_path: Path) -> None: disabled_sections=set(), turn_injection_prompt="", ) - await snapshot.composition_root.context.serial(PROMPT_RENDER_EVENT, v3_prompt) + _ = await snapshot.composition_root.context.serial( + PROMPT_RENDER_AFTER_EVENT_BUS, + v3_prompt, + ) v3_answer = AfterReasoningCtx( session_key="telegram:1", channel="telegram", @@ -452,12 +489,12 @@ async def test_citation_meme_cross_repository_parity(tmp_path: Path) -> None: context_retry={}, reply=reply, ) - await snapshot.composition_root.context.serial( - AFTER_REASONING_PREPROCESS_EVENT, + _ = await snapshot.composition_root.context.serial( + AFTER_REASONING_BEFORE_EVENT_BUS, v3_answer, ) - await snapshot.composition_root.context.serial( - AFTER_REASONING_CLEANUP_EVENT, + _ = await snapshot.composition_root.context.serial( + AFTER_REASONING_BEFORE_PERSIST, v3_answer, )