diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5e9d219f..077a52de 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -54,7 +54,7 @@ jobs:
PYTHONWARNINGS: "ignore:.*AbstractEventLoopPolicy.*:DeprecationWarning"
run: |
.venv/bin/python scripts/check_test_budget.py
- .venv/bin/pytest -q -W error tests/
+ .venv/bin/pytest -q tests/
- name: Run Web regressions
run: |
diff --git a/agent/context.py b/agent/context.py
index 80568e34..10ec23c1 100644
--- a/agent/context.py
+++ b/agent/context.py
@@ -273,6 +273,7 @@ def render(
*,
system_sections_top: list[PromptSectionRender] | None = None,
system_sections_bottom: list[PromptSectionRender] | None = None,
+ context_frame_sections: list[PromptSectionRender] | None = None,
) -> AssembledTurnInput:
turn_injection_context = self.build_turn_injection_context(
turn_injection_prompt=request.turn_injection_prompt
@@ -290,6 +291,7 @@ def render(
turn_injection_context=turn_injection_context,
system_sections_top=system_sections_top,
system_sections_bottom=system_sections_bottom,
+ context_frame_sections=context_frame_sections,
)
self._last_render_diagnostics.set(
(
diff --git a/agent/lifecycle/phases/prompt_render.py b/agent/lifecycle/phases/prompt_render.py
index 0abb7563..e5092c5f 100644
--- a/agent/lifecycle/phases/prompt_render.py
+++ b/agent/lifecycle/phases/prompt_render.py
@@ -102,6 +102,7 @@ async def run(self, frame: PromptRenderFrame) -> PromptRenderFrame:
),
system_sections_top=ctx.system_sections_top,
system_sections_bottom=ctx.system_sections_bottom,
+ context_frame_sections=ctx.context_frame_sections,
)
if ctx.extra_hints:
rendered.messages.append(
diff --git a/agent/lifecycle/types.py b/agent/lifecycle/types.py
index 334f6a77..6288394e 100644
--- a/agent/lifecycle/types.py
+++ b/agent/lifecycle/types.py
@@ -91,6 +91,9 @@ class PromptRenderCtx:
system_sections_bottom: list[PromptSectionRender] = field(
default_factory=list[PromptSectionRender]
)
+ context_frame_sections: list[PromptSectionRender] = field(
+ default_factory=list[PromptSectionRender]
+ )
# 保留旧导入名;运行时结果由 ContextBuilder 的唯一组装结果承载。
diff --git a/agent/prompting/assembler.py b/agent/prompting/assembler.py
index e02346e3..88e1a62f 100644
--- a/agent/prompting/assembler.py
+++ b/agent/prompting/assembler.py
@@ -49,7 +49,6 @@ def set(self, scope: str, section_name: str, signature: str, content: str) -> No
_CONTEXT_FRAME_SECTIONS = {
"active_skills",
- "retrieved_memory",
}
SYSTEM_CONTEXT_FRAME_MARKER = ''
SYSTEM_CONTEXT_FRAME_END = ""
@@ -99,6 +98,7 @@ def assemble(
turn_injection_context: dict[str, str] | None = None,
system_sections_top: list[PromptSectionRender] | None = None,
system_sections_bottom: list[PromptSectionRender] | None = None,
+ context_frame_sections: list[PromptSectionRender] | None = None,
) -> AssembledTurnInput:
# assembler 负责把“主 prompt + turn injection + message envelope”
# 收束成一份统一输入,避免调用方各自手拼消息顺序。
@@ -135,11 +135,23 @@ def assemble(
for section in all_sections
if section.name not in _CONTEXT_FRAME_SECTIONS
]
- frame_sections = [
+ built_frame_sections = [
section
for section in all_sections
if section.name in _CONTEXT_FRAME_SECTIONS
]
+ contributed_frame_sections = [
+ section
+ for section in (context_frame_sections or [])
+ if section.name not in disabled
+ ]
+ frame_sections = [*built_frame_sections, *contributed_frame_sections]
+ frame_sections.sort(
+ key=lambda section: (
+ section.order is None,
+ section.order if section.order is not None else 0,
+ )
+ )
for name, content in injection_context.items():
text = content.strip()
if text:
@@ -171,6 +183,7 @@ def assemble(
*_section_meta(top_sections),
*_section_meta(built_sections),
*_section_meta(bottom_sections),
+ *_section_meta(contributed_frame_sections),
],
)
diff --git a/plugins/akasha/plugin.py b/plugins/akasha/plugin.py
index b703c949..48d70ee7 100644
--- a/plugins/akasha/plugin.py
+++ b/plugins/akasha/plugin.py
@@ -641,11 +641,12 @@ async def _inject_memory(
)
block = result.text_block.strip()
if block:
- event.system_sections_bottom.append(
+ event.context_frame_sections.append(
PromptSectionRender(
- name="memory",
+ name=RETRIEVED_MEMORY_SECTION,
content=block,
is_static=False,
+ order=10,
)
)
diff --git a/pytest.ini b/pytest.ini
index c55b2526..fc6f7d70 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -2,7 +2,7 @@
asyncio_mode = auto
asyncio_default_fixture_loop_scope = function
asyncio_default_test_loop_scope = session
-addopts = -W error
+addopts = -W error -W ignore::DeprecationWarning:starlette.testclient
testpaths = tests
markers =
scenario_mvp: agent loop 基础场景测试
diff --git a/tests/test_plugin_composition_lifecycle.py b/tests/test_plugin_composition_lifecycle.py
index d7aeb341..c306b1c5 100644
--- a/tests/test_plugin_composition_lifecycle.py
+++ b/tests/test_plugin_composition_lifecycle.py
@@ -1,15 +1,18 @@
from __future__ import annotations
import asyncio
-from contextlib import asynccontextmanager
+from contextlib import asynccontextmanager, nullcontext
from datetime import datetime
import subprocess
import sys
+from types import SimpleNamespace
from typing import Any, AsyncIterator, cast
+from unittest.mock import AsyncMock
import pytest
from agent.core.response_parser import ResponseMetadata
+from agent.context import MessageEnvelopeBuilder
from agent.lifecycle.composition import (
AFTER_REASONING_CLEANUP_EVENT,
AFTER_REASONING_PREPROCESS_EVENT,
@@ -61,6 +64,12 @@
from core.memory.events import MemoryWritten, RetrievalCompleted
from agent.retrieval.events import build_retrieval_completed
from agent.retrieval.protocol import RetrievalRequest
+from agent.prompting import PromptAssembler, PromptSectionRender
+from plugins.akasha.plugin import _inject_memory
+from plugins.openai_compatible.driver import (
+ _merge_leading_system_messages,
+ _normalize_messages,
+)
@asynccontextmanager
@@ -93,6 +102,76 @@ def _prompt_ctx() -> PromptRenderCtx:
)
+async def _assert_akasha_inserts_first_user_context_frame_block() -> None:
+ ctx = _prompt_ctx()
+ runtime = SimpleNamespace(
+ query=AsyncMock(return_value=MemoryQueryResult(text_block="fresh recall"))
+ )
+ diagnostics = SimpleNamespace(
+ operation=lambda _name: nullcontext(),
+ measure=lambda _name, _value: None,
+ )
+
+ await _inject_memory(ctx, cast(Any, runtime), cast(Any, diagnostics))
+
+ assert ctx.system_sections_bottom == []
+ assert [
+ (section.name, section.content, section.order)
+ for section in ctx.context_frame_sections
+ ] == [("memory", "fresh recall", 10)]
+
+
+def _assert_context_frame_keeps_dynamic_memory_after_stable_history() -> None:
+ history = [
+ {"role": "user", "content": "old question"},
+ {"role": "assistant", "content": "old answer"},
+ ]
+
+ class _ContextStub:
+ _envelope_builder = MessageEnvelopeBuilder()
+
+ @staticmethod
+ def _build_system_prompt_sections(**_kwargs: object) -> list[PromptSectionRender]:
+ return [
+ PromptSectionRender("stable", "stable system", True, order=20),
+ PromptSectionRender("active_skills", "active skill", False, order=50),
+ ]
+
+ assembler = PromptAssembler(cast(Any, _ContextStub()))
+
+ def assemble(memory: str):
+ return assembler.assemble(
+ history=history,
+ current_message="current question",
+ multimodal=False,
+ context_frame_sections=[
+ PromptSectionRender("memory", memory, False, order=10)
+ ],
+ )
+
+ first = assemble("recall one")
+ second = assemble("recall two")
+ provider_messages = _merge_leading_system_messages(
+ _normalize_messages(first.messages)
+ )
+
+ assert first.system_prompt == second.system_prompt == "stable system"
+ assert first.messages[:3] == second.messages[:3]
+ assert [message["role"] for message in provider_messages] == [
+ "system",
+ "user",
+ "assistant",
+ "user",
+ "user",
+ ]
+ reminder = provider_messages[-2]
+ assert reminder["role"] == "user"
+ assert str(reminder["content"]).startswith(" BeforeTurnCtx:
return BeforeTurnCtx(
session_key="session",
@@ -489,6 +568,9 @@ async def enqueue() -> None:
@pytest.mark.asyncio
async def test_retrieval_completed_event_payload() -> None:
+ await _assert_akasha_inserts_first_user_context_frame_block()
+ _assert_context_frame_keeps_dynamic_memory_after_stable_history()
+
observed: list[RetrievalCompleted] = []
root = CompositionRoot("retrieval-completed")