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: 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/
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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。

---

Expand All @@ -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 由下游持久化模块写入数据库,用于更新记忆条目的被引用计数和时间戳。
Expand Down
104 changes: 80 additions & 24 deletions plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 工具返回的条目,在回复正文末尾另起一行输出:
Expand All @@ -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)
Expand All @@ -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


Expand All @@ -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


Expand All @@ -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"
Expand Down
Loading
Loading