Skip to content
Merged
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
28 changes: 0 additions & 28 deletions .github/workflows/plugin-api-v2.yml

This file was deleted.

51 changes: 51 additions & 0 deletions .github/workflows/plugin-api-v3.yml
Original file line number Diff line number Diff line change
@@ -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/
16 changes: 9 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 末尾所有残留的 `<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"
151 changes: 75 additions & 76 deletions plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<trailing>(?:\s*{_TRAILING_PROTOCOL_TAG}\s*)*)$",
Expand All @@ -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 工具返回的条目,在回复正文末尾另起一行输出:
Expand All @@ -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]]:
Expand Down
Loading
Loading