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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
2 changes: 2 additions & 0 deletions agent/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
(
Expand Down
1 change: 1 addition & 0 deletions agent/lifecycle/phases/prompt_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions agent/lifecycle/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 的唯一组装结果承载。
Expand Down
17 changes: 15 additions & 2 deletions agent/prompting/assembler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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-reminder data-system-context-frame="true">'
SYSTEM_CONTEXT_FRAME_END = "</system-reminder>"
Expand Down Expand Up @@ -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”
# 收束成一份统一输入,避免调用方各自手拼消息顺序。
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -171,6 +183,7 @@ def assemble(
*_section_meta(top_sections),
*_section_meta(built_sections),
*_section_meta(bottom_sections),
*_section_meta(contributed_frame_sections),
],
)

Expand Down
5 changes: 3 additions & 2 deletions plugins/akasha/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
)

Expand Down
2 changes: 1 addition & 1 deletion pytest.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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 基础场景测试
Expand Down
84 changes: 83 additions & 1 deletion tests/test_plugin_composition_lifecycle.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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("<system-reminder")
assert str(reminder["content"]).index("## memory") < str(
reminder["content"]
).index("## active_skills")


def _before_turn_ctx() -> BeforeTurnCtx:
return BeforeTurnCtx(
session_key="session",
Expand Down Expand Up @@ -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")

Expand Down
Loading