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
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:
@@ -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
|
||||
@@ -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()`")
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user