Python: PR1 — New session and context provider types (side-by-side) (#3763)

* PR1: Add core context provider types and tests

New types in _sessions.py (no changes to existing code):
- SessionContext: per-invocation state with extend_messages/get_messages/
  extend_instructions/extend_tools and read-only response property
- _ContextProviderBase: base class with before_run/after_run hooks
- _HistoryProviderBase: storage base with load/store flags, abstract
  get_messages/save_messages, default before_run/after_run
- AgentSession: lightweight session with state dict, to_dict/from_dict
- InMemoryHistoryProvider: built-in provider storing in session.state

35 unit tests covering all classes and configuration flags.

* feat: keyword-only params, stateless InMemoryHistoryProvider, deep serialization

- Make before_run/after_run parameters keyword-only
- InMemoryHistoryProvider stores ChatMessage objects directly (no per-cycle serialization)
- Deep serialization via to_dict/from_dict only at session boundary
- State type registry for automatic deserialization of registered types
- Updated tests for new serialization approach

* feat: add new-pattern provider implementations for external packages

- _RedisContextProvider(BaseContextProvider) - Redis search/vector context
- _RedisHistoryProvider(BaseHistoryProvider) - Redis-backed message storage
- _Mem0ContextProvider(BaseContextProvider) - Mem0 semantic memory
- _AzureAISearchContextProvider(BaseContextProvider) - Azure AI Search (semantic + agentic)

All use temporary _ prefix names for side-by-side coexistence with existing providers.
Will be renamed in PR2 when old ContextProvider/ChatMessageStore are removed.

* test: add tests for new-pattern provider implementations

- 32 tests for _RedisContextProvider and _RedisHistoryProvider
- 29 tests for _Mem0ContextProvider
- 17 tests for _AzureAISearchContextProvider

* fix: address PR review comments and CI failures

- Move module docstring before imports in _sessions.py (review comment)
- Import TYPE_CHECKING unconditionally in Redis _context_provider.py (NameError on Python <3.12)
- Fix Mem0 test_init_auto_creates_client_when_none to patch at class level

* feat: add source attribution to extend_messages

Set attribution marker in additional_properties for each message
added via extend_messages(), matching the tool attribution pattern.
Uses setdefault to preserve any existing attribution.

* refactor: make attribution value a dict with source_id key

* add attribution and use sets for filters

* Add source_type to message attribution and copy messages in extend_messages

- SessionContext.extend_messages now accepts source as str or object with
  source_id attribute; when an object is passed, its class name is recorded
  as source_type in the attribution dict
- Messages are shallow-copied before attribution is added so callers'
  original objects are never mutated
- Filter framework-internal keys (attribution) from A2A wire metadata
  to prevent leaking internal state over the wire

* fix: correct mypy type: ignore comment from union-attr to attr-defined

* set attribution to _attribution

* adjusted naming of bools
This commit is contained in:
Eduard van Valkenburg
2026-02-10 22:19:15 +01:00
committed by GitHub
Unverified
parent ccff3d3452
commit ac0e6b0ee1
13 changed files with 3494 additions and 2 deletions
@@ -0,0 +1,522 @@
# Copyright (c) Microsoft. All rights reserved.
"""Unified context management types for the agent framework.
This module provides the core types for the context provider pipeline:
- SessionContext: Per-invocation state passed through providers
- BaseContextProvider: Base class for context providers (renamed to ContextProvider in PR2)
- BaseHistoryProvider: Base class for history storage providers (renamed to HistoryProvider in PR2)
- AgentSession: Lightweight session state container
- InMemoryHistoryProvider: Built-in in-memory history provider
"""
from __future__ import annotations
import copy
import uuid
from abc import abstractmethod
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any
from ._tools import ToolProtocol
from ._types import AgentResponse, ChatMessage
if TYPE_CHECKING:
from ._agents import SupportsAgentRun
__all__ = [
"AgentSession",
"BaseContextProvider",
"BaseHistoryProvider",
"InMemoryHistoryProvider",
"SessionContext",
]
# Registry of known types for state deserialization
_STATE_TYPE_REGISTRY: dict[str, type] = {}
def _register_state_type(cls: type) -> None:
"""Register a type for automatic deserialization in session state."""
type_id: str = getattr(cls, "_get_type_identifier", lambda: cls.__name__.lower())()
_STATE_TYPE_REGISTRY[type_id] = cls
def _serialize_value(value: Any) -> Any:
"""Serialize a single value, handling objects with to_dict()."""
if hasattr(value, "to_dict") and callable(value.to_dict):
return value.to_dict() # pyright: ignore[reportUnknownMemberType]
if isinstance(value, list):
return [_serialize_value(item) for item in value] # pyright: ignore[reportUnknownVariableType]
if isinstance(value, dict):
return {str(k): _serialize_value(v) for k, v in value.items()} # pyright: ignore[reportUnknownVariableType, reportUnknownArgumentType]
return value
def _deserialize_value(value: Any) -> Any:
"""Deserialize a single value, restoring registered types."""
if isinstance(value, dict) and "type" in value:
type_id = str(value["type"]) # pyright: ignore[reportUnknownArgumentType]
cls = _STATE_TYPE_REGISTRY.get(type_id)
if cls is not None and hasattr(cls, "from_dict"):
return cls.from_dict(value) # type: ignore[union-attr]
if isinstance(value, list):
return [_deserialize_value(item) for item in value] # pyright: ignore[reportUnknownVariableType]
if isinstance(value, dict):
return {str(k): _deserialize_value(v) for k, v in value.items()} # pyright: ignore[reportUnknownVariableType, reportUnknownArgumentType]
return value
def _serialize_state(state: dict[str, Any]) -> dict[str, Any]:
"""Deep-serialize a state dict, converting SerializationProtocol objects to dicts."""
return {k: _serialize_value(v) for k, v in state.items()}
def _deserialize_state(state: dict[str, Any]) -> dict[str, Any]:
"""Deep-deserialize a state dict, restoring SerializationProtocol objects."""
return {k: _deserialize_value(v) for k, v in state.items()}
# Register known types
_register_state_type(ChatMessage)
class SessionContext:
"""Per-invocation state passed through the context provider pipeline.
Created fresh for each agent.run() call. Providers read from and write to
the mutable fields to add context before invocation and process responses after.
Attributes:
session_id: The ID of the current session.
service_session_id: Service-managed session ID (if present, service handles storage).
input_messages: The new messages being sent to the agent (set by caller).
context_messages: Dict mapping source_id -> messages added by that provider.
Maintains insertion order (provider execution order).
instructions: Additional instructions added by providers.
tools: Additional tools added by providers.
response: After invocation, contains the full AgentResponse, should not be changed.
options: Options passed to agent.run() - read-only, for reflection only.
metadata: Shared metadata dictionary for cross-provider communication.
"""
def __init__(
self,
*,
session_id: str | None = None,
service_session_id: str | None = None,
input_messages: list[ChatMessage],
context_messages: dict[str, list[ChatMessage]] | None = None,
instructions: list[str] | None = None,
tools: list[ToolProtocol] | None = None,
options: dict[str, Any] | None = None,
metadata: dict[str, Any] | None = None,
):
"""Initialize the session context.
Args:
session_id: The ID of the current session.
service_session_id: Service-managed session ID.
input_messages: The new messages being sent to the agent.
context_messages: Pre-populated context messages by source.
instructions: Pre-populated instructions.
tools: Pre-populated tools.
options: Options from agent.run() - read-only for providers.
metadata: Shared metadata for cross-provider communication.
"""
self.session_id = session_id
self.service_session_id = service_session_id
self.input_messages = input_messages
self.context_messages: dict[str, list[ChatMessage]] = context_messages or {}
self.instructions: list[str] = instructions or []
self.tools: list[ToolProtocol] = tools or []
self._response: AgentResponse | None = None
self.options: dict[str, Any] = options or {}
self.metadata: dict[str, Any] = metadata or {}
@property
def response(self) -> AgentResponse | None:
"""The agent's response. Set by the framework after invocation, read-only for providers."""
return self._response
def extend_messages(self, source: str | object, messages: Sequence[ChatMessage]) -> None:
"""Add context messages from a specific source.
Messages are copied before attribution is added, so the caller's
original message objects are never mutated. The copies are stored
keyed by source_id, maintaining insertion order based on provider
execution order. Each message gets an ``attribution`` marker in
``additional_properties`` for downstream filtering.
Args:
source: Either a plain ``source_id`` string, or an object with a
``source_id`` attribute (e.g. a context provider). When an
object is passed, its class name is recorded as
``source_type`` in the attribution.
messages: The messages to add.
"""
if isinstance(source, str):
source_id = source
attribution: dict[str, str] = {"source_id": source_id}
else:
source_id = source.source_id # type: ignore[attr-defined]
attribution = {"source_id": source_id, "source_type": type(source).__name__}
copied: list[ChatMessage] = []
for message in messages:
msg_copy = copy.copy(message)
msg_copy.additional_properties = dict(message.additional_properties)
msg_copy.additional_properties.setdefault("_attribution", attribution)
copied.append(msg_copy)
if source_id not in self.context_messages:
self.context_messages[source_id] = []
self.context_messages[source_id].extend(copied)
def extend_instructions(self, source_id: str, instructions: str | Sequence[str]) -> None:
"""Add instructions to be prepended to the conversation.
Args:
source_id: The provider source_id adding these instructions.
instructions: A single instruction string or sequence of strings.
"""
if isinstance(instructions, str):
instructions = [instructions]
self.instructions.extend(instructions)
def extend_tools(self, source_id: str, tools: Sequence[ToolProtocol]) -> None:
"""Add tools to be available for this invocation.
Tools are added with source attribution in their metadata.
Args:
source_id: The provider source_id adding these tools.
tools: The tools to add.
"""
for tool in tools:
if hasattr(tool, "additional_properties") and isinstance(tool.additional_properties, dict):
tool.additional_properties["context_source"] = source_id
self.tools.extend(tools)
def get_messages(
self,
*,
sources: set[str] | None = None,
exclude_sources: set[str] | None = None,
include_input: bool = False,
include_response: bool = False,
) -> list[ChatMessage]:
"""Get context messages, optionally filtered and including input/response.
Returns messages in provider execution order (dict insertion order),
with input and response appended if requested.
Args:
sources: If provided, only include context messages from these sources.
exclude_sources: If provided, exclude context messages from these sources.
include_input: If True, append input_messages after context.
include_response: If True, append response.messages at the end.
Returns:
Flattened list of messages in conversation order.
"""
result: list[ChatMessage] = []
for source_id, messages in self.context_messages.items():
if sources is not None and source_id not in sources:
continue
if exclude_sources is not None and source_id in exclude_sources:
continue
result.extend(messages)
if include_input and self.input_messages:
result.extend(self.input_messages)
if include_response and self.response and self.response.messages:
result.extend(self.response.messages)
return result
class BaseContextProvider:
"""Base class for context providers (hooks pattern).
Context providers participate in the context engineering pipeline,
adding context before model invocation and processing responses after.
Note:
This class uses a temporary name prefixed with ``_`` to avoid collision
with the existing ``ContextProvider`` in ``_memory.py``. It will be
renamed to ``ContextProvider`` in PR2 when the old class is removed.
Attributes:
source_id: Unique identifier for this provider instance (required).
Used for message/tool attribution so other providers can filter.
"""
def __init__(self, source_id: str):
"""Initialize the provider.
Args:
source_id: Unique identifier for this provider instance.
"""
self.source_id = source_id
async def before_run(
self,
*,
agent: SupportsAgentRun,
session: AgentSession,
context: SessionContext,
state: dict[str, Any],
) -> None:
"""Called before model invocation.
Override to add context (messages, instructions, tools) to the
SessionContext before the model is invoked.
Args:
agent: The agent running this invocation.
session: The current session.
context: The invocation context - add messages/instructions/tools here.
state: The session's mutable state dict.
"""
async def after_run(
self,
*,
agent: SupportsAgentRun,
session: AgentSession,
context: SessionContext,
state: dict[str, Any],
) -> None:
"""Called after model invocation.
Override to process the response (store messages, extract info, etc.).
The context.response will be populated at this point.
Args:
agent: The agent that ran this invocation.
session: The current session.
context: The invocation context with response populated.
state: The session's mutable state dict.
"""
class BaseHistoryProvider(BaseContextProvider):
"""Base class for conversation history storage providers.
A single class configurable for different use cases:
- Primary memory storage (loads + stores messages)
- Audit/logging storage (stores only, doesn't load)
- Evaluation storage (stores only for later analysis)
Note:
This class uses a temporary name prefixed with ``_`` to avoid collision
with existing types. It will be renamed to ``HistoryProvider`` in PR2.
Subclasses only need to implement ``get_messages()`` and ``save_messages()``.
The default ``before_run``/``after_run`` handle loading and storing based on
configuration flags. Override them for custom behavior.
Attributes:
load_messages: Whether to load messages before invocation (default True).
When False, the agent skips calling ``before_run`` entirely.
store_inputs: Whether to store input messages (default True).
store_context_messages: Whether to store context from other providers (default False).
store_context_from: If set, only store context from these source_ids.
store_outputs: Whether to store response messages (default True).
"""
def __init__(
self,
source_id: str,
*,
load_messages: bool = True,
store_inputs: bool = True,
store_context_messages: bool = False,
store_context_from: set[str] | None = None,
store_outputs: bool = True,
):
"""Initialize the history provider.
Args:
source_id: Unique identifier for this provider instance.
load_messages: Whether to load messages before invocation.
store_inputs: Whether to store input messages.
store_context_messages: Whether to store context from other providers.
store_context_from: If set, only store context from these source_ids.
store_outputs: Whether to store response messages.
"""
super().__init__(source_id)
self.load_messages = load_messages
self.store_inputs = store_inputs
self.store_context_messages = store_context_messages
self.store_context_from = store_context_from
self.store_outputs = store_outputs
@abstractmethod
async def get_messages(self, session_id: str | None, **kwargs: Any) -> list[ChatMessage]:
"""Retrieve stored messages for this session.
Args:
session_id: The session ID to retrieve messages for.
**kwargs: Additional arguments (e.g., ``state`` for in-memory providers).
Returns:
List of stored messages.
"""
...
@abstractmethod
async def save_messages(self, session_id: str | None, messages: Sequence[ChatMessage], **kwargs: Any) -> None:
"""Persist messages for this session.
Args:
session_id: The session ID to store messages for.
messages: The messages to persist.
**kwargs: Additional arguments (e.g., ``state`` for in-memory providers).
"""
...
def _get_context_messages_to_store(self, context: SessionContext) -> list[ChatMessage]:
"""Get context messages that should be stored based on configuration."""
if not self.store_context_messages:
return []
if self.store_context_from is not None:
return context.get_messages(sources=self.store_context_from)
return context.get_messages(exclude_sources={self.source_id})
async def before_run(
self,
*,
agent: SupportsAgentRun,
session: AgentSession,
context: SessionContext,
state: dict[str, Any],
) -> None:
"""Load history into context. Skipped by the agent when load_messages=False."""
history = await self.get_messages(context.session_id, state=state)
context.extend_messages(self, history)
async def after_run(
self,
*,
agent: SupportsAgentRun,
session: AgentSession,
context: SessionContext,
state: dict[str, Any],
) -> None:
"""Store messages based on configuration."""
messages_to_store: list[ChatMessage] = []
messages_to_store.extend(self._get_context_messages_to_store(context))
if self.store_inputs:
messages_to_store.extend(context.input_messages)
if self.store_outputs and context.response and context.response.messages:
messages_to_store.extend(context.response.messages)
if messages_to_store:
await self.save_messages(context.session_id, messages_to_store, state=state)
class AgentSession:
"""A conversation session with an agent.
Lightweight state container. Provider instances are owned by the agent,
not the session. The session only holds session IDs and a mutable state dict.
Attributes:
session_id: Unique identifier for this session.
service_session_id: Service-managed session ID (if using service-side storage).
state: Mutable state dict shared with all providers.
"""
def __init__(
self,
*,
session_id: str | None = None,
service_session_id: str | None = None,
):
"""Initialize the session.
Args:
session_id: Optional session ID (generated if not provided).
service_session_id: Optional service-managed session ID.
"""
self._session_id = session_id or str(uuid.uuid4())
self.service_session_id = service_session_id
self.state: dict[str, Any] = {}
@property
def session_id(self) -> str:
"""The unique identifier for this session."""
return self._session_id
def to_dict(self) -> dict[str, Any]:
"""Serialize session to a plain dict for storage/transfer.
Values in ``state`` that implement ``SerializationProtocol`` (i.e. have
``to_dict``/``from_dict``) are serialized automatically. Built-in types
(str, int, float, bool, None, list, dict) are kept as-is.
"""
return {
"type": "session",
"session_id": self._session_id,
"service_session_id": self.service_session_id,
"state": _serialize_state(self.state),
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> AgentSession:
"""Restore session from a previously serialized dict.
Values in ``state`` that were serialized via ``SerializationProtocol``
(containing a ``type`` key) are restored to their original types.
Args:
data: Dict from a previous ``to_dict()`` call.
Returns:
Restored AgentSession instance.
"""
session = cls(
session_id=data["session_id"],
service_session_id=data.get("service_session_id"),
)
session.state = _deserialize_state(data.get("state", {}))
return session
class InMemoryHistoryProvider(BaseHistoryProvider):
"""Built-in history provider that stores messages in session.state.
Messages are stored in ``state[source_id]["messages"]`` as a list of
``ChatMessage`` objects. Serialization to/from dicts is handled by
``AgentSession.to_dict()``/``from_dict()`` using ``SerializationProtocol``.
This provider holds no instance state — all data lives in the session's
state dict, passed as a named ``state`` parameter to ``get_messages``/``save_messages``.
This is the default provider auto-added by the agent when no providers
are configured and ``conversation_id`` or ``store=True`` is set.
"""
async def get_messages(
self, session_id: str | None, *, state: dict[str, Any] | None = None, **kwargs: Any
) -> list[ChatMessage]:
"""Retrieve messages from session state."""
if state is None:
return []
my_state = state.get(self.source_id, {})
return list(my_state.get("messages", []))
async def save_messages(
self,
session_id: str | None,
messages: Sequence[ChatMessage],
*,
state: dict[str, Any] | None = None,
**kwargs: Any,
) -> None:
"""Persist messages to session state."""
if state is None:
return
my_state = state.setdefault(self.source_id, {})
existing = my_state.get("messages", [])
my_state["messages"] = [*existing, *messages]
@@ -0,0 +1,421 @@
# Copyright (c) Microsoft. All rights reserved.
import json
from collections.abc import Sequence
from agent_framework import ChatMessage
from agent_framework._sessions import (
AgentSession,
BaseContextProvider,
BaseHistoryProvider,
InMemoryHistoryProvider,
SessionContext,
)
# ---------------------------------------------------------------------------
# SessionContext tests
# ---------------------------------------------------------------------------
class TestSessionContext:
def test_init_defaults(self) -> None:
ctx = SessionContext(input_messages=[])
assert ctx.session_id is None
assert ctx.service_session_id is None
assert ctx.input_messages == []
assert ctx.context_messages == {}
assert ctx.instructions == []
assert ctx.tools == []
assert ctx.response is None
assert ctx.options == {}
assert ctx.metadata == {}
def test_extend_messages_creates_key(self) -> None:
ctx = SessionContext(input_messages=[])
msg = ChatMessage(role="user", contents=["hello"])
ctx.extend_messages("rag", [msg])
assert "rag" in ctx.context_messages
assert len(ctx.context_messages["rag"]) == 1
assert ctx.context_messages["rag"][0].text == "hello"
def test_extend_messages_appends_to_existing(self) -> None:
ctx = SessionContext(input_messages=[])
msg1 = ChatMessage(role="user", contents=["first"])
msg2 = ChatMessage(role="user", contents=["second"])
ctx.extend_messages("src", [msg1])
ctx.extend_messages("src", [msg2])
assert len(ctx.context_messages["src"]) == 2
def test_extend_messages_preserves_source_order(self) -> None:
ctx = SessionContext(input_messages=[])
ctx.extend_messages("a", [ChatMessage(role="user", contents=["a"])])
ctx.extend_messages("b", [ChatMessage(role="user", contents=["b"])])
ctx.extend_messages("c", [ChatMessage(role="user", contents=["c"])])
assert list(ctx.context_messages.keys()) == ["a", "b", "c"]
def test_extend_messages_sets_attribution(self) -> None:
ctx = SessionContext(input_messages=[])
msg = ChatMessage(role="system", contents=["context"])
ctx.extend_messages("rag", [msg])
stored = ctx.context_messages["rag"][0]
assert stored.additional_properties["_attribution"] == {"source_id": "rag"}
# Original message is not mutated
assert "_attribution" not in msg.additional_properties
def test_extend_messages_does_not_overwrite_existing_attribution(self) -> None:
ctx = SessionContext(input_messages=[])
msg = ChatMessage(
role="system", contents=["context"], additional_properties={"_attribution": {"source_id": "custom"}}
)
ctx.extend_messages("rag", [msg])
stored = ctx.context_messages["rag"][0]
assert stored.additional_properties["_attribution"] == {"source_id": "custom"}
def test_extend_messages_copies_messages(self) -> None:
ctx = SessionContext(input_messages=[])
msg = ChatMessage(role="user", contents=["hello"])
ctx.extend_messages("src", [msg])
stored = ctx.context_messages["src"][0]
assert stored is not msg
assert stored.text == "hello"
# Mutating stored copy does not affect original
stored.additional_properties["extra"] = True
assert "extra" not in msg.additional_properties
def test_extend_messages_sender_sets_source_type(self) -> None:
class MyProvider:
source_id = "rag"
ctx = SessionContext(input_messages=[])
msg = ChatMessage(role="system", contents=["ctx"])
ctx.extend_messages(MyProvider(), [msg])
stored = ctx.context_messages["rag"][0]
assert stored.additional_properties["_attribution"] == {"source_id": "rag", "source_type": "MyProvider"}
def test_extend_instructions_string(self) -> None:
ctx = SessionContext(input_messages=[])
ctx.extend_instructions("sys", "Be helpful")
assert ctx.instructions == ["Be helpful"]
def test_extend_instructions_sequence(self) -> None:
ctx = SessionContext(input_messages=[])
ctx.extend_instructions("sys", ["Be helpful", "Be concise"])
assert ctx.instructions == ["Be helpful", "Be concise"]
def test_get_messages_all(self) -> None:
ctx = SessionContext(input_messages=[])
ctx.extend_messages("a", [ChatMessage(role="user", contents=["a"])])
ctx.extend_messages("b", [ChatMessage(role="user", contents=["b"])])
result = ctx.get_messages()
assert len(result) == 2
assert result[0].text == "a"
assert result[1].text == "b"
def test_get_messages_filter_sources(self) -> None:
ctx = SessionContext(input_messages=[])
ctx.extend_messages("a", [ChatMessage(role="user", contents=["a"])])
ctx.extend_messages("b", [ChatMessage(role="user", contents=["b"])])
result = ctx.get_messages(sources=["a"])
assert len(result) == 1
assert result[0].text == "a"
def test_get_messages_exclude_sources(self) -> None:
ctx = SessionContext(input_messages=[])
ctx.extend_messages("a", [ChatMessage(role="user", contents=["a"])])
ctx.extend_messages("b", [ChatMessage(role="user", contents=["b"])])
result = ctx.get_messages(exclude_sources=["a"])
assert len(result) == 1
assert result[0].text == "b"
def test_get_messages_include_input(self) -> None:
input_msg = ChatMessage(role="user", contents=["input"])
ctx = SessionContext(input_messages=[input_msg])
ctx.extend_messages("a", [ChatMessage(role="user", contents=["context"])])
result = ctx.get_messages(include_input=True)
assert len(result) == 2
assert result[1].text == "input"
def test_get_messages_include_response(self) -> None:
from agent_framework import AgentResponse
ctx = SessionContext(input_messages=[])
ctx._response = AgentResponse(messages=[ChatMessage(role="assistant", contents=["reply"])])
result = ctx.get_messages(include_response=True)
assert len(result) == 1
assert result[0].text == "reply"
def test_response_readonly(self) -> None:
ctx = SessionContext(input_messages=[])
assert ctx.response is None
# Can set via _response internally
from agent_framework import AgentResponse
resp = AgentResponse(messages=[])
ctx._response = resp
assert ctx.response is resp
# ---------------------------------------------------------------------------
# BaseContextProvider tests
# ---------------------------------------------------------------------------
class TestContextProviderBase:
def test_source_id_required(self) -> None:
provider = BaseContextProvider(source_id="test")
assert provider.source_id == "test"
async def test_before_run_is_noop(self) -> None:
provider = BaseContextProvider(source_id="test")
session = AgentSession()
ctx = SessionContext(input_messages=[])
# Should not raise
await provider.before_run(agent=None, session=session, context=ctx, state={}) # type: ignore[arg-type]
async def test_after_run_is_noop(self) -> None:
provider = BaseContextProvider(source_id="test")
session = AgentSession()
ctx = SessionContext(input_messages=[])
await provider.after_run(agent=None, session=session, context=ctx, state={}) # type: ignore[arg-type]
# ---------------------------------------------------------------------------
# BaseHistoryProvider tests
# ---------------------------------------------------------------------------
class ConcreteHistoryProvider(BaseHistoryProvider):
"""Concrete test implementation."""
def __init__(self, source_id: str, stored_messages: list[ChatMessage] | None = None, **kwargs) -> None:
super().__init__(source_id, **kwargs)
self.stored: list[ChatMessage] = []
self._stored_messages = stored_messages or []
async def get_messages(self, session_id: str | None, **kwargs) -> list[ChatMessage]:
return list(self._stored_messages)
async def save_messages(self, session_id: str | None, messages: Sequence[ChatMessage], **kwargs) -> None:
self.stored.extend(messages)
class TestHistoryProviderBase:
def test_default_flags(self) -> None:
provider = ConcreteHistoryProvider("mem")
assert provider.load_messages is True
assert provider.store_outputs is True
assert provider.store_inputs is True
assert provider.store_context_messages is False
assert provider.store_context_from is None
def test_custom_flags(self) -> None:
provider = ConcreteHistoryProvider(
"audit",
load_messages=False,
store_inputs=False,
store_context_messages=True,
store_context_from={"rag"},
)
assert provider.load_messages is False
assert provider.store_inputs is False
assert provider.store_context_messages is True
assert provider.store_context_from == {"rag"}
async def test_before_run_loads_messages(self) -> None:
msgs = [ChatMessage(role="user", contents=["history"])]
provider = ConcreteHistoryProvider("mem", stored_messages=msgs)
session = AgentSession()
ctx = SessionContext(session_id="s1", input_messages=[])
await provider.before_run(agent=None, session=session, context=ctx, state={}) # type: ignore[arg-type]
assert len(ctx.context_messages["mem"]) == 1
assert ctx.context_messages["mem"][0].text == "history"
async def test_after_run_stores_inputs_and_responses(self) -> None:
from agent_framework import AgentResponse
provider = ConcreteHistoryProvider("mem")
session = AgentSession()
input_msg = ChatMessage(role="user", contents=["hello"])
resp_msg = ChatMessage(role="assistant", contents=["hi"])
ctx = SessionContext(session_id="s1", input_messages=[input_msg])
ctx._response = AgentResponse(messages=[resp_msg])
await provider.after_run(agent=None, session=session, context=ctx, state={}) # type: ignore[arg-type]
assert len(provider.stored) == 2
assert provider.stored[0].text == "hello"
assert provider.stored[1].text == "hi"
async def test_after_run_skips_inputs_when_disabled(self) -> None:
from agent_framework import AgentResponse
provider = ConcreteHistoryProvider("mem", store_inputs=False)
ctx = SessionContext(session_id="s1", input_messages=[ChatMessage(role="user", contents=["hello"])])
ctx._response = AgentResponse(messages=[ChatMessage(role="assistant", contents=["hi"])])
await provider.after_run(agent=None, session=AgentSession(), context=ctx, state={}) # type: ignore[arg-type]
assert len(provider.stored) == 1
assert provider.stored[0].text == "hi"
async def test_after_run_skips_responses_when_disabled(self) -> None:
from agent_framework import AgentResponse
provider = ConcreteHistoryProvider("mem", store_outputs=False)
ctx = SessionContext(session_id="s1", input_messages=[ChatMessage(role="user", contents=["hello"])])
ctx._response = AgentResponse(messages=[ChatMessage(role="assistant", contents=["hi"])])
await provider.after_run(agent=None, session=AgentSession(), context=ctx, state={}) # type: ignore[arg-type]
assert len(provider.stored) == 1
assert provider.stored[0].text == "hello"
async def test_after_run_stores_context_messages(self) -> None:
from agent_framework import AgentResponse
provider = ConcreteHistoryProvider("audit", load_messages=False, store_context_messages=True)
ctx = SessionContext(session_id="s1", input_messages=[ChatMessage(role="user", contents=["hello"])])
ctx.extend_messages("rag", [ChatMessage(role="system", contents=["context"])])
ctx._response = AgentResponse(messages=[ChatMessage(role="assistant", contents=["hi"])])
await provider.after_run(agent=None, session=AgentSession(), context=ctx, state={}) # type: ignore[arg-type]
# Should store: context from rag + input + response
texts = [m.text for m in provider.stored]
assert "context" in texts
assert "hello" in texts
assert "hi" in texts
async def test_after_run_stores_context_from_specific_sources(self) -> None:
from agent_framework import AgentResponse
provider = ConcreteHistoryProvider(
"audit", load_messages=False, store_context_messages=True, store_context_from={"rag"}
)
ctx = SessionContext(session_id="s1", input_messages=[])
ctx.extend_messages("rag", [ChatMessage(role="system", contents=["rag-context"])])
ctx.extend_messages("other", [ChatMessage(role="system", contents=["other-context"])])
ctx._response = AgentResponse(messages=[])
await provider.after_run(agent=None, session=AgentSession(), context=ctx, state={}) # type: ignore[arg-type]
texts = [m.text for m in provider.stored]
assert "rag-context" in texts
assert "other-context" not in texts
# ---------------------------------------------------------------------------
# AgentSession tests
# ---------------------------------------------------------------------------
class TestAgentSession:
def test_auto_generates_session_id(self) -> None:
session = AgentSession()
assert session.session_id is not None
assert len(session.session_id) > 0
def test_custom_session_id(self) -> None:
session = AgentSession(session_id="custom-123")
assert session.session_id == "custom-123"
def test_state_starts_empty(self) -> None:
session = AgentSession()
assert session.state == {}
def test_service_session_id(self) -> None:
session = AgentSession(service_session_id="svc-456")
assert session.service_session_id == "svc-456"
def test_to_dict(self) -> None:
session = AgentSession(session_id="s1", service_session_id="svc1")
session.state = {"key": "value"}
d = session.to_dict()
assert d["type"] == "session"
assert d["session_id"] == "s1"
assert d["service_session_id"] == "svc1"
assert d["state"] == {"key": "value"}
def test_from_dict(self) -> None:
data = {
"type": "session",
"session_id": "s1",
"service_session_id": "svc1",
"state": {"key": "value"},
}
session = AgentSession.from_dict(data)
assert session.session_id == "s1"
assert session.service_session_id == "svc1"
assert session.state == {"key": "value"}
def test_roundtrip(self) -> None:
session = AgentSession(session_id="rt-1")
session.state = {"messages": ["a", "b"], "count": 42}
json_str = json.dumps(session.to_dict())
restored = AgentSession.from_dict(json.loads(json_str))
assert restored.session_id == "rt-1"
assert restored.state == {"messages": ["a", "b"], "count": 42}
def test_from_dict_missing_state(self) -> None:
data = {"session_id": "s1"}
session = AgentSession.from_dict(data)
assert session.state == {}
# ---------------------------------------------------------------------------
# InMemoryHistoryProvider tests
# ---------------------------------------------------------------------------
class TestInMemoryHistoryProvider:
async def test_empty_state_returns_no_messages(self) -> None:
provider = InMemoryHistoryProvider("memory")
session = AgentSession()
ctx = SessionContext(session_id="s1", input_messages=[])
await provider.before_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type]
assert ctx.context_messages.get("memory", []) == []
async def test_stores_and_loads_messages(self) -> None:
from agent_framework import AgentResponse
provider = InMemoryHistoryProvider("memory")
session = AgentSession()
# First run: send input, get response
input_msg = ChatMessage(role="user", contents=["hello"])
resp_msg = ChatMessage(role="assistant", contents=["hi there"])
ctx1 = SessionContext(session_id="s1", input_messages=[input_msg])
await provider.before_run(agent=None, session=session, context=ctx1, state=session.state) # type: ignore[arg-type]
ctx1._response = AgentResponse(messages=[resp_msg])
await provider.after_run(agent=None, session=session, context=ctx1, state=session.state) # type: ignore[arg-type]
# Second run: should load previous messages
ctx2 = SessionContext(session_id="s1", input_messages=[ChatMessage(role="user", contents=["again"])])
await provider.before_run(agent=None, session=session, context=ctx2, state=session.state) # type: ignore[arg-type]
loaded = ctx2.context_messages.get("memory", [])
assert len(loaded) == 2
assert loaded[0].text == "hello"
assert loaded[1].text == "hi there"
async def test_state_is_serializable(self) -> None:
from agent_framework import AgentResponse
provider = InMemoryHistoryProvider("memory")
session = AgentSession()
input_msg = ChatMessage(role="user", contents=["test"])
ctx = SessionContext(session_id="s1", input_messages=[input_msg])
await provider.before_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type]
ctx._response = AgentResponse(messages=[ChatMessage(role="assistant", contents=["reply"])])
await provider.after_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type]
# State contains ChatMessage objects (not dicts)
assert isinstance(session.state["memory"]["messages"][0], ChatMessage)
# to_dict() serializes them via SerializationProtocol
session_dict = session.to_dict()
json_str = json.dumps(session_dict)
assert json_str # no error
# Round-trip through session serialization restores ChatMessage objects
restored = AgentSession.from_dict(json.loads(json_str))
assert isinstance(restored.state["memory"]["messages"][0], ChatMessage)
assert restored.state["memory"]["messages"][0].text == "test"
assert restored.state["memory"]["messages"][1].text == "reply"
async def test_source_id_attribution(self) -> None:
provider = InMemoryHistoryProvider("custom-source")
assert provider.source_id == "custom-source"
ctx = SessionContext(session_id="s1", input_messages=[])
ctx.extend_messages("custom-source", [ChatMessage(role="user", contents=["test"])])
assert "custom-source" in ctx.context_messages