Files
microsoft--agent-framework/python/scripts/session_serialization_benchmark.py
Eduard van Valkenburg 28389df805 Python: Move SessionStore to core and persist Foundry Responses sessions (#7306)
* Python: Move session persistence into core

Move SessionStore and durable msgspec-backed storage into core, restore sessions in Foundry Responses hosting with per-user isolation, and document the serialization design.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 3e5c81ad-75e8-4e92-a883-8bbd676c6c8c

* Python: Address session persistence review feedback

Harden scoped file paths and corruption recovery, preserve session serialization compatibility, clarify dependency placement, and add reproducible benchmark evidence.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 3e5c81ad-75e8-4e92-a883-8bbd676c6c8c

* Python: Preserve session snapshot compatibility

Deep-copy in-memory session writes and retain existing Telegram session keys so stored conversations continue resolving.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 3e5c81ad-75e8-4e92-a883-8bbd676c6c8c

* Python: Simplify Foundry session isolation

Add experimental FoundrySessionStore backed by Agent Server request context, remove resolver plumbing, and centralize v2 user isolation for sessions, checkpoints, and approvals.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 3e5c81ad-75e8-4e92-a883-8bbd676c6c8c

* Python: Reduce Foundry session helper layering

Inline the single-use request user accessor while keeping separate context validation, fingerprint, and directory helpers for their distinct callers.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 3e5c81ad-75e8-4e92-a883-8bbd676c6c8c

* Python: Clarify Foundry request context validation

Separate fail-fast request validation from context retrieval so Responses no longer appears to discard a returned context.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 3e5c81ad-75e8-4e92-a883-8bbd676c6c8c

* Python: Share Foundry request context helpers

Move protocol validation and user-scope derivation into a dedicated request-context module, leaving the session-store module focused on storage.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 3e5c81ad-75e8-4e92-a883-8bbd676c6c8c

* Restore Foundry checkpoint storage paths

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 3e5c81ad-75e8-4e92-a883-8bbd676c6c8c

* Simplify Foundry session storage paths

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 3e5c81ad-75e8-4e92-a883-8bbd676c6c8c

* Persist Foundry sessions under hosted home

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 3e5c81ad-75e8-4e92-a883-8bbd676c6c8c

* Make hosted path test platform independent

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 3e5c81ad-75e8-4e92-a883-8bbd676c6c8c

* Address session persistence review feedback

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 3e5c81ad-75e8-4e92-a883-8bbd676c6c8c

* Isolate Foundry session path handling

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 3e5c81ad-75e8-4e92-a883-8bbd676c6c8c

* Clarify Foundry session path terminology

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 3e5c81ad-75e8-4e92-a883-8bbd676c6c8c

* Align Foundry sessions with Responses continuity

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 3e5c81ad-75e8-4e92-a883-8bbd676c6c8c

* Finalize Foundry Responses session persistence

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 3e5c81ad-75e8-4e92-a883-8bbd676c6c8c

* Add session store feature usage telemetry

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 3e5c81ad-75e8-4e92-a883-8bbd676c6c8c

* Fix hosted per-call history persistence

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 3e5c81ad-75e8-4e92-a883-8bbd676c6c8c

---------

Copilot-Session: 3e5c81ad-75e8-4e92-a883-8bbd676c6c8c
2026-07-30 13:04:08 +00:00

583 lines
18 KiB
Python

# Copyright (c) Microsoft. All rights reserved.
"""Compare AgentSession serialization formats using realistic Agent Framework objects.
Run from ``python/``:
uv run --with orjson python scripts/session_serialization_benchmark.py
The benchmark compares:
- Standard-library JSON
- orjson
- Pydantic `model_dump_json` / `model_validate_json`
- msgspec JSON
- msgspec MessagePack (binary)
- An AgentSession-shaped msgspec Struct using JSON
- An AgentSession-shaped msgspec Struct using MessagePack
The first five measurements include AgentSession ``to_dict`` / ``from_dict``
conversion. The Struct variants instead map session fields directly and route
only the dynamic state dictionary through the same registry helpers. JSON and
MessagePack payloads are written to disk to report actual file size and cached
filesystem round-trip latency.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import math
import statistics
import tempfile
import time
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal
import msgspec
import orjson
from agent_framework import (
AgentSession,
Content,
InMemoryHistoryProvider,
Message,
register_state_type,
)
from agent_framework._sessions import ( # pyright: ignore[reportPrivateUsage]
_deserialize_state,
_serialize_state,
_validate_durable_state_value,
)
from pydantic import BaseModel
@dataclass(slots=True)
class BenchmarkClassState:
"""Representative application-defined state class."""
item_id: int
label: str
scores: list[float]
attributes: dict[str, str]
TYPE = "benchmark_class_state"
def to_dict(self) -> dict[str, Any]:
"""Serialize this state object."""
return {
"item_id": self.item_id,
"label": self.label,
"scores": self.scores,
"attributes": self.attributes,
}
@classmethod
def from_dict(cls, value: Mapping[str, Any]) -> BenchmarkClassState:
"""Restore this state object."""
return cls(
item_id=int(value["item_id"]),
label=str(value["label"]),
scores=[float(score) for score in value["scores"]],
attributes={str(key): str(item) for key, item in value["attributes"].items()},
)
class BenchmarkProfileState(BaseModel):
"""Representative explicitly registered Pydantic state."""
user_id: str
preferences: dict[str, str]
counters: list[int]
class PydanticSessionSnapshot(BaseModel):
"""Typed Pydantic representation used by the Pydantic benchmark."""
type: Literal["session"]
session_id: str
service_session_id: str | dict[str, Any] | None = None
state: dict[str, Any]
class StructStatePayload:
"""Opaque wrapper that forces msgspec to invoke the state registry hook."""
__slots__ = ("value",)
def __init__(self, value: dict[str, Any]) -> None:
self.value = value
class StructAgentSession(msgspec.Struct):
"""AgentSession-shaped msgspec Struct used by the direct benchmark."""
session_id: str
service_session_id: str | dict[str, Any] | None
state: StructStatePayload
version: Literal["1.0"] = "1.0"
register_state_type(BenchmarkClassState)
register_state_type(BenchmarkProfileState, type_id="benchmark_profile_state")
@dataclass(frozen=True, slots=True)
class Codec:
"""One benchmarked serialization codec."""
name: str
suffix: str
encode: Callable[[AgentSession], bytes]
decode: Callable[[bytes], AgentSession]
@dataclass(frozen=True, slots=True)
class BenchmarkResult:
"""Collected timing and size metrics for one codec."""
codec: str
file_size: int
encode_median_ms: float
encode_p95_ms: float
decode_median_ms: float
decode_p95_ms: float
roundtrip_median_ms: float
roundtrip_p95_ms: float
disk_roundtrip_median_ms: float
disk_roundtrip_p95_ms: float
def _stdlib_json_encode(value: dict[str, Any]) -> bytes:
return json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
def _stdlib_json_decode(value: bytes) -> Any:
return json.loads(value)
def _pydantic_json_encode(value: dict[str, Any]) -> bytes:
snapshot = PydanticSessionSnapshot.model_validate(value)
return snapshot.model_dump_json().encode("utf-8")
def _pydantic_json_decode(value: bytes) -> Any:
return PydanticSessionSnapshot.model_validate_json(value).model_dump()
def _encode_via_dict(
session: AgentSession,
encoder: Callable[[dict[str, Any]], bytes],
) -> bytes:
return encoder(session.to_dict())
def _decode_via_dict(
payload: bytes,
decoder: Callable[[bytes], Any],
*,
codec_name: str,
) -> AgentSession:
decoded = decoder(payload)
if not isinstance(decoded, Mapping):
raise TypeError(f"{codec_name} decoded the session to {type(decoded).__name__}, not a mapping")
return AgentSession.from_dict(dict(decoded))
def _struct_enc_hook(value: Any) -> Any:
if isinstance(value, StructStatePayload):
serialized = _serialize_state(value.value)
_validate_durable_state_value(serialized, path="state")
return serialized
raise NotImplementedError(f"Unsupported type: {type(value).__name__}")
def _struct_dec_hook(target_type: type[Any], value: Any) -> Any:
if target_type is StructStatePayload:
if not isinstance(value, Mapping):
raise TypeError("Struct state payload must decode to a mapping")
return StructStatePayload(_deserialize_state(dict(value)))
raise NotImplementedError(f"Unsupported type: {target_type.__name__}")
STRUCT_JSON_ENCODER = msgspec.json.Encoder(enc_hook=_struct_enc_hook)
STRUCT_JSON_DECODER = msgspec.json.Decoder(StructAgentSession, dec_hook=_struct_dec_hook)
STRUCT_MSGPACK_ENCODER = msgspec.msgpack.Encoder(enc_hook=_struct_enc_hook)
STRUCT_MSGPACK_DECODER = msgspec.msgpack.Decoder(StructAgentSession, dec_hook=_struct_dec_hook)
def _to_struct(session: AgentSession) -> StructAgentSession:
service_session_id = session.service_session_id
return StructAgentSession(
session_id=session.session_id,
service_session_id=dict(service_session_id) if isinstance(service_session_id, Mapping) else service_session_id,
state=StructStatePayload(session.state),
)
def _from_struct(snapshot: StructAgentSession) -> AgentSession:
session = AgentSession(
session_id=snapshot.session_id,
service_session_id=snapshot.service_session_id,
)
session.state = snapshot.state.value
return session
def _dict_codec(
*,
name: str,
suffix: str,
encoder: Callable[[dict[str, Any]], bytes],
decoder: Callable[[bytes], Any],
) -> Codec:
return Codec(
name=name,
suffix=suffix,
encode=lambda session: _encode_via_dict(session, encoder),
decode=lambda payload: _decode_via_dict(payload, decoder, codec_name=name),
)
CODECS = (
_dict_codec(
name="stdlib-json",
suffix=".stdlib.json",
encoder=_stdlib_json_encode,
decoder=_stdlib_json_decode,
),
_dict_codec(
name="orjson",
suffix=".orjson.json",
encoder=orjson.dumps,
decoder=orjson.loads,
),
_dict_codec(
name="pydantic-json",
suffix=".pydantic.json",
encoder=_pydantic_json_encode,
decoder=_pydantic_json_decode,
),
_dict_codec(
name="msgspec-json",
suffix=".msgspec.json",
encoder=msgspec.json.encode,
decoder=msgspec.json.decode,
),
_dict_codec(
name="msgspec-binary",
suffix=".msgspec.msgpack",
encoder=msgspec.msgpack.encode,
decoder=msgspec.msgpack.decode,
),
Codec(
name="agent-struct-json",
suffix=".agent-struct.json",
encode=lambda session: STRUCT_JSON_ENCODER.encode(_to_struct(session)),
decode=lambda payload: _from_struct(STRUCT_JSON_DECODER.decode(payload)),
),
Codec(
name="agent-struct-binary",
suffix=".agent-struct.msgpack",
encode=lambda session: STRUCT_MSGPACK_ENCODER.encode(_to_struct(session)),
decode=lambda payload: _from_struct(STRUCT_MSGPACK_DECODER.decode(payload)),
),
)
def _build_messages(count: int, text_bytes: int) -> list[Message]:
"""Build a varied conversation dominated by Message objects."""
padding = "x" * max(0, text_bytes - 80)
messages: list[Message] = []
for index in range(count):
role = "user" if index % 2 == 0 else "assistant"
contents = [
Content.from_text(
text=(
f"Message {index}: benchmark conversation text with Unicode café 東京. "
f"Payload={padding}"
)
)
]
if index % 20 == 5:
contents.append(
Content.from_function_call(
call_id=f"call_{index}",
name="lookup",
arguments={"query": f"item-{index}", "limit": 5},
)
)
elif index % 20 == 6:
contents.append(
Content.from_function_result(
call_id=f"call_{index - 1}",
result={"items": [f"result-{index}-{item}" for item in range(5)]},
)
)
messages.append(
Message(
role=role,
contents=contents,
author_name=f"participant-{index % 7}",
additional_properties={
"sequence": index,
"trace": {"span": f"span-{index}", "sampled": index % 3 == 0},
},
)
)
return messages
async def build_large_session(
*,
message_count: int,
class_state_count: int,
text_bytes: int,
) -> AgentSession:
"""Build a large session through InMemoryHistoryProvider."""
session = AgentSession(
session_id="serialization-benchmark",
service_session_id={
"conversation_id": "benchmark-conversation",
"response_id": "benchmark-response",
},
)
history = InMemoryHistoryProvider()
history_state = session.state.setdefault(history.source_id, {})
await history.save_messages(
session.session_id,
_build_messages(message_count, text_bytes),
state=history_state,
)
session.state["plain"] = {
"flags": [True, False, None],
"numbers": list(range(class_state_count)),
"nested": {
f"key_{index}": {
"value": index,
"text": f"plain-state-{index}",
"tags": [f"tag-{item}" for item in range(5)],
}
for index in range(class_state_count)
},
}
session.state["classes"] = [
BenchmarkClassState(
item_id=index,
label=f"class-state-{index}",
scores=[index / 10, index / 20, index / 30],
attributes={
"category": f"category-{index % 11}",
"partition": f"partition-{index % 17}",
},
)
for index in range(class_state_count)
]
session.state["profiles"] = [
BenchmarkProfileState(
user_id=f"user-{index}",
preferences={
"language": "en",
"theme": "dark" if index % 2 else "light",
"timezone": f"UTC+{index % 12}",
},
counters=[index, index * 2, index * 3],
)
for index in range(max(1, class_state_count // 10))
]
return session
def _percentile(values: list[int], percentile: float) -> float:
ordered = sorted(values)
index = max(0, math.ceil(len(ordered) * percentile) - 1)
return ordered[index] / 1_000_000
def _median_ms(values: list[int]) -> float:
return statistics.median(values) / 1_000_000
def _time_ns(function: Callable[[], Any], iterations: int) -> list[int]:
timings: list[int] = []
for _ in range(iterations):
started = time.perf_counter_ns()
function()
timings.append(time.perf_counter_ns() - started)
return timings
def _verify_roundtrip(original: AgentSession, restored: AgentSession) -> None:
"""Verify that framework and custom objects were reconstructed."""
original_messages = original.state[InMemoryHistoryProvider.DEFAULT_SOURCE_ID]["messages"]
restored_messages = restored.state[InMemoryHistoryProvider.DEFAULT_SOURCE_ID]["messages"]
if len(restored_messages) != len(original_messages):
raise AssertionError("Message count changed during round-trip")
if not isinstance(restored_messages[0], Message):
raise AssertionError("Message objects were not reconstructed")
if not isinstance(restored.state["classes"][0], BenchmarkClassState):
raise AssertionError("Custom class state was not reconstructed")
if not isinstance(restored.state["profiles"][0], BenchmarkProfileState):
raise AssertionError("Pydantic state was not reconstructed")
def benchmark_codec(
codec: Codec,
session: AgentSession,
*,
output_directory: Path,
warmups: int,
iterations: int,
) -> BenchmarkResult:
"""Benchmark one codec and write its representative payload to disk."""
payload = codec.encode(session)
restored = codec.decode(payload)
_verify_roundtrip(session, restored)
for _ in range(warmups):
codec.encode(session)
codec.decode(payload)
codec.decode(codec.encode(session))
encode_timings = _time_ns(lambda: codec.encode(session), iterations)
decode_timings = _time_ns(lambda: codec.decode(payload), iterations)
roundtrip_timings = _time_ns(lambda: codec.decode(codec.encode(session)), iterations)
output_path = output_directory / f"session{codec.suffix}"
output_path.write_bytes(payload)
def disk_roundtrip() -> None:
current_payload = codec.encode(session)
output_path.write_bytes(current_payload)
codec.decode(output_path.read_bytes())
disk_roundtrip_timings = _time_ns(disk_roundtrip, iterations)
return BenchmarkResult(
codec=codec.name,
file_size=output_path.stat().st_size,
encode_median_ms=_median_ms(encode_timings),
encode_p95_ms=_percentile(encode_timings, 0.95),
decode_median_ms=_median_ms(decode_timings),
decode_p95_ms=_percentile(decode_timings, 0.95),
roundtrip_median_ms=_median_ms(roundtrip_timings),
roundtrip_p95_ms=_percentile(roundtrip_timings, 0.95),
disk_roundtrip_median_ms=_median_ms(disk_roundtrip_timings),
disk_roundtrip_p95_ms=_percentile(disk_roundtrip_timings, 0.95),
)
def _format_bytes(value: int) -> str:
if value < 1024:
return f"{value} B"
if value < 1024 * 1024:
return f"{value / 1024:.1f} KiB"
return f"{value / (1024 * 1024):.2f} MiB"
def print_results(results: list[BenchmarkResult]) -> None:
"""Print an aligned summary table and relative size ratios."""
headers = (
"codec",
"size",
"encode med/p95 ms",
"decode med/p95 ms",
"roundtrip med/p95 ms",
"disk med/p95 ms",
)
rows = [
(
result.codec,
_format_bytes(result.file_size),
f"{result.encode_median_ms:.3f}/{result.encode_p95_ms:.3f}",
f"{result.decode_median_ms:.3f}/{result.decode_p95_ms:.3f}",
f"{result.roundtrip_median_ms:.3f}/{result.roundtrip_p95_ms:.3f}",
f"{result.disk_roundtrip_median_ms:.3f}/{result.disk_roundtrip_p95_ms:.3f}",
)
for result in results
]
widths = [
max(len(headers[index]), *(len(row[index]) for row in rows))
for index in range(len(headers))
]
def render(row: tuple[str, ...]) -> str:
return " | ".join(value.ljust(widths[index]) for index, value in enumerate(row))
print(render(headers))
print("-+-".join("-" * width for width in widths))
for row in rows:
print(render(row))
baseline = results[0].file_size
print("\nFile size relative to stdlib JSON:")
for result in results:
print(f" {result.codec:<16} {result.file_size / baseline:>7.3f}x")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--messages", type=int, default=2_000, help="Number of Message objects in history.")
parser.add_argument("--class-state", type=int, default=500, help="Number of custom class state objects.")
parser.add_argument("--text-bytes", type=int, default=512, help="Approximate text payload per Message.")
parser.add_argument("--iterations", type=int, default=25, help="Measured iterations per operation.")
parser.add_argument("--warmups", type=int, default=5, help="Warmup iterations per codec.")
parser.add_argument(
"--output-dir",
type=Path,
help="Keep representative payload files in this directory instead of a temporary directory.",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
session = asyncio.run(
build_large_session(
message_count=args.messages,
class_state_count=args.class_state,
text_bytes=args.text_bytes,
)
)
history_count = len(session.state[InMemoryHistoryProvider.DEFAULT_SOURCE_ID]["messages"])
print(
f"Session: {history_count} messages, {args.class_state} class objects, "
f"{len(session.state['profiles'])} Pydantic objects"
)
print(f"Iterations: {args.iterations} measured, {args.warmups} warmups\n")
if args.output_dir is not None:
args.output_dir.mkdir(parents=True, exist_ok=True)
results = [
benchmark_codec(
codec,
session,
output_directory=args.output_dir,
warmups=args.warmups,
iterations=args.iterations,
)
for codec in CODECS
]
print_results(results)
print(f"\nPayload files retained in: {args.output_dir.resolve()}")
return
with tempfile.TemporaryDirectory(prefix="agent-session-serialization-") as temporary_directory:
results = [
benchmark_codec(
codec,
session,
output_directory=Path(temporary_directory),
warmups=args.warmups,
iterations=args.iterations,
)
for codec in CODECS
]
print_results(results)
if __name__ == "__main__":
main()