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")