Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/plugin-api-v3.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:[...]§` 标签:

Expand All @@ -31,6 +31,6 @@

提取到的 ID 由下游持久化模块写入数据库,用于更新记忆条目的被引用计数和时间戳。

### 3. 清理协议标签(ProtocolTagCleanupModule)
### 3. 清理协议标签

在 persist 之前再做一次扫描,用正则清除 reply 末尾所有残留的 `<tag:value>` 形式协议标签(包括其他插件可能留下的),保证对外输出的文本干净。
5 changes: 5 additions & 0 deletions akashic.plugin.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
schema_version = 1
name = "citation"
version = "1.0.0"
api_version = 3
entrypoint = "plugin.py"
69 changes: 6 additions & 63 deletions plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<trailing>(?:\s*{_TRAILING_PROTOCOL_TAG}\s*)*)$",
Expand Down Expand Up @@ -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:
Expand All @@ -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]]:
Expand Down
73 changes: 31 additions & 42 deletions tests/test_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
from datetime import datetime, timezone
from pathlib import Path
import shutil
from types import SimpleNamespace

import pytest

Expand All @@ -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,
Expand Down Expand Up @@ -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]§ <meme:shy>")
assert clean == "答复正文 <meme:shy>"
Expand All @@ -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]§ <meme:shy>")
frame = SimpleNamespace(slots={"reasoning:ctx": ctx})
await CitationAfterReasoningModule().run(frame)
assert frame.slots["persist:assistant:cited_memory_ids"] == ["mem_1"]
assert ctx.reply == "答复正文 <meme:shy>"
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:
Expand All @@ -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]§ <meme:shy>"
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 == "答复正文 <meme:shy>"
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
Expand Down Expand Up @@ -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 == ()
Loading