From 7527251b88c7530b20685f38b5dbab6107fc1f5b Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Fri, 14 Aug 2026 19:02:20 +0800 Subject: [PATCH 1/6] feat: migrate citation to composition api --- README.md | 10 +-- plugin.py | 104 +++++++++++++++++++++------- tests/test_plugin.py | 157 ++++++++++++++++++++++++++++++++++++------- 3 files changed, 218 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index e9d45e6..eea19f2 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,11 @@ | 接入方式 | 阶段 | |---|---| -| `prompt_render_modules()` | `prompt_render.emit` 之后——注入引用协议文本 | -| `after_reasoning_modules()` | `after_reasoning.build_ctx` 之后——提取 cited ID | -| `after_reasoning_modules()` | `after_reasoning.emit` 之后——清理残留协议标签 | +| v3 `PROMPT_RENDER_EVENT` | 注入引用协议文本 | +| v3 `AFTER_REASONING_PREPROCESS_EVENT` | 提取 cited ID 到 `persist_assistant_metadata` | +| v3 `AFTER_REASONING_CLEANUP_EVENT` | 清理残留协议标签 | + +插件通过模块命名导出 `api_version = 3` 与 `apply(ctx, config)` 注册这些 listener,并提供 `citation.protocol` Service 给依赖引用协议顺序的插件。旧 `CitationPlugin` 与 phase module 暂时保留,只用于迁移期行为等价验证;新 Core 不再从固定 PluginManager 列表装配 Citation。 --- @@ -24,7 +26,7 @@ 推理完成后,用正则扫描 `reply` 尾部,匹配 `§cited:[...]§` 标签: -- 若匹配成功,提取 ID 列表,写入 `persist:assistant:cited_memory_ids` slot,并把标签从 reply 中剥除。 +- 若匹配成功,提取 ID 列表,v3 写入 `AfterReasoningCtx.persist_assistant_metadata["cited_memory_ids"]`,并把标签从 reply 中剥除。 - 若 reply 里没有引用行,fallback 到工具调用链:扫描 `recall_memory` 工具的返回结果,从 JSON 里取出 `cited_item_ids` 或 `items[].id`,作为本轮引用 ID。 提取到的 ID 由下游持久化模块写入数据库,用于更新记忆条目的被引用计数和时间戳。 diff --git a/plugin.py b/plugin.py index a973197..981a307 100644 --- a/plugin.py +++ b/plugin.py @@ -2,9 +2,16 @@ import json import re +from dataclasses import dataclass from typing import Any, cast -from agent.lifecycle.types import PromptRenderCtx +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 Context, ServiceKey from agent.plugins import Plugin from agent.prompting import PromptSectionRender @@ -20,7 +27,9 @@ rf"(?:\s*{_TRAILING_PROTOCOL_TAG}\s*)+$", re.IGNORECASE, ) -_INLINE_MEMORY_REF_RE = re.compile(r"[ \t]*(?:\[§[A-Za-z0-9:_-]{1,128}\])+", re.IGNORECASE) +_INLINE_MEMORY_REF_RE = re.compile( + r"[ \t]*(?:\[§[A-Za-z0-9:_-]{1,128}\])+", re.IGNORECASE +) _CITATION_PROTOCOL = """### 记忆引用协议 - 内部元数据,对用户不可见 每轮回复若用到了系统注入的记忆条目 [item_id] 前缀标识,或 recall_memory / fetch_messages 工具返回的条目,在回复正文末尾另起一行输出: @@ -31,6 +40,47 @@ 你了解用户的事是因为你们相处了很久,直接说你上次、我记得,不要暴露内部机制。""" +@dataclass(frozen=True, slots=True) +class CitationProtocol: + version: int = 1 + + +CITATION_PROTOCOL_SERVICE = ServiceKey[CitationProtocol]("citation.protocol") + + +def append_citation_protocol(ctx: PromptRenderCtx) -> None: + ctx.system_sections_bottom.append( + PromptSectionRender( + name="citation_protocol", + content=_CITATION_PROTOCOL, + is_static=True, + ) + ) + + +def preprocess_citation(ctx: AfterReasoningCtx) -> list[str]: + """Strip citation metadata and return the IDs that Core should persist.""" + + # 1. Prefer the explicit response protocol and preserve later plugin tags. + reply = str(ctx.reply or "") + cleaned, cited_ids = extract_cited_ids(reply) + cleaned = strip_inline_memory_refs(cleaned) + + # 2. Fall back to the real recall tool chain when no IDs were declared. + if not cited_ids: + cited_ids = extract_cited_ids_from_tool_chain(list(ctx.tool_chain or ())) + if cleaned != reply: + ctx.reply = cleaned + return cited_ids + + +def cleanup_protocol_tags(ctx: AfterReasoningCtx) -> None: + reply = str(ctx.reply or "") + cleaned = strip_inline_memory_refs(strip_trailing_protocol_tags(reply)) + if cleaned != reply: + ctx.reply = cleaned + + class CitationPromptModule: slot = "citation.prompt" requires = ("prompt_render.emit", _PROMPT_CTX_SLOT) @@ -40,13 +90,7 @@ async def run(self, frame: Any) -> Any: ctx = frame.slots.get(_PROMPT_CTX_SLOT) if not isinstance(ctx, PromptRenderCtx): return frame - ctx.system_sections_bottom.append( - PromptSectionRender( - name="citation_protocol", - content=_CITATION_PROTOCOL, - is_static=True, - ) - ) + append_citation_protocol(ctx) return frame @@ -59,19 +103,9 @@ async def run(self, frame: Any) -> Any: ctx = frame.slots.get(_REASONING_CTX_SLOT) if ctx is None: return frame - reply = str(getattr(ctx, "reply", "") or "") - cleaned, cited_ids = extract_cited_ids(reply) - cleaned = strip_inline_memory_refs(cleaned) + cited_ids = preprocess_citation(cast(AfterReasoningCtx, ctx)) if cited_ids: frame.slots[_PERSIST_CITED_SLOT] = cited_ids - else: - fallback_ids = extract_cited_ids_from_tool_chain( - list(getattr(ctx, "tool_chain", ()) or ()) - ) - if fallback_ids: - frame.slots[_PERSIST_CITED_SLOT] = fallback_ids - if cleaned != reply: - ctx.reply = cleaned return frame @@ -84,13 +118,35 @@ async def run(self, frame: Any) -> Any: ctx = frame.slots.get(_REASONING_CTX_SLOT) if ctx is None: return frame - reply = str(getattr(ctx, "reply", "") or "") - cleaned = strip_inline_memory_refs(strip_trailing_protocol_tags(reply)) - if cleaned != reply: - ctx.reply = cleaned + cleanup_protocol_tags(cast(AfterReasoningCtx, ctx)) return frame +def _persist_v3_citation(ctx: AfterReasoningCtx) -> None: + cited_ids = preprocess_citation(ctx) + if cited_ids: + ctx.persist_assistant_metadata["cited_memory_ids"] = cited_ids + + +api_version = 3 +name = "citation" +version = "1.0.0" +inject: tuple[ServiceKey[object], ...] = () + + +async def apply(ctx: Context, config: object) -> None: + """Register citation lifecycle behavior and its ordering Service.""" + + # 1. Register the three behaviorally equivalent lifecycle listeners. + _ = config + await ctx.on(PROMPT_RENDER_EVENT, append_citation_protocol) + await ctx.on(AFTER_REASONING_PREPROCESS_EVENT, _persist_v3_citation) + await ctx.on(AFTER_REASONING_CLEANUP_EVENT, cleanup_protocol_tags) + + # 2. Publish last so dependents unload before citation listeners disappear. + await ctx.provide(CITATION_PROTOCOL_SERVICE, CitationProtocol()) + + class CitationPlugin(Plugin): api_version = 2 name = "citation" diff --git a/tests/test_plugin.py b/tests/test_plugin.py index ed1320d..c2f76f9 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -1,21 +1,67 @@ from __future__ import annotations from datetime import datetime, timezone +from pathlib import Path +import shutil from types import SimpleNamespace import pytest +import plugin as citation_module 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 CompositionRoot, PluginRuntime +from agent.plugins.composable import ComposablePlugin +from agent.plugins.manager import PluginManager +from bus.event_bus import EventBus from plugin import ( + CITATION_PROTOCOL_SERVICE, CitationAfterReasoningModule, CitationPromptModule, ProtocolTagCleanupModule, + apply, extract_cited_ids, extract_cited_ids_from_tool_chain, + inject, ) +def _prompt_ctx() -> PromptRenderCtx: + return PromptRenderCtx( + session_key="telegram:1", + channel="telegram", + chat_id="1", + content="hi", + 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="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, + ) + + def test_extract_cited_ids_keeps_trailing_meme_tag() -> None: clean, ids = extract_cited_ids("答复正文\n§cited:[mem_1]§ ") assert clean == "答复正文 " @@ -40,19 +86,7 @@ def test_extract_cited_ids_from_recall_memory_tool_chain() -> None: @pytest.mark.asyncio async def test_prompt_module_injects_protocol() -> None: - ctx = PromptRenderCtx( - session_key="telegram:1", - channel="telegram", - chat_id="1", - content="hi", - media=None, - timestamp=datetime.now(timezone.utc), - history=[], - skill_names=[], - retrieved_memory_block="", - disabled_sections=set(), - turn_injection_prompt="", - ) + ctx = _prompt_ctx() frame = SimpleNamespace(slots={"prompt:ctx": ctx}) await CitationPromptModule().run(frame) assert ctx.system_sections_bottom[0].name == "citation_protocol" @@ -60,21 +94,94 @@ async def test_prompt_module_injects_protocol() -> None: @pytest.mark.asyncio async def test_after_reasoning_modules_strip_and_persist() -> None: - 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="答复正文\n§cited:[mem_1]§ ", - ) + ctx = _answer_ctx("答复正文\n§cited:[mem_1]§ ") frame = SimpleNamespace(slots={"reasoning:ctx": ctx}) await CitationAfterReasoningModule().run(frame) assert frame.slots["persist:assistant:cited_memory_ids"] == ["mem_1"] assert ctx.reply == "答复正文 " await ProtocolTagCleanupModule().run(frame) assert ctx.reply == "答复正文" + + +@pytest.mark.asyncio +async def test_v3_named_exports_match_legacy_lifecycle_behavior( + tmp_path: Path, +) -> None: + ComposablePlugin.from_module(citation_module) + root = CompositionRoot("citation-parity") + + async def mount(ctx) -> None: + await apply(ctx, object()) + + _ = await root.mount( + mount, + name="citation", + inject=inject, + runtime=PluginRuntime( + plugin_id="citation", + plugin_dir=Path(__file__).parents[1], + data_dir=tmp_path / "data", + workspace=tmp_path / "workspace", + config=object(), + ), + ) + assert root.receipt().ready is True + assert root.context.require(CITATION_PROTOCOL_SERVICE).version == 1 + + legacy_prompt = _prompt_ctx() + await CitationPromptModule().run( + SimpleNamespace(slots={"prompt:ctx": legacy_prompt}) + ) + v3_prompt = _prompt_ctx() + await root.context.serial(PROMPT_RENDER_EVENT, v3_prompt) + assert v3_prompt.system_sections_bottom == legacy_prompt.system_sections_bottom + + reply = "答复正文\n§cited:[mem_1]§ " + legacy_answer = _answer_ctx(reply) + legacy_frame = SimpleNamespace(slots={"reasoning:ctx": legacy_answer}) + await CitationAfterReasoningModule().run(legacy_frame) + await ProtocolTagCleanupModule().run(legacy_frame) + v3_answer = _answer_ctx(reply) + await root.context.serial(AFTER_REASONING_PREPROCESS_EVENT, v3_answer) + await root.context.serial(AFTER_REASONING_CLEANUP_EVENT, v3_answer) + + assert v3_answer.reply == legacy_answer.reply + assert v3_answer.persist_assistant_metadata["cited_memory_ids"] == ( + legacy_frame.slots["persist:assistant:cited_memory_ids"] + ) + await root.dispose() + + +@pytest.mark.asyncio +async def test_v3_plugin_loads_through_real_generation_manager( + tmp_path: Path, +) -> None: + plugin_home = tmp_path / "plugins" + plugin_home.mkdir() + shutil.copytree( + Path(__file__).parents[1], + plugin_home / "citation", + ignore=shutil.ignore_patterns(".git", ".pytest_cache", "__pycache__"), + ) + manager = PluginManager( + plugin_dirs=[plugin_home], + event_bus=EventBus(), + tool_registry=None, + workspace=tmp_path / "workspace", + installed_cache_root=tmp_path / "plugin-home" / "cache", + ) + + await manager.load_all() + + generation = manager.generation("citation") + snapshot = manager.current_snapshot + assert generation is not None and snapshot is not None + assert isinstance(generation.instance, ComposablePlugin) + assert snapshot.composition_topology is not None + assert "citation.protocol" in snapshot.composition_topology.services + assert snapshot.composition_topology.listeners == ( + "serial:turn.prompt_render:citation", + "serial:turn.after_reasoning.preprocess:citation", + "serial:turn.after_reasoning.cleanup:citation", + ) + await manager.terminate_all() From 12f9552e45db794fdc1cdb9e1367d0ca8f132f9b Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Sun, 16 Aug 2026 00:34:21 +0800 Subject: [PATCH 2/6] ci: pin current v3 Core contract --- .github/workflows/plugin-api-v2.yml | 28 ---------------- .github/workflows/plugin-api-v3.yml | 51 +++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 28 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..bc8e778 --- /dev/null +++ b/.github/workflows/plugin-api-v3.yml @@ -0,0 +1,51 @@ +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/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 Citation receipts + env: + AKASHIC_AGENT_ROOT: .akashic-core + PYTHONPATH: .akashic-core + run: python -m pytest -q tests/ From a9abeb31c25458b8e799dc6aae25d3e83b912c83 Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Sun, 16 Aug 2026 00:59:25 +0800 Subject: [PATCH 3/6] =?UTF-8?q?test:=20=E9=9A=94=E7=A6=BB=20Core=20checkou?= =?UTF-8?q?t=20=E5=A4=8D=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_plugin.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_plugin.py b/tests/test_plugin.py index c2f76f9..0464809 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -161,7 +161,13 @@ async def test_v3_plugin_loads_through_real_generation_manager( shutil.copytree( Path(__file__).parents[1], plugin_home / "citation", - ignore=shutil.ignore_patterns(".git", ".pytest_cache", "__pycache__"), + ignore=shutil.ignore_patterns( + ".akashic-core", + ".git", + ".plugin-contracts", + ".pytest_cache", + "__pycache__", + ), ) manager = PluginManager( plugin_dirs=[plugin_home], From b82453cd1eae71da8d25eb31aada30b01c659b54 Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Fri, 14 Aug 2026 19:16:48 +0800 Subject: [PATCH 4/6] refactor: remove citation plugin v2 shell --- .github/workflows/plugin-api-v3.yml | 2 +- README.md | 8 ++-- plugin.py | 69 +++-------------------------- tests/test_plugin.py | 61 ++++++++----------------- 4 files changed, 30 insertions(+), 110 deletions(-) diff --git a/.github/workflows/plugin-api-v3.yml b/.github/workflows/plugin-api-v3.yml index bc8e778..695e30f 100644 --- a/.github/workflows/plugin-api-v3.yml +++ b/.github/workflows/plugin-api-v3.yml @@ -44,7 +44,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 Citation receipts + - name: Verify Citation v3 behavior env: AKASHIC_AGENT_ROOT: .akashic-core PYTHONPATH: .akashic-core diff --git a/README.md b/README.md index eea19f2..4890ead 100644 --- a/README.md +++ b/README.md @@ -12,17 +12,17 @@ | v3 `AFTER_REASONING_PREPROCESS_EVENT` | 提取 cited ID 到 `persist_assistant_metadata` | | v3 `AFTER_REASONING_CLEANUP_EVENT` | 清理残留协议标签 | -插件通过模块命名导出 `api_version = 3` 与 `apply(ctx, config)` 注册这些 listener,并提供 `citation.protocol` Service 给依赖引用协议顺序的插件。旧 `CitationPlugin` 与 phase module 暂时保留,只用于迁移期行为等价验证;新 Core 不再从固定 PluginManager 列表装配 Citation。 +插件通过模块命名导出 `api_version = 3` 与 `apply(ctx, config)` 注册这些 listener,并提供 `citation.protocol` Service 给依赖引用协议顺序的插件。Core 只负责生命周期接入、作用域回收和依赖排序,引用协议及其数据解释仍由插件拥有。 --- ## 运作逻辑 -### 1. 注入引用协议(CitationPromptModule) +### 1. 注入引用协议 每轮推理前,在系统 prompt 底部追加一段隐藏指令(`_CITATION_PROTOCOL`),要求 LLM 在用到记忆条目时,在回复末尾输出 `§cited:[id1,id2]§` 格式的引用行,且不向用户暴露这行的存在。 -### 2. 提取 cited ID(CitationAfterReasoningModule) +### 2. 提取 cited ID 推理完成后,用正则扫描 `reply` 尾部,匹配 `§cited:[...]§` 标签: @@ -31,6 +31,6 @@ 提取到的 ID 由下游持久化模块写入数据库,用于更新记忆条目的被引用计数和时间戳。 -### 3. 清理协议标签(ProtocolTagCleanupModule) +### 3. 清理协议标签 在 persist 之前再做一次扫描,用正则清除 reply 末尾所有残留的 `` 形式协议标签(包括其他插件可能留下的),保证对外输出的文本干净。 diff --git a/plugin.py b/plugin.py index 981a307..868fc57 100644 --- a/plugin.py +++ b/plugin.py @@ -3,7 +3,7 @@ import json import re from dataclasses import dataclass -from typing import Any, cast +from typing import cast from agent.lifecycle.composition import ( AFTER_REASONING_CLEANUP_EVENT, @@ -12,12 +12,8 @@ ) from agent.lifecycle.types import AfterReasoningCtx, PromptRenderCtx from agent.plugin_composition import Context, ServiceKey -from agent.plugins import Plugin from agent.prompting import PromptSectionRender -_PROMPT_CTX_SLOT = "prompt:ctx" -_REASONING_CTX_SLOT = "reasoning:ctx" -_PERSIST_CITED_SLOT = "persist:assistant:cited_memory_ids" _TRAILING_PROTOCOL_TAG = r"<[a-zA-Z][a-zA-Z0-9_-]*:[^<>\s]+>" _CITED_RE = re.compile( rf"(?:\n|\r\n)?§cited:\[([A-Za-z0-9_:,\-\s]*)\]§(?P(?:\s*{_TRAILING_PROTOCOL_TAG}\s*)*)$", @@ -81,47 +77,6 @@ def cleanup_protocol_tags(ctx: AfterReasoningCtx) -> None: ctx.reply = cleaned -class CitationPromptModule: - slot = "citation.prompt" - requires = ("prompt_render.emit", _PROMPT_CTX_SLOT) - produces = (_PROMPT_CTX_SLOT,) - - async def run(self, frame: Any) -> Any: - ctx = frame.slots.get(_PROMPT_CTX_SLOT) - if not isinstance(ctx, PromptRenderCtx): - return frame - append_citation_protocol(ctx) - return frame - - -class CitationAfterReasoningModule: - slot = "citation.after_reasoning" - requires = ("after_reasoning.build_ctx", _REASONING_CTX_SLOT) - produces = (_REASONING_CTX_SLOT, _PERSIST_CITED_SLOT) - - async def run(self, frame: Any) -> Any: - ctx = frame.slots.get(_REASONING_CTX_SLOT) - if ctx is None: - return frame - cited_ids = preprocess_citation(cast(AfterReasoningCtx, ctx)) - if cited_ids: - frame.slots[_PERSIST_CITED_SLOT] = cited_ids - return frame - - -class ProtocolTagCleanupModule: - slot = "citation.protocol_cleanup" - requires = ("after_reasoning.emit", _REASONING_CTX_SLOT) - produces = (_REASONING_CTX_SLOT,) - - async def run(self, frame: Any) -> Any: - ctx = frame.slots.get(_REASONING_CTX_SLOT) - if ctx is None: - return frame - cleanup_protocol_tags(cast(AfterReasoningCtx, ctx)) - return frame - - def _persist_v3_citation(ctx: AfterReasoningCtx) -> None: cited_ids = preprocess_citation(ctx) if cited_ids: @@ -137,26 +92,14 @@ def _persist_v3_citation(ctx: AfterReasoningCtx) -> None: async def apply(ctx: Context, config: object) -> None: """Register citation lifecycle behavior and its ordering Service.""" - # 1. Register the three behaviorally equivalent lifecycle listeners. + # 1. Register the three lifecycle listeners in their explicit event order. _ = config - await ctx.on(PROMPT_RENDER_EVENT, append_citation_protocol) - await ctx.on(AFTER_REASONING_PREPROCESS_EVENT, _persist_v3_citation) - await ctx.on(AFTER_REASONING_CLEANUP_EVENT, cleanup_protocol_tags) + _ = await ctx.on(PROMPT_RENDER_EVENT, append_citation_protocol) + _ = await ctx.on(AFTER_REASONING_PREPROCESS_EVENT, _persist_v3_citation) + _ = await ctx.on(AFTER_REASONING_CLEANUP_EVENT, cleanup_protocol_tags) # 2. Publish last so dependents unload before citation listeners disappear. - await ctx.provide(CITATION_PROTOCOL_SERVICE, CitationProtocol()) - - -class CitationPlugin(Plugin): - api_version = 2 - name = "citation" - version = "1.0.0" - - def prompt_render_modules(self) -> list[object]: - return [CitationPromptModule()] - - def after_reasoning_modules(self) -> list[object]: - return [CitationAfterReasoningModule(), ProtocolTagCleanupModule()] + _ = await ctx.provide(CITATION_PROTOCOL_SERVICE, CitationProtocol()) def extract_cited_ids(response: str) -> tuple[str, list[str]]: diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 0464809..a631035 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -3,7 +3,6 @@ from datetime import datetime, timezone from pathlib import Path import shutil -from types import SimpleNamespace import pytest @@ -21,9 +20,6 @@ from bus.event_bus import EventBus from plugin import ( CITATION_PROTOCOL_SERVICE, - CitationAfterReasoningModule, - CitationPromptModule, - ProtocolTagCleanupModule, apply, extract_cited_ids, extract_cited_ids_from_tool_chain, @@ -85,29 +81,10 @@ def test_extract_cited_ids_from_recall_memory_tool_chain() -> None: @pytest.mark.asyncio -async def test_prompt_module_injects_protocol() -> None: - ctx = _prompt_ctx() - frame = SimpleNamespace(slots={"prompt:ctx": ctx}) - await CitationPromptModule().run(frame) - assert ctx.system_sections_bottom[0].name == "citation_protocol" - - -@pytest.mark.asyncio -async def test_after_reasoning_modules_strip_and_persist() -> None: - ctx = _answer_ctx("答复正文\n§cited:[mem_1]§ ") - frame = SimpleNamespace(slots={"reasoning:ctx": ctx}) - await CitationAfterReasoningModule().run(frame) - assert frame.slots["persist:assistant:cited_memory_ids"] == ["mem_1"] - assert ctx.reply == "答复正文 " - await ProtocolTagCleanupModule().run(frame) - assert ctx.reply == "答复正文" - - -@pytest.mark.asyncio -async def test_v3_named_exports_match_legacy_lifecycle_behavior( +async def test_v3_named_exports_run_complete_lifecycle_behavior( tmp_path: Path, ) -> None: - ComposablePlugin.from_module(citation_module) + _ = ComposablePlugin.from_module(citation_module) root = CompositionRoot("citation-parity") async def mount(ctx) -> None: @@ -128,28 +105,23 @@ async def mount(ctx) -> None: assert root.receipt().ready is True assert root.context.require(CITATION_PROTOCOL_SERVICE).version == 1 - legacy_prompt = _prompt_ctx() - await CitationPromptModule().run( - SimpleNamespace(slots={"prompt:ctx": legacy_prompt}) - ) v3_prompt = _prompt_ctx() - await root.context.serial(PROMPT_RENDER_EVENT, v3_prompt) - assert v3_prompt.system_sections_bottom == legacy_prompt.system_sections_bottom + _ = await root.context.serial(PROMPT_RENDER_EVENT, v3_prompt) + assert [section.name for section in v3_prompt.system_sections_bottom] == [ + "citation_protocol" + ] reply = "答复正文\n§cited:[mem_1]§ " - legacy_answer = _answer_ctx(reply) - legacy_frame = SimpleNamespace(slots={"reasoning:ctx": legacy_answer}) - await CitationAfterReasoningModule().run(legacy_frame) - await ProtocolTagCleanupModule().run(legacy_frame) v3_answer = _answer_ctx(reply) - await root.context.serial(AFTER_REASONING_PREPROCESS_EVENT, v3_answer) - await root.context.serial(AFTER_REASONING_CLEANUP_EVENT, v3_answer) - - assert v3_answer.reply == legacy_answer.reply - assert v3_answer.persist_assistant_metadata["cited_memory_ids"] == ( - legacy_frame.slots["persist:assistant:cited_memory_ids"] - ) + _ = await root.context.serial(AFTER_REASONING_PREPROCESS_EVENT, v3_answer) + assert v3_answer.reply == "答复正文 " + assert v3_answer.persist_assistant_metadata["cited_memory_ids"] == ["mem_1"] + _ = await root.context.serial(AFTER_REASONING_CLEANUP_EVENT, v3_answer) + assert v3_answer.reply == "答复正文" await root.dispose() + assert root.receipt().services == () + assert root.receipt().effects == () + assert root.topology_view().listeners == () @pytest.mark.asyncio @@ -190,4 +162,9 @@ async def test_v3_plugin_loads_through_real_generation_manager( "serial:turn.after_reasoning.preprocess:citation", "serial:turn.after_reasoning.cleanup:citation", ) + root = snapshot.composition_root + assert root is not None await manager.terminate_all() + assert root.receipt().services == () + assert root.receipt().effects == () + assert root.topology_view().listeners == () From 1c9695ded36ed16ed4b131ef8b789c2664329fc0 Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Tue, 18 Aug 2026 00:29:29 +0800 Subject: [PATCH 5/6] =?UTF-8?q?refactor(plugin):=20=E6=94=B6=E5=8F=A3=20ci?= =?UTF-8?q?tation=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 | 12 ++++++++++++ 2 files changed, 17 insertions(+) create mode 100644 akashic.plugin.toml diff --git a/akashic.plugin.toml b/akashic.plugin.toml new file mode 100644 index 0000000..9f34514 --- /dev/null +++ b/akashic.plugin.toml @@ -0,0 +1,5 @@ +schema_version = 1 +name = "citation" +version = "1.0.0" +api_version = 3 +entrypoint = "plugin.py" diff --git a/tests/test_plugin.py b/tests/test_plugin.py index a631035..63c54ef 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -17,6 +17,7 @@ from agent.plugin_composition import CompositionRoot, PluginRuntime from agent.plugins.composable import ComposablePlugin from agent.plugins.manager import PluginManager +from agent.plugins.static_manifest import load_static_plugin_manifest from bus.event_bus import EventBus from plugin import ( CITATION_PROTOCOL_SERVICE, @@ -58,6 +59,17 @@ def _answer_ctx(reply: str) -> AfterReasoningCtx: ) +def test_static_manifest_matches_v3_module() -> None: + manifest = load_static_plugin_manifest( + Path(citation_module.__file__ or "").resolve().parent + ) + + assert manifest.name == citation_module.name == "citation" + assert manifest.version == citation_module.version == "1.0.0" + assert manifest.api_version == citation_module.api_version == 3 + assert manifest.entrypoint == "plugin.py" + + def test_extract_cited_ids_keeps_trailing_meme_tag() -> None: clean, ids = extract_cited_ids("答复正文\n§cited:[mem_1]§ ") assert clean == "答复正文 " From a886c74c55c4ef400ecd81451eb84b0970b60869 Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Sat, 22 Aug 2026 01:12:59 +0800 Subject: [PATCH 6/6] 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 695e30f..7140925 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/setup-python@v5 with: