Python: feat: cross-session origin attribution on context messages (#7041)
* Python: feat: cross-session origin attribution on context messages Add an optional origin_session_id parameter to SessionContext.extend_messages that propagates into the existing _attribution payload on Message.additional_properties. Downstream context observers can use it to detect when a provider injects content stored under a different session than the requesting one. Populate the field from the harness memory consolidation pipeline (_harness/_memory.py) when injected topics include contributions from sessions other than the current one. Add a self-contained sample observer under samples/02-agents/context_providers/cross_session_observer.py demonstrating how to subscribe to the signal. Backward-compatible: omitting the parameter preserves the existing attribution shape exactly. Tests added in test_sessions.py and test_harness_memory.py cover the new parameter, the harness cross-session case, and the same-session case. Motivated by Dai et al., Stateful Agent Backdoor (arXiv:2605.06158, May 2026), which specifically surveys MAF in section 6.1 / Table 10. See #5914 for design discussion. Surfaced during independent audit conducted by @finnoybu (Ken Tannenbaum, AEGIS Initiative); [MEDIUM, python/packages/core]. * Address cross-session attribution review feedback * Address follow-up review feedback * Address follow-up review comments * Address origin attribution review feedback --------- Co-authored-by: finnoybu <21694570+finnoybu@users.noreply.github.com>
This commit is contained in:
@@ -75,7 +75,8 @@ agent_framework/
|
||||
|
||||
- **`AgentSession`** - Manages conversation state and session metadata
|
||||
- **`ServiceSessionId`** - Mapping alias for structured service-owned continuation handles used in `AgentSession.service_session_id`
|
||||
- **`SessionContext`** - Context object for session-scoped data during agent runs
|
||||
- **`SessionContext`** - Context object for session-scoped data during agent runs. `extend_messages(...)` can attach
|
||||
ordered, deduplicated `origin_session_ids` attribution when a provider injects content from other sessions.
|
||||
- **`ContextProvider`** - Base class for context providers (RAG, memory systems)
|
||||
- **`HistoryProvider`** - Base class for conversation history storage
|
||||
- **`InMemoryHistoryProvider`** - Built-in session-state history provider for local runs
|
||||
|
||||
@@ -1306,6 +1306,19 @@ class MemoryContextProvider(HistoryProvider):
|
||||
)
|
||||
if recent_history_messages:
|
||||
context.extend_messages(self.source_id, recent_history_messages)
|
||||
|
||||
# Surface every cross-session origin so downstream context observers
|
||||
# can distinguish injected memory from content native to the current
|
||||
# session. Loaded topic files may carry contributions from multiple
|
||||
# earlier sessions, tracked in ``MemoryTopicRecord.session_ids``.
|
||||
current_session_id = context.session_id
|
||||
cross_session_origins = [
|
||||
contributor
|
||||
for record in selected_topics
|
||||
for contributor in record.session_ids
|
||||
if contributor and contributor != current_session_id
|
||||
]
|
||||
|
||||
context.extend_messages(
|
||||
self.source_id,
|
||||
[
|
||||
@@ -1322,6 +1335,7 @@ class MemoryContextProvider(HistoryProvider):
|
||||
],
|
||||
)
|
||||
],
|
||||
origin_session_ids=cross_session_origins,
|
||||
)
|
||||
|
||||
async def after_run(
|
||||
|
||||
@@ -22,7 +22,7 @@ import uuid
|
||||
import weakref
|
||||
from abc import abstractmethod
|
||||
from base64 import urlsafe_b64encode
|
||||
from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, Sequence
|
||||
from collections.abc import AsyncIterable, Awaitable, Callable, Iterable, Mapping, Sequence
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, TypeAlias, TypeGuard, cast
|
||||
@@ -66,6 +66,17 @@ def _default_json_loads(value: str | bytes) -> Any:
|
||||
return json.loads(value)
|
||||
|
||||
|
||||
def _deduplicate_origin_session_ids(origin_session_ids: Iterable[str]) -> list[str]:
|
||||
"""Return origin session IDs in first-seen order without duplicates."""
|
||||
unique_origin_session_ids: list[str] = []
|
||||
seen_origin_session_ids: set[str] = set()
|
||||
for origin_session_id in origin_session_ids:
|
||||
if origin_session_id not in seen_origin_session_ids:
|
||||
seen_origin_session_ids.add(origin_session_id)
|
||||
unique_origin_session_ids.append(origin_session_id)
|
||||
return unique_origin_session_ids
|
||||
|
||||
|
||||
def _is_middleware_sequence(
|
||||
middleware: MiddlewareTypes | Sequence[MiddlewareTypes],
|
||||
) -> TypeGuard[Sequence[MiddlewareTypes]]:
|
||||
@@ -230,7 +241,13 @@ class SessionContext:
|
||||
"""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[Message]) -> None:
|
||||
def extend_messages(
|
||||
self,
|
||||
source: str | object,
|
||||
messages: Sequence[Message],
|
||||
*,
|
||||
origin_session_ids: Sequence[str] | None = None,
|
||||
) -> None:
|
||||
"""Add context messages from a specific source.
|
||||
|
||||
Messages are copied before attribution is added, so the caller's
|
||||
@@ -245,19 +262,56 @@ class SessionContext:
|
||||
object is passed, its class name is recorded as
|
||||
``source_type`` in the attribution.
|
||||
messages: The messages to add.
|
||||
|
||||
Keyword Args:
|
||||
origin_session_ids: Optional session IDs that originally produced
|
||||
these messages, when different from the current session. Set
|
||||
by providers that inject content stored under other sessions
|
||||
(cross-session memory). The IDs describe the contributing
|
||||
sessions for every message supplied in this call; they are not
|
||||
positionally paired with messages, and a composed message can
|
||||
have multiple origins. The values are exposed under
|
||||
``additional_properties["_attribution"]["origin_session_ids"]``
|
||||
so downstream context observers can detect cross-session
|
||||
content for governance, audit, or behavioral-analysis
|
||||
purposes. Omit (default) when content originates in the
|
||||
current session; absence of the field means that no origin
|
||||
information was supplied.
|
||||
"""
|
||||
if isinstance(source, str):
|
||||
source_id = source
|
||||
attribution: dict[str, str] = {"source_id": source_id}
|
||||
attribution: dict[str, Any] = {"source_id": source_id}
|
||||
else:
|
||||
source_id = source.source_id # type: ignore[attr-defined]
|
||||
attribution = {"source_id": source_id, "source_type": type(source).__name__}
|
||||
if origin_session_ids:
|
||||
attribution["origin_session_ids"] = _deduplicate_origin_session_ids(origin_session_ids)
|
||||
|
||||
copied: list[Message] = []
|
||||
for message in messages:
|
||||
msg_copy = copy.copy(message)
|
||||
msg_copy.additional_properties = dict(message.additional_properties)
|
||||
msg_copy.additional_properties.setdefault("_attribution", attribution)
|
||||
message_attribution = dict(attribution)
|
||||
if "origin_session_ids" in message_attribution:
|
||||
message_attribution["origin_session_ids"] = list(message_attribution["origin_session_ids"])
|
||||
existing_attribution = msg_copy.additional_properties.get("_attribution")
|
||||
if isinstance(existing_attribution, Mapping):
|
||||
merged_attribution = dict(cast(Mapping[str, Any], existing_attribution))
|
||||
for key, value in message_attribution.items():
|
||||
if key == "origin_session_ids":
|
||||
existing_origins = merged_attribution.get(key)
|
||||
if isinstance(existing_origins, Sequence) and not isinstance(existing_origins, str):
|
||||
existing_origin_values = cast(Sequence[Any], existing_origins)
|
||||
value = _deduplicate_origin_session_ids(
|
||||
[origin for origin in existing_origin_values if isinstance(origin, str)]
|
||||
+ cast(list[str], value)
|
||||
)
|
||||
merged_attribution[key] = value
|
||||
else:
|
||||
merged_attribution.setdefault(key, value)
|
||||
msg_copy.additional_properties["_attribution"] = merged_attribution
|
||||
else:
|
||||
msg_copy.additional_properties.setdefault("_attribution", message_attribution)
|
||||
copied.append(msg_copy)
|
||||
if source_id not in self.context_messages:
|
||||
self.context_messages[source_id] = []
|
||||
|
||||
@@ -505,6 +505,94 @@ async def test_memory_context_provider_recent_turns_can_skip_tool_call_groups(tm
|
||||
assert with_tools_messages[4].text == "Second final answer"
|
||||
|
||||
|
||||
async def test_memory_context_provider_marks_cross_session_origins(tmp_path) -> None:
|
||||
"""Injected memory should carry all prior session origins without duplicates.
|
||||
|
||||
Exercises the cross-session attribution surface added to support downstream observers
|
||||
detecting attacks of the class documented in Dai et al. (arXiv:2605.06158).
|
||||
"""
|
||||
session = AgentSession(session_id="session-current")
|
||||
session.state["owner_id"] = "alice"
|
||||
store = MemoryFileStore(
|
||||
tmp_path,
|
||||
owner_state_key="owner_id",
|
||||
dumps=lambda value: json.dumps(value, separators=(",", ":"), sort_keys=True),
|
||||
loads=json.loads,
|
||||
)
|
||||
updated_at = datetime(2026, 4, 21, tzinfo=timezone.utc).replace(microsecond=0).isoformat()
|
||||
store.write_topic(
|
||||
session,
|
||||
MemoryTopicRecord(
|
||||
topic="travel preferences",
|
||||
summary="Loves Oslo trips.",
|
||||
memories=["Prefers Oslo in summer."],
|
||||
updated_at=updated_at,
|
||||
session_ids=["session-current", "session-prior-1", "session-prior-2", "session-prior-1"],
|
||||
),
|
||||
source_id=DEFAULT_MEMORY_SOURCE_ID,
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
client=_MemoryHarnessClient(), # type: ignore[arg-type] # pyrefly: ignore[bad-argument-type] # ty: ignore[invalid-argument-type]
|
||||
context_providers=[MemoryContextProvider(store=store)],
|
||||
default_options=_no_store_options(),
|
||||
)
|
||||
|
||||
session_context, _ = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
|
||||
session=session,
|
||||
input_messages=[Message(role="user", contents=["Tell me about my travel preferences."])],
|
||||
)
|
||||
|
||||
memory_messages = [
|
||||
m for m in session_context.context_messages.get(DEFAULT_MEMORY_SOURCE_ID, []) if "### MEMORY.md" in m.text
|
||||
]
|
||||
assert memory_messages, "expected an injected memory block under the memory source"
|
||||
attribution: dict[str, Any] = memory_messages[0].additional_properties.get("_attribution") or {}
|
||||
assert attribution.get("origin_session_ids") == ["session-prior-1", "session-prior-2"]
|
||||
|
||||
|
||||
async def test_memory_context_provider_omits_origin_when_only_current_session(tmp_path) -> None:
|
||||
"""When all contributing topics are from the current session, attribution must NOT advertise an origin."""
|
||||
session = AgentSession(session_id="session-current")
|
||||
session.state["owner_id"] = "alice"
|
||||
store = MemoryFileStore(
|
||||
tmp_path,
|
||||
owner_state_key="owner_id",
|
||||
dumps=lambda value: json.dumps(value, separators=(",", ":"), sort_keys=True),
|
||||
loads=json.loads,
|
||||
)
|
||||
updated_at = datetime(2026, 4, 21, tzinfo=timezone.utc).replace(microsecond=0).isoformat()
|
||||
store.write_topic(
|
||||
session,
|
||||
MemoryTopicRecord(
|
||||
topic="travel preferences",
|
||||
summary="Loves Oslo trips.",
|
||||
memories=["Prefers Oslo in summer."],
|
||||
updated_at=updated_at,
|
||||
session_ids=["session-current"],
|
||||
),
|
||||
source_id=DEFAULT_MEMORY_SOURCE_ID,
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
client=_MemoryHarnessClient(), # type: ignore[arg-type] # pyrefly: ignore[bad-argument-type] # ty: ignore[invalid-argument-type]
|
||||
context_providers=[MemoryContextProvider(store=store)],
|
||||
default_options=_no_store_options(),
|
||||
)
|
||||
|
||||
session_context, _ = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
|
||||
session=session,
|
||||
input_messages=[Message(role="user", contents=["Tell me about my travel preferences."])],
|
||||
)
|
||||
|
||||
memory_messages = [
|
||||
m for m in session_context.context_messages.get(DEFAULT_MEMORY_SOURCE_ID, []) if "### MEMORY.md" in m.text
|
||||
]
|
||||
assert memory_messages
|
||||
attribution: dict[str, Any] = memory_messages[0].additional_properties.get("_attribution") or {}
|
||||
assert "origin_session_ids" not in attribution
|
||||
|
||||
|
||||
async def test_memory_context_provider_uses_explicit_consolidation_client(tmp_path) -> None:
|
||||
"""The memory provider should use the explicit consolidation client when one is configured."""
|
||||
session = AgentSession(session_id="session-1")
|
||||
|
||||
@@ -107,6 +107,92 @@ class TestSessionContext:
|
||||
stored = ctx.context_messages["rag"][0]
|
||||
assert stored.additional_properties["_attribution"] == {"source_id": "rag", "source_type": "MyProvider"}
|
||||
|
||||
def test_extend_messages_origin_session_ids_default_omits_field(self) -> None:
|
||||
ctx = SessionContext(input_messages=[])
|
||||
msg = Message(role="system", contents=["ctx"])
|
||||
ctx.extend_messages("rag", [msg])
|
||||
stored = ctx.context_messages["rag"][0]
|
||||
# Default (no origin_session_ids passed) preserves the historical attribution shape
|
||||
# so observers can distinguish "no origin info" from "explicit cross-session marker."
|
||||
assert "origin_session_ids" not in stored.additional_properties["_attribution"]
|
||||
|
||||
def test_extend_messages_origin_session_ids_recorded_on_attribution(self) -> None:
|
||||
ctx = SessionContext(session_id="current", input_messages=[])
|
||||
msg = Message(role="system", contents=["loaded from a prior session"])
|
||||
ctx.extend_messages(
|
||||
"memory_provider",
|
||||
[msg],
|
||||
origin_session_ids=["prior-session-id", "another-session", "prior-session-id"],
|
||||
)
|
||||
stored = ctx.context_messages["memory_provider"][0]
|
||||
assert stored.additional_properties["_attribution"] == {
|
||||
"source_id": "memory_provider",
|
||||
"origin_session_ids": ["prior-session-id", "another-session"],
|
||||
}
|
||||
|
||||
def test_extend_messages_origin_session_ids_with_provider_object(self) -> None:
|
||||
class MyMemoryProvider:
|
||||
source_id = "memory"
|
||||
|
||||
ctx = SessionContext(session_id="current", input_messages=[])
|
||||
msg = Message(role="assistant", contents=["consolidated memory content"])
|
||||
ctx.extend_messages(MyMemoryProvider(), [msg], origin_session_ids=["prior"])
|
||||
stored = ctx.context_messages["memory"][0]
|
||||
assert stored.additional_properties["_attribution"] == {
|
||||
"source_id": "memory",
|
||||
"source_type": "MyMemoryProvider",
|
||||
"origin_session_ids": ["prior"],
|
||||
}
|
||||
|
||||
def test_extend_messages_applies_all_origins_to_each_message(self) -> None:
|
||||
ctx = SessionContext(session_id="current", input_messages=[])
|
||||
messages = [
|
||||
Message(role="assistant", contents=["first composed memory"]),
|
||||
Message(role="assistant", contents=["second composed memory"]),
|
||||
]
|
||||
|
||||
ctx.extend_messages("memory_provider", messages, origin_session_ids=["session-a", "session-b"])
|
||||
|
||||
stored_messages = ctx.context_messages["memory_provider"]
|
||||
assert [message.additional_properties["_attribution"] for message in stored_messages] == [
|
||||
{
|
||||
"source_id": "memory_provider",
|
||||
"origin_session_ids": ["session-a", "session-b"],
|
||||
},
|
||||
{
|
||||
"source_id": "memory_provider",
|
||||
"origin_session_ids": ["session-a", "session-b"],
|
||||
},
|
||||
]
|
||||
|
||||
def test_extend_messages_adds_origin_to_existing_attribution(self) -> None:
|
||||
ctx = SessionContext(session_id="current", input_messages=[])
|
||||
msg = Message(
|
||||
role="system",
|
||||
contents=["loaded from a prior session"],
|
||||
additional_properties={
|
||||
"_attribution": {
|
||||
"source_id": "custom",
|
||||
"custom_key": "value",
|
||||
"origin_session_ids": ["existing", "prior"],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
ctx.extend_messages("memory_provider", [msg], origin_session_ids=["prior", "new"])
|
||||
|
||||
stored = ctx.context_messages["memory_provider"][0]
|
||||
assert stored.additional_properties["_attribution"] == {
|
||||
"source_id": "custom",
|
||||
"custom_key": "value",
|
||||
"origin_session_ids": ["existing", "prior", "new"],
|
||||
}
|
||||
assert msg.additional_properties["_attribution"] == {
|
||||
"source_id": "custom",
|
||||
"custom_key": "value",
|
||||
"origin_session_ids": ["existing", "prior"],
|
||||
}
|
||||
|
||||
def test_extend_instructions_string(self) -> None:
|
||||
ctx = SessionContext(input_messages=[])
|
||||
ctx.extend_instructions("sys", "Be helpful")
|
||||
|
||||
@@ -7,6 +7,7 @@ These samples demonstrate how to use context providers to enrich agent conversat
|
||||
| File / Folder | Description |
|
||||
|---------------|-------------|
|
||||
| [`simple_context_provider.py`](simple_context_provider.py) | Implement a custom context provider by extending `ContextProvider` to extract and inject structured user information across turns. |
|
||||
| [`cross_session_observer.py`](cross_session_observer.py) | Detect injected context messages whose origins differ from the current session, via the `Message.additional_properties["_attribution"]["origin_session_ids"]` field. Self-contained — no LLM credentials required. |
|
||||
| [`azure_ai_foundry_memory.py`](azure_ai_foundry_memory.py) | Use `FoundryMemoryProvider` to add semantic memory — automatically retrieves, searches, and stores memories via Microsoft Foundry. |
|
||||
| [`file_access_data_processing/`](file_access_data_processing/) | Use `FileAccessProvider` with `FileSystemAgentFileStore` to give an agent read/write/search access to a folder of CSV data files. See its own [README](file_access_data_processing/README.md). |
|
||||
| [`azure_ai_search/`](azure_ai_search/) | Retrieval Augmented Generation (RAG) with Azure AI Search in semantic and agentic modes. See its own [README](azure_ai_search/README.md). |
|
||||
@@ -15,6 +16,9 @@ These samples demonstrate how to use context providers to enrich agent conversat
|
||||
|
||||
## Prerequisites
|
||||
|
||||
**For `cross_session_observer.py`:**
|
||||
- No external dependencies; runs against in-memory `SessionContext`.
|
||||
|
||||
**For `simple_context_provider.py`:**
|
||||
- `FOUNDRY_PROJECT_ENDPOINT`: Your Microsoft Foundry project endpoint
|
||||
- `FOUNDRY_MODEL`: Model deployment name
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from typing import Any, cast
|
||||
|
||||
from agent_framework import AgentSession, ContextProvider, Message, SessionContext
|
||||
|
||||
"""This sample demonstrates how to detect cross-session memory injection.
|
||||
|
||||
When a context provider injects messages from a different ``session_id`` than
|
||||
the requesting one — the legitimate cross-session memory use case (consolidated
|
||||
memories, Mem0 with default scope, shared knowledge bases) — the framework
|
||||
records the originating sessions under
|
||||
``message.additional_properties["_attribution"]["origin_session_ids"]``.
|
||||
|
||||
Downstream context observers can subscribe to this signal for governance,
|
||||
audit, and behavioral analysis purposes. This is useful for defending against
|
||||
the stateful-agent-backdoor attack class documented in Dai et al.,
|
||||
arXiv:2605.06158, in which an adversary chains sub-backdoors across sessions
|
||||
under permission isolation via persisted memory state.
|
||||
|
||||
The sample is self-contained: it constructs ``SessionContext`` directly and
|
||||
invokes provider lifecycle methods manually, so no LLM credentials are
|
||||
required to run it.
|
||||
"""
|
||||
|
||||
|
||||
class CrossSessionObserver(ContextProvider):
|
||||
"""Detect injected context messages whose origin differs from the current session.
|
||||
|
||||
Subscribes via the standard ``ContextProvider`` pipeline. In ``before_run``,
|
||||
walks the accumulated context messages and invokes a user-supplied
|
||||
callback for each message whose ``_attribution["origin_session_ids"]``
|
||||
contains one or more sessions other than the current ``session_id``.
|
||||
|
||||
The callback receives the source_id that injected the content, the
|
||||
originating session IDs, the current session_id, and the message itself.
|
||||
Use it to log, alert, increment metrics, or enforce policy — the observer
|
||||
itself only surfaces the signal, leaving the response policy to the caller.
|
||||
"""
|
||||
|
||||
DEFAULT_SOURCE_ID = "cross_session_observer"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
on_cross_session_access: Callable[[str, Sequence[str], str | None, Message], None],
|
||||
*,
|
||||
source_id: str = DEFAULT_SOURCE_ID,
|
||||
) -> None:
|
||||
"""Initialize the observer.
|
||||
|
||||
Args:
|
||||
on_cross_session_access: Callback invoked for each detected
|
||||
cross-session message. Signature is
|
||||
``(source_id, origin_session_ids, current_session_id, message)``.
|
||||
source_id: Unique identifier for this observer instance.
|
||||
"""
|
||||
super().__init__(source_id)
|
||||
self._on_cross_session_access = on_cross_session_access
|
||||
|
||||
async def before_run(
|
||||
self,
|
||||
*,
|
||||
agent: Any,
|
||||
session: AgentSession | None,
|
||||
context: SessionContext,
|
||||
state: dict[str, Any],
|
||||
) -> None:
|
||||
"""Inspect accumulated context messages for cross-session origin."""
|
||||
current_session_id = context.session_id
|
||||
for source_id, messages in context.context_messages.items():
|
||||
if source_id == self.source_id:
|
||||
continue
|
||||
for message in messages:
|
||||
attribution_raw = message.additional_properties.get("_attribution")
|
||||
if not isinstance(attribution_raw, Mapping):
|
||||
continue
|
||||
attribution = cast(Mapping[str, Any], attribution_raw)
|
||||
origins = attribution.get("origin_session_ids")
|
||||
if not isinstance(origins, Sequence) or isinstance(origins, str):
|
||||
continue
|
||||
cross_session_origins = [
|
||||
origin for origin in origins if isinstance(origin, str) and origin != current_session_id
|
||||
]
|
||||
if cross_session_origins:
|
||||
self._on_cross_session_access(source_id, cross_session_origins, current_session_id, message)
|
||||
|
||||
|
||||
def _on_detected(source_id: str, origins: Sequence[str], current: str | None, message: Message) -> None:
|
||||
"""Sample callback that logs cross-session detections to stdout."""
|
||||
preview = " ".join(message.text.split())[:80]
|
||||
print(
|
||||
f"[cross-session detected] source={source_id!r} "
|
||||
f"origin_sessions={list(origins)!r} current_session={current!r} "
|
||||
f"preview={preview!r}"
|
||||
)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Demonstrate the observer firing on cross-session injection."""
|
||||
observer = CrossSessionObserver(_on_detected)
|
||||
|
||||
# --- Case 1: same-session injection (observer should be silent) ---
|
||||
same_session_context = SessionContext(
|
||||
session_id="session-A",
|
||||
input_messages=[Message("user", ["What did we discuss last time?"])],
|
||||
)
|
||||
# Simulate a same-session provider injecting same-session history. Omitting
|
||||
# origin_session_ids means "no origin info"; observers treat it as equivalent
|
||||
# to same-session for backward compatibility.
|
||||
same_session_context.extend_messages(
|
||||
"history_provider",
|
||||
[Message("assistant", ["We talked about Q3 revenue projections."])],
|
||||
)
|
||||
await observer.before_run(
|
||||
agent=None,
|
||||
session=None,
|
||||
context=same_session_context,
|
||||
state={},
|
||||
)
|
||||
print("--- Same-session case complete (no detections expected above) ---\n")
|
||||
|
||||
# --- Case 2: cross-session injection (observer should fire) ---
|
||||
cross_session_context = SessionContext(
|
||||
session_id="session-B",
|
||||
input_messages=[Message("user", ["Continue from where we left off."])],
|
||||
)
|
||||
# Simulate a cross-session memory provider injecting content originally
|
||||
# written in sessions A and C while we're now running in session B.
|
||||
cross_session_context.extend_messages(
|
||||
"memory_provider",
|
||||
[Message("assistant", ["Remember: API key for prod is <REDACTED> (from prior sessions)."])],
|
||||
origin_session_ids=["session-A", "session-C"],
|
||||
)
|
||||
await observer.before_run(
|
||||
agent=None,
|
||||
session=None,
|
||||
context=cross_session_context,
|
||||
state={},
|
||||
)
|
||||
print("--- Cross-session case complete (one detection expected above) ---")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
||||
|
||||
"""
|
||||
Sample output:
|
||||
--- Same-session case complete (no detections expected above) ---
|
||||
|
||||
[cross-session detected] source='memory_provider' origin_sessions=['session-A', 'session-C'] \
|
||||
current_session='session-B' preview='Remember: API key for prod is <REDACTED> (from prior sessions).'
|
||||
--- Cross-session case complete (one detection expected above) ---
|
||||
"""
|
||||
@@ -54,9 +54,7 @@ async def main():
|
||||
"Why is the sky blue?",
|
||||
]
|
||||
|
||||
with get_tracer().start_as_current_span(
|
||||
"Scenario: Agent Chat", kind=SpanKind.CLIENT
|
||||
) as current_span:
|
||||
with get_tracer().start_as_current_span("Scenario: Agent Chat", kind=SpanKind.CLIENT) as current_span:
|
||||
print(f"Trace ID: {format_trace_id(current_span.get_span_context().trace_id)}")
|
||||
|
||||
agent = Agent(
|
||||
|
||||
Reference in New Issue
Block a user