Spaces:
Paused
Paused
| # -*- coding: utf-8 -*- | |
| """Unit tests for the :class:`RAGMiddleware` class.""" | |
| from contextlib import AsyncExitStack | |
| from types import SimpleNamespace | |
| from typing import Any, AsyncGenerator | |
| from unittest.async_case import IsolatedAsyncioTestCase | |
| from utils import AnyString | |
| from agentscope.embedding import EmbeddingResponse | |
| from agentscope.event import EventType, HintBlockEvent | |
| from agentscope.message import ( | |
| Base64Source, | |
| DataBlock, | |
| Msg, | |
| TextBlock, | |
| UserMsg, | |
| ) | |
| from agentscope.middleware import RAGMiddleware | |
| from agentscope.rag import Chunk, KnowledgeBase, QdrantStore, VectorRecord | |
| _HINT_SOURCE = '{"label": "KnowledgeBase", "sublabel": ""}' | |
| _EXPECTED_HINT = ( | |
| "<system-reminder>The following content is retrieved from the " | |
| "knowledge base(s) and may be helpful for the current " | |
| "request:\n" | |
| "<content>[1] (source: doc-1.txt)\n" | |
| "Paris is in France.</content></system-reminder>" | |
| ) | |
| class _StubEmbeddingModel: | |
| """A stub embedding model returning a fixed vector per input.""" | |
| supports_multimodal = False | |
| dimensions = 3 | |
| def __init__(self, vector: list[float]) -> None: | |
| """Initialize the stub. | |
| Args: | |
| vector (`list[float]`): | |
| The vector returned for every input. | |
| """ | |
| self.vector = vector | |
| self.calls: list[list] = [] | |
| async def __call__(self, inputs: list) -> EmbeddingResponse: | |
| """Return the fixed vector for each input. | |
| Args: | |
| inputs (`list`): | |
| The input queries. | |
| Returns: | |
| `EmbeddingResponse`: | |
| The response with one fixed vector per input. | |
| """ | |
| self.calls.append(inputs) | |
| return EmbeddingResponse(embeddings=[self.vector] * len(inputs)) | |
| def _make_record( | |
| text: str, | |
| vector: list[float], | |
| document_id: str, | |
| ) -> VectorRecord: | |
| """Build a VectorRecord for testing. | |
| Args: | |
| text (`str`): | |
| The chunk text content. | |
| vector (`list[float]`): | |
| The embedding vector. | |
| document_id (`str`): | |
| The ID of the source document the record belongs to. | |
| Returns: | |
| `VectorRecord`: | |
| The constructed record. | |
| """ | |
| return VectorRecord( | |
| vector=vector, | |
| document_id=document_id, | |
| chunk=Chunk( | |
| content=TextBlock(text=text), | |
| source=f"{document_id}.txt", | |
| chunk_index=0, | |
| total_chunks=1, | |
| ), | |
| ) | |
| def _make_agent( | |
| context: list[Msg] | None = None, | |
| cur_iter: int = 0, | |
| ) -> Any: | |
| """Build a minimal stand-in for an Agent. | |
| Args: | |
| context (`list[Msg] | None`, optional): | |
| The initial agent context. | |
| cur_iter (`int`, defaults to ``0``): | |
| Value for ``state.cur_iter``; the middleware only searches | |
| on the first reasoning step (``0``). | |
| Returns: | |
| `Any`: | |
| An object with ``name`` and ``state.context`` / | |
| ``state.reply_id`` / ``state.session_id`` / | |
| ``state.cur_iter`` / ``state.append_context``. | |
| """ | |
| msgs: list[Msg] = context if context is not None else [] | |
| def _append_context(name: str, blocks: list) -> None: | |
| # Always append a new assistant carrier message keyed on the | |
| # static reply_id used in these tests. Mirrors the real | |
| # ``AgentState.append_context`` for the purposes of the | |
| # middleware's reverse-scan removal logic. | |
| carrier = Msg(name=name, role="assistant", content=blocks) | |
| carrier.id = "reply-1" | |
| msgs.append(carrier) | |
| state = SimpleNamespace( | |
| context=msgs, | |
| reply_id="reply-1", | |
| session_id="session-1", | |
| cur_iter=cur_iter, | |
| append_context=_append_context, | |
| ) | |
| return SimpleNamespace(name="assistant", state=state) | |
| async def _drain(generator: AsyncGenerator) -> list: | |
| """Exhaust an async generator into a list. | |
| Args: | |
| generator (`AsyncGenerator`): | |
| The generator to drain. | |
| Returns: | |
| `list`: | |
| All yielded items. | |
| """ | |
| return [item async for item in generator] | |
| class RAGMiddlewareTest(IsolatedAsyncioTestCase): | |
| """The test cases for the :class:`RAGMiddleware` class.""" | |
| async def asyncSetUp(self) -> None: | |
| """Create an in-memory store seeded with one collection + | |
| one :class:`KnowledgeBase` handle wired to it.""" | |
| self._exit_stack = AsyncExitStack() | |
| self.store = await self._exit_stack.enter_async_context( | |
| QdrantStore(location=":memory:"), | |
| ) | |
| await self.store.create_collection("kb-1", dimensions=3) | |
| await self.store.insert( | |
| "kb-1", | |
| [ | |
| _make_record("Paris is in France.", [1.0, 0.0, 0.0], "doc-1"), | |
| _make_record("Cats are mammals.", [0.0, 1.0, 0.0], "doc-2"), | |
| ], | |
| ) | |
| self.embedding_model = _StubEmbeddingModel([1.0, 0.0, 0.0]) | |
| # Build the KnowledgeBase handle once; tests share it. The | |
| # collection already exists, so ``ensure_collection`` will | |
| # short-circuit on first use. | |
| self.knowledge = KnowledgeBase( | |
| name="paris-kb", | |
| description="Trivia about Paris and cats.", | |
| embedding_model=self.embedding_model, | |
| vector_store=self.store, | |
| collection="kb-1", | |
| ) | |
| async def asyncTearDown(self) -> None: | |
| """Close the store after each test.""" | |
| await self._exit_stack.aclose() | |
| def _middleware( | |
| self, | |
| knowledges: list[KnowledgeBase] | None = None, | |
| **kwargs: Any, | |
| ) -> RAGMiddleware: | |
| """Build a middleware bound to ``self.knowledge`` with a | |
| :class:`SearchConfig` assembled from ``kwargs``. | |
| Args: | |
| knowledges (`list[KnowledgeBase] | None`, optional): | |
| Override the bound knowledge bases. Defaults to | |
| ``[self.knowledge]``. | |
| **kwargs (`Any`): | |
| Forwarded to :class:`SearchConfig` (e.g. ``mode``, | |
| ``top_k``, ``score_threshold``, ``emit_hint_event``, | |
| ``persist_hint``). | |
| Returns: | |
| `RAGMiddleware`: | |
| The middleware under test. | |
| """ | |
| return RAGMiddleware( | |
| knowledge_bases=knowledges | |
| if knowledges is not None | |
| else [ | |
| self.knowledge, | |
| ], | |
| parameters=RAGMiddleware.Parameters(**kwargs), | |
| ) | |
| async def _run_with_inputs( | |
| self, | |
| middleware: RAGMiddleware, | |
| agent: Any, | |
| inputs: Msg | list[Msg] | None, | |
| context_during_reasoning: list[dict] | None = None, | |
| ) -> list: | |
| """Drive ``on_reply`` → ``on_reasoning`` end-to-end. | |
| Mirrors the real agent loop: ``on_reply`` captures the inputs | |
| in the middleware's scratchpad, then ``on_reasoning`` runs | |
| (with ``state.cur_iter == 0``) and may inject a hint. The | |
| reasoning step yields a sentinel ``"reasoning-evt"`` so callers | |
| can assert event order; if ``context_during_reasoning`` is | |
| provided it is filled with a dump of ``agent.state.context`` as | |
| seen by the innermost reasoning callback. | |
| Args: | |
| middleware (`RAGMiddleware`): | |
| The middleware under test. | |
| agent (`Any`): | |
| The fake agent. | |
| inputs (`Msg | list[Msg] | None`): | |
| The reply inputs to pass through ``on_reply``. | |
| context_during_reasoning (`list[dict] | None`, optional): | |
| When provided, receives a dump of the agent context as | |
| seen by the wrapped (innermost) reasoning call. | |
| Returns: | |
| `list`: | |
| All events yielded by the on_reply → on_reasoning chain. | |
| """ | |
| async def reasoning_next(**_kwargs: Any) -> AsyncGenerator: | |
| if context_during_reasoning is not None: | |
| context_during_reasoning.extend( | |
| msg.model_dump() for msg in agent.state.context | |
| ) | |
| yield "reasoning-evt" | |
| async def reply_next(**_kwargs: Any) -> AsyncGenerator: | |
| # The reply branch drives the reasoning branch — same as | |
| # the real composition. | |
| async for evt in middleware.on_reasoning( | |
| agent=agent, | |
| input_kwargs={"tool_choice": None}, | |
| next_handler=reasoning_next, | |
| ): | |
| yield evt | |
| return await _drain( | |
| middleware.on_reply( | |
| agent=agent, | |
| input_kwargs={"inputs": inputs}, | |
| next_handler=reply_next, | |
| ), | |
| ) | |
| # ------------------------------------------------------------------ | |
| # Static mode (auto-injection) | |
| # ------------------------------------------------------------------ | |
| async def test_static_one_shot_injection(self) -> None: | |
| """The hint participates in one reasoning step and is removed | |
| afterwards (``persist_hint=False``, default).""" | |
| middleware = self._middleware( | |
| mode="static", | |
| top_k=1, | |
| emit_hint_event=False, | |
| ) | |
| agent = _make_agent() | |
| seen_context: list[dict] = [] | |
| events = await self._run_with_inputs( | |
| middleware, | |
| agent, | |
| UserMsg(name="user", content="Where is Paris?"), | |
| context_during_reasoning=seen_context, | |
| ) | |
| # No HintBlockEvent (emit_hint_event=False); only downstream | |
| # events. | |
| self.assertEqual(events, ["reasoning-evt"]) | |
| # The reasoning callback observed exactly one carrier message | |
| # holding the injected hint block. | |
| self.assertEqual(len(seen_context), 1) | |
| carrier = seen_context[0] | |
| self.assertEqual(carrier["role"], "assistant") | |
| self.assertEqual(carrier["id"], "reply-1") | |
| self.assertEqual(len(carrier["content"]), 1) | |
| block = carrier["content"][0] | |
| self.assertEqual(block["type"], "hint") | |
| self.assertEqual(block["source"], _HINT_SOURCE) | |
| self.assertEqual(block["hint"], _EXPECTED_HINT) | |
| # One-shot: after on_reasoning unwinds, the carrier is emptied. | |
| post = [msg.model_dump() for msg in agent.state.context] | |
| self.assertEqual(len(post), 1) | |
| self.assertEqual(post[0]["content"], []) | |
| async def test_static_persistent_injection(self) -> None: | |
| """``persist_hint=True`` keeps the hint in the context.""" | |
| middleware = self._middleware( | |
| mode="static", | |
| top_k=1, | |
| persist_hint=True, | |
| emit_hint_event=False, | |
| ) | |
| agent = _make_agent() | |
| seen_context: list[dict] = [] | |
| await self._run_with_inputs( | |
| middleware, | |
| agent, | |
| UserMsg(name="user", content="Where is Paris?"), | |
| context_during_reasoning=seen_context, | |
| ) | |
| self.assertEqual( | |
| [msg.model_dump() for msg in agent.state.context], | |
| seen_context, | |
| ) | |
| async def test_static_event_emission(self) -> None: | |
| """``emit_hint_event=True`` yields one :class:`HintBlockEvent`.""" | |
| middleware = self._middleware( | |
| mode="static", | |
| top_k=1, | |
| emit_hint_event=True, | |
| ) | |
| agent = _make_agent() | |
| events = await self._run_with_inputs( | |
| middleware, | |
| agent, | |
| UserMsg(name="user", content="Where is Paris?"), | |
| ) | |
| self.assertEqual(len(events), 2) | |
| self.assertIsInstance(events[0], HintBlockEvent) | |
| self.assertEqual( | |
| events[0].model_dump(), | |
| { | |
| "type": EventType.HINT_BLOCK, | |
| "reply_id": "reply-1", | |
| "block_id": AnyString(), | |
| "source": _HINT_SOURCE, | |
| "hint": _EXPECTED_HINT, | |
| "id": AnyString(), | |
| "created_at": AnyString(), | |
| "metadata": {}, | |
| }, | |
| ) | |
| self.assertEqual(events[1], "reasoning-evt") | |
| async def test_static_skips_event_inputs(self) -> None: | |
| """Non-message inputs (resumption events / ``None``) skip the | |
| search entirely.""" | |
| middleware = self._middleware(mode="static") | |
| agent = _make_agent() | |
| events = await self._run_with_inputs(middleware, agent, None) | |
| self.assertEqual(events, ["reasoning-evt"]) | |
| self.assertEqual(self.embedding_model.calls, []) | |
| self.assertEqual(agent.state.context, []) | |
| async def test_multimodal_query_extraction(self) -> None: | |
| """DataBlocks reach the embedding model when it declares | |
| ``supports_multimodal``.""" | |
| self.embedding_model.supports_multimodal = True | |
| middleware = self._middleware( | |
| mode="static", | |
| top_k=1, | |
| emit_hint_event=False, | |
| ) | |
| agent = _make_agent() | |
| data_block = DataBlock( | |
| source=Base64Source(data="aGk=", media_type="image/png"), | |
| ) | |
| await self._run_with_inputs( | |
| middleware, | |
| agent, | |
| UserMsg( | |
| name="user", | |
| content=[TextBlock(text="What is this?"), data_block], | |
| ), | |
| ) | |
| # The query path prepends ``{name}: `` to the first text | |
| # block; the data block is passed through verbatim. | |
| self.assertEqual(len(self.embedding_model.calls), 1) | |
| query = self.embedding_model.calls[0] | |
| self.assertEqual(len(query), 2) | |
| self.assertEqual(query[0].text, "user: What is this?") | |
| self.assertEqual(query[1], data_block) | |
| async def test_multimodal_blocks_dropped_for_text_only_model( | |
| self, | |
| ) -> None: | |
| """A text-only embedding model silently drops DataBlock queries | |
| (no exception, no crash).""" | |
| middleware = self._middleware( | |
| mode="static", | |
| top_k=1, | |
| emit_hint_event=False, | |
| ) | |
| agent = _make_agent() | |
| data_block = DataBlock( | |
| source=Base64Source(data="aGk=", media_type="image/png"), | |
| ) | |
| await self._run_with_inputs( | |
| middleware, | |
| agent, | |
| UserMsg( | |
| name="user", | |
| content=[TextBlock(text="What is this?"), data_block], | |
| ), | |
| ) | |
| # ``KnowledgeBase.search`` strips the DataBlock when the bound | |
| # embedding model isn't multimodal — the model only saw text. | |
| self.assertEqual(len(self.embedding_model.calls), 1) | |
| for item in self.embedding_model.calls[0]: | |
| self.assertNotIsInstance(item, DataBlock) | |
| # ------------------------------------------------------------------ | |
| # Agentic mode (tool exposure) | |
| # ------------------------------------------------------------------ | |
| async def test_agentic_list_tools(self) -> None: | |
| """Agentic mode exposes the search tool; static mode none.""" | |
| agentic_tools = await self._middleware(mode="agentic").list_tools() | |
| static_tools = await self._middleware(mode="static").list_tools() | |
| self.assertEqual( | |
| [tool.name for tool in agentic_tools], | |
| ["search_knowledge"], | |
| ) | |
| self.assertEqual(static_tools, []) | |
| async def test_agentic_no_auto_injection(self) -> None: | |
| """Agentic mode never searches or injects automatically.""" | |
| middleware = self._middleware(mode="agentic") | |
| agent = _make_agent() | |
| events = await self._run_with_inputs( | |
| middleware, | |
| agent, | |
| UserMsg(name="user", content="Where is Paris?"), | |
| ) | |
| self.assertEqual(events, ["reasoning-evt"]) | |
| self.assertEqual(self.embedding_model.calls, []) | |
| self.assertEqual(agent.state.context, []) | |
| async def test_search_knowledge_tool_call(self) -> None: | |
| """The tool returns a formatted ``ToolChunk`` for a query. | |
| ``_SearchKnowledgeTool.call`` is a regular async function (not | |
| an async generator), so ``ToolBase.__call__`` awaits it and | |
| returns the single ``ToolChunk`` directly. | |
| """ | |
| middleware = self._middleware(mode="agentic", top_k=1) | |
| tool = (await middleware.list_tools())[0] | |
| chunk = await tool(query="Where is Paris?") | |
| self.assertEqual( | |
| chunk.model_dump(), | |
| { | |
| "content": [ | |
| { | |
| "type": "text", | |
| "text": ( | |
| "[1] (source: doc-1.txt)\nParis is in France." | |
| ), | |
| "id": AnyString(), | |
| }, | |
| ], | |
| "state": "success", | |
| "is_last": True, | |
| "metadata": {}, | |
| "id": AnyString(), | |
| }, | |
| ) | |
| async def test_search_knowledge_tool_input_schema_enum(self) -> None: | |
| """The tool's ``input_schema`` narrows ``knowledge_bases.items`` | |
| to the equipped KB names.""" | |
| middleware = self._middleware(mode="agentic") | |
| tool = (await middleware.list_tools())[0] | |
| schema = tool.input_schema | |
| kb_schema = schema["properties"]["knowledge_bases"] | |
| # Pydantic emits Optional[list[str]] as anyOf; pick the array | |
| # branch. | |
| array_variant = next( | |
| v for v in kb_schema["anyOf"] if v.get("type") == "array" | |
| ) | |
| self.assertEqual(array_variant["items"]["enum"], ["paris-kb"]) | |
| async def test_search_knowledge_tool_filters_by_name(self) -> None: | |
| """Passing ``knowledge_bases=[<unknown>]`` returns the | |
| ``"No relevant content found."`` notice without touching the | |
| embedding model.""" | |
| middleware = self._middleware(mode="agentic", top_k=1) | |
| tool = (await middleware.list_tools())[0] | |
| chunk = await tool( | |
| query="Where is Paris?", | |
| knowledge_bases=["does-not-exist"], | |
| ) | |
| self.assertEqual( | |
| [b["text"] for b in chunk.model_dump()["content"]], | |
| ["No relevant content found."], | |
| ) | |
| self.assertEqual(self.embedding_model.calls, []) | |
| # ------------------------------------------------------------------ | |
| # Config validation | |
| # ------------------------------------------------------------------ | |
| async def test_hint_template_must_have_context_placeholder(self) -> None: | |
| """:class:`SearchConfig` rejects a template without exactly one | |
| ``{context}``.""" | |
| with self.assertRaises(ValueError): | |
| RAGMiddleware.Parameters(hint_template="no placeholder here") | |
| with self.assertRaises(ValueError): | |
| RAGMiddleware.Parameters(hint_template="{context} twice {context}") | |
| # Exactly one placeholder is fine. | |
| RAGMiddleware.Parameters(hint_template="wrapped: {context}.") | |