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..7140925 --- /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: 3005f838bcd96e2cbc58616aede46e4f39df4523 + 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: Verify Citation v3 behavior + 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..4890ead 100644 --- a/README.md +++ b/README.md @@ -8,27 +8,29 @@ | 接入方式 | 阶段 | |---|---| -| `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 给依赖引用协议顺序的插件。Core 只负责生命周期接入、作用域回收和依赖排序,引用协议及其数据解释仍由插件拥有。 --- ## 运作逻辑 -### 1. 注入引用协议(CitationPromptModule) +### 1. 注入引用协议 每轮推理前,在系统 prompt 底部追加一段隐藏指令(`_CITATION_PROTOCOL`),要求 LLM 在用到记忆条目时,在回复末尾输出 `§cited:[id1,id2]§` 格式的引用行,且不向用户暴露这行的存在。 -### 2. 提取 cited ID(CitationAfterReasoningModule) +### 2. 提取 cited ID 推理完成后,用正则扫描 `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 由下游持久化模块写入数据库,用于更新记忆条目的被引用计数和时间戳。 -### 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 a973197..868fc57 100644 --- a/plugin.py +++ b/plugin.py @@ -2,15 +2,18 @@ import json import re -from typing import Any, cast +from dataclasses import dataclass +from typing import cast -from agent.lifecycle.types import PromptRenderCtx -from agent.plugins import Plugin +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.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*)*)$", @@ -20,7 +23,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,76 +36,70 @@ 你了解用户的事是因为你们相处了很久,直接说你上次、我记得,不要暴露内部机制。""" -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 - ctx.system_sections_bottom.append( - PromptSectionRender( - name="citation_protocol", - content=_CITATION_PROTOCOL, - is_static=True, - ) +@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, ) - 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 - reply = str(getattr(ctx, "reply", "") or "") - cleaned, cited_ids = extract_cited_ids(reply) - cleaned = strip_inline_memory_refs(cleaned) - 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 - - -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 - reply = str(getattr(ctx, "reply", "") or "") - cleaned = strip_inline_memory_refs(strip_trailing_protocol_tags(reply)) - if cleaned != reply: - ctx.reply = cleaned - return frame - - -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()] + ) + + +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 + + +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 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) + + # 2. Publish last so dependents unload before citation listeners disappear. + _ = 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 ed1320d..63c54ef 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -1,21 +1,75 @@ from __future__ import annotations from datetime import datetime, timezone -from types import SimpleNamespace +from pathlib import Path +import shutil 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 agent.plugins.static_manifest import load_static_plugin_manifest +from bus.event_bus import EventBus from plugin import ( - CitationAfterReasoningModule, - CitationPromptModule, - ProtocolTagCleanupModule, + CITATION_PROTOCOL_SERVICE, + 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_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 == "答复正文 " @@ -39,42 +93,90 @@ 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="", +async def test_v3_named_exports_run_complete_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(), + ), ) - frame = SimpleNamespace(slots={"prompt:ctx": ctx}) - await CitationPromptModule().run(frame) - assert ctx.system_sections_bottom[0].name == "citation_protocol" + assert root.receipt().ready is True + assert root.context.require(CITATION_PROTOCOL_SERVICE).version == 1 + + v3_prompt = _prompt_ctx() + _ = 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]§ " + v3_answer = _answer_ctx(reply) + _ = 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 -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]§ ", +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", ) - 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 == "答复正文" + 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 == ()