Merge branch 'main' into feature/python-foundry-hosted-agent-vnext

This commit is contained in:
Tao Chen
2026-04-15 20:59:51 -07:00
Unverified
23 changed files with 1502 additions and 55 deletions
+5
View File
@@ -10,6 +10,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- **agent-framework-azure-cosmos**: [BREAKING] `CosmosCheckpointStorage` now uses restricted pickle deserialization by default, matching `FileCheckpointStorage` behavior. If your checkpoints contain application-defined types, pass them via `allowed_checkpoint_types=["my_app.models:MyState"]`. ([#5200](https://github.com/microsoft/agent-framework/issues/5200))
## [devui-1.0.0b260414] - 2026-04-14
### Fixed
- **agent-framework-devui**: Fix streaming memory growth in DevUI frontend ([#5221](https://github.com/microsoft/agent-framework/pull/5221))
## [1.0.1] - 2026-04-09
### Added
@@ -1,5 +1,6 @@
# Copyright (c) Microsoft. All rights reserved.
import os
import re
from pathlib import Path
from typing import Annotated, Any
from unittest.mock import MagicMock, patch
@@ -1503,6 +1504,8 @@ async def test_anthropic_client_integration_function_calling() -> None:
@skip_if_anthropic_integration_tests_disabled
async def test_anthropic_client_integration_hosted_tools() -> None:
"""Integration test for hosted tools."""
import anthropic
client = AnthropicClient()
messages = [Message(role="user", contents=["What tools do you have available?"])]
@@ -1515,10 +1518,18 @@ async def test_anthropic_client_integration_hosted_tools() -> None:
),
]
response = await client.get_response(
messages=messages,
options={"tools": tools, "max_tokens": 100},
)
try:
response = await client.get_response(
messages=messages,
options={"tools": tools, "max_tokens": 100},
)
except (
anthropic.BadRequestError,
anthropic.InternalServerError,
anthropic.APIConnectionError,
anthropic.APITimeoutError,
) as e:
pytest.skip(f"Upstream MCP server unavailable: {e}")
assert response is not None
assert response.text is not None
@@ -1607,7 +1618,8 @@ async def test_anthropic_client_integration_images() -> None:
assert response is not None
assert response.messages[0].text is not None
assert "house" in response.messages[0].text.lower()
text = response.messages[0].text.lower()
assert re.search(r"\b(house|home|building|cottage|mansion|villa)\b", text)
# Response Format Tests
+2
View File
@@ -63,6 +63,8 @@ agent_framework/
- **`SessionContext`** - Context object for session-scoped data during agent runs
- **`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
- **`FileHistoryProvider`** - JSON Lines file-backed history provider storing one file per session with one message record per line
### Skills (`_skills.py`)
@@ -103,6 +103,7 @@ from ._middleware import (
from ._sessions import (
AgentSession,
ContextProvider,
FileHistoryProvider,
HistoryProvider,
InMemoryHistoryProvider,
SessionContext,
@@ -318,6 +319,7 @@ __all__ = [
"FanInEdgeGroup",
"FanOutEdgeGroup",
"FileCheckpointStorage",
"FileHistoryProvider",
"FinalT",
"FinishReason",
"FinishReasonLiteral",
@@ -47,6 +47,7 @@ class ExperimentalFeature(str, Enum):
"""
EVALS = "EVALS"
FILE_HISTORY = "FILE_HISTORY"
SKILLS = "SKILLS"
@@ -8,16 +8,24 @@ This module provides the core types for the context provider pipeline:
- HistoryProvider: Base class for history storage providers
- AgentSession: Lightweight session state container
- InMemoryHistoryProvider: Built-in in-memory history provider
- FileHistoryProvider: Built-in JSON Lines file history provider
"""
from __future__ import annotations
import asyncio
import copy
import json
import threading
import uuid
import weakref
from abc import abstractmethod
from base64 import urlsafe_b64encode
from collections.abc import Awaitable, Callable, Mapping, Sequence
from typing import TYPE_CHECKING, Any, ClassVar, TypeGuard, cast
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar, TypeAlias, TypeGuard, cast
from ._feature_stage import ExperimentalFeature, experimental
from ._middleware import ChatContext, ChatMiddleware
from ._types import AgentResponse, ChatResponse, Message, ResponseStream
from .exceptions import ChatClientInvalidResponseException
@@ -30,6 +38,17 @@ if TYPE_CHECKING:
# Registry of known types for state deserialization
_STATE_TYPE_REGISTRY: dict[str, type] = {}
JsonDumps: TypeAlias = Callable[[Any], str | bytes]
JsonLoads: TypeAlias = Callable[[str | bytes], Any]
def _default_json_dumps(value: Any) -> str:
return json.dumps(value, ensure_ascii=False)
def _default_json_loads(value: str | bytes) -> Any:
return json.loads(value)
def _is_middleware_sequence(
middleware: MiddlewareTypes | Sequence[MiddlewareTypes],
@@ -837,3 +856,247 @@ class InMemoryHistoryProvider(HistoryProvider):
return
existing = state.get("messages", [])
state["messages"] = [*existing, *messages]
@experimental(feature_id=ExperimentalFeature.FILE_HISTORY)
class FileHistoryProvider(HistoryProvider):
"""File-backed history provider that stores one JSON Lines file per session.
Each persisted message is written as a single JSON object per line. The
provider does not serialize full session snapshots into the file. By default
it uses the standard library ``json`` module, but callers can inject
alternative ``dumps`` and ``loads`` callables compatible with the JSON
Lines format.
Security posture:
Persisted history is stored as plaintext JSONL on the local filesystem.
Treat ``storage_path`` as trusted application storage, not as a secret
store. Encoded fallback filenames and resolved-path validation help
prevent path traversal via ``session_id``, but they do not encrypt file
contents or provide cross-process / cross-host locking. Use OS-level
file permissions, trusted directories, and carefully review what agent
or tool output is allowed to be persisted.
"""
DEFAULT_SOURCE_ID: ClassVar[str] = "file_history"
DEFAULT_SESSION_FILE_STEM: ClassVar[str] = "default"
FILE_EXTENSION: ClassVar[str] = ".jsonl"
_FILE_LOCK_STRIPE_COUNT: ClassVar[int] = 64
_ENCODED_SESSION_PREFIX: ClassVar[str] = "~session-"
_FILE_WRITE_LOCKS: ClassVar[tuple[threading.Lock, ...]] = tuple(
threading.Lock() for _ in range(_FILE_LOCK_STRIPE_COUNT)
)
_WINDOWS_RESERVED_FILE_STEMS: ClassVar[frozenset[str]] = frozenset({
"CON",
"PRN",
"AUX",
"NUL",
"COM1",
"COM2",
"COM3",
"COM4",
"COM5",
"COM6",
"COM7",
"COM8",
"COM9",
"LPT1",
"LPT2",
"LPT3",
"LPT4",
"LPT5",
"LPT6",
"LPT7",
"LPT8",
"LPT9",
})
def __init__(
self,
storage_path: str | Path,
*,
source_id: str = DEFAULT_SOURCE_ID,
load_messages: bool = True,
store_inputs: bool = True,
store_context_messages: bool = False,
store_context_from: set[str] | None = None,
store_outputs: bool = True,
skip_excluded: bool = False,
dumps: JsonDumps | None = None,
loads: JsonLoads | None = None,
) -> None:
"""Initialize the file history provider.
Args:
storage_path: Directory path where session history files will be stored.
Keyword 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.
skip_excluded: When True, ``get_messages`` omits messages whose
``additional_properties["_excluded"]`` is truthy.
dumps: Callable that serializes a message payload dict to JSON text
or UTF-8 bytes. The returned JSON must fit on a single line.
loads: Callable that deserializes JSON text or bytes back to a
message payload dict.
"""
super().__init__(
source_id=source_id,
load_messages=load_messages,
store_inputs=store_inputs,
store_context_messages=store_context_messages,
store_context_from=store_context_from,
store_outputs=store_outputs,
)
self.storage_path = Path(storage_path)
self.storage_path.mkdir(parents=True, exist_ok=True)
self._storage_root = self.storage_path.resolve()
self.skip_excluded = skip_excluded
self.dumps = dumps or _default_json_dumps
self.loads = loads or _default_json_loads
self._async_write_locks_by_loop: weakref.WeakKeyDictionary[
asyncio.AbstractEventLoop,
tuple[asyncio.Lock, ...],
] = weakref.WeakKeyDictionary()
async def get_messages(
self,
session_id: str | None,
*,
state: dict[str, Any] | None = None,
**kwargs: Any,
) -> list[Message]:
"""Retrieve messages from the session's JSON Lines file."""
del state, kwargs
file_path = self._session_file_path(session_id)
async_lock = self._session_async_write_lock(file_path)
thread_lock = self._session_write_lock(file_path)
def _read_messages() -> list[Message]:
with thread_lock:
if not file_path.exists():
return []
messages: list[Message] = []
with file_path.open(encoding="utf-8") as file_handle:
for line_number, line in enumerate(file_handle, start=1):
serialized = line.strip()
if not serialized:
continue
try:
payload = self.loads(serialized)
except (TypeError, ValueError) as exc:
raise ValueError(
f"Failed to deserialize history line {line_number} from '{file_path}'."
) from exc
if not isinstance(payload, Mapping):
raise ValueError(
f"History line {line_number} in '{file_path}' did not deserialize to a mapping."
)
try:
message = Message.from_dict(dict(cast(Mapping[str, Any], payload)))
except ValueError as exc:
raise ValueError(
f"History line {line_number} in '{file_path}' is not a valid Message payload."
) from exc
messages.append(message)
return messages
async with async_lock:
messages = await asyncio.to_thread(_read_messages)
if self.skip_excluded:
messages = [m for m in messages if not m.additional_properties.get("_excluded", False)]
return messages
async def save_messages(
self,
session_id: str | None,
messages: Sequence[Message],
*,
state: dict[str, Any] | None = None,
**kwargs: Any,
) -> None:
"""Append messages to the session's JSON Lines file."""
del state, kwargs
if not messages:
return
file_path = self._session_file_path(session_id)
async_lock = self._session_async_write_lock(file_path)
file_lock = self._session_write_lock(file_path)
def _append_messages() -> None:
with file_lock, file_path.open("a", encoding="utf-8") as file_handle:
for message in messages:
file_handle.write(f"{self._serialize_message(message)}\n")
async with async_lock:
await asyncio.to_thread(_append_messages)
def _serialize_message(self, message: Message) -> str:
"""Serialize a message payload to a single JSON Lines record."""
serialized = self.dumps(message.to_dict())
if isinstance(serialized, bytes):
serialized_text = serialized.decode("utf-8")
elif isinstance(serialized, str):
serialized_text = serialized
else:
raise TypeError("FileHistoryProvider.dumps must return str or bytes.")
if "\n" in serialized_text or "\r" in serialized_text:
raise ValueError("FileHistoryProvider.dumps must return single-line JSON for JSON Lines storage.")
return serialized_text
def _session_file_path(self, session_id: str | None) -> Path:
"""Resolve the on-disk history file path for a session."""
file_path = (self._storage_root / f"{self._session_file_stem(session_id)}{self.FILE_EXTENSION}").resolve()
if not file_path.is_relative_to(self._storage_root):
raise ValueError(f"Session history path escaped storage directory: {session_id!r}")
return file_path
def _session_file_stem(self, session_id: str | None) -> str:
"""Return the filename stem for a session."""
raw_session_id = session_id or self.DEFAULT_SESSION_FILE_STEM
if self._is_literal_session_file_stem_safe(raw_session_id):
return raw_session_id
encoded_session_id = urlsafe_b64encode(raw_session_id.encode("utf-8")).decode("ascii").rstrip("=")
return f"{self._ENCODED_SESSION_PREFIX}{encoded_session_id or self.DEFAULT_SESSION_FILE_STEM}"
def _session_async_write_lock(self, file_path: Path) -> asyncio.Lock:
"""Return the event-loop-local async lock for a session history file."""
loop = asyncio.get_running_loop()
locks = self._async_write_locks_by_loop.get(loop)
if locks is None:
locks = tuple(asyncio.Lock() for _ in range(self._FILE_LOCK_STRIPE_COUNT))
self._async_write_locks_by_loop[loop] = locks
return locks[self._lock_index(file_path)]
@classmethod
def _session_write_lock(cls, file_path: Path) -> threading.Lock:
"""Return the process-local thread lock for a session history file."""
return cls._FILE_WRITE_LOCKS[cls._lock_index(file_path)]
@classmethod
def _lock_index(cls, file_path: Path) -> int:
"""Map a session history file to a bounded lock stripe."""
return hash(file_path) % cls._FILE_LOCK_STRIPE_COUNT
@classmethod
def _is_literal_session_file_stem_safe(cls, session_id: str) -> bool:
"""Return whether the session ID can be used directly as a filename stem."""
if (
not session_id
or session_id.startswith(".")
or session_id.endswith((" ", "."))
or session_id.upper() in cls._WINDOWS_RESERVED_FILE_STEMS
):
return False
if any(ord(character) < 32 for character in session_id):
return False
return all(character.isalnum() or character in "._-" for character in session_id)
@@ -244,10 +244,10 @@ class FileCheckpointStorage:
is serialized using pickle and embedded as base64-encoded strings within the JSON. This allows
for human-readable checkpoint files while preserving the ability to store complex Python objects.
By default, checkpoint deserialization is restricted to a built-in set of safe
Python types (primitives, datetime, uuid, ...) and all ``agent_framework``
internal types. To allow additional application-specific types, pass them via
the ``allowed_checkpoint_types`` parameter using ``"module:qualname"`` format.
By default, checkpoint deserialization is restricted to a built-in set of safe Python types
(primitives, datetime, uuid, ...), all ``agent_framework`` internal types, and OpenAI SDK types
(``openai.types``). To allow additional application-specific types, pass them via the
``allowed_checkpoint_types`` parameter using ``"module:qualname"`` format.
Example::
@@ -10,9 +10,9 @@ This hybrid approach provides:
When ``allowed_types`` is supplied to :func:`decode_checkpoint_value`, a
``RestrictedUnpickler`` is used that limits which classes may be instantiated
during deserialization. The default built-in safe set covers common Python
value types (primitives, datetime, uuid, ...) and all ``agent_framework``
internal types. Callers can extend the set by passing additional
``"module:qualname"`` strings.
value types (primitives, datetime, uuid, ...), all ``agent_framework`` internal
types, and all ``openai.types`` types. Callers can extend the set by passing
additional ``"module:qualname"`` strings.
"""
from __future__ import annotations
@@ -37,6 +37,9 @@ _JSON_NATIVE_TYPES = (str, int, float, bool, type(None))
# Module prefix for framework-internal types that are always allowed
_FRAMEWORK_MODULE_PREFIX = "agent_framework."
# Module prefix for OpenAI SDK types that are always allowed
_OPENAI_MODULE_PREFIX = "openai.types."
# Built-in types considered safe for checkpoint deserialization.
# Each entry is a ``module:qualname`` string matching the format produced by
# :func:`_type_to_key`. These are the classes for which pickle's
@@ -84,8 +87,9 @@ class _RestrictedUnpickler(pickle.Unpickler): # noqa: S301
"""Unpickler that restricts which classes may be instantiated.
Only classes whose ``module:qualname`` key appears in the combined allow
set (built-in safe types + framework types + caller-specified extras) are
permitted. All other classes raise :class:`pickle.UnpicklingError`.
set (built-in safe types + framework types + OpenAI SDK types +
caller-specified extras) are permitted. All other classes raise
:class:`pickle.UnpicklingError`.
"""
def __init__(self, data: bytes, allowed_types: frozenset[str]) -> None:
@@ -99,6 +103,7 @@ class _RestrictedUnpickler(pickle.Unpickler): # noqa: S301
type_key in _BUILTIN_ALLOWED_TYPE_KEYS
or type_key in self._allowed_types
or module.startswith(_FRAMEWORK_MODULE_PREFIX)
or module.startswith(_OPENAI_MODULE_PREFIX)
):
return super().find_class(module, name) # type: ignore[no-any-return] # nosec
@@ -11,11 +11,11 @@ import logging
import types
import uuid
from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, Sequence
from typing import Any, Literal, overload
from typing import TYPE_CHECKING, Any, Literal, overload
from .._sessions import ContextProvider
from .._types import ResponseStream
from ..observability import OtelAttr, capture_exception, create_workflow_span
from ._agent import WorkflowAgent
from ._checkpoint import CheckpointStorage
from ._const import DEFAULT_MAX_ITERATIONS, GLOBAL_KWARGS_KEY, WORKFLOW_RUN_KWARGS_KEY
from ._edge import (
@@ -35,6 +35,9 @@ from ._runner_context import RunnerContext
from ._state import State
from ._typing_utils import is_instance_of, try_coerce_to_type
if TYPE_CHECKING:
from ._agent import WorkflowAgent
logger = logging.getLogger(__name__)
@@ -910,7 +913,14 @@ class Workflow(DictConvertible):
return list(output_types)
def as_agent(self, name: str | None = None) -> WorkflowAgent:
def as_agent(
self,
name: str | None = None,
*,
description: str | None = None,
context_providers: Sequence[ContextProvider] | None = None,
**kwargs: Any,
) -> WorkflowAgent:
"""Create a WorkflowAgent that wraps this workflow.
The returned agent converts standard agent inputs (strings, Message, or lists of these)
@@ -924,7 +934,10 @@ class Workflow(DictConvertible):
initialization will fail with a ValueError.
Args:
name: Optional name for the agent. If None, a default name will be generated.
name: Optional name for the agent. Defaults to workflow name.
description: Optional description of the agent. Defaults to workflow description.
context_providers: Optional sequence of context providers for the agent.
**kwargs: Additional keyword arguments passed to BaseAgent.
Returns:
A WorkflowAgent instance that wraps this workflow.
@@ -935,4 +948,10 @@ class Workflow(DictConvertible):
# Import here to avoid circular imports
from ._agent import WorkflowAgent
return WorkflowAgent(workflow=self, name=name)
return WorkflowAgent(
workflow=self,
name=name if name is not None else self.name,
description=description if description is not None else self.description,
context_providers=context_providers,
**kwargs,
)
@@ -1,7 +1,12 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import json
import threading
import time
from collections.abc import Awaitable, Callable, Sequence
from pathlib import Path
from typing import Any
import pytest
@@ -10,6 +15,8 @@ from agent_framework import (
AgentSession,
ChatContext,
ContextProvider,
ExperimentalFeature,
FileHistoryProvider,
HistoryProvider,
InMemoryHistoryProvider,
Message,
@@ -505,3 +512,217 @@ class TestInMemoryHistoryProvider:
ctx = SessionContext(session_id="s1", input_messages=[])
ctx.extend_messages("custom-source", [Message(role="user", contents=["test"])])
assert "custom-source" in ctx.context_messages
class TestFileHistoryProvider:
def test_is_marked_experimental(self) -> None:
assert FileHistoryProvider.__feature_stage__ == "experimental"
assert FileHistoryProvider.__feature_id__ == ExperimentalFeature.FILE_HISTORY.value
assert FileHistoryProvider.__doc__ is not None
assert ".. warning:: Experimental" in FileHistoryProvider.__doc__
async def test_stores_and_loads_messages(self, tmp_path: Path) -> None:
from agent_framework import AgentResponse
provider = FileHistoryProvider(tmp_path)
session = AgentSession(session_id="s1")
input_message = Message(role="user", contents=["hello"])
response_message = Message(role="assistant", contents=["hi there"])
first_context = SessionContext(session_id=session.session_id, input_messages=[input_message])
await provider.before_run( # type: ignore[arg-type]
agent=None,
session=session,
context=first_context,
state={},
)
first_context._response = AgentResponse(messages=[response_message])
await provider.after_run( # type: ignore[arg-type]
agent=None,
session=session,
context=first_context,
state={},
)
session_file = provider._session_file_path(session.session_id)
assert session_file.name == "s1.jsonl"
assert session_file.exists()
raw_lines = (await asyncio.to_thread(session_file.read_text, encoding="utf-8")).splitlines()
assert len(raw_lines) == 2
payloads = [json.loads(line) for line in raw_lines]
assert all(payload["type"] == "message" for payload in payloads)
assert all("session_id" not in payload for payload in payloads)
second_context = SessionContext(
session_id=session.session_id, input_messages=[Message(role="user", contents=["again"])]
)
await provider.before_run( # type: ignore[arg-type]
agent=None,
session=session,
context=second_context,
state={},
)
loaded = second_context.context_messages.get(provider.source_id, [])
assert len(loaded) == 2
assert loaded[0].text == "hello"
assert loaded[1].text == "hi there"
def test_creates_storage_directory(self, tmp_path: Path) -> None:
nested_path = tmp_path / "nested" / "history"
provider = FileHistoryProvider(nested_path)
assert provider.storage_path == nested_path
assert nested_path.exists()
assert nested_path.is_dir()
async def test_uses_encoded_filename_for_unsafe_session_id(self, tmp_path: Path) -> None:
provider = FileHistoryProvider(tmp_path)
unsafe_session_id = "../unsafe/session"
await provider.save_messages(unsafe_session_id, [Message(role="user", contents=["hello"])])
session_file = provider._session_file_path(unsafe_session_id)
assert session_file.parent == provider.storage_path
assert session_file.name.startswith("~session-")
assert session_file.suffix == ".jsonl"
assert session_file.exists()
jsonl_files = await asyncio.to_thread(
lambda: sorted(path.name for path in provider.storage_path.glob("*.jsonl"))
)
assert jsonl_files == [session_file.name]
async def test_allows_custom_serializers_returning_bytes(self, tmp_path: Path) -> None:
calls: list[str] = []
def dumps(payload: object) -> bytes:
calls.append("dumps")
return json.dumps(payload).encode("utf-8")
def loads(payload: str | bytes) -> object:
calls.append("loads")
if isinstance(payload, bytes):
payload = payload.decode("utf-8")
return json.loads(payload)
provider = FileHistoryProvider(tmp_path, dumps=dumps, loads=loads)
await provider.save_messages("custom-serializer", [Message(role="user", contents=["hello"])])
loaded = await provider.get_messages("custom-serializer")
assert calls == ["dumps", "loads"]
assert len(loaded) == 1
assert loaded[0].text == "hello"
async def test_invalid_jsonl_line_raises(self, tmp_path: Path) -> None:
provider = FileHistoryProvider(tmp_path)
await asyncio.to_thread(provider._session_file_path("broken").write_text, "{not-json}\n", encoding="utf-8")
with pytest.raises(ValueError, match="Failed to deserialize history line 1"):
await provider.get_messages("broken")
async def test_missing_session_file_returns_empty_messages(self, tmp_path: Path) -> None:
provider = FileHistoryProvider(tmp_path)
loaded = await provider.get_messages("missing")
assert loaded == []
async def test_none_session_id_uses_default_jsonl_file(self, tmp_path: Path) -> None:
provider = FileHistoryProvider(tmp_path)
await provider.save_messages(None, [Message(role="user", contents=["hello"])])
session_file = provider._session_file_path(None)
assert session_file.name == "default.jsonl"
loaded = await provider.get_messages(None)
assert [message.text for message in loaded] == ["hello"]
async def test_non_mapping_jsonl_line_raises(self, tmp_path: Path) -> None:
provider = FileHistoryProvider(tmp_path)
await asyncio.to_thread(provider._session_file_path("non-mapping").write_text, "[1, 2, 3]\n", encoding="utf-8")
with pytest.raises(ValueError, match="did not deserialize to a mapping"):
await provider.get_messages("non-mapping")
async def test_skip_excluded_omits_excluded_messages(self, tmp_path: Path) -> None:
provider = FileHistoryProvider(tmp_path, skip_excluded=True)
await provider.save_messages(
"skip-excluded",
[
Message(role="user", contents=["keep"]),
Message(role="assistant", contents=["skip"], additional_properties={"_excluded": True}),
],
)
loaded = await provider.get_messages("skip-excluded")
assert [message.text for message in loaded] == ["keep"]
async def test_serializer_must_return_single_line_json(self, tmp_path: Path) -> None:
def dumps(payload: object) -> str:
return json.dumps(payload, indent=2)
provider = FileHistoryProvider(tmp_path, dumps=dumps)
with pytest.raises(ValueError, match="single-line JSON"):
await provider.save_messages("pretty-json", [Message(role="user", contents=["hello"])])
async def test_concurrent_writes_for_same_session_are_locked(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
provider = FileHistoryProvider(tmp_path)
session_id = "shared-session"
file_path = provider._session_file_path(session_id)
real_open = Path.open
write_started = threading.Event()
active_writes = 0
overlap_detected = False
class _TrackingFile:
def __init__(self, wrapped: Any) -> None:
self._wrapped = wrapped
def __enter__(self) -> "_TrackingFile":
self._wrapped.__enter__()
return self
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
self._wrapped.__exit__(exc_type, exc_val, exc_tb)
def write(self, data: str) -> int:
nonlocal active_writes, overlap_detected
write_started.set()
active_writes += 1
overlap_detected = overlap_detected or active_writes > 1
try:
time.sleep(0.05)
return int(self._wrapped.write(data))
finally:
active_writes -= 1
def __getattr__(self, name: str) -> Any:
return getattr(self._wrapped, name)
def tracked_open(path: Path, *args: Any, **kwargs: Any) -> Any:
handle = real_open(path, *args, **kwargs)
if path == file_path and args and args[0] == "a":
return _TrackingFile(handle)
return handle
monkeypatch.setattr(Path, "open", tracked_open)
first_save = asyncio.create_task(provider.save_messages(session_id, [Message(role="user", contents=["first"])]))
started = await asyncio.to_thread(write_started.wait, 1.0)
assert started
second_save = asyncio.create_task(
provider.save_messages(session_id, [Message(role="assistant", contents=["second"])])
)
await asyncio.gather(first_save, second_save)
assert not overlap_detected
loaded = await provider.get_messages(session_id)
assert [message.text for message in loaded] == ["first", "second"]
@@ -216,3 +216,50 @@ def test_restricted_unpickler_raises_pickle_error():
unpickler = _RestrictedUnpickler(pickled, frozenset())
with pytest.raises(pickle.UnpicklingError, match="deserialization blocked"):
unpickler.load()
def test_restricted_decode_allows_openai_types():
"""OpenAI SDK types are always allowed during restricted deserialization."""
from openai.types.chat.chat_completion import ChatCompletion, Choice
from openai.types.chat.chat_completion_message import ChatCompletionMessage
from openai.types.completion_usage import CompletionUsage
completion = ChatCompletion(
id="chatcmpl-test",
choices=[
Choice(
finish_reason="stop",
index=0,
message=ChatCompletionMessage(role="assistant", content="hello"),
)
],
created=1700000000,
model="gpt-4",
object="chat.completion",
usage=CompletionUsage(completion_tokens=1, prompt_tokens=1, total_tokens=2),
)
encoded = encode_checkpoint_value(completion)
decoded = decode_checkpoint_value(encoded, allowed_types=frozenset())
assert isinstance(decoded, ChatCompletion)
assert decoded.id == "chatcmpl-test"
assert decoded.choices[0].message.content == "hello"
def test_restricted_decode_allows_openai_response_types():
"""OpenAI Responses API types are always allowed during restricted deserialization."""
from openai.types.responses.response_usage import InputTokensDetails, OutputTokensDetails, ResponseUsage
usage = ResponseUsage(
input_tokens=10,
output_tokens=20,
total_tokens=30,
input_tokens_details=InputTokensDetails(cached_tokens=0),
output_tokens_details=OutputTokensDetails(reasoning_tokens=0),
)
encoded = encode_checkpoint_value(usage)
decoded = decode_checkpoint_value(encoded, allowed_types=frozenset())
assert isinstance(decoded, ResponseUsage)
assert decoded.input_tokens == 10
assert decoded.output_tokens == 20
@@ -313,6 +313,37 @@ class TestWorkflowAgent:
assert isinstance(agent_no_name, WorkflowAgent)
assert agent_no_name.workflow is workflow
def test_workflow_as_agent_with_description_and_context_providers(self) -> None:
"""Test that Workflow.as_agent() forwards description and context_providers."""
executor = SimpleExecutor(id="executor1", response_text="Response")
workflow = WorkflowBuilder(start_executor=executor).build()
history_provider = InMemoryHistoryProvider()
agent = workflow.as_agent(
name="MyAgent",
description="A test agent",
context_providers=[history_provider],
)
assert isinstance(agent, WorkflowAgent)
assert agent.name == "MyAgent"
assert agent.description == "A test agent"
assert history_provider in agent.context_providers
def test_workflow_as_agent_defaults_name_and_description_from_workflow(self) -> None:
"""Test that as_agent() defaults name and description to the workflow's own values."""
executor = SimpleExecutor(id="executor1", response_text="Response")
workflow = WorkflowBuilder(
start_executor=executor,
name="my-workflow",
description="Workflow description",
).build()
agent = workflow.as_agent()
assert agent.name == "my-workflow"
assert agent.description == "Workflow description"
def test_workflow_as_agent_cannot_handle_agent_inputs(self) -> None:
"""Test that Workflow.as_agent() raises an error if the start executor cannot handle agent inputs."""
+1 -1
View File
@@ -4,7 +4,7 @@ description = "Debug UI for Microsoft Agent Framework with OpenAI-compatible API
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260409"
version = "1.0.0b260414"
license-files = ["LICENSE"]
urls.homepage = "https://github.com/microsoft/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -31,7 +31,7 @@ from agent_framework.exceptions import AgentException
try:
from copilot import CopilotClient, CopilotSession, SubprocessConfig
from copilot.generated.session_events import PermissionRequest, SessionEvent, SessionEventType
from copilot.session import MCPServerConfig, PermissionRequestResult, SystemMessageConfig
from copilot.session import MCPServerConfig, PermissionRequestResult, ProviderConfig, SystemMessageConfig
from copilot.tools import Tool as CopilotTool
from copilot.tools import ToolInvocation, ToolResult
except ImportError as _copilot_import_error:
@@ -120,6 +120,12 @@ class GitHubCopilotOptions(TypedDict, total=False):
Supports both local (stdio) and remote (HTTP/SSE) servers.
"""
provider: ProviderConfig
"""Custom API provider configuration for BYOK (Bring Your Own Key) scenarios.
Allows routing requests through your own OpenAI, Azure, or Anthropic endpoint
instead of the default GitHub Copilot backend.
"""
OptionsT = TypeVar(
"OptionsT",
@@ -232,6 +238,7 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
log_level = opts.pop("log_level", None)
on_permission_request: PermissionHandlerType | None = opts.pop("on_permission_request", None)
mcp_servers: dict[str, MCPServerConfig] | None = opts.pop("mcp_servers", None)
provider: ProviderConfig | None = opts.pop("provider", None)
self._settings = load_settings(
GitHubCopilotSettings,
@@ -247,6 +254,7 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
self._tools = normalize_tools(tools)
self._permission_handler = on_permission_request
self._mcp_servers = mcp_servers
self._provider = provider
self._default_options = opts
self._started = False
@@ -730,6 +738,7 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
opts.get("on_permission_request") or self._permission_handler or _deny_all_permissions
)
mcp_servers = opts.get("mcp_servers") or self._mcp_servers or None
provider = opts.get("provider") or self._provider or None
tools = self._prepare_tools(self._tools) if self._tools else None
return await self._client.create_session(
@@ -739,6 +748,7 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
system_message=system_message or None,
tools=tools or None,
mcp_servers=mcp_servers or None,
provider=provider or None,
)
async def _resume_session(self, session_id: str, streaming: bool) -> CopilotSession:
@@ -755,4 +765,5 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
streaming=streaming,
tools=tools or None,
mcp_servers=self._mcp_servers or None,
provider=self._provider or None,
)
@@ -861,6 +861,7 @@ class TestGitHubCopilotAgentSessionManagement:
streaming=unittest.mock.ANY,
tools=unittest.mock.ANY,
mcp_servers=unittest.mock.ANY,
provider=unittest.mock.ANY,
)
async def test_session_config_includes_model(
@@ -1084,6 +1085,198 @@ class TestGitHubCopilotAgentMCPServers:
assert config["mcp_servers"] is None
class TestGitHubCopilotAgentProvider:
"""Test cases for provider configuration (BYOK / Managed Identity)."""
async def test_provider_passed_to_create_session(
self,
mock_client: MagicMock,
) -> None:
"""Test that provider config is passed through to create_session."""
from copilot.session import ProviderConfig
provider: ProviderConfig = {
"type": "azure",
"base_url": "https://my-resource.openai.azure.com",
"bearer_token": "test-token",
}
agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent(
client=mock_client,
default_options={"provider": provider},
)
await agent.start()
await agent._get_or_create_session(AgentSession()) # type: ignore
call_args = mock_client.create_session.call_args
config = call_args.kwargs
assert config["provider"]["type"] == "azure"
assert config["provider"]["base_url"] == "https://my-resource.openai.azure.com"
assert config["provider"]["bearer_token"] == "test-token"
async def test_provider_passed_to_resume_session(
self,
mock_client: MagicMock,
) -> None:
"""Test that provider config is passed through to resume_session."""
from copilot.session import ProviderConfig
provider: ProviderConfig = {
"type": "azure",
"base_url": "https://my-resource.openai.azure.com",
"bearer_token": "test-token",
}
agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent(
client=mock_client,
default_options={"provider": provider},
)
await agent.start()
session = AgentSession()
session.service_session_id = "existing-session-id"
await agent._get_or_create_session(session) # type: ignore
mock_client.resume_session.assert_called_once()
call_args = mock_client.resume_session.call_args
config = call_args.kwargs
assert config["provider"]["type"] == "azure"
async def test_session_config_excludes_provider_when_not_set(
self,
mock_client: MagicMock,
) -> None:
"""Test that provider is None in session config when not set."""
agent = GitHubCopilotAgent(client=mock_client)
await agent.start()
await agent._get_or_create_session(AgentSession()) # type: ignore
call_args = mock_client.create_session.call_args
config = call_args.kwargs
assert config["provider"] is None
async def test_resume_session_excludes_provider_when_not_set(
self,
mock_client: MagicMock,
) -> None:
"""Test that provider is None in resume session config when not set."""
agent = GitHubCopilotAgent(client=mock_client)
await agent.start()
session = AgentSession()
session.service_session_id = "existing-session-id"
await agent._get_or_create_session(session) # type: ignore
call_args = mock_client.resume_session.call_args
config = call_args.kwargs
assert config["provider"] is None
async def test_runtime_provider_takes_precedence(
self,
mock_client: MagicMock,
) -> None:
"""Test that runtime provider options override default_options provider."""
from copilot.session import ProviderConfig
default_provider: ProviderConfig = {
"type": "azure",
"base_url": "https://default.openai.azure.com",
"bearer_token": "default-token",
}
runtime_provider: ProviderConfig = {
"type": "openai",
"base_url": "https://runtime.openai.com",
"api_key": "runtime-key",
}
agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent(
client=mock_client,
default_options={"provider": default_provider},
)
await agent.start()
await agent._get_or_create_session( # type: ignore
AgentSession(),
runtime_options={"provider": runtime_provider},
)
call_args = mock_client.create_session.call_args
config = call_args.kwargs
assert config["provider"]["type"] == "openai"
assert config["provider"]["base_url"] == "https://runtime.openai.com"
async def test_provider_not_leaked_into_default_options(
self,
mock_client: MagicMock,
) -> None:
"""Test that provider is popped from opts and not left in _default_options."""
from copilot.session import ProviderConfig
provider: ProviderConfig = {
"type": "azure",
"base_url": "https://my-resource.openai.azure.com",
"bearer_token": "test-token",
}
agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent(
client=mock_client,
default_options={"provider": provider, "model": "gpt-5"},
)
assert "provider" not in agent._default_options
assert agent._provider is not None
assert agent._provider["type"] == "azure"
async def test_provider_coexists_with_other_options(
self,
mock_client: MagicMock,
) -> None:
"""Test that provider works alongside model, tools, and mcp_servers."""
from copilot.session import MCPServerConfig, ProviderConfig
provider: ProviderConfig = {
"type": "azure",
"base_url": "https://my-resource.openai.azure.com",
"bearer_token": "test-token",
}
mcp_servers: dict[str, MCPServerConfig] = {
"test-server": {
"type": "stdio",
"command": "echo",
"args": ["hello"],
"tools": ["*"],
},
}
def my_tool(arg: str) -> str:
"""A test tool."""
return arg
agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent(
client=mock_client,
tools=[my_tool],
default_options={
"model": "gpt-5",
"provider": provider,
"mcp_servers": mcp_servers,
},
)
await agent.start()
await agent._get_or_create_session(AgentSession()) # type: ignore
call_args = mock_client.create_session.call_args
config = call_args.kwargs
assert config["provider"]["type"] == "azure"
assert config["model"] == "gpt-5"
assert config["mcp_servers"] is not None
assert config["tools"] is not None
class TestGitHubCopilotAgentToolConversion:
"""Test cases for tool conversion."""
@@ -1161,7 +1161,16 @@ class RawOpenAIChatClient( # type: ignore[misc]
# First turn: prepend instructions as system message
messages = prepend_instructions_to_messages(list(messages), instructions, role="system")
# Continuation turn: instructions already exist in conversation context, skip prepending
request_input = self._prepare_messages_for_openai(messages)
request_uses_service_side_storage = False
for key in ("conversation_id", "previous_response_id", "conversation"):
value = options.get(key)
if isinstance(value, str) and value:
request_uses_service_side_storage = True
break
request_input = self._prepare_messages_for_openai(
messages,
request_uses_service_side_storage=request_uses_service_side_storage,
)
if not request_input:
raise ChatClientInvalidRequestException("Messages are required for chat completions")
conversation_id = options.get("conversation_id")
@@ -1235,7 +1244,12 @@ class RawOpenAIChatClient( # type: ignore[misc]
raise ValueError("model must be a non-empty string")
options["model"] = self.model
def _prepare_messages_for_openai(self, chat_messages: Sequence[Message]) -> list[dict[str, Any]]:
def _prepare_messages_for_openai(
self,
chat_messages: Sequence[Message],
*,
request_uses_service_side_storage: bool = True,
) -> list[dict[str, Any]]:
"""Prepare the chat messages for a request.
Allowing customization of the key names for role/author, and optionally overriding the role.
@@ -1248,31 +1262,27 @@ class RawOpenAIChatClient( # type: ignore[misc]
Args:
chat_messages: The chat history to prepare.
request_uses_service_side_storage: Whether this request continues a service-managed
response/conversation and can safely reference service-scoped response items.
Returns:
The prepared chat messages for a request.
"""
list_of_list = [self._prepare_message_for_openai(message) for message in chat_messages]
list_of_list = [
self._prepare_message_for_openai(
message,
request_uses_service_side_storage=request_uses_service_side_storage,
)
for message in chat_messages
]
# Flatten the list of lists into a single list
return list(chain.from_iterable(list_of_list))
@staticmethod
def _message_replays_provider_context(message: Message) -> bool:
"""Return whether the message came from provider-attributed replay context.
Responses ``fc_id`` values are response-scoped and only valid while replaying
the same live tool loop. Once a message comes back through a context provider
(for example, loaded session history), that message is historical input and
must not reuse the original response-scoped ``fc_id``.
"""
additional_properties = getattr(message, "additional_properties", None)
if not additional_properties:
return False
return "_attribution" in additional_properties
def _prepare_message_for_openai(
self,
message: Message,
*,
request_uses_service_side_storage: bool = True,
) -> list[dict[str, Any]]:
"""Prepare a chat message for the OpenAI Responses API format."""
all_messages: list[dict[str, Any]] = []
@@ -1280,34 +1290,63 @@ class RawOpenAIChatClient( # type: ignore[misc]
"type": "message",
"role": message.role,
}
additional_properties = message.additional_properties
replays_local_storage = "_attribution" in additional_properties
uses_service_side_storage = request_uses_service_side_storage and not replays_local_storage
# Reasoning items are only valid in input when they directly preceded a function_call
# in the same response. Including a reasoning item that preceded a text response
# in the same response. Including a reasoning item that preceded a text response
# (i.e. no function_call in the same message) causes an API error:
# "reasoning was provided without its required following item."
#
# Local storage is stricter: response-scoped reasoning items (rs_*) cannot be replayed
# back to the service unless that message is using service-side storage.
# In that mode we omit reasoning items and rely on function call + tool output replay.
has_function_call = any(c.type == "function_call" for c in message.contents)
for content in message.contents:
match content.type:
case "text_reasoning":
if not has_function_call:
if not uses_service_side_storage or not has_function_call:
continue # reasoning not followed by a function_call is invalid in input
reasoning = self._prepare_content_for_openai(message.role, content, message=message)
reasoning = self._prepare_content_for_openai(
message.role,
content,
replays_local_storage=replays_local_storage,
)
if reasoning:
all_messages.append(reasoning)
case "function_result":
new_args: dict[str, Any] = {}
new_args.update(self._prepare_content_for_openai(message.role, content, message=message))
new_args.update(
self._prepare_content_for_openai(
message.role,
content,
replays_local_storage=replays_local_storage,
)
)
if new_args:
all_messages.append(new_args)
case "function_call":
function_call = self._prepare_content_for_openai(message.role, content, message=message)
function_call = self._prepare_content_for_openai(
message.role,
content,
replays_local_storage=replays_local_storage,
)
if function_call:
all_messages.append(function_call)
case "function_approval_response" | "function_approval_request":
prepared = self._prepare_content_for_openai(message.role, content, message=message)
prepared = self._prepare_content_for_openai(
message.role,
content,
replays_local_storage=replays_local_storage,
)
if prepared:
all_messages.append(prepared)
case _:
prepared_content = self._prepare_content_for_openai(message.role, content, message=message)
prepared_content = self._prepare_content_for_openai(
message.role,
content,
replays_local_storage=replays_local_storage,
)
if prepared_content:
if "content" not in args:
args["content"] = []
@@ -1321,7 +1360,7 @@ class RawOpenAIChatClient( # type: ignore[misc]
role: Role | str,
content: Content,
*,
message: Message | None = None,
replays_local_storage: bool = False,
) -> dict[str, Any]:
"""Prepare content for the OpenAI Responses API format."""
role = Role(role)
@@ -1401,11 +1440,7 @@ class RawOpenAIChatClient( # type: ignore[misc]
logger.warning(f"FunctionCallContent missing call_id for function '{content.name}'")
return {}
fc_id = content.call_id
if (
message is not None
and not self._message_replays_provider_context(message)
and content.additional_properties
):
if not replays_local_storage and content.additional_properties:
live_fc_id = content.additional_properties.get("fc_id")
if isinstance(live_fc_id, str) and live_fc_id:
fc_id = live_fc_id
@@ -1015,6 +1015,84 @@ async def test_shell_call_is_invoked_as_local_shell_function_loop() -> None:
assert len(local_shell_outputs) == 0
async def test_tool_loop_store_false_omits_reasoning_items_from_second_request() -> None:
"""Stateless tool-loop replay must omit response-scoped reasoning items."""
client = OpenAIChatClient(model="test-model", api_key="test-key")
mock_response1 = MagicMock()
mock_response1.output_parsed = None
mock_response1.metadata = {}
mock_response1.usage = None
mock_response1.id = "resp-1"
mock_response1.model = "test-model"
mock_response1.created_at = 1000000000
mock_response1.status = "completed"
mock_response1.finish_reason = "tool_calls"
mock_response1.incomplete = None
mock_response1.conversation = None
mock_reasoning_item = MagicMock()
mock_reasoning_item.type = "reasoning"
mock_reasoning_item.id = "rs_local_only"
mock_reasoning_item.content = []
mock_reasoning_item.summary = []
mock_reasoning_item.encrypted_content = None
mock_function_call_item = MagicMock()
mock_function_call_item.type = "function_call"
mock_function_call_item.id = "fc_tool123"
mock_function_call_item.call_id = "call_123"
mock_function_call_item.name = "get_weather"
mock_function_call_item.arguments = '{"location":"Amsterdam"}'
mock_function_call_item.status = "completed"
mock_response1.output = [mock_reasoning_item, mock_function_call_item]
mock_response2 = MagicMock()
mock_response2.output_parsed = None
mock_response2.metadata = {}
mock_response2.usage = None
mock_response2.id = "resp-2"
mock_response2.model = "test-model"
mock_response2.created_at = 1000000001
mock_response2.status = "completed"
mock_response2.finish_reason = "stop"
mock_response2.incomplete = None
mock_response2.conversation = None
mock_text_item = MagicMock()
mock_text_item.type = "message"
mock_text_content = MagicMock()
mock_text_content.type = "output_text"
mock_text_content.text = "The weather in Amsterdam is sunny."
mock_text_item.content = [mock_text_content]
mock_response2.output = [mock_text_item]
with patch.object(client.client.responses, "create", side_effect=[mock_response1, mock_response2]) as mock_create:
response = await client.get_response(
messages=[Message(role="user", contents=["What's the weather in Amsterdam?"])],
options={
"store": False,
"tools": [get_weather],
"tool_choice": {"mode": "required", "required_function_name": "get_weather"},
},
)
assert response.text == "The weather in Amsterdam is sunny."
assert mock_create.call_count == 2
second_call_input = mock_create.call_args_list[1].kwargs["input"]
assert not any(item.get("type") == "reasoning" for item in second_call_input)
function_calls = [item for item in second_call_input if item.get("type") == "function_call"]
assert len(function_calls) == 1
assert function_calls[0]["id"] == "fc_tool123"
function_outputs = [item for item in second_call_input if item.get("type") == "function_call_output"]
assert len(function_outputs) == 1
assert function_outputs[0]["call_id"] == "call_123"
def test_response_content_creation_with_shell_call() -> None:
"""Test _parse_response_from_openai with shell_call output."""
client = OpenAIChatClient(model="test-model", api_key="test-key")
@@ -3221,6 +3299,164 @@ async def test_prepare_options_store_parameter_handling() -> None:
assert "previous_response_id" not in options
async def test_prepare_options_store_false_omits_reasoning_items_for_stateless_replay() -> None:
client = OpenAIChatClient(model="test-model", api_key="test-key")
messages = [
Message(role="user", contents=[Content.from_text(text="search for hotels")]),
Message(
role="assistant",
contents=[
Content.from_text_reasoning(
id="rs_test123",
text="I need to search for hotels",
additional_properties={"status": "completed"},
),
Content.from_function_call(
call_id="call_1",
name="search_hotels",
arguments='{"city": "Paris"}',
additional_properties={"fc_id": "fc_test456"},
),
],
),
Message(
role="tool",
contents=[
Content.from_function_result(
call_id="call_1",
result="Found 3 hotels in Paris",
),
],
),
]
options = await client._prepare_options(messages, ChatOptions(store=False)) # type: ignore[arg-type]
assert not any(item.get("type") == "reasoning" for item in options["input"])
assert any(item.get("type") == "function_call" for item in options["input"])
assert any(item.get("type") == "function_call_output" for item in options["input"])
async def test_prepare_options_with_conversation_id_keeps_reasoning_items() -> None:
client = OpenAIChatClient(model="test-model", api_key="test-key")
messages = [
Message(role="user", contents=[Content.from_text(text="search for hotels")]),
Message(
role="assistant",
contents=[
Content.from_text_reasoning(
id="rs_test123",
text="I need to search for hotels",
additional_properties={"status": "completed"},
),
Content.from_function_call(
call_id="call_1",
name="search_hotels",
arguments='{"city": "Paris"}',
additional_properties={"fc_id": "fc_test456"},
),
],
),
Message(
role="tool",
contents=[
Content.from_function_result(
call_id="call_1",
result="Found 3 hotels in Paris",
),
],
),
]
options = await client._prepare_options(
messages,
ChatOptions(store=False, conversation_id="resp_prev123"), # type: ignore[arg-type]
)
reasoning_items = [item for item in options["input"] if item.get("type") == "reasoning"]
assert len(reasoning_items) == 1
assert reasoning_items[0]["id"] == "rs_test123"
assert options["previous_response_id"] == "resp_prev123"
async def test_prepare_options_with_conversation_id_omits_reasoning_items_for_attributed_replay() -> None:
client = OpenAIChatClient(model="test-model", api_key="test-key")
messages = [
Message(role="user", contents=[Content.from_text(text="search for hotels")]),
Message(
role="assistant",
contents=[
Content.from_text_reasoning(
id="rs_history123",
text="I need to search history for hotels",
additional_properties={"status": "completed"},
),
Content.from_function_call(
call_id="call_history",
name="search_hotels",
arguments='{"city": "Paris"}',
additional_properties={"fc_id": "fc_history456"},
),
],
additional_properties={"_attribution": {"source_id": "history", "source_type": "InMemoryHistoryProvider"}},
),
Message(
role="tool",
contents=[
Content.from_function_result(
call_id="call_history",
result="Found 3 hotels in Paris",
),
],
),
Message(
role="assistant",
contents=[
Content.from_text_reasoning(
id="rs_live123",
text="I should refine the search for a live follow-up",
additional_properties={"status": "completed"},
),
Content.from_function_call(
call_id="call_live",
name="search_hotels",
arguments='{"city": "London"}',
additional_properties={"fc_id": "fc_live456"},
),
],
),
Message(
role="tool",
contents=[
Content.from_function_result(
call_id="call_live",
result="Found 4 hotels in London",
),
],
),
]
options = await client._prepare_options(
messages,
ChatOptions(store=False, conversation_id="resp_prev123"), # type: ignore[arg-type]
)
reasoning_items = [item for item in options["input"] if item.get("type") == "reasoning"]
assert [item["id"] for item in reasoning_items] == ["rs_live123"]
assert any(
item.get("type") == "function_call" and item.get("call_id") == "call_history" for item in options["input"]
)
assert any(item.get("type") == "function_call" and item.get("call_id") == "call_live" for item in options["input"])
assert any(
item.get("type") == "function_call_output" and item.get("call_id") == "call_history"
for item in options["input"]
)
assert any(
item.get("type") == "function_call_output" and item.get("call_id") == "call_live" for item in options["input"]
)
assert options["previous_response_id"] == "resp_prev123"
def _create_mock_responses_text_response(*, response_id: str) -> MagicMock:
mock_response = MagicMock()
mock_response.id = response_id
@@ -8,6 +8,8 @@ These samples demonstrate different approaches to managing conversation history
|------|-------------|
| [`suspend_resume_session.py`](suspend_resume_session.py) | Suspend and resume conversation sessions, comparing service-managed sessions (Azure AI Foundry) with in-memory sessions (OpenAI). |
| [`custom_history_provider.py`](custom_history_provider.py) | Implement a custom history provider by extending `HistoryProvider`, enabling conversation persistence in your preferred storage backend. |
| [`file_history_provider.py`](file_history_provider.py) | Use the experimental `FileHistoryProvider` with `FoundryChatClient` and a function tool so the local JSON Lines file shows the full tool-calling loop. |
| [`file_history_provider_conversation_persistence.py`](file_history_provider_conversation_persistence.py) | Persist a tool-driven weather conversation with `FileHistoryProvider`, inspect the stored JSONL records, and continue with another city. |
| [`cosmos_history_provider.py`](cosmos_history_provider.py) | Use Azure Cosmos DB as a history provider for durable conversation storage with `CosmosHistoryProvider`. |
| [`cosmos_history_provider_conversation_persistence.py`](cosmos_history_provider_conversation_persistence.py) | Persist and resume conversations across application restarts using `CosmosHistoryProvider` — serialize session state, restore it, and continue with full Cosmos DB history. |
| [`cosmos_history_provider_messages.py`](cosmos_history_provider_messages.py) | Direct message history operations — retrieve stored messages as a transcript, clear session history, and verify data deletion. |
@@ -25,6 +27,20 @@ These samples demonstrate different approaches to managing conversation history
**For `custom_history_provider.py`:**
- `OPENAI_API_KEY`: Your OpenAI API key
**For `file_history_provider.py`:**
- `FOUNDRY_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint
- `FOUNDRY_MODEL`: The Foundry model deployment name
- Azure CLI authentication (`az login`)
- The sample writes plaintext JSONL conversation logs to disk; use a trusted
local directory and avoid treating the history files as secure secret storage
**For `file_history_provider_conversation_persistence.py`:**
- `FOUNDRY_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint
- `FOUNDRY_MODEL`: The Foundry model deployment name
- Azure CLI authentication (`az login`)
- The sample writes plaintext JSONL conversation logs to disk; use a trusted
local directory and avoid treating the history files as secure secret storage
**For Cosmos DB samples (`cosmos_history_provider*.py`):**
- `FOUNDRY_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint
- `FOUNDRY_MODEL`: The Foundry model deployment name
@@ -0,0 +1,157 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import asyncio
import os
import tempfile
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import Annotated
# Uncomment this filter to suppress the experimental FileHistoryProvider warning
# before running the sample.
# import warnings # isort: skip
# warnings.filterwarnings("ignore", message=r"\[FILE_HISTORY\].*", category=FutureWarning)
from agent_framework import Agent, FileHistoryProvider, tool
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
from pydantic import Field
try:
import orjson
except ImportError:
orjson = None
# Load environment variables from .env file.
load_dotenv()
"""
File History Provider
This sample demonstrates how to use the experimental `FileHistoryProvider` with
`FoundryChatClient` and a function tool so the persisted JSON Lines file shows
the tool-calling loop as well as the regular chat turns.
Environment variables:
FOUNDRY_PROJECT_ENDPOINT: Azure AI Foundry project endpoint.
FOUNDRY_MODEL: Foundry model deployment name.
Key components:
- `FileHistoryProvider`: Stores one message JSON object per line in a local
`.jsonl` file for each session.
- `lookup_weather`: A function tool that makes the persisted file show the
assistant function call and tool result lines.
- `json.dumps(..., indent=2)`: Pretty-prints selected records in the sample
output while keeping the on-disk JSONL file compact and valid.
- `USE_TEMP_DIRECTORY`: Toggle between a temporary directory and a persistent
`sessions/` folder next to this sample file.
Security posture:
- The history files are plaintext JSONL on disk, so use a trusted storage
directory and treat the files as conversation logs, not as secure secret
storage.
- Path safety checks protect the filename derived from the session id, but they
do not redact message contents or encrypt the file.
"""
USE_TEMP_DIRECTORY = False
"""When True, store JSONL files in a temporary directory for this run only."""
LOCAL_SESSIONS_DIRECTORY_NAME = "sessions"
"""Folder name used when persisting history next to this sample file."""
@tool(approval_mode="never_require")
def lookup_weather(
location: Annotated[str, Field(description="The city to look up weather for.")],
) -> str:
"""Return a deterministic weather report for a city."""
weather_reports = {
"Seattle": "Seattle is rainy with a high of 13C.",
"Amsterdam": "Amsterdam is cloudy with a high of 16C.",
}
return weather_reports.get(location, f"{location} is sunny with a high of 20C.")
@contextmanager
def _resolve_storage_directory() -> Iterator[Path]:
"""Yield the configured storage directory for the sample run."""
if USE_TEMP_DIRECTORY:
with tempfile.TemporaryDirectory(prefix="af-file-history-") as temp_directory:
yield Path(temp_directory)
return
storage_directory = Path(__file__).resolve().parent / LOCAL_SESSIONS_DIRECTORY_NAME
storage_directory.mkdir(parents=True, exist_ok=True)
yield storage_directory
async def main() -> None:
"""Run the file history provider sample."""
with _resolve_storage_directory() as storage_directory:
print(f"Using temporary directory: {USE_TEMP_DIRECTORY}")
print(f"Storage directory: {storage_directory}\n")
# 2. Create the agent with a tool so the JSONL file includes tool-calling messages.
agent = Agent(
client=FoundryChatClient(
project_endpoint=os.getenv("FOUNDRY_PROJECT_ENDPOINT"),
model=os.getenv("FOUNDRY_MODEL"),
credential=AzureCliCredential(),
),
name="FileHistoryAgent",
instructions=(
"You are a helpful assistant, use the lookup_weather tool for weather questions and "
"answer with the tool result in one sentence."
),
tools=[lookup_weather],
# if orjson is available, use it for faster JSON serialization in the FileHistoryProvider,
# otherwise fall back to the default json module.
context_providers=[
FileHistoryProvider(
storage_directory,
dumps=orjson.dumps if orjson else None,
loads=orjson.loads if orjson else None,
)
],
default_options={"store": False},
)
# 3. Let Agent create the default UUID session id for this conversation.
session = agent.create_session()
# 4. Ask a question that triggers the weather tool.
print("=== Run with tool calling ===")
query = "Use the lookup_weather tool for Seattle and tell me the weather."
response = await agent.run(query, session=session)
print(f"User: {query}")
print(f"Assistant: {response.text}\n")
# 5. Ask a follow-up question that triggers the weather tool as well
print("=== Follow-up question ===")
query = "And what about Amsterdam?"
response = await agent.run(query, session=session)
print(f"User: {query}")
print(f"Assistant: {response.text}\n")
if __name__ == "__main__":
asyncio.run(main())
"""
Sample output:
Using temporary directory: False
Storage directory: /path/to/samples/02-agents/conversations/sessions
=== Run with tool calling ===
User: Use the lookup_weather tool for Seattle and tell me the weather.
Assistant: <model response varies>
=== Follow-up question ===
User: And what about Amsterdam?
Assistant: <model response varies>
"""
@@ -0,0 +1,185 @@
# Copyright (c) Microsoft. All rights reserved.
# ruff: noqa: T201
from __future__ import annotations
import asyncio
import json
import tempfile
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import Annotated
# Uncomment this filter to suppress the experimental FileHistoryProvider warning
# before running the sample.
# import warnings # isort: skip
# warnings.filterwarnings("ignore", message=r"\[FILE_HISTORY\].*", category=FutureWarning)
from agent_framework import Agent, FileHistoryProvider, tool
from agent_framework.foundry import FoundryChatClient
from azure.identity.aio import AzureCliCredential
from dotenv import load_dotenv
from pydantic import Field
try:
import orjson
except ImportError:
orjson = None
load_dotenv()
"""
File History Provider Conversation Persistence
This sample demonstrates persisting a tool-driven conversation with the
experimental `FileHistoryProvider`, reading the stored JSONL file back from
disk, and then continuing the same conversation with another city.
Environment variables:
FOUNDRY_PROJECT_ENDPOINT: Azure AI Foundry project endpoint.
FOUNDRY_MODEL: Foundry model deployment name.
Key components:
- `FileHistoryProvider`: Stores one message JSON object per line in a local
`.jsonl` file for each session.
- `get_weather`: A function tool that makes the persisted file show the
assistant function call and tool result records.
- `json.dumps(..., indent=2)`: Pretty-prints a few persisted JSONL records
while keeping the on-disk file compact and valid.
- `load_dotenv()`: Loads `.env` values up front so the sample can stay focused
on history persistence instead of manual environment variable plumbing.
- Optional `orjson`: Uses `orjson.dumps` / `orjson.loads` automatically when
available, otherwise falls back to the standard library `json` module.
Security posture:
- The history file is plaintext JSONL on disk, so use a trusted storage
directory and treat it as conversation logging, not as secure secret storage.
- Path safety checks protect the filename derived from the session id, but they
do not redact message contents or encrypt the file.
"""
USE_TEMP_DIRECTORY = False
"""When True, store JSONL files in a temporary directory for this run only."""
LOCAL_SESSIONS_DIRECTORY_NAME = "sessions"
"""Folder name used when persisting history next to this sample file."""
@tool(approval_mode="never_require")
def get_weather(
city: Annotated[str, Field(description="The city to get the weather for.")],
) -> str:
"""Return a deterministic weather report for a city."""
weather_reports = {
"Seattle": "Seattle is rainy with a high of 13C.",
"Amsterdam": "Amsterdam is cloudy with a high of 16C.",
}
return weather_reports.get(city, f"{city} is sunny with a high of 20C.")
@contextmanager
def _resolve_storage_directory() -> Iterator[Path]:
"""Yield the configured storage directory for the sample run."""
if USE_TEMP_DIRECTORY:
with tempfile.TemporaryDirectory(prefix="af-file-history-resume-") as temp_directory:
yield Path(temp_directory)
return
storage_directory = Path(__file__).resolve().parent / LOCAL_SESSIONS_DIRECTORY_NAME
storage_directory.mkdir(parents=True, exist_ok=True)
yield storage_directory
async def main() -> None:
"""Run the file history provider conversation persistence sample."""
with _resolve_storage_directory() as storage_directory:
print(f"Using temporary directory: {USE_TEMP_DIRECTORY}")
print(f"Storage directory: {storage_directory}\n")
# 1. Create the client, history provider, and tool-enabled agent.
agent = Agent(
client=FoundryChatClient(
credential=AzureCliCredential(),
),
name="WeatherHistoryAgent",
instructions=(
"You are a helpful assistant. Use the get_weather tool for weather questions "
"and answer in one sentence using the tool result."
),
tools=[get_weather],
context_providers=[
FileHistoryProvider(
storage_directory,
dumps=orjson.dumps if orjson else None,
loads=orjson.loads if orjson else None,
)
],
default_options={"store": False},
)
# 2. Ask about the first city so the JSONL file is created on disk.
session = agent.create_session()
history_file = storage_directory / f"{session.session_id}.jsonl"
print("=== First weather question ===\n")
first_query = "Use the get_weather tool and tell me the weather in Seattle."
first_response = await agent.run(first_query, session=session)
print(f"User: {first_query}")
print(f"Assistant: {first_response.text}\n")
# 3. Read the stored JSONL records back from disk and pretty-print a few of them.
raw_lines = (await asyncio.to_thread(history_file.read_text, encoding="utf-8")).splitlines()
print(f"Stored message lines after first question: {len(raw_lines)}")
print(f"History file: {history_file}\n")
print("=== JSONL preview from disk ===\n")
for index, line in enumerate(raw_lines[:4], start=1):
print(f"Record {index}:")
print(json.dumps(json.loads(line), indent=2))
print()
# 4. Continue the same persisted conversation with another city.
print("=== Second weather question ===\n")
second_query = "Now use the get_weather tool for Amsterdam."
second_response = await agent.run(second_query, session=session)
print(f"User: {second_query}")
print(f"Assistant: {second_response.text}\n")
updated_lines = (await asyncio.to_thread(history_file.read_text, encoding="utf-8")).splitlines()
print(f"Stored message lines after second question: {len(updated_lines)}")
print(f"History file: {history_file}")
if __name__ == "__main__":
asyncio.run(main())
"""
Sample output:
Using temporary directory: False
Storage directory: /path/to/samples/02-agents/conversations/sessions
=== First weather question ===
User: Use the get_weather tool and tell me the weather in Seattle.
Assistant: <model response varies>
Stored message lines after first question: 4
History file: /path/to/samples/02-agents/conversations/sessions/<session-uuid>.jsonl
=== JSONL preview from disk ===
Record 1:
{
"type": "message",
"role": "user",
...
}
=== Second weather question ===
User: Now use the get_weather tool for Amsterdam.
Assistant: <model response varies>
Stored message lines after second question: 8
History file: /path/to/samples/02-agents/conversations/sessions/<session-uuid>.jsonl
"""
+7 -1
View File
@@ -417,7 +417,7 @@ dev = [{ name = "types-pyyaml", specifier = "==6.0.12.20250915" }]
[[package]]
name = "agent-framework-devui"
version = "1.0.0b260409"
version = "1.0.0b260414"
source = { editable = "packages/devui" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -2487,6 +2487,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/38/3f/9859f655d11901e7b2996c6e3d33e0caa9a1d4572c3bc61ed0faa64b2f4c/greenlet-3.3.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9bc885b89709d901859cf95179ec9f6bb67a3d2bb1f0e88456461bd4b7f8fd0d", size = 277747, upload-time = "2026-02-20T20:16:21.325Z" },
{ url = "https://files.pythonhosted.org/packages/fb/07/cb284a8b5c6498dbd7cba35d31380bb123d7dceaa7907f606c8ff5993cbf/greenlet-3.3.2-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b568183cf65b94919be4438dc28416b234b678c608cafac8874dfeeb2a9bbe13", size = 579202, upload-time = "2026-02-20T20:47:28.955Z" },
{ url = "https://files.pythonhosted.org/packages/ed/45/67922992b3a152f726163b19f890a85129a992f39607a2a53155de3448b8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:527fec58dc9f90efd594b9b700662ed3fb2493c2122067ac9c740d98080a620e", size = 590620, upload-time = "2026-02-20T20:55:55.581Z" },
{ url = "https://files.pythonhosted.org/packages/03/5f/6e2a7d80c353587751ef3d44bb947f0565ec008a2e0927821c007e96d3a7/greenlet-3.3.2-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508c7f01f1791fbc8e011bd508f6794cb95397fdb198a46cb6635eb5b78d85a7", size = 602132, upload-time = "2026-02-20T21:02:43.261Z" },
{ url = "https://files.pythonhosted.org/packages/ad/55/9f1ebb5a825215fadcc0f7d5073f6e79e3007e3282b14b22d6aba7ca6cb8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ad0c8917dd42a819fe77e6bdfcb84e3379c0de956469301d9fd36427a1ca501f", size = 591729, upload-time = "2026-02-20T20:20:58.395Z" },
{ url = "https://files.pythonhosted.org/packages/24/b4/21f5455773d37f94b866eb3cf5caed88d6cea6dd2c6e1f9c34f463cba3ec/greenlet-3.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:97245cc10e5515dbc8c3104b2928f7f02b6813002770cfaffaf9a6e0fc2b94ef", size = 1551946, upload-time = "2026-02-20T20:49:31.102Z" },
{ url = "https://files.pythonhosted.org/packages/00/68/91f061a926abead128fe1a87f0b453ccf07368666bd59ffa46016627a930/greenlet-3.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8c1fdd7d1b309ff0da81d60a9688a8bd044ac4e18b250320a96fc68d31c209ca", size = 1618494, upload-time = "2026-02-20T20:21:06.541Z" },
@@ -2494,6 +2495,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/47/16400cb42d18d7a6bb46f0626852c1718612e35dcb0dffa16bbaffdf5dd2/greenlet-3.3.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c56692189a7d1c7606cb794be0a8381470d95c57ce5be03fb3d0ef57c7853b86", size = 278890, upload-time = "2026-02-20T20:19:39.263Z" },
{ url = "https://files.pythonhosted.org/packages/a3/90/42762b77a5b6aa96cd8c0e80612663d39211e8ae8a6cd47c7f1249a66262/greenlet-3.3.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ebd458fa8285960f382841da585e02201b53a5ec2bac6b156fc623b5ce4499f", size = 581120, upload-time = "2026-02-20T20:47:30.161Z" },
{ url = "https://files.pythonhosted.org/packages/bf/6f/f3d64f4fa0a9c7b5c5b3c810ff1df614540d5aa7d519261b53fba55d4df9/greenlet-3.3.2-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a443358b33c4ec7b05b79a7c8b466f5d275025e750298be7340f8fc63dff2a55", size = 594363, upload-time = "2026-02-20T20:55:56.965Z" },
{ url = "https://files.pythonhosted.org/packages/9c/8b/1430a04657735a3f23116c2e0d5eb10220928846e4537a938a41b350bed6/greenlet-3.3.2-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4375a58e49522698d3e70cc0b801c19433021b5c37686f7ce9c65b0d5c8677d2", size = 605046, upload-time = "2026-02-20T21:02:45.234Z" },
{ url = "https://files.pythonhosted.org/packages/72/83/3e06a52aca8128bdd4dcd67e932b809e76a96ab8c232a8b025b2850264c5/greenlet-3.3.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e2cd90d413acbf5e77ae41e5d3c9b3ac1d011a756d7284d7f3f2b806bbd6358", size = 594156, upload-time = "2026-02-20T20:20:59.955Z" },
{ url = "https://files.pythonhosted.org/packages/70/79/0de5e62b873e08fe3cef7dbe84e5c4bc0e8ed0c7ff131bccb8405cd107c8/greenlet-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:442b6057453c8cb29b4fb36a2ac689382fc71112273726e2423f7f17dc73bf99", size = 1554649, upload-time = "2026-02-20T20:49:32.293Z" },
{ url = "https://files.pythonhosted.org/packages/5a/00/32d30dee8389dc36d42170a9c66217757289e2afb0de59a3565260f38373/greenlet-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45abe8eb6339518180d5a7fa47fa01945414d7cca5ecb745346fc6a87d2750be", size = 1619472, upload-time = "2026-02-20T20:21:07.966Z" },
@@ -2502,6 +2504,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" },
{ url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" },
{ url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" },
{ url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" },
{ url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" },
{ url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" },
{ url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" },
@@ -2510,6 +2513,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" },
{ url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" },
{ url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" },
{ url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" },
{ url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" },
{ url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" },
{ url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" },
@@ -2518,6 +2522,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" },
{ url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" },
{ url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" },
{ url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" },
{ url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" },
{ url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" },
{ url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" },
@@ -2526,6 +2531,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" },
{ url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" },
{ url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" },
{ url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" },
{ url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" },
{ url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" },
{ url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" },