From 3ca7e1415d50c05a7f475595b26032f3db9faae2 Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Fri, 14 Aug 2026 19:08:10 +0800 Subject: [PATCH 1/7] feat: migrate meme to composition api --- README.md | 9 +- plugin.py | 87 ++++++++++--- tests/test_plugin.py | 298 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 374 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index de70376..2190826 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,17 @@ | 接入方式 | 阶段 | |---|---| -| `prompt_render_modules()` | `prompt_render.emit` 之后——注入表情包目录说明 | -| `@on_after_reasoning()` | AfterReasoning GATE——解析 meme 标签,附加媒体 | +| v3 `PROMPT_RENDER_EVENT` | 注入表情包目录说明 | +| v3 `AFTER_REASONING_PREPROCESS_EVENT` | 解析 meme 标签,附加媒体 | +| `PLUGIN_ASSETS` Service | 注册 `skills/` 与 `dashboard.py` | + +插件通过模块命名导出 `api_version = 3` 与 `apply(ctx, config)` 自行构建 `MemeCatalog`/`MemeDecorator` 并注册接入点。`citation.protocol` 是硬依赖:Citation 先剥离 cited metadata 并保留 meme tag,Meme 再完成媒体装饰,Citation cleanup 最后清除残留协议标签。旧 `MemePlugin` 暂时保留,只用于迁移期差分验证。 --- ## 运作逻辑 -### 1. 初始化(initialize) +### 1. 初始化 从工作区路径(`workspace/memes/`)加载 `manifest.json`,构建 `MemeCatalog` 和 `MemeDecorator` 实例。`MemeCatalog` 按需检测 manifest 的 mtime,变动时自动热重载,不需要重启。 diff --git a/plugin.py b/plugin.py index cad2f69..3541bd5 100644 --- a/plugin.py +++ b/plugin.py @@ -4,7 +4,16 @@ 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, + Context, + ServiceKey, +) from agent.plugins import Plugin, on_after_reasoning from agent.prompting import PromptSectionRender from .runtime import MemeCatalog, MemeDecorator @@ -15,6 +24,29 @@ re.IGNORECASE, ) +CITATION_PROTOCOL_SERVICE = ServiceKey[object]("citation.protocol") + + +def append_meme_prompt(ctx: PromptRenderCtx, catalog: MemeCatalog) -> None: + block = catalog.build_prompt_block() + if not block: + return + ctx.system_sections_bottom.append( + PromptSectionRender( + name="memes", + content=f"# Memes\n\n{block}", + is_static=False, + ) + ) + + +def decorate_meme_ctx(ctx: AfterReasoningCtx, decorator: MemeDecorator) -> None: + cleaned, tag = _extract_meme_tag(ctx.reply) + decorated = decorator.decorate(cleaned, meme_tag=tag) + ctx.reply = decorated.content + ctx.media.extend(decorated.media) + ctx.meme_tag = decorated.tag + class MemePromptModule: slot = "meme.prompt" @@ -28,21 +60,45 @@ async def run(self, frame: Any) -> Any: ctx = frame.slots.get(_CTX_SLOT) if not isinstance(ctx, PromptRenderCtx): return frame - block = self._plugin.catalog.build_prompt_block() - if not block: - return frame - ctx.system_sections_bottom.append( - PromptSectionRender( - name="memes", - content=f"# Memes\n\n{block}", - is_static=False, - ) - ) + append_meme_prompt(ctx, self._plugin.catalog) return frame +api_version = 3 +name = "meme" +version = "1.0.0" +inject: tuple[ServiceKey[object], ...] = ( + CITATION_PROTOCOL_SERVICE, + PLUGIN_ASSETS, +) + + +async def apply(ctx: Context, config: object) -> None: + """Build Meme domain objects and register their Core-hosted adapters.""" + + # 1. Domain state remains plugin-owned and reads the assigned workspace. + _ = config + 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") + + 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) + + class MemePlugin(Plugin): api_version = 2 + @classmethod def dashboard_module(cls) -> str | None: return "dashboard.py" @@ -53,11 +109,14 @@ def dashboard_module(cls) -> str | None: @classmethod def skill_roots(cls) -> tuple[str, ...]: return ("skills",) + _catalog: Any = None _decorator: Any = None async def prepare(self) -> None: - memes_dir = _workspace(self.context.plugin_dir, self.context.workspace) / "memes" + memes_dir = ( + _workspace(self.context.plugin_dir, self.context.workspace) / "memes" + ) self._catalog = MemeCatalog(memes_dir) self._decorator = MemeDecorator(self._catalog) @@ -66,11 +125,7 @@ def prompt_render_modules(self) -> list[object]: @on_after_reasoning() async def decorate_meme(self, ctx: AfterReasoningCtx) -> AfterReasoningCtx: - cleaned, tag = _extract_meme_tag(ctx.reply) - decorated = self.decorator.decorate(cleaned, meme_tag=tag) - ctx.reply = decorated.content - ctx.media.extend(decorated.media) - ctx.meme_tag = decorated.tag + decorate_meme_ctx(ctx, self.decorator) return ctx @property diff --git a/tests/test_plugin.py b/tests/test_plugin.py index d4091a9..da696b8 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -2,6 +2,8 @@ import json import importlib.util +import os +import shutil from datetime import datetime, timezone from types import SimpleNamespace from pathlib import Path @@ -10,8 +12,23 @@ 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, + CompositionRoot, + PluginAssets, + PluginRuntime, +) +from agent.plugins.composable import ComposablePlugin from agent.plugins.context import PluginContext, PluginKVStore +from agent.plugins.dashboard_host import PluginDashboardHost +from agent.plugins.manager import PluginManager +from bus.event_bus import EventBus from runtime import MemeCatalog, MemeDecorator @@ -33,6 +50,9 @@ def _load_meme_plugin_module(): _meme_plugin_module = _load_meme_plugin_module() MemePlugin = _meme_plugin_module.MemePlugin MemePromptModule = _meme_plugin_module.MemePromptModule +CITATION_PROTOCOL_SERVICE = _meme_plugin_module.CITATION_PROTOCOL_SERVICE +apply = _meme_plugin_module.apply +inject = _meme_plugin_module.inject def _write_meme_workspace(workspace: Path) -> Path: @@ -78,7 +98,9 @@ def test_catalog_builds_prompt_block(tmp_path: Path) -> None: def test_decorator_picks_image_for_tag(tmp_path: Path) -> None: image = _write_meme_workspace(tmp_path) - result = MemeDecorator(MemeCatalog(tmp_path / "memes")).decorate("好的", meme_tag="shy") + result = MemeDecorator(MemeCatalog(tmp_path / "memes")).decorate( + "好的", meme_tag="shy" + ) assert result.content == "好的" assert result.media == [str(image)] @@ -173,3 +195,277 @@ async def test_meme_plugin_ignores_code_tag(tmp_path: Path) -> None: assert out.reply == "应该是 ``。\n\n<æm>shy" assert out.media == [] assert out.meme_tag is None + + +@pytest.mark.asyncio +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) + root = CompositionRoot("meme-parity") + assets = PluginAssets() + _ = await root.context.provide(PLUGIN_ASSETS, assets) + _ = await root.context.provide(CITATION_PROTOCOL_SERVICE, object()) + + async def mount(ctx) -> None: + await apply(ctx, object()) + + plugin_dir = Path(__file__).parents[1] + _ = await root.mount( + mount, + name="meme", + inject=inject, + runtime=PluginRuntime( + plugin_id="meme", + plugin_dir=plugin_dir, + data_dir=tmp_path / "plugin-data", + workspace=tmp_path, + 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" + + legacy_prompt = PromptRenderCtx( + session_key="telegram:1", + channel="telegram", + chat_id="1", + content="你好", + media=None, + timestamp=datetime.now(timezone.utc), + history=[], + skill_names=[], + retrieved_memory_block="", + disabled_sections=set(), + turn_injection_prompt="", + ) + await legacy.prompt_render_modules()[0].run( + SimpleNamespace(slots={"prompt:ctx": legacy_prompt}) + ) + v3_prompt = PromptRenderCtx( + session_key="telegram:1", + channel="telegram", + chat_id="1", + content="你好", + media=None, + timestamp=legacy_prompt.timestamp, + history=[], + skill_names=[], + retrieved_memory_block="", + disabled_sections=set(), + turn_injection_prompt="", + ) + await root.context.serial(PROMPT_RENDER_EVENT, v3_prompt) + assert v3_prompt.system_sections_bottom == legacy_prompt.system_sections_bottom + + legacy_answer = AfterReasoningCtx( + session_key="telegram:1", + channel="telegram", + chat_id="1", + tools_used=(), + thinking=None, + response_metadata=ResponseMetadata(raw_text="好的 "), + streamed=False, + tool_chain=(), + context_retry={}, + reply="好的 ", + ) + await legacy.decorate_meme(legacy_answer) + v3_answer = AfterReasoningCtx( + session_key="telegram:1", + channel="telegram", + chat_id="1", + tools_used=(), + thinking=None, + response_metadata=ResponseMetadata(raw_text="好的 "), + streamed=False, + tool_chain=(), + context_retry={}, + reply="好的 ", + ) + await root.context.serial(AFTER_REASONING_PREPROCESS_EVENT, 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() + + +@pytest.mark.asyncio +async def test_v3_plugin_loads_assets_through_real_manager(tmp_path: Path) -> None: + _write_meme_workspace(tmp_path / "workspace") + plugin_home = tmp_path / "plugins" + citation_dir = plugin_home / "citation" + citation_dir.mkdir(parents=True) + (citation_dir / "plugin.py").write_text( + "from agent.plugin_composition import ServiceKey\n" + "api_version = 3\n" + "name = 'citation'\n" + "version = '1.0.0'\n" + "SERVICE = ServiceKey('citation.protocol')\n" + "async def apply(ctx, config):\n" + " await ctx.provide(SERVICE, object())\n", + encoding="utf-8", + ) + shutil.copytree( + Path(__file__).parents[1], + plugin_home / "meme", + ignore=shutil.ignore_patterns(".git", ".pytest_cache", "__pycache__"), + ) + workspace = tmp_path / "workspace" + manager = PluginManager( + plugin_dirs=[plugin_home], + event_bus=EventBus(), + tool_registry=None, + workspace=workspace, + installed_cache_root=tmp_path / "plugin-home" / "cache", + ) + + await manager.load_all() + + generation = manager.generation("meme") + snapshot = manager.current_snapshot + assert generation is not None and snapshot is not None + assert isinstance(generation.instance, ComposablePlugin) + assert generation.contributions.skill_roots == (plugin_home / "meme" / "skills",) + assert generation.contributions.dashboard_module == ( + plugin_home / "meme" / "dashboard.py" + ) + assert snapshot.plugin_skill_index is not None + assert "meme-manage" in snapshot.plugin_skill_index.records + dashboard = PluginDashboardHost( + workspace=workspace, + memory_admin=object(), + memory_store=object(), + core_routes=(), + ) + dashboard.prepare_snapshot(snapshot) + assert tuple(binding.plugin_id for binding in snapshot.dashboard_bindings) == ( + "meme", + ) + await manager.terminate_all() + + +@pytest.mark.asyncio +async def test_citation_meme_cross_repository_parity(tmp_path: Path) -> None: + raw_citation_root = os.environ.get("AKASHIC_CITATION_ROOT", "").strip() + if not raw_citation_root: + pytest.skip("set AKASHIC_CITATION_ROOT to run the pinned cross-repository gate") + citation_root = Path(raw_citation_root) + citation_spec = importlib.util.spec_from_file_location( + "test_citation_plugin", + citation_root / "plugin.py", + ) + if citation_spec is None or citation_spec.loader is None: + raise ImportError(str(citation_root / "plugin.py")) + citation_module = importlib.util.module_from_spec(citation_spec) + sys.modules[citation_spec.name] = citation_module + citation_spec.loader.exec_module(citation_module) + + workspace = tmp_path / "workspace" + image = _write_meme_workspace(workspace) + legacy_meme = await _make_plugin(workspace) + legacy_prompt = PromptRenderCtx( + session_key="telegram:1", + channel="telegram", + chat_id="1", + content="你好", + media=None, + timestamp=datetime.now(timezone.utc), + history=[], + skill_names=[], + retrieved_memory_block="", + disabled_sections=set(), + turn_injection_prompt="", + ) + await citation_module.CitationPromptModule().run( + SimpleNamespace(slots={"prompt:ctx": legacy_prompt}) + ) + await legacy_meme.prompt_render_modules()[0].run( + SimpleNamespace(slots={"prompt:ctx": legacy_prompt}) + ) + reply = "答复正文\n§cited:[mem_1]§ " + legacy_answer = AfterReasoningCtx( + session_key="telegram:1", + channel="telegram", + chat_id="1", + tools_used=(), + thinking=None, + response_metadata=ResponseMetadata(raw_text=reply), + streamed=False, + tool_chain=(), + context_retry={}, + reply=reply, + ) + legacy_frame = SimpleNamespace(slots={"reasoning:ctx": legacy_answer}) + await citation_module.CitationAfterReasoningModule().run(legacy_frame) + await legacy_meme.decorate_meme(legacy_answer) + await citation_module.ProtocolTagCleanupModule().run(legacy_frame) + + plugin_home = tmp_path / "plugins" + shutil.copytree( + citation_root, + plugin_home / "citation", + ignore=shutil.ignore_patterns(".git", ".pytest_cache", "__pycache__"), + ) + shutil.copytree( + Path(__file__).parents[1], + plugin_home / "meme", + ignore=shutil.ignore_patterns(".git", ".pytest_cache", "__pycache__"), + ) + manager = PluginManager( + plugin_dirs=[plugin_home], + event_bus=EventBus(), + tool_registry=None, + workspace=workspace, + installed_cache_root=tmp_path / "plugin-home" / "cache", + ) + await manager.load_all() + snapshot = manager.current_snapshot + assert snapshot is not None and snapshot.composition_root is not None + + v3_prompt = PromptRenderCtx( + session_key="telegram:1", + channel="telegram", + chat_id="1", + content="你好", + media=None, + timestamp=legacy_prompt.timestamp, + history=[], + skill_names=[], + retrieved_memory_block="", + disabled_sections=set(), + turn_injection_prompt="", + ) + await snapshot.composition_root.context.serial(PROMPT_RENDER_EVENT, v3_prompt) + v3_answer = AfterReasoningCtx( + session_key="telegram:1", + channel="telegram", + chat_id="1", + tools_used=(), + thinking=None, + response_metadata=ResponseMetadata(raw_text=reply), + streamed=False, + tool_chain=(), + context_retry={}, + reply=reply, + ) + await snapshot.composition_root.context.serial( + AFTER_REASONING_PREPROCESS_EVENT, + v3_answer, + ) + await snapshot.composition_root.context.serial( + AFTER_REASONING_CLEANUP_EVENT, + v3_answer, + ) + + assert v3_prompt.system_sections_bottom == legacy_prompt.system_sections_bottom + assert v3_answer.reply == legacy_answer.reply == "答复正文" + assert v3_answer.persist_assistant_metadata["cited_memory_ids"] == ( + legacy_frame.slots["persist:assistant:cited_memory_ids"] + ) + assert v3_answer.media == legacy_answer.media == [str(image)] + assert v3_answer.meme_tag == legacy_answer.meme_tag == "shy" + await manager.terminate_all() From 6de810e74d3776b5009882e0bf68e03990af6009 Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Sun, 16 Aug 2026 00:40:49 +0800 Subject: [PATCH 2/7] feat: bind meme to v3 package contributions --- .github/workflows/plugin-api-v2.yml | 28 ---- .github/workflows/plugin-api-v3.yml | 57 ++++++++ README.md | 6 +- dashboard.py | 8 +- plugin.py | 26 ++-- tests/test_plugin.py | 204 ++++++++++++++++++++++++---- 6 files changed, 250 insertions(+), 79 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..ee5e5a4 --- /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: a047470a39d4f7d2e6be1d2a8e2824916d52fad1 + path: .akashic-core + - uses: actions/checkout@v4 + with: + repository: akashic-plugins/citation + ref: 12f9552e45db794fdc1cdb9e1367d0ca8f132f9b + 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..fe20f82 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,11 @@ |---|---| | v3 `PROMPT_RENDER_EVENT` | 注入表情包目录说明 | | v3 `AFTER_REASONING_PREPROCESS_EVENT` | 解析 meme 标签,附加媒体 | -| `PLUGIN_ASSETS` Service | 注册 `skills/` 与 `dashboard.py` | +| `skill_roots = ("skills",)` | 声明管理 Skill | +| `dashboard_module = "dashboard.py"` | 声明 v3 Dashboard | +| `workspace_roots = ("memes",)` | 取得 Core 分配的表情包资产根 | -插件通过模块命名导出 `api_version = 3` 与 `apply(ctx, config)` 自行构建 `MemeCatalog`/`MemeDecorator` 并注册接入点。`citation.protocol` 是硬依赖:Citation 先剥离 cited metadata 并保留 meme tag,Meme 再完成媒体装饰,Citation cleanup 最后清除残留协议标签。旧 `MemePlugin` 暂时保留,只用于迁移期差分验证。 +插件通过模块命名导出声明静态贡献,通过 `apply(ctx, config)` 自行构建 `MemeCatalog`/`MemeDecorator` 并注册接入点。`citation.protocol` 是硬依赖:Citation 先剥离 cited metadata 并保留 meme tag,Meme 再完成媒体装饰,Citation cleanup 最后清除残留协议标签。Core 只分配生命周期、依赖顺序与 workspace root;Meme 的目录结构、随机选图和 Dashboard 仍由插件拥有。旧 `MemePlugin` 暂时保留,只用于迁移期差分验证。 --- diff --git a/dashboard.py b/dashboard.py index 994ee52..fc996a0 100644 --- a/dashboard.py +++ b/dashboard.py @@ -1,17 +1,17 @@ from __future__ import annotations import os -from pathlib import Path from typing import Any from fastapi import FastAPI, HTTPException from fastapi.responses import FileResponse -from .plugin import _workspace +from agent.plugin_composition import DashboardContext from .runtime import MemeCatalog -def register(app: FastAPI, plugin_dir: Path, workspace: Path) -> None: - memes_dir = _workspace(plugin_dir, workspace) / "memes" + +def register(app: FastAPI, context: DashboardContext) -> None: + memes_dir = context.workspace_root("memes") catalog = MemeCatalog(memes_dir) @app.get("/api/dashboard/meme/categories") diff --git a/plugin.py b/plugin.py index 3541bd5..7f6e1f6 100644 --- a/plugin.py +++ b/plugin.py @@ -9,11 +9,7 @@ PROMPT_RENDER_EVENT, ) from agent.lifecycle.types import AfterReasoningCtx, PromptRenderCtx -from agent.plugin_composition import ( - PLUGIN_ASSETS, - Context, - ServiceKey, -) +from agent.plugin_composition import Context, ServiceKey from agent.plugins import Plugin, on_after_reasoning from agent.prompting import PromptSectionRender from .runtime import MemeCatalog, MemeDecorator @@ -67,10 +63,10 @@ async def run(self, frame: Any) -> Any: api_version = 3 name = "meme" version = "1.0.0" -inject: tuple[ServiceKey[object], ...] = ( - CITATION_PROTOCOL_SERVICE, - PLUGIN_ASSETS, -) +inject: tuple[ServiceKey[object], ...] = (CITATION_PROTOCOL_SERVICE,) +skill_roots = ("skills",) +dashboard_module = "dashboard.py" +workspace_roots = ("memes",) async def apply(ctx: Context, config: object) -> None: @@ -78,22 +74,18 @@ async def apply(ctx: Context, config: object) -> None: # 1. Domain state remains plugin-owned and reads the assigned workspace. _ = config - catalog = MemeCatalog(ctx.runtime.workspace / "memes") + catalog = MemeCatalog(ctx.workspace_root("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. Lifecycle behavior is owned by reversible Fiber effects. 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_EVENT, prompt_listener) + _ = await ctx.on(AFTER_REASONING_PREPROCESS_EVENT, answer_listener) class MemePlugin(Plugin): diff --git a/tests/test_plugin.py b/tests/test_plugin.py index da696b8..16c36d1 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import importlib import importlib.util import os import shutil @@ -10,6 +11,8 @@ import sys import pytest +from fastapi import FastAPI +from fastapi.routing import APIRoute from agent.core.response_parser import ResponseMetadata from agent.lifecycle.composition import ( @@ -19,15 +22,16 @@ ) from agent.lifecycle.types import AfterReasoningCtx, PromptRenderCtx from agent.plugin_composition import ( - PLUGIN_ASSETS, CompositionRoot, - PluginAssets, + Context, + DashboardContext, PluginRuntime, ) 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 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,14 @@ 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) + composable = ComposablePlugin.from_module(_meme_plugin_module) + assert composable.skill_roots == ("skills",) + assert composable.dashboard_module == "dashboard.py" + assert composable.workspace_roots == ("memes",) root = CompositionRoot("meme-parity") - assets = PluginAssets() - _ = await root.context.provide(PLUGIN_ASSETS, assets) _ = 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] @@ -221,12 +239,13 @@ async def mount(ctx) -> None: data_dir=tmp_path / "plugin-data", workspace=tmp_path, config=object(), + workspace_roots=("memes",), ), ) - 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 == () legacy_prompt = PromptRenderCtx( session_key="telegram:1", @@ -257,7 +276,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_EVENT, v3_prompt) assert v3_prompt.system_sections_bottom == legacy_prompt.system_sections_bottom legacy_answer = AfterReasoningCtx( @@ -285,16 +304,126 @@ async def mount(ctx) -> None: context_retry={}, reply="好的 ", ) - await root.context.serial(AFTER_REASONING_PREPROCESS_EVENT, v3_answer) + _ = await root.context.serial(AFTER_REASONING_PREPROCESS_EVENT, 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().effects == () + assert root.topology_view().listeners == () @pytest.mark.asyncio -async def test_v3_plugin_loads_assets_through_real_manager(tmp_path: Path) -> None: +async def test_v3_candidate_reads_only_its_projected_meme_root( + tmp_path: Path, +) -> None: + formal_workspace = tmp_path / "formal-workspace" + formal_image = _write_meme_workspace(formal_workspace) + candidate_workspace = ( + tmp_path + / "runtime" + / "plugin-validation" + / "meme" + / "composition" + / "attempt" + / "workspace" + ) + _ = shutil.copytree( + formal_workspace / "memes", + candidate_workspace / "memes", + ) + candidate_image = candidate_workspace / "memes" / "shy" / "001.png" + before = { + path.relative_to(candidate_workspace).as_posix(): path.read_bytes() + for path in candidate_workspace.rglob("*") + if path.is_file() + } + root = CompositionRoot("meme-candidate") + _ = await root.context.provide(CITATION_PROTOCOL_SERVICE, object()) + + async def mount(ctx: Context) -> None: + await apply(ctx, object()) + + _ = await root.mount( + mount, + name="meme", + inject=inject, + runtime=PluginRuntime( + plugin_id="meme", + plugin_dir=Path(__file__).parents[1], + data_dir=tmp_path / "candidate-data", + workspace=candidate_workspace, + config=object(), + workspace_roots=("memes",), + ), + ) + prompt = PromptRenderCtx( + session_key="webui:1", + channel="webui", + chat_id="1", + content="你好", + media=None, + timestamp=datetime.now(timezone.utc), + history=[], + skill_names=[], + retrieved_memory_block="", + disabled_sections=set(), + turn_injection_prompt="", + ) + _ = await root.context.serial(PROMPT_RENDER_EVENT, prompt) + answer = AfterReasoningCtx( + session_key="webui:1", + channel="webui", + chat_id="1", + tools_used=(), + thinking=None, + response_metadata=ResponseMetadata(raw_text="好的 "), + streamed=False, + tool_chain=(), + context_retry={}, + reply="好的 ", + ) + _ = await root.context.serial(AFTER_REASONING_PREPROCESS_EVENT, answer) + + dashboard_module = importlib.import_module("test_meme_plugin.dashboard") + app = FastAPI() + dashboard_module.register( + app, + DashboardContext( + plugin_id="meme", + plugin_dir=Path(__file__).parents[1], + data_root=tmp_path / "candidate-data", + validation=True, + _workspace_roots=(("memes", candidate_workspace / "memes"),), + ), + ) + candidate_route = next( + route + for route in app.routes + if isinstance(route, APIRoute) + and route.path == "/api/dashboard/meme/categories" + ) + categories = candidate_route.endpoint() + + after = { + path.relative_to(candidate_workspace).as_posix(): path.read_bytes() + for path in candidate_workspace.rglob("*") + if path.is_file() + } + assert before == after + assert formal_image.read_bytes() == candidate_image.read_bytes() + assert answer.media == [str(candidate_image)] + assert categories["categories"][0]["tag"] == "shy" + assert root.receipt().writes == () + assert root.receipt().external_effects == () + await root.dispose() + + +@pytest.mark.asyncio +async def test_v3_plugin_loads_package_and_dashboard_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 +438,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( @@ -333,6 +462,7 @@ async def test_v3_plugin_loads_assets_through_real_manager(tmp_path: Path) -> No assert generation.contributions.dashboard_module == ( plugin_home / "meme" / "dashboard.py" ) + assert generation.instance.workspace_roots == ("memes",) assert snapshot.plugin_skill_index is not None assert "meme-manage" in snapshot.plugin_skill_index.records dashboard = PluginDashboardHost( @@ -342,17 +472,32 @@ 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" + assert binding.validation is False + assert binding.runtime_workspace == workspace.resolve() + categories = next( + route.endpoint + for route in binding.routes + if route.path == "/api/dashboard/meme/categories" + )() + assert categories["categories"][0]["tag"] == "shy" + root = snapshot.composition_root + assert root is not None await manager.terminate_all() + assert root.receipt().effects == () + assert root.topology_view().listeners == () @pytest.mark.asyncio async def test_citation_meme_cross_repository_parity(tmp_path: Path) -> None: raw_citation_root = os.environ.get("AKASHIC_CITATION_ROOT", "").strip() if not raw_citation_root: - pytest.skip("set AKASHIC_CITATION_ROOT to run the pinned cross-repository gate") + raise RuntimeError( + "AKASHIC_CITATION_ROOT 必须指向 exact-commit Citation checkout" + ) citation_root = Path(raw_citation_root) citation_spec = importlib.util.spec_from_file_location( "test_citation_plugin", @@ -405,15 +550,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 +584,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_EVENT, + v3_prompt, + ) v3_answer = AfterReasoningCtx( session_key="telegram:1", channel="telegram", @@ -452,11 +600,11 @@ async def test_citation_meme_cross_repository_parity(tmp_path: Path) -> None: context_retry={}, reply=reply, ) - await snapshot.composition_root.context.serial( + _ = await snapshot.composition_root.context.serial( AFTER_REASONING_PREPROCESS_EVENT, v3_answer, ) - await snapshot.composition_root.context.serial( + _ = await snapshot.composition_root.context.serial( AFTER_REASONING_CLEANUP_EVENT, v3_answer, ) From c6c01fc8a778a516e9f9ba5a57f7aa556b6651ee Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Sun, 16 Aug 2026 01:00:03 +0800 Subject: [PATCH 3/7] =?UTF-8?q?ci:=20=E5=9B=BA=E5=AE=9A=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=90=8E=E7=9A=84=20Citation=20=E8=BF=81=E7=A7=BB=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/plugin-api-v3.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/plugin-api-v3.yml b/.github/workflows/plugin-api-v3.yml index ee5e5a4..5f8f753 100644 --- a/.github/workflows/plugin-api-v3.yml +++ b/.github/workflows/plugin-api-v3.yml @@ -40,7 +40,7 @@ jobs: - uses: actions/checkout@v4 with: repository: akashic-plugins/citation - ref: 12f9552e45db794fdc1cdb9e1367d0ca8f132f9b + ref: a9abeb31c25458b8e799dc6aae25d3e83b912c83 path: .citation - uses: actions/setup-python@v5 with: From 4828fe52d7a25badfd77438401dad01730f24e74 Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Sun, 16 Aug 2026 00:43:48 +0800 Subject: [PATCH 4/7] refactor: remove meme plugin v2 shell --- .github/workflows/plugin-api-v3.yml | 2 +- README.md | 4 +- plugin.py | 71 ------ tests/test_plugin.py | 373 +++++++--------------------- 4 files changed, 90 insertions(+), 360 deletions(-) diff --git a/.github/workflows/plugin-api-v3.yml b/.github/workflows/plugin-api-v3.yml index 5f8f753..e2a40c6 100644 --- a/.github/workflows/plugin-api-v3.yml +++ b/.github/workflows/plugin-api-v3.yml @@ -49,7 +49,7 @@ jobs: 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 + - name: Verify Meme v3 behavior env: AKASHIC_AGENT_ROOT: .akashic-core AKASHIC_CITATION_ROOT: .citation diff --git a/README.md b/README.md index fe20f82..a1661a7 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ | `dashboard_module = "dashboard.py"` | 声明 v3 Dashboard | | `workspace_roots = ("memes",)` | 取得 Core 分配的表情包资产根 | -插件通过模块命名导出声明静态贡献,通过 `apply(ctx, config)` 自行构建 `MemeCatalog`/`MemeDecorator` 并注册接入点。`citation.protocol` 是硬依赖:Citation 先剥离 cited metadata 并保留 meme tag,Meme 再完成媒体装饰,Citation cleanup 最后清除残留协议标签。Core 只分配生命周期、依赖顺序与 workspace root;Meme 的目录结构、随机选图和 Dashboard 仍由插件拥有。旧 `MemePlugin` 暂时保留,只用于迁移期差分验证。 +插件通过模块命名导出声明静态贡献,通过 `apply(ctx, config)` 自行构建 `MemeCatalog`/`MemeDecorator` 并注册接入点。`citation.protocol` 是硬依赖:Citation 先剥离 cited metadata 并保留 meme tag,Meme 再完成媒体装饰,Citation cleanup 最后清除残留协议标签。Core 只分配生命周期、依赖顺序与 workspace root;Meme 的目录结构、随机选图和 Dashboard 仍由插件拥有。 --- @@ -24,7 +24,7 @@ 从工作区路径(`workspace/memes/`)加载 `manifest.json`,构建 `MemeCatalog` 和 `MemeDecorator` 实例。`MemeCatalog` 按需检测 manifest 的 mtime,变动时自动热重载,不需要重启。 -### 2. 注入 catalog(MemePromptModule) +### 2. 注入 catalog 每轮推理前,调用 `catalog.build_prompt_block()` 把启用的表情包类别(名称、描述、别名)拼成文本块,追加到系统 prompt 底部,告知 LLM 可以在回复中嵌入 `` 标签。如果 catalog 为空则跳过注入。 diff --git a/plugin.py b/plugin.py index 7f6e1f6..fb579f1 100644 --- a/plugin.py +++ b/plugin.py @@ -1,8 +1,6 @@ from __future__ import annotations import re -from pathlib import Path -from typing import Any, cast from agent.lifecycle.composition import ( AFTER_REASONING_PREPROCESS_EVENT, @@ -10,11 +8,9 @@ ) from agent.lifecycle.types import AfterReasoningCtx, PromptRenderCtx from agent.plugin_composition import Context, ServiceKey -from agent.plugins import Plugin, on_after_reasoning from agent.prompting import PromptSectionRender from .runtime import MemeCatalog, MemeDecorator -_CTX_SLOT = "prompt:ctx" _MEME_RE = re.compile( r"(?(?!`)", re.IGNORECASE, @@ -44,22 +40,6 @@ def decorate_meme_ctx(ctx: AfterReasoningCtx, decorator: MemeDecorator) -> None: ctx.meme_tag = decorated.tag -class MemePromptModule: - slot = "meme.prompt" - requires = ("prompt_render.emit", "citation.prompt", _CTX_SLOT) - produces = (_CTX_SLOT,) - - def __init__(self, plugin: "MemePlugin") -> None: - self._plugin = plugin - - async def run(self, frame: Any) -> Any: - ctx = frame.slots.get(_CTX_SLOT) - if not isinstance(ctx, PromptRenderCtx): - return frame - append_meme_prompt(ctx, self._plugin.catalog) - return frame - - api_version = 3 name = "meme" version = "1.0.0" @@ -88,51 +68,6 @@ def answer_listener(answer: AfterReasoningCtx) -> None: _ = await ctx.on(AFTER_REASONING_PREPROCESS_EVENT, answer_listener) -class MemePlugin(Plugin): - api_version = 2 - - @classmethod - def dashboard_module(cls) -> str | None: - return "dashboard.py" - - name = "meme" - version = "1.0.0" - - @classmethod - def skill_roots(cls) -> tuple[str, ...]: - return ("skills",) - - _catalog: Any = None - _decorator: Any = None - - async def prepare(self) -> None: - memes_dir = ( - _workspace(self.context.plugin_dir, self.context.workspace) / "memes" - ) - self._catalog = MemeCatalog(memes_dir) - self._decorator = MemeDecorator(self._catalog) - - def prompt_render_modules(self) -> list[object]: - return [MemePromptModule(self)] - - @on_after_reasoning() - async def decorate_meme(self, ctx: AfterReasoningCtx) -> AfterReasoningCtx: - decorate_meme_ctx(ctx, self.decorator) - return ctx - - @property - def catalog(self) -> Any: - if self._catalog is None: - raise RuntimeError("meme 插件尚未初始化") - return self._catalog - - @property - def decorator(self) -> Any: - if self._decorator is None: - raise RuntimeError("meme 插件尚未初始化") - return self._decorator - - def _extract_meme_tag(response: str) -> tuple[str, str | None]: match = _MEME_RE.search(response) if match is None: @@ -141,9 +76,3 @@ def _extract_meme_tag(response: str) -> tuple[str, str | None]: cleaned = re.sub(r"[ \t]+\n", "\n", cleaned) cleaned = re.sub(r" {2,}", " ", cleaned) return cleaned.strip(), match.group(1).lower() - - -def _workspace(plugin_dir: Path, configured: Path | None) -> Path: - if configured is not None: - return configured - return cast(Path, plugin_dir.parent.parent) diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 16c36d1..1da9cd6 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -1,14 +1,13 @@ from __future__ import annotations -import json import importlib import importlib.util +import json import os -import shutil -from datetime import datetime, timezone -from types import SimpleNamespace from pathlib import Path +import shutil import sys +from datetime import datetime, timezone import pytest from fastapi import FastAPI @@ -28,10 +27,8 @@ PluginRuntime, ) from agent.plugins.composable import ComposablePlugin -from agent.plugins.context import PluginContext, PluginKVStore from agent.plugins.dashboard_host import DashboardBinding, PluginDashboardHost from agent.plugins.manager import PluginManager -from agent.plugins.scope import PluginScope, ScopedEventBus from bus.event_bus import EventBus from runtime import MemeCatalog, MemeDecorator @@ -52,10 +49,9 @@ def _load_meme_plugin_module(): _meme_plugin_module = _load_meme_plugin_module() -MemePlugin = _meme_plugin_module.MemePlugin -MemePromptModule = _meme_plugin_module.MemePromptModule CITATION_PROTOCOL_SERVICE = _meme_plugin_module.CITATION_PROTOCOL_SERVICE apply = _meme_plugin_module.apply +decorate_meme_ctx = _meme_plugin_module.decorate_meme_ctx inject = _meme_plugin_module.inject @@ -85,27 +81,39 @@ def _write_meme_workspace(workspace: Path) -> Path: return image -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=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, +def _prompt_ctx() -> PromptRenderCtx: + return PromptRenderCtx( + session_key="webui:1", + channel="webui", + chat_id="1", + content="你好", + media=None, + timestamp=datetime.now(timezone.utc), + history=[], + skill_names=[], + retrieved_memory_block="", + disabled_sections=set(), + turn_injection_prompt="", + ) + + +def _answer_ctx(reply: str) -> AfterReasoningCtx: + return AfterReasoningCtx( + session_key="webui:1", + channel="webui", + chat_id="1", + tools_used=(), + thinking=None, + response_metadata=ResponseMetadata(raw_text=reply), + streamed=False, + tool_chain=(), + context_retry={}, + reply=reply, ) - await plugin.prepare() - return plugin def test_catalog_builds_prompt_block(tmp_path: Path) -> None: - _write_meme_workspace(tmp_path) + _ = _write_meme_workspace(tmp_path) block = MemeCatalog(tmp_path / "memes").build_prompt_block() assert block is not None assert "" in block @@ -122,120 +130,55 @@ def test_decorator_picks_image_for_tag(tmp_path: Path) -> None: assert result.media == [str(image)] -@pytest.mark.asyncio -async def test_meme_prompt_module_injects_bottom_section(tmp_path: Path) -> None: - _write_meme_workspace(tmp_path) - plugin = await _make_plugin(tmp_path) - module = plugin.prompt_render_modules()[0] - assert isinstance(module, MemePromptModule) - ctx = PromptRenderCtx( - session_key="telegram:1", - channel="telegram", - chat_id="1", - content="你好", - media=None, - timestamp=datetime.now(timezone.utc), - history=[], - skill_names=[], - retrieved_memory_block="", - disabled_sections=set(), - turn_injection_prompt="", - ) - frame = SimpleNamespace(slots={"prompt:ctx": ctx}) - await module.run(frame) - assert ctx.system_sections_bottom[0].name == "memes" - - -@pytest.mark.asyncio -async def test_meme_plugin_decorates_after_reasoning(tmp_path: Path) -> None: +def test_decorate_meme_ctx_updates_answer_metadata(tmp_path: Path) -> None: image = _write_meme_workspace(tmp_path) - plugin = await _make_plugin(tmp_path) - ctx = AfterReasoningCtx( - session_key="telegram:1", - channel="telegram", - chat_id="1", - tools_used=(), - thinking=None, - response_metadata=ResponseMetadata(raw_text="好的 "), - streamed=False, - tool_chain=(), - context_retry={}, - reply="好的 ", - ) - out = await plugin.decorate_meme(ctx) - assert out.reply == "好的" - assert out.media == [str(image)] - assert out.meme_tag == "shy" + ctx = _answer_ctx("好的 ") + decorate_meme_ctx(ctx, MemeDecorator(MemeCatalog(tmp_path / "memes"))) + assert ctx.reply == "好的" + assert ctx.media == [str(image)] + assert ctx.meme_tag == "shy" -@pytest.mark.asyncio -async def test_meme_plugin_accepts_inline_tag(tmp_path: Path) -> None: +def test_decorate_meme_ctx_accepts_inline_tag(tmp_path: Path) -> None: image = _write_meme_workspace(tmp_path) - plugin = await _make_plugin(tmp_path) - ctx = AfterReasoningCtx( - session_key="telegram:1", - channel="telegram", - chat_id="1", - tools_used=(), - thinking=None, - response_metadata=ResponseMetadata(raw_text="快了 \n\n马上到了"), - streamed=False, - tool_chain=(), - context_retry={}, - reply="快了 \n\n马上到了", - ) - out = await plugin.decorate_meme(ctx) - assert out.reply == "快了\n\n马上到了" - assert out.media == [str(image)] - assert out.meme_tag == "shy" + ctx = _answer_ctx("快了 \n\n马上到了") + decorate_meme_ctx(ctx, MemeDecorator(MemeCatalog(tmp_path / "memes"))) + assert ctx.reply == "快了\n\n马上到了" + assert ctx.media == [str(image)] + assert ctx.meme_tag == "shy" -@pytest.mark.asyncio -async def test_meme_plugin_ignores_code_tag(tmp_path: Path) -> None: - _write_meme_workspace(tmp_path) - plugin = await _make_plugin(tmp_path) - ctx = AfterReasoningCtx( - session_key="telegram:1", - channel="telegram", - chat_id="1", - tools_used=(), - thinking=None, - response_metadata=ResponseMetadata( - raw_text="应该是 ``。\n\n<æm>shy" - ), - streamed=False, - tool_chain=(), - context_retry={}, - reply="应该是 ``。\n\n<æm>shy", - ) - out = await plugin.decorate_meme(ctx) - assert out.reply == "应该是 ``。\n\n<æm>shy" - assert out.media == [] - assert out.meme_tag is None +def test_decorate_meme_ctx_ignores_code_tag(tmp_path: Path) -> None: + _ = _write_meme_workspace(tmp_path) + ctx = _answer_ctx("应该是 ``。\n\n<æm>shy") + decorate_meme_ctx(ctx, MemeDecorator(MemeCatalog(tmp_path / "memes"))) + assert ctx.reply == "应该是 ``。\n\n<æm>shy" + assert ctx.media == [] + assert ctx.meme_tag is None @pytest.mark.asyncio -async def test_v3_named_exports_match_legacy_behavior(tmp_path: Path) -> None: +async def test_v3_named_exports_run_complete_lifecycle_behavior( + tmp_path: Path, +) -> None: image = _write_meme_workspace(tmp_path) - legacy = await _make_plugin(tmp_path) composable = ComposablePlugin.from_module(_meme_plugin_module) assert composable.skill_roots == ("skills",) assert composable.dashboard_module == "dashboard.py" assert composable.workspace_roots == ("memes",) - root = CompositionRoot("meme-parity") + root = CompositionRoot("meme-v3") _ = await root.context.provide(CITATION_PROTOCOL_SERVICE, object()) async def mount(ctx: Context) -> None: await apply(ctx, object()) - plugin_dir = Path(__file__).parents[1] _ = await root.mount( mount, name="meme", inject=inject, runtime=PluginRuntime( plugin_id="meme", - plugin_dir=plugin_dir, + plugin_dir=Path(__file__).parents[1], data_dir=tmp_path / "plugin-data", workspace=tmp_path, config=object(), @@ -247,68 +190,16 @@ async def mount(ctx: Context) -> None: assert receipt.writes == () assert receipt.external_effects == () - legacy_prompt = PromptRenderCtx( - session_key="telegram:1", - channel="telegram", - chat_id="1", - content="你好", - media=None, - timestamp=datetime.now(timezone.utc), - history=[], - skill_names=[], - retrieved_memory_block="", - disabled_sections=set(), - turn_injection_prompt="", - ) - await legacy.prompt_render_modules()[0].run( - SimpleNamespace(slots={"prompt:ctx": legacy_prompt}) - ) - v3_prompt = PromptRenderCtx( - session_key="telegram:1", - channel="telegram", - chat_id="1", - content="你好", - media=None, - timestamp=legacy_prompt.timestamp, - history=[], - skill_names=[], - retrieved_memory_block="", - disabled_sections=set(), - turn_injection_prompt="", - ) - _ = await root.context.serial(PROMPT_RENDER_EVENT, v3_prompt) - assert v3_prompt.system_sections_bottom == legacy_prompt.system_sections_bottom + prompt = _prompt_ctx() + _ = await root.context.serial(PROMPT_RENDER_EVENT, prompt) + assert [section.name for section in prompt.system_sections_bottom] == ["memes"] - legacy_answer = AfterReasoningCtx( - session_key="telegram:1", - channel="telegram", - chat_id="1", - tools_used=(), - thinking=None, - response_metadata=ResponseMetadata(raw_text="好的 "), - streamed=False, - tool_chain=(), - context_retry={}, - reply="好的 ", - ) - await legacy.decorate_meme(legacy_answer) - v3_answer = AfterReasoningCtx( - session_key="telegram:1", - channel="telegram", - chat_id="1", - tools_used=(), - thinking=None, - response_metadata=ResponseMetadata(raw_text="好的 "), - streamed=False, - tool_chain=(), - context_retry={}, - reply="好的 ", - ) - _ = await root.context.serial(AFTER_REASONING_PREPROCESS_EVENT, v3_answer) + answer = _answer_ctx("好的 ") + _ = await root.context.serial(AFTER_REASONING_PREPROCESS_EVENT, answer) + assert answer.reply == "好的" + assert answer.media == [str(image)] + assert answer.meme_tag == "shy" - 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().effects == () assert root.topology_view().listeners == () @@ -358,32 +249,9 @@ async def mount(ctx: Context) -> None: workspace_roots=("memes",), ), ) - prompt = PromptRenderCtx( - session_key="webui:1", - channel="webui", - chat_id="1", - content="你好", - media=None, - timestamp=datetime.now(timezone.utc), - history=[], - skill_names=[], - retrieved_memory_block="", - disabled_sections=set(), - turn_injection_prompt="", - ) + prompt = _prompt_ctx() _ = await root.context.serial(PROMPT_RENDER_EVENT, prompt) - answer = AfterReasoningCtx( - session_key="webui:1", - channel="webui", - chat_id="1", - tools_used=(), - thinking=None, - response_metadata=ResponseMetadata(raw_text="好的 "), - streamed=False, - tool_chain=(), - context_retry={}, - reply="好的 ", - ) + answer = _answer_ctx("好的 ") _ = await root.context.serial(AFTER_REASONING_PREPROCESS_EVENT, answer) dashboard_module = importlib.import_module("test_meme_plugin.dashboard") @@ -424,7 +292,7 @@ async def mount(ctx: Context) -> None: async def test_v3_plugin_loads_package_and_dashboard_through_real_manager( tmp_path: Path, ) -> None: - _write_meme_workspace(tmp_path / "workspace") + _ = _write_meme_workspace(tmp_path / "workspace") plugin_home = tmp_path / "plugins" citation_dir = plugin_home / "citation" citation_dir.mkdir(parents=True) @@ -465,6 +333,7 @@ async def test_v3_plugin_loads_package_and_dashboard_through_real_manager( assert generation.instance.workspace_roots == ("memes",) assert snapshot.plugin_skill_index is not None assert "meme-manage" in snapshot.plugin_skill_index.records + dashboard = PluginDashboardHost( workspace=workspace, memory_admin=object(), @@ -484,6 +353,7 @@ async def test_v3_plugin_loads_package_and_dashboard_through_real_manager( if route.path == "/api/dashboard/meme/categories" )() assert categories["categories"][0]["tag"] == "shy" + root = snapshot.composition_root assert root is not None await manager.terminate_all() @@ -492,63 +362,16 @@ async def test_v3_plugin_loads_package_and_dashboard_through_real_manager( @pytest.mark.asyncio -async def test_citation_meme_cross_repository_parity(tmp_path: Path) -> None: +async def test_citation_meme_cross_repository_v3_behavior(tmp_path: Path) -> None: raw_citation_root = os.environ.get("AKASHIC_CITATION_ROOT", "").strip() if not raw_citation_root: raise RuntimeError( "AKASHIC_CITATION_ROOT 必须指向 exact-commit Citation checkout" ) citation_root = Path(raw_citation_root) - citation_spec = importlib.util.spec_from_file_location( - "test_citation_plugin", - citation_root / "plugin.py", - ) - if citation_spec is None or citation_spec.loader is None: - raise ImportError(str(citation_root / "plugin.py")) - citation_module = importlib.util.module_from_spec(citation_spec) - sys.modules[citation_spec.name] = citation_module - citation_spec.loader.exec_module(citation_module) workspace = tmp_path / "workspace" image = _write_meme_workspace(workspace) - legacy_meme = await _make_plugin(workspace) - legacy_prompt = PromptRenderCtx( - session_key="telegram:1", - channel="telegram", - chat_id="1", - content="你好", - media=None, - timestamp=datetime.now(timezone.utc), - history=[], - skill_names=[], - retrieved_memory_block="", - disabled_sections=set(), - turn_injection_prompt="", - ) - await citation_module.CitationPromptModule().run( - SimpleNamespace(slots={"prompt:ctx": legacy_prompt}) - ) - await legacy_meme.prompt_render_modules()[0].run( - SimpleNamespace(slots={"prompt:ctx": legacy_prompt}) - ) - reply = "答复正文\n§cited:[mem_1]§ " - legacy_answer = AfterReasoningCtx( - session_key="telegram:1", - channel="telegram", - chat_id="1", - tools_used=(), - thinking=None, - response_metadata=ResponseMetadata(raw_text=reply), - streamed=False, - tool_chain=(), - context_retry={}, - reply=reply, - ) - legacy_frame = SimpleNamespace(slots={"reasoning:ctx": legacy_answer}) - await citation_module.CitationAfterReasoningModule().run(legacy_frame) - await legacy_meme.decorate_meme(legacy_answer) - await citation_module.ProtocolTagCleanupModule().run(legacy_frame) - plugin_home = tmp_path / "plugins" _ = shutil.copytree( citation_root, @@ -571,49 +394,27 @@ async def test_citation_meme_cross_repository_parity(tmp_path: Path) -> None: snapshot = manager.current_snapshot assert snapshot is not None and snapshot.composition_root is not None - v3_prompt = PromptRenderCtx( - session_key="telegram:1", - channel="telegram", - chat_id="1", - content="你好", - media=None, - timestamp=legacy_prompt.timestamp, - history=[], - skill_names=[], - retrieved_memory_block="", - disabled_sections=set(), - turn_injection_prompt="", - ) - _ = await snapshot.composition_root.context.serial( - PROMPT_RENDER_EVENT, - v3_prompt, - ) - v3_answer = AfterReasoningCtx( - session_key="telegram:1", - channel="telegram", - chat_id="1", - tools_used=(), - thinking=None, - response_metadata=ResponseMetadata(raw_text=reply), - streamed=False, - tool_chain=(), - context_retry={}, - reply=reply, - ) + prompt = _prompt_ctx() + _ = await snapshot.composition_root.context.serial(PROMPT_RENDER_EVENT, prompt) + answer = _answer_ctx("答复正文\n§cited:[mem_1]§ ") _ = await snapshot.composition_root.context.serial( AFTER_REASONING_PREPROCESS_EVENT, - v3_answer, + answer, ) _ = await snapshot.composition_root.context.serial( AFTER_REASONING_CLEANUP_EVENT, - v3_answer, + answer, ) - assert v3_prompt.system_sections_bottom == legacy_prompt.system_sections_bottom - assert v3_answer.reply == legacy_answer.reply == "答复正文" - assert v3_answer.persist_assistant_metadata["cited_memory_ids"] == ( - legacy_frame.slots["persist:assistant:cited_memory_ids"] - ) - assert v3_answer.media == legacy_answer.media == [str(image)] - assert v3_answer.meme_tag == legacy_answer.meme_tag == "shy" + assert [section.name for section in prompt.system_sections_bottom] == [ + "citation_protocol", + "memes", + ] + assert answer.reply == "答复正文" + assert answer.persist_assistant_metadata["cited_memory_ids"] == ["mem_1"] + assert answer.media == [str(image)] + assert answer.meme_tag == "shy" + root = snapshot.composition_root await manager.terminate_all() + assert root.receipt().effects == () + assert root.topology_view().listeners == () From cbb02f2edf3903c7a73a6223eb3e4a41a8cc1836 Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Sun, 16 Aug 2026 00:47:28 +0800 Subject: [PATCH 5/7] =?UTF-8?q?test:=20=E5=9B=BA=E5=AE=9A=E7=BA=AF=20v3=20?= =?UTF-8?q?Citation=20=E7=BB=84=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/plugin-api-v3.yml | 2 +- tests/test_plugin.py | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/workflows/plugin-api-v3.yml b/.github/workflows/plugin-api-v3.yml index e2a40c6..eabcabf 100644 --- a/.github/workflows/plugin-api-v3.yml +++ b/.github/workflows/plugin-api-v3.yml @@ -40,7 +40,7 @@ jobs: - uses: actions/checkout@v4 with: repository: akashic-plugins/citation - ref: a9abeb31c25458b8e799dc6aae25d3e83b912c83 + ref: b82453cd1eae71da8d25eb31aada30b01c659b54 path: .citation - uses: actions/setup-python@v5 with: diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 1da9cd6..6641d4d 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -48,6 +48,21 @@ def _load_meme_plugin_module(): return module +def _load_exact_citation_module(citation_root: Path): + path = citation_root / "plugin.py" + spec = importlib.util.spec_from_file_location( + "test_exact_citation_plugin", + path, + submodule_search_locations=[str(path.parent)], + ) + if spec is None or spec.loader is None: + raise ImportError(str(path)) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + _meme_plugin_module = _load_meme_plugin_module() CITATION_PROTOCOL_SERVICE = _meme_plugin_module.CITATION_PROTOCOL_SERVICE apply = _meme_plugin_module.apply @@ -369,6 +384,8 @@ async def test_citation_meme_cross_repository_v3_behavior(tmp_path: Path) -> Non "AKASHIC_CITATION_ROOT 必须指向 exact-commit Citation checkout" ) citation_root = Path(raw_citation_root) + citation_module = _load_exact_citation_module(citation_root) + assert not hasattr(citation_module, "CitationPlugin") workspace = tmp_path / "workspace" image = _write_meme_workspace(workspace) From 6761c87560faf08a2fc312fcca973bde92400e07 Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Tue, 18 Aug 2026 00:29:37 +0800 Subject: [PATCH 6/7] =?UTF-8?q?refactor(plugin):=20=E6=94=B6=E5=8F=A3=20me?= =?UTF-8?q?me=20=E7=BA=AF=20v3=20manifest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- akashic.plugin.toml | 5 +++++ tests/test_plugin.py | 15 ++++++++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) create mode 100644 akashic.plugin.toml diff --git a/akashic.plugin.toml b/akashic.plugin.toml new file mode 100644 index 0000000..7f4da6f --- /dev/null +++ b/akashic.plugin.toml @@ -0,0 +1,5 @@ +schema_version = 1 +name = "meme" +version = "1.0.0" +api_version = 3 +entrypoint = "plugin.py" diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 6641d4d..76fd844 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -29,6 +29,7 @@ from agent.plugins.composable import ComposablePlugin from agent.plugins.dashboard_host import DashboardBinding, PluginDashboardHost from agent.plugins.manager import PluginManager +from agent.plugins.static_manifest import load_static_plugin_manifest from bus.event_bus import EventBus from runtime import MemeCatalog, MemeDecorator @@ -96,6 +97,17 @@ def _write_meme_workspace(workspace: Path) -> Path: return image +def test_static_manifest_matches_v3_module() -> None: + manifest = load_static_plugin_manifest( + Path(_meme_plugin_module.__file__ or "").resolve().parent + ) + + assert manifest.name == _meme_plugin_module.name == "meme" + assert manifest.version == _meme_plugin_module.version == "1.0.0" + assert manifest.api_version == _meme_plugin_module.api_version == 3 + assert manifest.entrypoint == "plugin.py" + + def _prompt_ctx() -> PromptRenderCtx: return PromptRenderCtx( session_key="webui:1", @@ -350,9 +362,6 @@ async def test_v3_plugin_loads_package_and_dashboard_through_real_manager( assert "meme-manage" in snapshot.plugin_skill_index.records dashboard = PluginDashboardHost( - workspace=workspace, - memory_admin=object(), - memory_store=object(), core_routes=(), ) dashboard.prepare_snapshot(snapshot) From 5cc12c8b7529e0850c0f4b3b92f3eb78bfbd9af3 Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Sat, 22 Aug 2026 01:12:59 +0800 Subject: [PATCH 7/7] ci: pin pure v3 core --- .github/workflows/plugin-api-v3.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/plugin-api-v3.yml b/.github/workflows/plugin-api-v3.yml index eabcabf..392cdec 100644 --- a/.github/workflows/plugin-api-v3.yml +++ b/.github/workflows/plugin-api-v3.yml @@ -35,7 +35,7 @@ jobs: - uses: actions/checkout@v4 with: repository: kachofugetsu09/akashic-agent - ref: a047470a39d4f7d2e6be1d2a8e2824916d52fad1 + ref: 3005f838bcd96e2cbc58616aede46e4f39df4523 path: .akashic-core - uses: actions/checkout@v4 with: