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/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/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..63c54ef 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 @@ -18,12 +17,10 @@ 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, - CitationAfterReasoningModule, - CitationPromptModule, - ProtocolTagCleanupModule, apply, extract_cited_ids, extract_cited_ids_from_tool_chain, @@ -62,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 == "答复正文 " @@ -85,29 +93,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 +117,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 +174,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 == ()