chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:25 +08:00
commit db620d33df
5151 changed files with 925932 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
+93
View File
@@ -0,0 +1,93 @@
# agent-framework-hosting
Shared execution-state helpers for app-owned Agent Framework hosting.
This package keeps Agent Framework state separate from web-framework concerns:
- `AgentState` — pairs an agent target with a `SessionStore`
(`session_id -> AgentSession`).
- `WorkflowState` — resolves a workflow target, including direct `Workflow`
instances, workflow factories, `WorkflowBuilder`, and orchestration builders.
`SessionStore` is plain storage: `get`/`set`/`delete` by an app-selected id,
nothing more. It does not know how to create a new value for an id it hasn't
seen before — use `AgentState.get_or_create_session(...)` for that, since only
the state object has both the store and the resolved target. Workflow
checkpointing should use the existing `CheckpointStorage` abstraction directly;
if an app needs per-session resume, keep a small app-owned cursor such as
`session_id -> checkpoint_id`.
Use FastAPI, Starlette, Azure Functions, Django, or another framework for route
registration, auth, middleware, response construction, and background work.
> The built-in `SessionStore` is an in-memory `dict` with no eviction — every
> id ever stored stays resolvable for the life of the process. That is
> intentional: protocols such as OpenAI Responses'
> `previous_response_id` are designed to let a caller continue from *any*
> earlier point in a conversation, not just the latest turn, so every id
> handed out needs to stay independently resolvable. If you back the store
> with real storage (Redis, a database, ...), you are responsible for that
> store's own TTL/eviction policy; this in-memory reference implementation
> does not model that concern.
## Quickstart
```python
from agent_framework.openai import OpenAIChatClient
from agent_framework_hosting import AgentState
agent = OpenAIChatClient().as_agent(name="Assistant")
state = AgentState(agent)
session = await state.get_or_create_session("conversation-1")
result = await (await state.get_target()).run("Hello", session=session)
```
If a protocol mints a new continuation id on every response, store the session
explicitly after `run(...)` returns. `run(...)` may update the session, so store
the post-run object:
```python
session = await state.get_or_create_session(previous_response_id)
result = await (await state.get_target()).run("Hello", session=session)
await state.set_session(response_id, session)
```
Targets can be direct instances, synchronous factories, asynchronous factories,
or awaitables:
```python
state = AgentState(create_agent) # cached by default
state = AgentState(create_agent, cache_target=False)
```
`WorkflowState` mirrors this shape for workflow targets:
```python
from agent_framework import InMemoryCheckpointStorage
from agent_framework_hosting import WorkflowState
state = WorkflowState(create_workflow)
storage = InMemoryCheckpointStorage()
result = await (await state.get_target()).run("Hello", checkpoint_storage=storage)
latest = await storage.get_latest(workflow_name=(await state.get_target()).name)
```
`WorkflowState` also accepts an unbuilt workflow builder directly:
```python
from agent_framework import WorkflowBuilder
from agent_framework_hosting import WorkflowState
builder = WorkflowBuilder(start_executor=executor)
state = WorkflowState(builder) # calls builder.build() when the target is resolved
```
This is structural: orchestration builders from `agent_framework_orchestrations`
(`SequentialBuilder`, `ConcurrentBuilder`, `HandoffBuilder`, `GroupChatBuilder`,
and `MagenticBuilder`) also work because they expose the same zero-argument
`build() -> Workflow` method.
Cross-channel identity linking, multicast delivery, background runs,
continuation tokens, and durable delivery runners are follow-up enhancements,
not part of this v1 state surface.
@@ -0,0 +1,29 @@
# Copyright (c) Microsoft. All rights reserved.
"""Execution-state helpers for app-owned Agent Framework hosting routes."""
import importlib.metadata
from ._state import (
AgentRunArgs,
AgentState,
SessionStore,
SupportsBuild,
WorkflowRunArgs,
WorkflowState,
)
try:
__version__ = importlib.metadata.version(__name__)
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0"
__all__ = [
"AgentRunArgs",
"AgentState",
"SessionStore",
"SupportsBuild",
"WorkflowRunArgs",
"WorkflowState",
"__version__",
]
@@ -0,0 +1,338 @@
# Copyright (c) Microsoft. All rights reserved.
"""Shared execution state for app-owned hosting routes.
Two independent state holders, one per target kind, since agents and
workflows keep different continuation state:
- ``AgentState`` pairs an agent target with a ``SessionStore``
(``session_id -> AgentSession``).
- ``WorkflowState`` resolves a workflow target. Workflow checkpointing uses
the existing ``CheckpointStorage`` abstraction directly; apps can keep their
own ``session_id -> checkpoint_id`` cursor when they need per-session resume.
``SessionStore`` is plain storage: it only gets/sets/deletes what it is given.
It does not know how to create a new value for a ``session_id`` it hasn't seen
before -- that is ``AgentState``'s job (see ``AgentState.get_or_create_session``),
since only the state object has both the store and the resolved target.
"""
from __future__ import annotations
import asyncio
import inspect
from collections.abc import Awaitable, Callable, Mapping
from typing import Any, Generic, Protocol, TypedDict, TypeVar, cast, runtime_checkable
from agent_framework import (
AgentRunInputs,
AgentSession,
ChatOptions,
SupportsAgentRun,
Workflow,
)
class SessionStore:
"""Plain in-memory ``session_id -> AgentSession`` lookup.
This store only stores and retrieves; it does not create sessions. Use
:meth:`AgentState.get_or_create_session` for that -- it resolves the
agent target and calls ``target.create_session(...)`` the first time a
given ``session_id`` is seen, then stores the result here.
No eviction: every id ever stored stays resolvable for the life of the
process. That is intentional -- protocols such as OpenAI Responses'
``previous_response_id`` are designed to let a caller continue from *any*
earlier point in a conversation, not just the latest turn, so every id
that has been handed out needs to stay independently resolvable. If you
back a ``SessionStore``-shaped store with real storage (Redis, a
database, ...), you are responsible for that store's own TTL/eviction
policy; this in-memory reference implementation does not model that
concern.
"""
def __init__(self) -> None:
"""Create an empty session store."""
self._sessions: dict[str, AgentSession] = {}
async def get(self, session_id: str) -> AgentSession | None:
"""Return the stored session for ``session_id``, or ``None`` if absent.
Args:
session_id: Opaque app-selected session id.
Raises:
ValueError: If ``session_id`` is empty.
"""
if not session_id:
raise ValueError("session_id must be a non-empty string")
return self._sessions.get(session_id)
async def set(self, session_id: str, session: AgentSession) -> None:
"""Store ``session`` under ``session_id``, replacing any existing entry.
Args:
session_id: Opaque app-selected session id.
session: The session to store.
Raises:
ValueError: If ``session_id`` is empty.
"""
if not session_id:
raise ValueError("session_id must be a non-empty string")
self._sessions[session_id] = session
async def delete(self, session_id: str) -> None:
"""Forget the stored session for ``session_id``, if any.
Args:
session_id: Opaque app-selected session id.
Raises:
ValueError: If ``session_id`` is empty.
"""
if not session_id:
raise ValueError("session_id must be a non-empty string")
self._sessions.pop(session_id, None)
AgentT = TypeVar("AgentT", bound=SupportsAgentRun)
WorkflowT = TypeVar("WorkflowT", bound=Workflow)
@runtime_checkable
class SupportsBuild(Protocol):
"""A builder that produces a ``Workflow`` via a zero-argument ``build()``.
Matches ``agent_framework.WorkflowBuilder`` and the orchestration
builders in ``agent_framework_orchestrations`` (``ConcurrentBuilder``,
``GroupChatBuilder``, ``HandoffBuilder``, ``MagenticBuilder``,
``SequentialBuilder``) structurally, without ``agent-framework-hosting``
depending on either package.
"""
def build(self) -> Workflow: ...
class AgentRunArgs(TypedDict):
"""Arguments prepared for ``Agent.run``."""
messages: AgentRunInputs
options: ChatOptions[Any]
stream: bool
class WorkflowRunArgs(TypedDict):
"""Arguments prepared for ``Workflow.run``."""
message: Any | None
responses: Mapping[str, Any] | None
stream: bool
class AgentState(Generic[AgentT]):
"""Shared execution state for app-owned agent hosting routes.
Holds the Agent Framework agent target and a :class:`SessionStore` that
route code may share. Does not own routes, middleware, protocol
dispatch, or native SDK calls -- web frameworks keep those concerns.
"""
def __init__(
self,
target: AgentT | Awaitable[AgentT] | Callable[[], AgentT | Awaitable[AgentT]],
*,
session_store: SessionStore | None = None,
cache_target: bool = True,
) -> None:
"""Create shared state for ``target``.
Args:
target: Agent target used by route code. May be a target
instance, a synchronous factory, an asynchronous factory, or
an awaitable target.
Keyword Args:
session_store: Existing store to use. Defaults to a fresh
in-memory :class:`SessionStore`.
cache_target: Whether to cache a resolved callable/awaitable
target. Defaults to ``True`` so expensive target setup
happens once.
Raises:
ValueError: If ``cache_target=False`` is used with a one-shot
awaitable target.
"""
if not cache_target and inspect.isawaitable(target):
raise ValueError("cache_target=False requires a target instance or callable target factory")
self._target_source = target
self._cache_target = cache_target
self._cached_target: AgentT | None = None
self._target_lock = asyncio.Lock()
if not callable(target) and not inspect.isawaitable(target):
self._cached_target = target
self._session_store: SessionStore = session_store if session_store is not None else SessionStore()
self._session_locks: dict[str, asyncio.Lock] = {}
async def get_target(self) -> AgentT:
"""Return the resolved target.
Returns:
The target instance. Callable and awaitable targets are resolved
first and cached by default.
"""
if self._cache_target and self._cached_target is not None:
return self._cached_target
if self._cache_target:
async with self._target_lock:
if self._cached_target is not None:
return self._cached_target
target = self._target_source() if callable(self._target_source) else self._target_source
if inspect.isawaitable(target):
target = await target
self._cached_target = target
return target
target = self._target_source() if callable(self._target_source) else self._target_source
if inspect.isawaitable(target):
target = await target
return target
@property
def target(self) -> AgentT:
"""Return a synchronously available target.
Raises:
RuntimeError: If the target is a callable or awaitable that has not
been resolved with :meth:`get_target`.
"""
if self._cached_target is not None:
return self._cached_target
if not callable(self._target_source) and not inspect.isawaitable(self._target_source):
return self._target_source
raise RuntimeError("target is resolved asynchronously; use `await state.get_target()`")
@property
def session_store(self) -> SessionStore:
"""Return the session store for this state."""
return self._session_store
async def get_or_create_session(self, session_id: str) -> AgentSession:
"""Return the session for ``session_id``, creating and storing one if missing.
Args:
session_id: Opaque app-selected session id.
Returns:
The stored or newly created ``AgentSession``.
"""
if not session_id:
raise ValueError("session_id must be a non-empty string")
session_lock = self._session_locks.setdefault(session_id, asyncio.Lock())
async with session_lock:
session = await self._session_store.get(session_id)
if session is None:
target = await self.get_target()
session = target.create_session(session_id=session_id)
await self._session_store.set(session_id, session)
return session
async def set_session(self, session_id: str, session: AgentSession) -> None:
"""Store ``session`` under ``session_id`` in this state's session store.
Args:
session_id: Opaque app-selected session id.
session: Session to store.
"""
await self._session_store.set(session_id, session)
class WorkflowState(Generic[WorkflowT]):
"""Shared execution state for app-owned workflow hosting routes.
Holds the Agent Framework workflow target. Does not own routes,
middleware, protocol dispatch, native SDK calls, or checkpoint storage --
web frameworks and workflow/app code keep those concerns.
"""
def __init__(
self,
target: WorkflowT | SupportsBuild | Awaitable[WorkflowT] | Callable[[], WorkflowT | Awaitable[WorkflowT]],
*,
cache_target: bool = True,
) -> None:
"""Create shared state for ``target``.
Args:
target: Workflow target used by route code. May be a target
instance, a ``WorkflowBuilder``-shaped builder (see
:class:`SupportsBuild`; the state calls ``build()`` for you),
a synchronous factory, an asynchronous factory, or an
awaitable target.
Keyword Args:
cache_target: Whether to cache a resolved callable/awaitable/built
target. Defaults to ``True`` so expensive target setup
happens once.
Raises:
ValueError: If ``cache_target=False`` is used with a one-shot
awaitable target.
"""
if isinstance(target, SupportsBuild):
# WorkflowBuilder (and the orchestration builders) are not
# themselves callable or awaitable, so normalize to the bound
# `build` method -- the resolution logic below already knows how
# to treat a zero-arg factory. `build()` is typed to return the
# `Workflow` base class rather than this instance's narrower
# `WorkflowT`, but it is the same object the caller asked for.
target = cast("Callable[[], WorkflowT]", target.build)
if not cache_target and inspect.isawaitable(target):
raise ValueError("cache_target=False requires a target instance or callable target factory")
self._target_source = target
self._cache_target = cache_target
self._cached_target: WorkflowT | None = None
self._target_lock = asyncio.Lock()
if not callable(target) and not inspect.isawaitable(target):
self._cached_target = target
async def get_target(self) -> WorkflowT:
"""Return the resolved target.
Returns:
The target instance. Callable and awaitable targets are resolved
first and cached by default.
"""
if self._cache_target and self._cached_target is not None:
return self._cached_target
if self._cache_target:
async with self._target_lock:
if self._cached_target is not None:
return self._cached_target
target = self._target_source() if callable(self._target_source) else self._target_source
if inspect.isawaitable(target):
target = await target
self._cached_target = target
return target
target = self._target_source() if callable(self._target_source) else self._target_source
if inspect.isawaitable(target):
target = await target
return target
@property
def target(self) -> WorkflowT:
"""Return a synchronously available target.
Raises:
RuntimeError: If the target is a callable or awaitable that has not
been resolved with :meth:`get_target`.
"""
if self._cached_target is not None:
return self._cached_target
if not callable(self._target_source) and not inspect.isawaitable(self._target_source):
return self._target_source
raise RuntimeError("target is resolved asynchronously; use `await state.get_target()`")
+78
View File
@@ -0,0 +1,78 @@
[project]
name = "agent-framework-hosting"
description = "Execution-state helpers for app-owned Agent Framework hosting routes."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0a260709"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
urls.issues = "https://github.com/microsoft/agent-framework/issues"
classifiers = [
"License :: OSI Approved :: MIT License",
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.11.0,<2",
]
[tool.uv]
prerelease = "if-necessary-or-explicit"
environments = [
"sys_platform == 'darwin'",
"sys_platform == 'linux'",
"sys_platform == 'win32'"
]
[tool.uv-dynamic-versioning]
fallback-version = "0.0.0"
[tool.pytest.ini_options]
testpaths = 'tests'
addopts = "-ra -q -r fEX"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = []
timeout = 120
markers = [
"integration: marks tests as integration tests that require external services",
]
[tool.ruff]
extend = "../../pyproject.toml"
[tool.coverage.run]
omit = [
"**/__init__.py"
]
[tool.pyright]
extends = "../../pyproject.toml"
include = ["agent_framework_hosting"]
exclude = ['tests']
[tool.bandit]
targets = ["agent_framework_hosting"]
exclude_dirs = ["tests"]
[tool.poe]
executor.type = "uv"
include = "../../shared_tasks.toml"
[tool.poe.tasks.test]
help = "Run the default unit test suite for this package."
cmd = 'pytest -m "not integration" --cov=agent_framework_hosting --cov-report=term-missing:skip-covered tests'
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
build-backend = "flit_core.buildapi"
@@ -0,0 +1,50 @@
# Copyright (c) Microsoft. All rights reserved.
"""Workflow fixtures for hosting tests.
Defined in a module that does not use ``from __future__ import annotations``
because the workflow handler validation reflects on real annotation objects
rather than stringified forms.
"""
from typing import Any
from agent_framework import Executor, Workflow, WorkflowBuilder, WorkflowContext, handler
class _UpperExecutor(Executor):
@handler
async def handle(self, text: str, ctx: WorkflowContext[Any, str]) -> None:
await ctx.yield_output(text.upper())
class _EchoExecutor(Executor):
@handler
async def handle(self, text: str, ctx: WorkflowContext[Any, str]) -> None:
await ctx.yield_output(text)
def build_upper_workflow() -> Workflow:
return WorkflowBuilder(start_executor=_UpperExecutor(id="upper")).build()
def build_echo_workflow() -> Workflow:
return WorkflowBuilder(start_executor=_EchoExecutor(id="echo")).build()
def echo_workflow_builder() -> WorkflowBuilder:
"""Return an *unbuilt* echo ``WorkflowBuilder``, for testing builder-shaped targets."""
return WorkflowBuilder(start_executor=_EchoExecutor(id="echo"))
class _MultiChunkExecutor(Executor):
"""Yields three separate ``output`` events so streaming has something to chew on."""
@handler
async def handle(self, text: str, ctx: WorkflowContext[Any, str]) -> None:
for chunk in (f"{text}-1", f"{text}-2", f"{text}-3"):
await ctx.yield_output(chunk)
def build_multi_chunk_workflow() -> Workflow:
return WorkflowBuilder(start_executor=_MultiChunkExecutor(id="multi")).build()
@@ -0,0 +1,25 @@
# Copyright (c) Microsoft. All rights reserved.
"""Pytest configuration for hosting tests."""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
def pytest_configure() -> None:
"""Make workflow fixtures importable in package-local and aggregate test modes."""
module_name = "hosting_workflow_fixtures"
if module_name in sys.modules:
return
fixture_path = Path(__file__).with_name("_workflow_fixtures.py")
spec = importlib.util.spec_from_file_location(module_name, fixture_path)
if spec is None or spec.loader is None:
raise ImportError(f"Unable to load workflow fixtures from {fixture_path}")
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
@@ -0,0 +1,347 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import asyncio
import importlib
from collections.abc import AsyncIterator, Awaitable, Mapping
from typing import Any, Literal, overload
import pytest
from agent_framework import (
AgentResponse,
AgentResponseUpdate,
AgentRunInputs,
AgentSession,
Content,
Message,
ResponseStream,
Workflow,
)
from agent_framework_hosting import AgentState, SessionStore, WorkflowState
def _workflow_fixture(name: str) -> Any:
"""Load a fixture from ``_workflow_fixtures.py`` via the ``conftest``-registered alias.
Mirrors ``test_host.py``'s helper: the local ``conftest.py`` registers
``_workflow_fixtures.py`` under the collision-proof name
``hosting_workflow_fixtures`` so it stays importable in both
package-local and aggregate pytest runs.
"""
return getattr(importlib.import_module("hosting_workflow_fixtures"), name)
class _FakeAgent:
"""Minimal agent target for state tests.
Declares ``run`` with the same two overloads as ``SupportsAgentRun`` (one
per ``stream`` value) so it satisfies the protocol under static type
checking, not just at runtime.
"""
id: str = "fake-agent"
name: str | None = "Fake Agent"
description: str | None = "Fake agent for tests"
def __init__(self) -> None:
self.created_sessions: list[AgentSession] = []
def create_session(self, *, session_id: str | None = None) -> AgentSession:
session = AgentSession(session_id=session_id)
self.created_sessions.append(session)
return session
def get_session(self, service_session_id: Any, *, session_id: str | None = None) -> AgentSession:
return AgentSession(session_id=session_id, service_session_id=service_session_id)
@overload
def run(
self,
messages: AgentRunInputs | None = None,
*,
stream: Literal[False] = ...,
session: AgentSession | None = None,
function_invocation_kwargs: Mapping[str, Any] | None = None,
client_kwargs: Mapping[str, Any] | None = None,
) -> Awaitable[AgentResponse[Any]]: ...
@overload
def run(
self,
messages: AgentRunInputs | None = None,
*,
stream: Literal[True],
session: AgentSession | None = None,
function_invocation_kwargs: Mapping[str, Any] | None = None,
client_kwargs: Mapping[str, Any] | None = None,
) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ...
def run(
self,
messages: AgentRunInputs | None = None,
*,
stream: bool = False,
session: AgentSession | None = None,
function_invocation_kwargs: Mapping[str, Any] | None = None,
client_kwargs: Mapping[str, Any] | None = None,
) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]:
if stream:
async def _stream() -> AsyncIterator[AgentResponseUpdate]:
yield AgentResponseUpdate(contents=[Content.from_text(text="ok")], role="assistant")
return ResponseStream(_stream(), finalizer=lambda updates: AgentResponse.from_updates(updates))
async def _get_response() -> AgentResponse[Any]:
return AgentResponse(messages=Message(role="assistant", contents=[Content.from_text(text="ok")]))
return _get_response()
class TestSessionStore:
async def test_get_returns_none_for_missing_id(self) -> None:
store = SessionStore()
assert await store.get("session-1") is None
async def test_set_then_get_returns_stored_session(self) -> None:
store = SessionStore()
session = AgentSession(session_id="session-1")
await store.set("session-1", session)
assert await store.get("session-1") is session
async def test_set_can_store_same_session_under_additional_id(self) -> None:
store = SessionStore()
session = AgentSession(session_id="resp_1")
await store.set("resp_1", session)
await store.set("resp_2", session)
assert await store.get("resp_1") is session
assert await store.get("resp_2") is session
async def test_set_replaces_existing_entry(self) -> None:
store = SessionStore()
first = AgentSession(session_id="session-1")
second = AgentSession(session_id="session-1")
await store.set("session-1", first)
await store.set("session-1", second)
assert await store.get("session-1") is second
async def test_delete_forgets_session(self) -> None:
store = SessionStore()
await store.set("session-1", AgentSession(session_id="session-1"))
await store.delete("session-1")
assert await store.get("session-1") is None
async def test_delete_missing_id_is_a_no_op(self) -> None:
store = SessionStore()
await store.delete("never-stored")
async def test_empty_session_id_raises(self) -> None:
store = SessionStore()
session = AgentSession(session_id="session-1")
with pytest.raises(ValueError, match="session_id"):
await store.get("")
with pytest.raises(ValueError, match="session_id"):
await store.set("", session)
with pytest.raises(ValueError, match="session_id"):
await store.delete("")
class TestAgentState:
def test_default_session_store_is_fresh_in_memory_store(self) -> None:
agent = _FakeAgent()
state = AgentState(agent)
assert state.target is agent
assert isinstance(state.session_store, SessionStore)
def test_accepts_session_store_instance(self) -> None:
store = SessionStore()
state = AgentState(_FakeAgent(), session_store=store)
assert state.session_store is store
async def test_callable_target_cached_by_default(self) -> None:
calls = 0
def create_agent() -> _FakeAgent:
nonlocal calls
calls += 1
return _FakeAgent()
state = AgentState(create_agent)
first = await state.get_target()
second = await state.get_target()
assert first is second
assert calls == 1
async def test_callable_target_cache_can_be_disabled(self) -> None:
calls = 0
def create_agent() -> _FakeAgent:
nonlocal calls
calls += 1
return _FakeAgent()
state = AgentState(create_agent, cache_target=False)
first = await state.get_target()
second = await state.get_target()
assert first is not second
assert calls == 2
async def test_async_callable_target(self) -> None:
async def create_agent() -> _FakeAgent:
return _FakeAgent()
state = AgentState(create_agent)
assert isinstance(await state.get_target(), _FakeAgent)
async def test_bare_awaitable_target_is_awaited_once_for_concurrent_callers(self) -> None:
calls = 0
async def create_agent() -> _FakeAgent:
nonlocal calls
calls += 1
await asyncio.sleep(0)
return _FakeAgent()
state = AgentState(create_agent())
first, second = await asyncio.gather(state.get_target(), state.get_target())
assert first is second
assert calls == 1
def test_cache_target_false_rejects_bare_awaitable(self) -> None:
async def create_agent() -> _FakeAgent:
return _FakeAgent()
coro = create_agent()
try:
with pytest.raises(ValueError, match="cache_target=False"):
AgentState(coro, cache_target=False)
finally:
coro.close()
async def test_get_or_create_session_creates_and_stores_once(self) -> None:
agent = _FakeAgent()
state = AgentState(agent)
first = await state.get_or_create_session("session-1")
second = await state.get_or_create_session("session-1")
assert first is second
assert first.session_id == "session-1"
assert len(agent.created_sessions) == 1
async def test_get_or_create_session_creates_once_for_concurrent_callers(self) -> None:
class _YieldingSessionStore(SessionStore):
async def get(self, session_id: str) -> AgentSession | None:
await asyncio.sleep(0)
return await super().get(session_id)
async def set(self, session_id: str, session: AgentSession) -> None:
await asyncio.sleep(0)
await super().set(session_id, session)
agent = _FakeAgent()
state = AgentState(agent, session_store=_YieldingSessionStore())
sessions = await asyncio.gather(*(state.get_or_create_session("session-1") for _ in range(20)))
assert all(session is sessions[0] for session in sessions)
assert len(agent.created_sessions) == 1
async def test_get_or_create_session_reuses_a_session_set_on_the_state(self) -> None:
agent = _FakeAgent()
state = AgentState(agent)
pre_existing = AgentSession(session_id="session-1")
await state.set_session("session-1", pre_existing)
session = await state.get_or_create_session("session-1")
assert session is pre_existing
assert len(agent.created_sessions) == 0
class TestWorkflowState:
def test_accepts_workflow_target_instance(self) -> None:
workflow = _workflow_fixture("build_echo_workflow")()
state: WorkflowState[Workflow] = WorkflowState(workflow)
assert state.target is workflow
async def test_workflow_target_resolved_from_factory(self) -> None:
build_echo_workflow = _workflow_fixture("build_echo_workflow")
state: WorkflowState[Workflow] = WorkflowState(build_echo_workflow)
target = await state.get_target()
assert isinstance(target, Workflow)
async def test_bare_awaitable_workflow_target_is_awaited_once_for_concurrent_callers(self) -> None:
calls = 0
async def create_workflow() -> Workflow:
nonlocal calls
calls += 1
await asyncio.sleep(0)
return _workflow_fixture("build_echo_workflow")()
state: WorkflowState[Workflow] = WorkflowState(create_workflow())
first, second = await asyncio.gather(state.get_target(), state.get_target())
assert first is second
assert calls == 1
async def test_accepts_workflow_builder_instance_directly(self) -> None:
"""A ``WorkflowBuilder`` is not itself callable or awaitable; the state must
recognize its `build()` method and call it, not cache the raw builder."""
builder = _workflow_fixture("echo_workflow_builder")()
state: WorkflowState[Workflow] = WorkflowState(builder)
target = await state.get_target()
assert isinstance(target, Workflow)
assert state.target is target
async def test_workflow_builder_is_built_once_and_cached_by_default(self) -> None:
builder = _workflow_fixture("echo_workflow_builder")()
state: WorkflowState[Workflow] = WorkflowState(builder)
first = await state.get_target()
second = await state.get_target()
assert first is second
async def test_accepts_orchestration_style_builder_without_importing_orchestrations(self) -> None:
"""``SupportsBuild`` is structural: any object with a zero-arg ``build() -> Workflow``
is accepted, matching ``agent_framework_orchestrations``' builders without this
package depending on that one."""
workflow = _workflow_fixture("build_echo_workflow")()
class _FakeOrchestrationBuilder:
def build(self) -> Workflow:
return workflow
state: WorkflowState[Workflow] = WorkflowState(_FakeOrchestrationBuilder())
assert await state.get_target() is workflow