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/ 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..0464809 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,100 @@ 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( + ".akashic-core", + ".git", + ".plugin-contracts", + ".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()