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

This file was deleted.

57 changes: 57 additions & 0 deletions .github/workflows/plugin-api-v3.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
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: 435bbaf1e6f777951b4622702d7a7796665ed67a
path: .akashic-core
- uses: actions/checkout@v4
with:
repository: akashic-plugins/citation
ref: b2941ee472d73ac40547dde8f16c98f0dfbbcdba
path: .citation
- 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 Meme receipts
env:
AKASHIC_AGENT_ROOT: .akashic-core
AKASHIC_CITATION_ROOT: .citation
PYTHONPATH: .akashic-core
run: python -m pytest -q tests/
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@

| 接入方式 | 阶段 |
|---|---|
| v3 `PROMPT_RENDER_EVENT` | 注入表情包目录说明 |
| v3 `AFTER_REASONING_PREPROCESS_EVENT` | 解析 meme 标签,附加媒体 |
| `PLUGIN_ASSETS` Service | 注册 `skills/` 与 `dashboard.py` |
| v3 `PROMPT_RENDER_AFTER_EVENT_BUS` | legacy Prompt 事件后注入表情包目录说明 |
| v3 `AFTER_REASONING_BEFORE_EVENT_BUS` | legacy AfterReasoning 事件前解析 meme 标签 |
| v3 `SKILLS` Service | 注册 `skills/` catalog |
| v3 `UI_SLOTS` Service | 注册 `dashboard.py` backend |

插件通过模块命名导出 `api_version = 3` 与 `apply(ctx, config)` 自行构建 `MemeCatalog`/`MemeDecorator` 并注册接入点。`citation.protocol` 是硬依赖:Citation 先剥离 cited metadata 并保留 meme tag,Meme 再完成媒体装饰,Citation cleanup 最后清除残留协议标签。旧 `MemePlugin` 暂时保留,只用于迁移期差分验证。
插件通过模块命名导出 `api_version = 3` 与 `apply(ctx, config)` 自行构建 `MemeCatalog`/`MemeDecorator`,再从独立的 Skills、UI Slots 与 typed event 能力登记接入点。`citation.protocol` 是硬依赖:Citation 先剥离 cited metadata 并保留 meme tag,Meme 再完成媒体装饰,Citation cleanup 最后清除残留协议标签。旧 `MemePlugin` 暂时保留,只用于迁移期差分验证。

---

Expand Down
26 changes: 14 additions & 12 deletions plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,17 @@
from pathlib import Path
from typing import Any, cast

from agent.lifecycle.composition import (
AFTER_REASONING_PREPROCESS_EVENT,
PROMPT_RENDER_EVENT,
)
from agent.lifecycle.types import AfterReasoningCtx, PromptRenderCtx
from agent.plugin_composition import (
PLUGIN_ASSETS,
SKILLS,
UI_SLOTS,
Context,
ServiceKey,
)
from agent.plugins import Plugin, on_after_reasoning
from agent.prompting import PromptSectionRender
from agent.turn_events.after_reasoning import AFTER_REASONING_BEFORE_EVENT_BUS
from agent.turn_events.prompt_render import PROMPT_RENDER_AFTER_EVENT_BUS
from .runtime import MemeCatalog, MemeDecorator

_CTX_SLOT = "prompt:ctx"
Expand Down Expand Up @@ -69,7 +68,8 @@ async def run(self, frame: Any) -> Any:
version = "1.0.0"
inject: tuple[ServiceKey[object], ...] = (
CITATION_PROTOCOL_SERVICE,
PLUGIN_ASSETS,
SKILLS,
UI_SLOTS,
)


Expand All @@ -81,19 +81,21 @@ async def apply(ctx: Context, config: object) -> None:
catalog = MemeCatalog(ctx.runtime.workspace / "memes")
decorator = MemeDecorator(catalog)

# 2. Assets and lifecycle behavior are reversible Fiber effects.
assets = ctx.require(PLUGIN_ASSETS)
await assets.register_skill(ctx, "skills")
await assets.register_dashboard(ctx, "dashboard.py")
# 2. Static capabilities remain separate reversible Fiber effects.
skills = ctx.require(SKILLS)
_ = await skills.register(ctx, "skills")
ui_slots = ctx.require(UI_SLOTS)
_ = await ui_slots.register_dashboard(ctx, "dashboard.py")

# 3. Turn behavior remains plugin-owned and ordered by typed events.
def prompt_listener(prompt: PromptRenderCtx) -> None:
append_meme_prompt(prompt, catalog)

def answer_listener(answer: AfterReasoningCtx) -> None:
decorate_meme_ctx(answer, decorator)

await ctx.on(PROMPT_RENDER_EVENT, prompt_listener)
await ctx.on(AFTER_REASONING_PREPROCESS_EVENT, answer_listener)
_ = await ctx.on(PROMPT_RENDER_AFTER_EVENT_BUS, prompt_listener)
_ = await ctx.on(AFTER_REASONING_BEFORE_EVENT_BUS, answer_listener)


class MemePlugin(Plugin):
Expand Down
105 changes: 71 additions & 34 deletions tests/test_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,26 @@
import pytest

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 (
PLUGIN_ASSETS,
SKILLS,
UI_SLOTS,
CompositionRoot,
PluginAssets,
Context,
PluginRuntime,
PluginSkills,
PluginUiSlots,
)
from agent.plugins.composable import ComposablePlugin
from agent.plugins.context import PluginContext, PluginKVStore
from agent.plugins.dashboard_host import PluginDashboardHost
from agent.plugins.dashboard_host import DashboardBinding, PluginDashboardHost
from agent.plugins.manager import PluginManager
from agent.plugins.scope import PluginScope, ScopedEventBus
from agent.turn_events.after_reasoning import (
AFTER_REASONING_BEFORE_EVENT_BUS,
AFTER_REASONING_BEFORE_PERSIST,
)
from agent.turn_events.prompt_render import PROMPT_RENDER_AFTER_EVENT_BUS
from bus.event_bus import EventBus
from runtime import MemeCatalog, MemeDecorator

Expand Down Expand Up @@ -55,6 +59,17 @@ def _load_meme_plugin_module():
inject = _meme_plugin_module.inject


def _copy_ignore():
return shutil.ignore_patterns(
".akashic-core",
".citation",
".git",
".plugin-contracts",
".pytest_cache",
"__pycache__",
)


def _write_meme_workspace(workspace: Path) -> Path:
memes = workspace / "memes"
(memes / "shy").mkdir(parents=True)
Expand All @@ -73,15 +88,17 @@ def _write_meme_workspace(workspace: Path) -> Path:
async def _make_plugin(tmp_path: Path) -> MemePlugin:
plugin_dir = tmp_path / "plugin"
plugin_dir.mkdir(parents=True)
scope = PluginScope("meme")
plugin = MemePlugin()
plugin.context = PluginContext(
event_bus=None,
event_bus=ScopedEventBus(EventBus(), scope),
tool_registry=None,
plugin_id="meme",
plugin_dir=plugin_dir,
data_dir=tmp_path,
kv_store=PluginKVStore(plugin_dir / ".kv.json"),
workspace=tmp_path,
scope=scope,
)
await plugin.prepare()
return plugin
Expand Down Expand Up @@ -201,13 +218,15 @@ async def test_meme_plugin_ignores_code_tag(tmp_path: Path) -> None:
async def test_v3_named_exports_match_legacy_behavior(tmp_path: Path) -> None:
image = _write_meme_workspace(tmp_path)
legacy = await _make_plugin(tmp_path)
ComposablePlugin.from_module(_meme_plugin_module)
_ = ComposablePlugin.from_module(_meme_plugin_module)
root = CompositionRoot("meme-parity")
assets = PluginAssets()
_ = await root.context.provide(PLUGIN_ASSETS, assets)
skills = PluginSkills()
ui_slots = PluginUiSlots()
_ = await root.context.provide(SKILLS, skills)
_ = await root.context.provide(UI_SLOTS, ui_slots)
_ = await root.context.provide(CITATION_PROTOCOL_SERVICE, object())

async def mount(ctx) -> None:
async def mount(ctx: Context) -> None:
await apply(ctx, object())

plugin_dir = Path(__file__).parents[1]
Expand All @@ -223,10 +242,12 @@ async def mount(ctx) -> None:
config=object(),
),
)
assert root.receipt().ready is True
declared = assets.freeze()["meme"]
assert declared.skill_roots == (plugin_dir / "skills",)
assert declared.dashboard_module == plugin_dir / "dashboard.py"
receipt = root.receipt()
assert receipt.ready is True
assert receipt.writes == ()
assert receipt.external_effects == ()
assert skills.freeze()["meme"].skill_roots == (plugin_dir / "skills",)
assert ui_slots.freeze()["meme"].dashboard_module == plugin_dir / "dashboard.py"

legacy_prompt = PromptRenderCtx(
session_key="telegram:1",
Expand Down Expand Up @@ -257,7 +278,7 @@ async def mount(ctx) -> None:
disabled_sections=set(),
turn_injection_prompt="",
)
await root.context.serial(PROMPT_RENDER_EVENT, v3_prompt)
_ = await root.context.serial(PROMPT_RENDER_AFTER_EVENT_BUS, v3_prompt)
assert v3_prompt.system_sections_bottom == legacy_prompt.system_sections_bottom

legacy_answer = AfterReasoningCtx(
Expand Down Expand Up @@ -285,16 +306,20 @@ async def mount(ctx) -> None:
context_retry={},
reply="好的 <meme:shy>",
)
await root.context.serial(AFTER_REASONING_PREPROCESS_EVENT, v3_answer)
_ = await root.context.serial(AFTER_REASONING_BEFORE_EVENT_BUS, v3_answer)

assert v3_answer.reply == legacy_answer.reply == "好的"
assert v3_answer.media == legacy_answer.media == [str(image)]
assert v3_answer.meme_tag == legacy_answer.meme_tag == "shy"
await root.dispose()
assert root.receipt().services == ()
assert root.receipt().effects == ()


@pytest.mark.asyncio
async def test_v3_plugin_loads_assets_through_real_manager(tmp_path: Path) -> None:
async def test_v3_plugin_loads_capabilities_through_real_manager(
tmp_path: Path,
) -> None:
_write_meme_workspace(tmp_path / "workspace")
plugin_home = tmp_path / "plugins"
citation_dir = plugin_home / "citation"
Expand All @@ -309,10 +334,10 @@ async def test_v3_plugin_loads_assets_through_real_manager(tmp_path: Path) -> No
" await ctx.provide(SERVICE, object())\n",
encoding="utf-8",
)
shutil.copytree(
_ = shutil.copytree(
Path(__file__).parents[1],
plugin_home / "meme",
ignore=shutil.ignore_patterns(".git", ".pytest_cache", "__pycache__"),
ignore=_copy_ignore(),
)
workspace = tmp_path / "workspace"
manager = PluginManager(
Expand All @@ -335,17 +360,26 @@ async def test_v3_plugin_loads_assets_through_real_manager(tmp_path: Path) -> No
)
assert snapshot.plugin_skill_index is not None
assert "meme-manage" in snapshot.plugin_skill_index.records
assert snapshot.composition_topology is not None
assert "core.skills" in snapshot.composition_topology.services
assert "core.ui_slots" in snapshot.composition_topology.services
assert "core.plugin_assets" not in snapshot.composition_topology.services
dashboard = PluginDashboardHost(
workspace=workspace,
memory_admin=object(),
memory_store=object(),
core_routes=(),
)
dashboard.prepare_snapshot(snapshot)
assert tuple(binding.plugin_id for binding in snapshot.dashboard_bindings) == (
"meme",
)
assert len(snapshot.dashboard_bindings) == 1
binding = snapshot.dashboard_bindings[0]
assert isinstance(binding, DashboardBinding)
assert binding.plugin_id == "meme"
root = snapshot.composition_root
assert root is not None
await manager.terminate_all()
assert root.receipt().services == ()
assert root.receipt().effects == ()


@pytest.mark.asyncio
Expand Down Expand Up @@ -405,15 +439,15 @@ async def test_citation_meme_cross_repository_parity(tmp_path: Path) -> None:
await citation_module.ProtocolTagCleanupModule().run(legacy_frame)

plugin_home = tmp_path / "plugins"
shutil.copytree(
_ = shutil.copytree(
citation_root,
plugin_home / "citation",
ignore=shutil.ignore_patterns(".git", ".pytest_cache", "__pycache__"),
ignore=_copy_ignore(),
)
shutil.copytree(
_ = shutil.copytree(
Path(__file__).parents[1],
plugin_home / "meme",
ignore=shutil.ignore_patterns(".git", ".pytest_cache", "__pycache__"),
ignore=_copy_ignore(),
)
manager = PluginManager(
plugin_dirs=[plugin_home],
Expand All @@ -439,7 +473,10 @@ async def test_citation_meme_cross_repository_parity(tmp_path: Path) -> None:
disabled_sections=set(),
turn_injection_prompt="",
)
await snapshot.composition_root.context.serial(PROMPT_RENDER_EVENT, v3_prompt)
_ = await snapshot.composition_root.context.serial(
PROMPT_RENDER_AFTER_EVENT_BUS,
v3_prompt,
)
v3_answer = AfterReasoningCtx(
session_key="telegram:1",
channel="telegram",
Expand All @@ -452,12 +489,12 @@ async def test_citation_meme_cross_repository_parity(tmp_path: Path) -> None:
context_retry={},
reply=reply,
)
await snapshot.composition_root.context.serial(
AFTER_REASONING_PREPROCESS_EVENT,
_ = await snapshot.composition_root.context.serial(
AFTER_REASONING_BEFORE_EVENT_BUS,
v3_answer,
)
await snapshot.composition_root.context.serial(
AFTER_REASONING_CLEANUP_EVENT,
_ = await snapshot.composition_root.context.serial(
AFTER_REASONING_BEFORE_PERSIST,
v3_answer,
)

Expand Down
Loading