mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Simplify Python hosting core (#6492)
Remove linking, multicast, durable delivery, and host push machinery from the v1 hosting core. Keep those scenarios in a proposed follow-up ADR and update channel packages, samples, docs, tests, and workspace metadata around the smaller host/channel contract. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
e5a6e35843
commit
36ce0950e4
@@ -2,94 +2,43 @@
|
||||
|
||||
Multi-channel hosting for Microsoft Agent Framework agents.
|
||||
|
||||
`agent-framework-hosting` lets you serve a single agent (or workflow)
|
||||
target through one or more **channels** — pluggable adapters that
|
||||
expose the target over different transports. The result is a single
|
||||
Starlette ASGI application you can host anywhere (local Hypercorn,
|
||||
Azure Container Apps, Foundry Hosted Agents, …).
|
||||
`agent-framework-hosting` lets you serve a single agent or workflow target
|
||||
through one or more **channels**. The host owns one Starlette ASGI app,
|
||||
route/lifecycle composition, and per-`isolation_key` session resolution.
|
||||
Each channel owns its protocol parsing and response rendering.
|
||||
|
||||
The base package contains only the channel-neutral plumbing:
|
||||
The base package contains only channel-neutral plumbing:
|
||||
|
||||
- `AgentFrameworkHost` — the Starlette host
|
||||
- `Channel` / `ChannelPush` — the channel protocols
|
||||
- `ChannelRequest` / `ChannelSession` / `ChannelIdentity` / `ResponseTarget`
|
||||
— the request envelope and routing primitives
|
||||
- `ChannelContext` / `ChannelContribution` / `ChannelCommand` — the
|
||||
channel-side hooks for invoking the target and contributing routes,
|
||||
commands, and lifecycle callbacks
|
||||
- `ChannelRunHook` / `ChannelStreamTransformHook` — the per-request
|
||||
customization seams
|
||||
- `DurableTaskRunner` + `InProcessTaskRunner` — the seam used to
|
||||
dispatch non-originating push fan-out; the in-process runner is the
|
||||
default. Plug in a durable adapter (e.g.
|
||||
`agent-framework-hosting-durabletask`) for `runtime_mode="ephemeral"`
|
||||
deployments.
|
||||
- `AgentFrameworkHost` — the Starlette host.
|
||||
- `Channel` — the channel protocol.
|
||||
- `ChannelRequest` / `ChannelSession` / `ChannelIdentity` — the request
|
||||
envelope and optional channel metadata.
|
||||
- `ChannelContext` / `ChannelContribution` / `ChannelCommand` — channel-side
|
||||
hooks for invoking the target and contributing routes, commands, and
|
||||
lifecycle callbacks.
|
||||
- `ChannelRunHook` / `ChannelResponseHook` / `ChannelStreamUpdateHook` —
|
||||
host-invoked customization seams.
|
||||
|
||||
Concrete channels live in their own packages so you only install what
|
||||
you use:
|
||||
`ChannelStreamUpdateHook` applies to streamed updates only. It is not a
|
||||
substitute for final-response redaction.
|
||||
|
||||
Concrete channels live in their own packages so you only install what you use:
|
||||
|
||||
| Package | Transport |
|
||||
|---|---|
|
||||
| `agent-framework-hosting-responses` | OpenAI Responses API |
|
||||
| `agent-framework-hosting-invocations` | Foundry-native invocation envelope |
|
||||
| `agent-framework-hosting-telegram` | Telegram Bot API |
|
||||
| `agent-framework-hosting-activity-protocol` | Bot Framework Activity Protocol (Teams, Direct Line, Web Chat, …) |
|
||||
| `agent-framework-hosting-teams` | Microsoft Teams (Teams SDK) |
|
||||
| `agent-framework-hosting-entra` | Entra (OAuth) identity-link sidecar |
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Caller[External caller /<br/>messaging app]
|
||||
|
||||
subgraph Host[AgentFrameworkHost]
|
||||
direction TB
|
||||
ASGI[Starlette app]
|
||||
Router[Channel router]
|
||||
Parse{parse →<br/>command or<br/>message?}
|
||||
Auth[host.authorize]
|
||||
Resolver[IdentityResolver]
|
||||
Delivery[_deliver_response]
|
||||
Push[_handle_push_task]
|
||||
end
|
||||
|
||||
Channels[Channels<br/>Responses · Invocations ·<br/>Telegram · Activity ·<br/>IdentityLinker]
|
||||
CmdHandler[CommandHandler<br/>via ChannelCommandContext]
|
||||
Target[(Agent or Workflow)]
|
||||
Runner[DurableTaskRunner]
|
||||
StateStore[(HostStateStore)]
|
||||
|
||||
Caller --> ASGI
|
||||
ASGI --> Router
|
||||
Router --> Parse
|
||||
Parse -- /command --> CmdHandler
|
||||
Parse -- message --> Auth
|
||||
CmdHandler -- ctx.run --> Auth
|
||||
CmdHandler -- local reply --> Channels
|
||||
Auth --> Resolver
|
||||
Resolver --> StateStore
|
||||
Auth --> Target
|
||||
Target --> Delivery
|
||||
Delivery -- originating sync --> Channels
|
||||
Delivery -- non-originating --> Runner
|
||||
Runner --> Push
|
||||
Push --> Channels
|
||||
Channels --> ASGI
|
||||
```
|
||||
|
||||
For a richer set of flow diagrams — identity linking, multi-channel
|
||||
fan-out, server-side relays, background runs, durable-runner codec
|
||||
envelopes, echo idempotency, workflow targets — see the
|
||||
[Python hosting spec](https://github.com/microsoft/agent-framework/blob/main/docs/specs/002-python-hosting-channels.md).
|
||||
| `agent-framework-hosting-activity-protocol` | Bot Framework Activity Protocol |
|
||||
| `agent-framework-hosting-discord` | Discord HTTP Interactions |
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install agent-framework-hosting agent-framework-hosting-responses
|
||||
# or with uvicorn pre-installed for the demo `host.serve(...)` helper
|
||||
# or with Hypercorn pre-installed for the demo `host.serve(...)` helper
|
||||
pip install "agent-framework-hosting[serve]" agent-framework-hosting-responses
|
||||
# add the [disk] extra to opt in to on-disk persistence (see below)
|
||||
# add the [disk] extra to persist reset-session aliases
|
||||
pip install "agent-framework-hosting[disk]"
|
||||
```
|
||||
|
||||
@@ -109,84 +58,46 @@ host = AgentFrameworkHost(target=agent, channels=channels)
|
||||
host.serve(port=8000)
|
||||
```
|
||||
|
||||
See the [hosting samples](https://github.com/microsoft/agent-framework/tree/main/python/samples/04-hosting/af-hosting)
|
||||
for richer multi-channel apps (Telegram + Teams + Responses fan-out,
|
||||
identity linking, `ResponseTarget` routing, etc.).
|
||||
## Session state and workflow checkpoints
|
||||
|
||||
## Optional disk persistence (`state_dir`)
|
||||
By default the host keeps live `AgentSession` objects and reset-session aliases
|
||||
in memory. Channels opt into continuity by setting
|
||||
`ChannelRequest.session = ChannelSession(isolation_key=...)`; requests with the
|
||||
same isolation key reuse the same host-created session.
|
||||
|
||||
By default the host keeps everything in memory: the durable-task runner's
|
||||
pending push queue, the per-isolation-key session aliases, the active-channel
|
||||
map, and the per-channel `ChannelIdentity` map. That is the right shape for
|
||||
**ephemeral** runtimes (Foundry Hosted Agents et al.) where the host is
|
||||
restarted per request and persistence lives behind a service like the Foundry
|
||||
response store, and for short-lived local dev.
|
||||
|
||||
For **long-running** deployments (an always-on container, a local dev server
|
||||
you restart often, a single-VM bot) opt in to disk persistence by passing
|
||||
`state_dir` to `AgentFrameworkHost`. The runner queue and the session
|
||||
bookkeeping use [`diskcache`](https://grantjenks.com/docs/diskcache/)
|
||||
(installed via the `[disk]` extra) protected by an OS-level advisory file
|
||||
lock so two hosts pointed at the same directory can't double-execute
|
||||
scheduled pushes. Workflow checkpoints (when the target is a `Workflow`)
|
||||
use the framework's `FileCheckpointStorage` — no extra dependency. The
|
||||
identity-link store path is offered to linkers that implement
|
||||
`SupportsLinkStorePath`; linkers that manage persistence themselves should
|
||||
be configured directly.
|
||||
For long-running deployments that need `reset_session(...)` aliases to survive
|
||||
restart, pass `state_dir`:
|
||||
|
||||
```python
|
||||
from agent_framework_hosting import AgentFrameworkHost
|
||||
|
||||
# Single path → host auto-derives `runner/`, `sessions/`, `links/`, and
|
||||
# (for workflow targets) `checkpoints/` subpaths.
|
||||
host = AgentFrameworkHost(
|
||||
target=agent,
|
||||
channels=channels,
|
||||
state_dir="./.host-state",
|
||||
)
|
||||
```
|
||||
|
||||
# Or route components to different roots — use the HostStatePaths TypedDict
|
||||
# (or a plain dict with the same keys) for editor autocomplete on the keys.
|
||||
# Omit a key to opt that component out of persistence.
|
||||
This creates `./.host-state/sessions/` and stores only lightweight alias
|
||||
bookkeeping. Live `AgentSession` objects are still rehydrated lazily by the
|
||||
configured history provider on the next turn.
|
||||
|
||||
For workflow targets, `checkpoint_location=...` is the clearest way to enable
|
||||
checkpoint persistence. As a convenience, `state_dir="./.host-state"` also
|
||||
derives `./.host-state/checkpoints/` for workflow targets. Use the mapping form
|
||||
when you want only one component:
|
||||
|
||||
```python
|
||||
from agent_framework_hosting import HostStatePaths
|
||||
|
||||
host = AgentFrameworkHost(
|
||||
target=workflow,
|
||||
channels=channels,
|
||||
state_dir=HostStatePaths(
|
||||
runner="/var/lib/myapp/tasks",
|
||||
sessions="/var/lib/myapp/state",
|
||||
sessions="/var/lib/myapp/sessions",
|
||||
checkpoints="/var/lib/myapp/checkpoints",
|
||||
links="/var/lib/myapp/links",
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
What survives a restart:
|
||||
|
||||
- **Pending durable-task records** — scheduled but not-yet-completed push
|
||||
deliveries replay on the next host startup via `runner.resume()`. Records
|
||||
that crashed mid-attempt resume with their already-consumed retry budget.
|
||||
- **`_session_aliases`** — per-isolation-key session-id rewrites (via the
|
||||
reset-session command).
|
||||
- **`_active`** — the most recently active channel for each isolation key
|
||||
(consumed by `ResponseTarget.active`).
|
||||
- **`_identities`** — channel-native `ChannelIdentity` rows used by
|
||||
`ResponseTarget.channels([...])` / `.all_linked` fan-out.
|
||||
- **Workflow checkpoints** — when the target is a `Workflow`, the host wraps
|
||||
the `checkpoints` path in a per-isolation-key `FileCheckpointStorage`
|
||||
(equivalent to passing `checkpoint_location=...` directly; the explicit
|
||||
parameter takes precedence and emits a warning when both are set).
|
||||
- **Identity-link store** — when the configured linker implements
|
||||
`SupportsLinkStorePath`, the host passes the `links` path to it so pending
|
||||
challenges, linked identities, and verified claims can survive restarts.
|
||||
|
||||
What doesn't:
|
||||
|
||||
- Live `AgentSession` objects (rehydrated lazily by the history provider on the
|
||||
next turn).
|
||||
- The `ContinuationToken` store (separate concern, plug in your own).
|
||||
|
||||
Unpicklable push payloads raise `PushPayloadNotPicklable` *eagerly* from
|
||||
`schedule()` so issues surface at the call site, not on the next restart.
|
||||
|
||||
Cross-channel identity linking, multicast delivery, background runs,
|
||||
continuation tokens, and durable delivery runners are follow-up enhancements,
|
||||
not part of this v1 host contract.
|
||||
|
||||
@@ -13,30 +13,7 @@ they need.
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
from ._authorization import (
|
||||
AllOfAllowlists,
|
||||
AllowAll,
|
||||
Allowed,
|
||||
AllowlistDecision,
|
||||
AnyOfAllowlists,
|
||||
AuthorizationContext,
|
||||
AuthorizationOutcome,
|
||||
AuthPolicy,
|
||||
CallableAllowlist,
|
||||
ChannelConfigurationError,
|
||||
ClaimValue,
|
||||
Denied,
|
||||
IdentityAllowlist,
|
||||
IdentityLinker,
|
||||
LinkChallenge,
|
||||
LinkedClaimAllowlist,
|
||||
LinkedIdentity,
|
||||
LinkRequired,
|
||||
LinkResolution,
|
||||
NativeIdAllowlist,
|
||||
SupportsLinkStorePath,
|
||||
)
|
||||
from ._host import AgentFrameworkHost, ChannelContext, RuntimeMode, logger
|
||||
from ._host import AgentFrameworkHost, ChannelContext, logger
|
||||
from ._isolation import (
|
||||
ISOLATION_HEADER_CHAT,
|
||||
ISOLATION_HEADER_USER,
|
||||
@@ -45,35 +22,19 @@ from ._isolation import (
|
||||
reset_current_isolation_keys,
|
||||
set_current_isolation_keys,
|
||||
)
|
||||
from ._runner import InProcessTaskRunner
|
||||
from ._types import (
|
||||
Channel,
|
||||
ChannelCommand,
|
||||
ChannelCommandContext,
|
||||
ChannelContribution,
|
||||
ChannelIdentity,
|
||||
ChannelPush,
|
||||
ChannelPushCodec,
|
||||
ChannelRequest,
|
||||
ChannelResponseContext,
|
||||
ChannelResponseHook,
|
||||
ChannelRunHook,
|
||||
ChannelSession,
|
||||
ChannelStreamTransformHook,
|
||||
DurableTaskPayloadMode,
|
||||
DurableTaskRunner,
|
||||
ChannelStreamUpdateHook,
|
||||
HostedRunResult,
|
||||
HostStatePaths,
|
||||
PushPayloadNotPicklable,
|
||||
PushPayloadNotSerializable,
|
||||
ResponseTarget,
|
||||
ResponseTargetKind,
|
||||
RetryPolicy,
|
||||
TaskHandle,
|
||||
TaskStatus,
|
||||
apply_channel_response_hook,
|
||||
apply_response_hook,
|
||||
apply_run_hook,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -85,59 +46,21 @@ __all__ = [
|
||||
"ISOLATION_HEADER_CHAT",
|
||||
"ISOLATION_HEADER_USER",
|
||||
"AgentFrameworkHost",
|
||||
"AllOfAllowlists",
|
||||
"AllowAll",
|
||||
"Allowed",
|
||||
"AllowlistDecision",
|
||||
"AnyOfAllowlists",
|
||||
"AuthPolicy",
|
||||
"AuthorizationContext",
|
||||
"AuthorizationOutcome",
|
||||
"CallableAllowlist",
|
||||
"Channel",
|
||||
"ChannelCommand",
|
||||
"ChannelCommandContext",
|
||||
"ChannelConfigurationError",
|
||||
"ChannelContext",
|
||||
"ChannelContribution",
|
||||
"ChannelIdentity",
|
||||
"ChannelPush",
|
||||
"ChannelPushCodec",
|
||||
"ChannelRequest",
|
||||
"ChannelResponseContext",
|
||||
"ChannelResponseHook",
|
||||
"ChannelRunHook",
|
||||
"ChannelSession",
|
||||
"ChannelStreamTransformHook",
|
||||
"ClaimValue",
|
||||
"Denied",
|
||||
"DurableTaskPayloadMode",
|
||||
"DurableTaskRunner",
|
||||
"ChannelStreamUpdateHook",
|
||||
"HostStatePaths",
|
||||
"HostedRunResult",
|
||||
"IdentityAllowlist",
|
||||
"IdentityLinker",
|
||||
"InProcessTaskRunner",
|
||||
"IsolationKeys",
|
||||
"LinkChallenge",
|
||||
"LinkRequired",
|
||||
"LinkResolution",
|
||||
"LinkedClaimAllowlist",
|
||||
"LinkedIdentity",
|
||||
"NativeIdAllowlist",
|
||||
"PushPayloadNotPicklable",
|
||||
"PushPayloadNotSerializable",
|
||||
"ResponseTarget",
|
||||
"ResponseTargetKind",
|
||||
"RetryPolicy",
|
||||
"RuntimeMode",
|
||||
"SupportsLinkStorePath",
|
||||
"TaskHandle",
|
||||
"TaskStatus",
|
||||
"__version__",
|
||||
"apply_channel_response_hook",
|
||||
"apply_response_hook",
|
||||
"apply_run_hook",
|
||||
"get_current_isolation_keys",
|
||||
"logger",
|
||||
"reset_current_isolation_keys",
|
||||
|
||||
@@ -1,485 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Authorization seam — :class:`IdentityAllowlist`, :class:`IdentityLinker`, and outcomes.
|
||||
|
||||
Channels that emit a :class:`ChannelIdentity` compose authorization from
|
||||
two **orthogonal** parameters set per channel:
|
||||
|
||||
- ``require_link: bool`` — "identity must be linked to an IdP claim". The
|
||||
host delegates this to the configured :class:`IdentityLinker`; pairing
|
||||
``require_link=True`` with no linker is rejected at construction
|
||||
(silent-deny-everyone is the worst possible default).
|
||||
- ``allowlist: IdentityAllowlist | Literal["inherit"] | None`` — "identity
|
||||
is on the accept list". The host evaluates the allowlist on every
|
||||
inbound message via :func:`AgentFrameworkHost.authorize`.
|
||||
|
||||
The two axes compose into the three named profiles **open** (no gate),
|
||||
**forced-link** (any authenticated identity), and **allowlist** (only
|
||||
listed identities, keyed either on the channel-native id pre-link or on
|
||||
a verified IdP claim post-link). See
|
||||
``docs/specs/002-python-hosting-channels.md`` §
|
||||
"Authorization profiles and the IdentityAllowlist seam".
|
||||
|
||||
This module ships the channel-neutral core pieces. Provider-specific
|
||||
linking channels (for example Entra OAuth helpers) can implement
|
||||
:class:`IdentityLinker` without the core package taking a dependency on
|
||||
their transport or identity-provider SDKs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Awaitable, Callable, Collection, Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Any, Literal, Protocol, TypeAlias, runtime_checkable
|
||||
|
||||
from ._types import ChannelIdentity
|
||||
|
||||
|
||||
class AllowlistDecision(str, Enum):
|
||||
"""Tri-state allowlist evaluation outcome.
|
||||
|
||||
``ABSTAIN`` is **not** a denial — it means "this allowlist has no
|
||||
information yet" (typically a claim-based allowlist evaluated at
|
||||
``pre_link``). The host's :meth:`AgentFrameworkHost.authorize`
|
||||
pipeline is what turns an all-``ABSTAIN`` outcome into the next
|
||||
step (allow when open, escalate to a link ceremony when the config
|
||||
calls for one). Boolean composition cannot distinguish "claim
|
||||
allowlist denies you" from "claim allowlist hasn't seen any claims
|
||||
yet" — a critical distinction for the **Mixed** profile.
|
||||
"""
|
||||
|
||||
ALLOW = "allow"
|
||||
DENY = "deny"
|
||||
ABSTAIN = "abstain"
|
||||
|
||||
|
||||
ClaimValue: TypeAlias = str | Sequence[str]
|
||||
"""Verified claim value shape understood by :class:`LinkedClaimAllowlist`."""
|
||||
|
||||
|
||||
def _empty_claim_mapping() -> Mapping[str, ClaimValue]:
|
||||
return {}
|
||||
|
||||
|
||||
def _empty_any_mapping() -> Mapping[str, Any]:
|
||||
return {}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthorizationContext:
|
||||
"""Inputs to a single :meth:`IdentityAllowlist.evaluate` call."""
|
||||
|
||||
identity: ChannelIdentity
|
||||
phase: Literal["pre_link", "post_link"]
|
||||
isolation_key: str | None = None
|
||||
verified_claims: Mapping[str, ClaimValue] = field(default_factory=_empty_claim_mapping)
|
||||
claim_source: Literal["linker", "channel", "none"] = "none"
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class IdentityAllowlist(Protocol):
|
||||
"""Per-channel accept/deny gate evaluated by the host.
|
||||
|
||||
``requires_linked_claims`` declares that this allowlist's
|
||||
:meth:`evaluate` cannot ``ALLOW`` until verified claims are
|
||||
available — the host's construction-time validator rejects
|
||||
configurations that would silently deny everyone (e.g. a
|
||||
:class:`LinkedClaimAllowlist` on a channel that neither has
|
||||
``require_link=True`` nor natively emits verified claims).
|
||||
"""
|
||||
|
||||
requires_linked_claims: bool
|
||||
|
||||
async def evaluate(self, context: AuthorizationContext) -> AllowlistDecision: ...
|
||||
|
||||
|
||||
class AllowAll:
|
||||
"""Explicit "open" sentinel.
|
||||
|
||||
Useful for tests, sample code, and for **overriding** a host-level
|
||||
``default_allowlist`` on a specific channel that should be public
|
||||
inside an otherwise locked-down host.
|
||||
"""
|
||||
|
||||
requires_linked_claims: bool = False
|
||||
|
||||
async def evaluate(self, context: AuthorizationContext) -> AllowlistDecision:
|
||||
return AllowlistDecision.ALLOW
|
||||
|
||||
|
||||
class NativeIdAllowlist:
|
||||
"""Accept only listed channel-native ids.
|
||||
|
||||
Telegram ``chat_id``, WhatsApp number, Slack user id, etc. The
|
||||
list can be a plain collection or an async loader so allowlist
|
||||
sources can be config files, secret stores, or feature flags.
|
||||
Pre-link and post-link behaviour is identical — native-id
|
||||
allowlists do not depend on link state.
|
||||
|
||||
When ``channel`` is set, the allowlist participates in
|
||||
:class:`AnyOfAllowlists` composition by returning ``ABSTAIN`` for
|
||||
requests from other channels — this lets per-channel native lists
|
||||
coexist under a single combinator without one channel's ``DENY``
|
||||
masking another channel's ``ALLOW``.
|
||||
|
||||
Keyword Args:
|
||||
native_ids: A static collection of ids, or an async loader.
|
||||
channel: When set, only requests whose
|
||||
``ChannelIdentity.channel`` matches participate; others
|
||||
``ABSTAIN``.
|
||||
"""
|
||||
|
||||
requires_linked_claims: bool = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
native_ids: Collection[str] | Callable[[], Awaitable[Collection[str]]],
|
||||
*,
|
||||
channel: str | None = None,
|
||||
) -> None:
|
||||
self._native_ids: Collection[str] | None
|
||||
self._loader: Callable[[], Awaitable[Collection[str]]] | None
|
||||
if callable(native_ids):
|
||||
self._native_ids = None
|
||||
self._loader = native_ids
|
||||
else:
|
||||
self._native_ids = frozenset(native_ids)
|
||||
self._loader = None
|
||||
self.channel = channel
|
||||
|
||||
async def _resolve(self) -> Collection[str]:
|
||||
if self._native_ids is not None:
|
||||
return self._native_ids
|
||||
loader = self._loader
|
||||
if loader is None: # pragma: no cover - defensive
|
||||
raise RuntimeError("NativeIdAllowlist: loader missing after cache miss")
|
||||
loaded = await loader()
|
||||
# Cache the resolved set so subsequent calls avoid re-loading.
|
||||
self._native_ids = frozenset(loaded)
|
||||
self._loader = None
|
||||
return self._native_ids
|
||||
|
||||
async def evaluate(self, context: AuthorizationContext) -> AllowlistDecision:
|
||||
if self.channel is not None and context.identity.channel != self.channel:
|
||||
return AllowlistDecision.ABSTAIN
|
||||
ids = await self._resolve()
|
||||
if context.identity.native_id in ids:
|
||||
return AllowlistDecision.ALLOW
|
||||
return AllowlistDecision.DENY
|
||||
|
||||
|
||||
class LinkedClaimAllowlist:
|
||||
"""Accept only identities whose verified IdP claim is on the list.
|
||||
|
||||
``evaluate`` returns ``ABSTAIN`` at ``pre_link`` (no claims yet)
|
||||
and ``ALLOW``/``DENY`` at ``post_link``. Claim values may be plain
|
||||
strings or a sequence of strings (for multi-valued claims such as
|
||||
group ids); any intersection with ``values`` allows the identity.
|
||||
|
||||
Keyword Args:
|
||||
claim: The verified-claim key to inspect (e.g. ``"oid"``,
|
||||
``"tid"``, ``"groups"``).
|
||||
values: Accepted values.
|
||||
"""
|
||||
|
||||
requires_linked_claims: bool = True
|
||||
|
||||
def __init__(self, claim: str, values: Collection[str]) -> None:
|
||||
self.claim = claim
|
||||
self.values = frozenset(values)
|
||||
|
||||
async def evaluate(self, context: AuthorizationContext) -> AllowlistDecision:
|
||||
if context.phase == "pre_link":
|
||||
return AllowlistDecision.ABSTAIN
|
||||
value = context.verified_claims.get(self.claim)
|
||||
if value is None:
|
||||
return AllowlistDecision.DENY
|
||||
if isinstance(value, str):
|
||||
return AllowlistDecision.ALLOW if value in self.values else AllowlistDecision.DENY
|
||||
return AllowlistDecision.ALLOW if any(item in self.values for item in value) else AllowlistDecision.DENY
|
||||
|
||||
|
||||
class AnyOfAllowlists:
|
||||
"""Combinator: any child ``ALLOW`` wins; ``DENY`` only if all children ``DENY``.
|
||||
|
||||
Use this for the **Mixed** profile (native id OR linked claim).
|
||||
Returns ``ABSTAIN`` when no child decides.
|
||||
"""
|
||||
|
||||
def __init__(self, *allowlists: IdentityAllowlist) -> None:
|
||||
self._children = allowlists
|
||||
self.requires_linked_claims = any(getattr(a, "requires_linked_claims", False) for a in allowlists)
|
||||
|
||||
async def evaluate(self, context: AuthorizationContext) -> AllowlistDecision:
|
||||
any_abstain = False
|
||||
all_deny = True
|
||||
for child in self._children:
|
||||
decision = await child.evaluate(context)
|
||||
if decision is AllowlistDecision.ALLOW:
|
||||
return AllowlistDecision.ALLOW
|
||||
if decision is AllowlistDecision.ABSTAIN:
|
||||
any_abstain = True
|
||||
all_deny = False
|
||||
# DENY contributes to all_deny without short-circuit.
|
||||
if all_deny and self._children:
|
||||
return AllowlistDecision.DENY
|
||||
if any_abstain:
|
||||
return AllowlistDecision.ABSTAIN
|
||||
# No children — treat as ABSTAIN to avoid surprise DENY.
|
||||
return AllowlistDecision.ABSTAIN
|
||||
|
||||
|
||||
class AllOfAllowlists:
|
||||
"""Combinator: any child ``DENY`` wins; ``ALLOW`` only if all children ``ALLOW``.
|
||||
|
||||
Use this to require multiple conditions (e.g. tenancy
|
||||
**and** group membership). Returns ``ABSTAIN`` when no child
|
||||
denies but at least one ``ABSTAIN``s.
|
||||
"""
|
||||
|
||||
def __init__(self, *allowlists: IdentityAllowlist) -> None:
|
||||
self._children = allowlists
|
||||
self.requires_linked_claims = any(getattr(a, "requires_linked_claims", False) for a in allowlists)
|
||||
|
||||
async def evaluate(self, context: AuthorizationContext) -> AllowlistDecision:
|
||||
any_abstain = False
|
||||
for child in self._children:
|
||||
decision = await child.evaluate(context)
|
||||
if decision is AllowlistDecision.DENY:
|
||||
return AllowlistDecision.DENY
|
||||
if decision is AllowlistDecision.ABSTAIN:
|
||||
any_abstain = True
|
||||
if not self._children:
|
||||
return AllowlistDecision.ABSTAIN
|
||||
if any_abstain:
|
||||
return AllowlistDecision.ABSTAIN
|
||||
return AllowlistDecision.ALLOW
|
||||
|
||||
|
||||
class CallableAllowlist:
|
||||
"""Escape hatch: wrap an arbitrary async function as an allowlist.
|
||||
|
||||
Recommended only after exhausting the structured variants —
|
||||
composition is harder to reason about with opaque callables.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
fn: Callable[[AuthorizationContext], Awaitable[AllowlistDecision]],
|
||||
*,
|
||||
requires_linked_claims: bool = False,
|
||||
) -> None:
|
||||
self._fn = fn
|
||||
self.requires_linked_claims = requires_linked_claims
|
||||
|
||||
async def evaluate(self, context: AuthorizationContext) -> AllowlistDecision:
|
||||
return await self._fn(context)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Outcome types #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LinkChallenge:
|
||||
"""Challenge a channel can render to complete an identity link.
|
||||
|
||||
Attributes:
|
||||
challenge_id: Opaque linker-owned id for correlating the challenge
|
||||
with the later completion callback.
|
||||
url: Optional URL (OAuth authorization URL, device-flow URL, etc.)
|
||||
the user should open.
|
||||
expires_at: Optional challenge expiry time.
|
||||
message: Optional safe text a channel may render with the challenge.
|
||||
attributes: Linker-specific structured metadata. Channels should
|
||||
only use keys documented by the concrete linker they integrate.
|
||||
"""
|
||||
|
||||
challenge_id: str
|
||||
url: str | None = None
|
||||
expires_at: datetime | None = None
|
||||
message: str | None = None
|
||||
attributes: Mapping[str, Any] = field(default_factory=_empty_any_mapping)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LinkedIdentity:
|
||||
"""Resolved IdP-backed identity returned by :class:`IdentityLinker`.
|
||||
|
||||
Attributes:
|
||||
isolation_key: Stable key the host should use for the linked user.
|
||||
verified_claims: Claims verified by the linker or by a channel that
|
||||
natively authenticates the user.
|
||||
claim_source: Where the claims came from.
|
||||
"""
|
||||
|
||||
isolation_key: str
|
||||
verified_claims: Mapping[str, ClaimValue] = field(default_factory=_empty_claim_mapping)
|
||||
claim_source: Literal["linker", "channel"] = "linker"
|
||||
|
||||
|
||||
LinkResolution: TypeAlias = LinkedIdentity | LinkChallenge
|
||||
"""Result returned by :meth:`IdentityLinker.resolve`."""
|
||||
|
||||
|
||||
class IdentityLinker(Protocol):
|
||||
"""Resolve a channel-native identity or return a challenge to link it.
|
||||
|
||||
Concrete linker packages own the storage, OAuth/device-code routes, and
|
||||
provider-specific claim mapping. The core host only consumes the single
|
||||
resolution call so authorization can be a one-round-trip decision.
|
||||
"""
|
||||
|
||||
async def resolve(self, identity: ChannelIdentity) -> LinkResolution:
|
||||
"""Return a linked identity or the challenge needed to create one."""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SupportsLinkStorePath(Protocol):
|
||||
"""Optional protocol for linkers that accept host-provided persistence.
|
||||
|
||||
When ``AgentFrameworkHost(state_dir=...)`` derives a ``links`` path, the
|
||||
host calls this hook on identity linkers that implement it. Linkers that
|
||||
manage their own persistence can ignore this protocol and should be
|
||||
configured directly by the application.
|
||||
"""
|
||||
|
||||
def configure_link_store_path(self, path: str | os.PathLike[str]) -> None:
|
||||
"""Configure where the linker should persist its link store."""
|
||||
...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Allowed:
|
||||
"""The identity is authorized; ``isolation_key`` is its stable key."""
|
||||
|
||||
isolation_key: str
|
||||
verified_claims: Mapping[str, ClaimValue] = field(default_factory=_empty_claim_mapping)
|
||||
claim_source: Literal["linker", "channel", "none"] = "none"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LinkRequired:
|
||||
"""The identity must complete the link ceremony before proceeding.
|
||||
|
||||
Channels render ``challenge`` through their native UX (the same
|
||||
path the ``link`` command uses).
|
||||
"""
|
||||
|
||||
challenge: LinkChallenge
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Denied:
|
||||
"""The identity is rejected.
|
||||
|
||||
Attributes:
|
||||
reason_code: Stable, machine-readable token (e.g.
|
||||
``"allowlist_denied_pre_link"``). Never echoed to end
|
||||
users.
|
||||
user_message: Safe to render publicly (group-chat-safe);
|
||||
``None`` falls back to a bland default ("You don't have
|
||||
access to this bot.").
|
||||
log_details: Structured payload for audit/observability;
|
||||
**never** shown to users.
|
||||
"""
|
||||
|
||||
reason_code: str
|
||||
user_message: str | None = None
|
||||
log_details: Mapping[str, Any] = field(default_factory=_empty_any_mapping)
|
||||
|
||||
|
||||
AuthorizationOutcome = Allowed | LinkRequired | Denied
|
||||
"""Result of :func:`AgentFrameworkHost.authorize`. Channels render
|
||||
each variant through their native UX."""
|
||||
|
||||
|
||||
class AuthPolicy:
|
||||
"""Factory helpers for common authorization policies.
|
||||
|
||||
These helpers are thin wrappers over the concrete allowlist types; they
|
||||
exist so application code can describe authorization intent without
|
||||
importing each building block separately.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def open() -> AllowAll:
|
||||
"""Allow every identity."""
|
||||
return AllowAll()
|
||||
|
||||
@staticmethod
|
||||
def native_ids(
|
||||
native_ids: Collection[str] | Callable[[], Awaitable[Collection[str]]],
|
||||
*,
|
||||
channel: str | None = None,
|
||||
) -> NativeIdAllowlist:
|
||||
"""Allow listed channel-native ids."""
|
||||
return NativeIdAllowlist(native_ids, channel=channel)
|
||||
|
||||
@staticmethod
|
||||
def linked_claim(claim: str, values: Collection[str]) -> LinkedClaimAllowlist:
|
||||
"""Allow identities whose verified claim matches one of ``values``."""
|
||||
return LinkedClaimAllowlist(claim, values)
|
||||
|
||||
@staticmethod
|
||||
def any_of(*allowlists: IdentityAllowlist) -> AnyOfAllowlists:
|
||||
"""Allow when any child allowlist allows."""
|
||||
return AnyOfAllowlists(*allowlists)
|
||||
|
||||
@staticmethod
|
||||
def all_of(*allowlists: IdentityAllowlist) -> AllOfAllowlists:
|
||||
"""Allow only when every child allowlist allows."""
|
||||
return AllOfAllowlists(*allowlists)
|
||||
|
||||
@staticmethod
|
||||
def custom(
|
||||
fn: Callable[[AuthorizationContext], Awaitable[AllowlistDecision]],
|
||||
*,
|
||||
requires_linked_claims: bool = False,
|
||||
) -> CallableAllowlist:
|
||||
"""Wrap a custom async allowlist function."""
|
||||
return CallableAllowlist(fn, requires_linked_claims=requires_linked_claims)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Configuration error #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class ChannelConfigurationError(ValueError):
|
||||
"""Raised at host construction for authorization config that would deny all users.
|
||||
|
||||
The host validator runs three rules (see spec §"Configuration
|
||||
validation"); any failure is reported here rather than letting
|
||||
the misconfigured host start up and reject every request.
|
||||
"""
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AllOfAllowlists",
|
||||
"AllowAll",
|
||||
"Allowed",
|
||||
"AllowlistDecision",
|
||||
"AnyOfAllowlists",
|
||||
"AuthPolicy",
|
||||
"AuthorizationContext",
|
||||
"AuthorizationOutcome",
|
||||
"CallableAllowlist",
|
||||
"ChannelConfigurationError",
|
||||
"ClaimValue",
|
||||
"Denied",
|
||||
"IdentityAllowlist",
|
||||
"IdentityLinker",
|
||||
"LinkChallenge",
|
||||
"LinkRequired",
|
||||
"LinkResolution",
|
||||
"LinkedClaimAllowlist",
|
||||
"LinkedIdentity",
|
||||
"NativeIdAllowlist",
|
||||
"SupportsLinkStorePath",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,25 +2,10 @@
|
||||
|
||||
"""Shared persistence primitives for the hosting package.
|
||||
|
||||
The hosting core ships with an opt-in disk-persistence layer for the
|
||||
in-process task runner and the host's session-related state. The
|
||||
on-disk format is provided by the ``diskcache`` package (a small,
|
||||
pure-Python, sqlite-backed dependency installed via the ``[disk]``
|
||||
optional extra).
|
||||
|
||||
This module centralises:
|
||||
|
||||
- :func:`load_diskcache` — lazy import that raises a helpful error when
|
||||
the optional extra is missing.
|
||||
- :func:`acquire_state_dir_lock` — single-owner file lock that fails
|
||||
fast when a second process points at the same directory.
|
||||
- :func:`normalize_state_dir` — turn the host-level ``state_dir``
|
||||
parameter (``str`` / ``PathLike`` / :class:`HostStatePaths` /
|
||||
``Mapping``) into a normalised ``dict[component_name -> Path | None]``.
|
||||
|
||||
Everything in this module is internal — public callers should go
|
||||
through :class:`AgentFrameworkHost` or
|
||||
:class:`InProcessTaskRunner` directly.
|
||||
The simplified hosting core keeps disk persistence only for session aliases
|
||||
created by :meth:`AgentFrameworkHost.reset_session` and for workflow
|
||||
checkpoint path derivation. The on-disk session-alias store uses the optional
|
||||
``diskcache`` package installed via the ``[disk]`` extra.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -35,29 +20,19 @@ from typing import TYPE_CHECKING, Any
|
||||
if TYPE_CHECKING:
|
||||
from ._types import HostStatePaths
|
||||
|
||||
# Known component keys recognised by the host's ``state_dir`` normaliser.
|
||||
# Adding a new component is a non-breaking change: extend this tuple and
|
||||
# add the matching key to :class:`HostStatePaths` in ``_types.py``.
|
||||
_KNOWN_COMPONENTS: tuple[str, ...] = ("runner", "sessions", "checkpoints", "links")
|
||||
_KNOWN_COMPONENTS: tuple[str, ...] = ("sessions", "checkpoints")
|
||||
|
||||
|
||||
def load_diskcache() -> Any:
|
||||
"""Lazy-import :mod:`diskcache` with a helpful error when missing.
|
||||
|
||||
The ``diskcache`` package is an optional dependency installed via
|
||||
the ``agent-framework-hosting[disk]`` extra. Users that never set
|
||||
``state_dir`` never trigger the import. This wrapper produces a
|
||||
single, consistent error message when the import is needed but the
|
||||
extra was not installed.
|
||||
"""
|
||||
"""Lazy-import :mod:`diskcache` with a helpful error when missing."""
|
||||
try:
|
||||
import diskcache # type: ignore[import-untyped]
|
||||
except ImportError as exc: # pragma: no cover - exercised via tests by monkeypatching
|
||||
raise ImportError(
|
||||
"agent-framework-hosting was asked to persist state to disk "
|
||||
"(state_dir is set) but the optional `diskcache` dependency "
|
||||
"agent-framework-hosting was asked to persist session aliases to disk "
|
||||
"(state_dir['sessions'] is set) but the optional `diskcache` dependency "
|
||||
"is not installed. Install the disk extra: "
|
||||
"`pip install 'agent-framework-hosting[disk]'`."
|
||||
"`pip install 'agent-framework-hosting[disk]`."
|
||||
) from exc
|
||||
return diskcache
|
||||
|
||||
@@ -65,24 +40,11 @@ def load_diskcache() -> Any:
|
||||
def acquire_state_dir_lock(component_dir: Path) -> Any:
|
||||
"""Acquire an exclusive single-owner lock on a component's state dir.
|
||||
|
||||
Two processes pointing at the same state directory would both scan
|
||||
pending records on startup and could execute the same task twice;
|
||||
we therefore enforce single-owner semantics with an OS-level
|
||||
advisory lock. The lock file lives at ``<component_dir>/.lock`` and
|
||||
is held for the lifetime of the returned file handle. Closing the
|
||||
handle (or process exit) releases it.
|
||||
|
||||
On Unix this uses :func:`fcntl.flock`. On Windows it uses
|
||||
:func:`msvcrt.locking`. The lock is *advisory* — the OS will not
|
||||
enforce it against processes that ignore it, but no
|
||||
well-behaved component of this package will.
|
||||
|
||||
Raises ``RuntimeError`` if another process already holds the lock.
|
||||
Raises:
|
||||
RuntimeError: If another process already holds the lock.
|
||||
"""
|
||||
component_dir.mkdir(parents=True, exist_ok=True)
|
||||
lock_path = component_dir / ".lock"
|
||||
# Open in append mode so we don't truncate an existing lock file
|
||||
# (some monitoring tools may inspect it).
|
||||
fh = open(lock_path, "a+", encoding="utf-8") # noqa: SIM115 - kept open for lifetime
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
@@ -94,8 +56,7 @@ def acquire_state_dir_lock(component_dir: Path) -> Any:
|
||||
fh.close()
|
||||
raise RuntimeError(
|
||||
f"Another process already holds the hosting state lock at {lock_path}. "
|
||||
"Two hosts (or two runners) pointing at the same state directory would "
|
||||
"double-execute scheduled tasks; point each host at its own state_dir."
|
||||
"Point each host at its own state_dir."
|
||||
) from exc
|
||||
else:
|
||||
import fcntl
|
||||
@@ -106,8 +67,7 @@ def acquire_state_dir_lock(component_dir: Path) -> Any:
|
||||
fh.close()
|
||||
raise RuntimeError(
|
||||
f"Another process already holds the hosting state lock at {lock_path}. "
|
||||
"Two hosts (or two runners) pointing at the same state directory would "
|
||||
"double-execute scheduled tasks; point each host at its own state_dir."
|
||||
"Point each host at its own state_dir."
|
||||
) from exc
|
||||
except RuntimeError:
|
||||
raise
|
||||
@@ -118,15 +78,10 @@ def acquire_state_dir_lock(component_dir: Path) -> Any:
|
||||
|
||||
|
||||
def release_state_dir_lock(handle: Any) -> None:
|
||||
"""Release a lock previously acquired by :func:`acquire_state_dir_lock`.
|
||||
|
||||
Closing the file handle is sufficient to drop the lock on both
|
||||
platforms, but we make the intent explicit so the caller doesn't
|
||||
have to know which mechanism (``fcntl`` vs ``msvcrt``) is in use.
|
||||
"""
|
||||
"""Release a lock previously acquired by :func:`acquire_state_dir_lock`."""
|
||||
if handle is None:
|
||||
return
|
||||
with contextlib.suppress(Exception): # close errors are not actionable
|
||||
with contextlib.suppress(Exception):
|
||||
handle.close()
|
||||
|
||||
|
||||
@@ -135,40 +90,27 @@ def normalize_state_dir(
|
||||
) -> dict[str, Path | None]:
|
||||
"""Resolve the host-level ``state_dir`` parameter into a per-component map.
|
||||
|
||||
Accepts any of:
|
||||
|
||||
- ``None`` → all components return ``None`` (fully in-memory; today's behavior).
|
||||
- ``str`` / :class:`os.PathLike` → all components share a parent
|
||||
directory and get an auto-allocated subfolder (``runner/``,
|
||||
``sessions/``, ``checkpoints/``, ``links/``).
|
||||
- :class:`HostStatePaths` typed dict / plain ``Mapping`` → per-key
|
||||
override. Components missing from the mapping fall back to ``None``
|
||||
(in-memory only). Unknown keys raise ``ValueError`` to surface
|
||||
typos early.
|
||||
|
||||
Returns a ``dict[component_name -> Path | None]`` covering every
|
||||
component in :data:`_KNOWN_COMPONENTS`.
|
||||
Accepts ``None``, a single root path, or a mapping with ``sessions`` and
|
||||
``checkpoints`` keys. Unknown keys raise ``ValueError`` so obsolete
|
||||
``runner`` / ``links`` configuration is rejected instead of silently
|
||||
doing nothing.
|
||||
"""
|
||||
result: dict[str, Path | None] = {name: None for name in _KNOWN_COMPONENTS}
|
||||
if state_dir is None:
|
||||
return result
|
||||
|
||||
# Strings and PathLikes use the default subfolder layout.
|
||||
if isinstance(state_dir, (str, os.PathLike)):
|
||||
root = Path(os.fspath(state_dir))
|
||||
for name in _KNOWN_COMPONENTS:
|
||||
result[name] = root / name
|
||||
return result
|
||||
|
||||
# Mappings (incl. TypedDict at runtime) get per-component overrides.
|
||||
if isinstance(state_dir, Mapping):
|
||||
unknown = [k for k in state_dir if k not in _KNOWN_COMPONENTS]
|
||||
if unknown:
|
||||
raise ValueError(
|
||||
f"state_dir mapping contains unknown component key(s): {unknown!r}. "
|
||||
f"Known components are: {list(_KNOWN_COMPONENTS)!r}. "
|
||||
"If you are trying to use a future component, upgrade "
|
||||
"agent-framework-hosting to a version that supports it."
|
||||
f"Known components are: {list(_KNOWN_COMPONENTS)!r}."
|
||||
)
|
||||
for name in _KNOWN_COMPONENTS:
|
||||
raw_value: Any = state_dir.get(name)
|
||||
@@ -184,12 +126,3 @@ def normalize_state_dir(
|
||||
raise TypeError(
|
||||
f"state_dir must be a str, PathLike, HostStatePaths mapping, or None — got {type(state_dir).__name__}"
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"_KNOWN_COMPONENTS",
|
||||
"acquire_state_dir_lock",
|
||||
"load_diskcache",
|
||||
"normalize_state_dir",
|
||||
"release_state_dir_lock",
|
||||
]
|
||||
|
||||
@@ -1,751 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""In-process implementation of :class:`DurableTaskRunner`.
|
||||
|
||||
This is the default runner the host wires in when the operator does not
|
||||
supply one. It runs tasks via :func:`asyncio.create_task` with a bounded
|
||||
retry loop following the supplied :class:`RetryPolicy`.
|
||||
|
||||
Two modes:
|
||||
|
||||
* **In-memory** (``state_dir=None``, default) — pending tasks live as
|
||||
``asyncio.Task`` references in process memory. In-flight tasks are
|
||||
lost on process death. Cheap, zero dependencies, suitable for unit
|
||||
tests and for long-running deployments where "the process dies,
|
||||
queued pushes are lost" is an acceptable failure mode.
|
||||
* **Disk-persistent** (``state_dir=<path>``) — pending tasks are
|
||||
pickled into a :mod:`diskcache`-backed sqlite store before the
|
||||
``asyncio.Task`` is created. On the next startup the host calls
|
||||
:meth:`InProcessTaskRunner.resume` which re-schedules every
|
||||
surviving ``"pending"`` record with its persisted attempt count.
|
||||
Graceful shutdown cancellations leave records in ``"pending"`` so
|
||||
they replay on the next boot. Suitable for ``runtime_mode="long_running"``
|
||||
deployments that survive container moves / OOMs.
|
||||
|
||||
For ``runtime_mode="ephemeral"`` deployments (Foundry Hosted Agent,
|
||||
Azure Functions, Lambda) plug in a durable adapter package
|
||||
(``agent-framework-hosting-durabletask`` for the gRPC TaskHub backend,
|
||||
a future Foundry adapter, …) — they all implement the same
|
||||
:class:`DurableTaskRunner` Protocol.
|
||||
|
||||
See ``docs/specs/002-python-hosting-channels.md`` § "Durable task runner".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import pickle # noqa: S403 # nosec B403 - used only to validate user payloads round-trip
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
from ._persistence import (
|
||||
acquire_state_dir_lock,
|
||||
load_diskcache,
|
||||
release_state_dir_lock,
|
||||
)
|
||||
from ._types import (
|
||||
DurableTaskPayloadMode,
|
||||
DurableTaskRunner,
|
||||
PushPayloadNotPicklable,
|
||||
RetryPolicy,
|
||||
TaskHandle,
|
||||
TaskStatus,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Keys used inside the per-task on-disk record. Kept as module constants
|
||||
# so the schema is documented once and refactors are mechanical.
|
||||
_REC_HANDLER_NAME = "handler_name"
|
||||
_REC_PAYLOAD = "payload"
|
||||
_REC_RETRY_POLICY = "retry_policy"
|
||||
_REC_ATTEMPTS = "attempts_completed"
|
||||
_REC_STATUS = "status"
|
||||
_REC_CREATED_AT = "created_at"
|
||||
_REC_TERMINAL_AT = "terminal_at"
|
||||
_REC_NAME = "name"
|
||||
|
||||
# Deque key inside the cache holding terminal task ids in insertion order.
|
||||
# Used for FIFO eviction of terminal records once the bounded cap is hit.
|
||||
_TERMINAL_ORDER_KEY = "__terminal_order__"
|
||||
|
||||
|
||||
class _PersistedPayloadDict(dict[str, Any]):
|
||||
"""Drop-in :class:`dict` that mirrors mutations back to disk.
|
||||
|
||||
Used by :class:`InProcessTaskRunner` when ``state_dir`` is set so
|
||||
handler-side cursors (``echo_done``) survive process restarts. The
|
||||
handler interacts with this object exactly as it would with a plain
|
||||
dict; the override on :meth:`__setitem__` is the only difference.
|
||||
|
||||
Held weakly by the runner so handlers that capture the dict in
|
||||
long-lived closures don't keep the runner alive past its natural
|
||||
lifetime.
|
||||
"""
|
||||
|
||||
# Type annotation for the persist callback; the actual attribute is
|
||||
# assigned via the __slots__-aware ``object.__setattr__`` dance
|
||||
# below so PyPy doesn't reject the assignment on a ``dict`` subclass.
|
||||
_persist_cb: Callable[[Mapping[str, Any]], None]
|
||||
|
||||
__slots__ = ("_persist_cb",)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
data: Mapping[str, Any],
|
||||
persist_cb: Callable[[Mapping[str, Any]], None],
|
||||
) -> None:
|
||||
super().__init__(data)
|
||||
# Use object.__setattr__ to bypass the __slots__ checker on
|
||||
# dict subclasses (CPython is liberal here but PyPy is strict).
|
||||
object.__setattr__(self, "_persist_cb", persist_cb)
|
||||
|
||||
def __setitem__(self, key: str, value: Any) -> None:
|
||||
super().__setitem__(key, value)
|
||||
# Re-serialise after each mutation. The cache stores opaque
|
||||
# pickled values, so partial-field updates aren't possible —
|
||||
# we send the whole payload mapping every time. Mutations on
|
||||
# the runner's hot path are rare (just the ``echo_done``
|
||||
# cursor today) so this is fine.
|
||||
self._persist_cb(dict(self))
|
||||
|
||||
|
||||
class InProcessTaskRunner(DurableTaskRunner):
|
||||
"""In-memory or disk-persistent :class:`DurableTaskRunner`.
|
||||
|
||||
Schedules each task as an :func:`asyncio.create_task` coroutine and
|
||||
retries on exception up to ``RetryPolicy.max_attempts`` times with
|
||||
exponential backoff. Terminal status (``succeeded`` / ``failed`` /
|
||||
``cancelled``) is reported via :meth:`get`.
|
||||
|
||||
Re-registration of the same handler name after :meth:`schedule` has
|
||||
been called is rejected to avoid silent re-orderings of in-flight
|
||||
work; the host registers all handlers at startup, before serving
|
||||
traffic.
|
||||
|
||||
Keyword Args:
|
||||
default_retry_policy: Per-runner default :class:`RetryPolicy`;
|
||||
overridable per-task at :meth:`schedule` call sites.
|
||||
terminal_cache_size: Maximum number of terminal task records to
|
||||
retain. Older entries are FIFO-evicted so a long-running
|
||||
host can't accumulate unbounded status entries.
|
||||
shutdown_grace_seconds: Window :meth:`shutdown` waits for
|
||||
in-flight tasks to drain before cancelling stragglers.
|
||||
state_dir: When set, the runner persists pending and terminal
|
||||
task records under this directory (a :mod:`diskcache`
|
||||
sqlite store at ``<state_dir>/cache.db`` and a single-owner
|
||||
lock at ``<state_dir>/.lock``). Persisted pending records
|
||||
survive process restarts and are replayed by :meth:`resume`.
|
||||
When ``None`` (default) the runner is purely in-memory and
|
||||
in-flight tasks are lost on process death. Requires the
|
||||
optional ``diskcache`` dependency — install with
|
||||
``pip install 'agent-framework-hosting[disk]'``.
|
||||
"""
|
||||
|
||||
# Declared at class level so the ``DurableTaskRunner`` Protocol's
|
||||
# ``payload_mode`` attribute resolves on instances without needing
|
||||
# to assign it in ``__init__``.
|
||||
payload_mode: DurableTaskPayloadMode = DurableTaskPayloadMode.OBJECT
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
default_retry_policy: RetryPolicy | None = None,
|
||||
terminal_cache_size: int = 1024,
|
||||
shutdown_grace_seconds: float = 5.0,
|
||||
state_dir: str | os.PathLike[str] | None = None,
|
||||
) -> None:
|
||||
self._handlers: dict[str, Callable[[Mapping[str, Any]], Awaitable[None]]] = {}
|
||||
self._default_retry_policy = default_retry_policy or RetryPolicy()
|
||||
self._terminal_cache_size = terminal_cache_size
|
||||
# How long ``shutdown()`` waits for in-flight tasks to finish on
|
||||
# their own before cancelling them. Channels may legitimately
|
||||
# schedule a final push during their own shutdown callback
|
||||
# (goodbye message, telemetry flush), so the runner gives them
|
||||
# this window to complete before cancellation kicks in.
|
||||
self._shutdown_grace_seconds = shutdown_grace_seconds
|
||||
|
||||
# Operational state. ``_pending`` holds asyncio tasks that are
|
||||
# scheduled or running. ``_terminal`` is an in-memory mirror of
|
||||
# the most recent terminal statuses (kept in-memory regardless of
|
||||
# ``state_dir`` so ``get`` is fast and works before/without the
|
||||
# cache being opened).
|
||||
self._pending: dict[str, asyncio.Task[None]] = {}
|
||||
self._terminal: dict[str, TaskStatus] = {}
|
||||
self._terminal_order: list[str] = []
|
||||
|
||||
# Set to True on the first ``schedule``/``resume`` call so subsequent
|
||||
# ``register`` calls fail loudly rather than silently swapping a
|
||||
# handler out from under in-flight work.
|
||||
self._started = False
|
||||
|
||||
# Set to True when ``shutdown()`` starts so the retry loop's
|
||||
# ``CancelledError`` handler distinguishes "the runner is going
|
||||
# down, leave my record in 'pending' for resume()" from "this
|
||||
# task was explicitly cancelled, mark it 'cancelled'".
|
||||
self._shutting_down = False
|
||||
|
||||
# Disk persistence — opt-in via ``state_dir``. ``None`` keeps
|
||||
# the runner pure-memory (the default behaviour).
|
||||
self._state_dir: Path | None = Path(os.fspath(state_dir)) if state_dir is not None else None
|
||||
self._cache: Any = None
|
||||
self._terminal_deque: Any = None
|
||||
self._lock_handle: Any = None
|
||||
if self._state_dir is not None:
|
||||
self._open_cache()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Cache lifecycle
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _open_cache(self) -> None:
|
||||
"""Open the disk cache and acquire the single-owner lock.
|
||||
|
||||
Called from ``__init__`` when ``state_dir`` is set. Splitting it
|
||||
out keeps the constructor body readable and gives tests a clean
|
||||
seam for monkeypatching.
|
||||
"""
|
||||
if self._state_dir is None: # pragma: no cover - guarded by caller
|
||||
raise RuntimeError("_open_cache called without state_dir")
|
||||
diskcache = load_diskcache()
|
||||
# Acquire the directory lock *before* opening the cache so two
|
||||
# runners pointed at the same dir don't both try to initialise
|
||||
# sqlite. The lock handle stays open for the runner's lifetime.
|
||||
self._lock_handle = acquire_state_dir_lock(self._state_dir)
|
||||
try:
|
||||
self._cache = diskcache.Cache(str(self._state_dir))
|
||||
# Re-hydrate the in-memory terminal mirror so ``get`` works
|
||||
# for task ids that completed in a prior process. Doing this
|
||||
# here (rather than lazily) means the mirror is consistent
|
||||
# the moment construction returns.
|
||||
order: Any = self._cache.get(_TERMINAL_ORDER_KEY, default=[])
|
||||
if not isinstance(order, list):
|
||||
# Defensive: a corrupted ordering list shouldn't take
|
||||
# the host down. Reset and continue — at worst we lose
|
||||
# ordering for FIFO eviction, not correctness.
|
||||
logger.warning(
|
||||
"InProcessTaskRunner: terminal-order entry in %s is not a list; resetting", self._state_dir
|
||||
)
|
||||
order = []
|
||||
self._cache.set(_TERMINAL_ORDER_KEY, order)
|
||||
self._terminal_order = [str(x) for x in cast(list[Any], order)]
|
||||
for task_id in self._terminal_order:
|
||||
rec_obj: Any
|
||||
try:
|
||||
rec_obj = self._cache.get(task_id)
|
||||
except Exception: # pragma: no cover - exercised via corrupt-entry test
|
||||
rec_obj = None
|
||||
if not isinstance(rec_obj, dict):
|
||||
continue
|
||||
rec = cast(dict[str, Any], rec_obj)
|
||||
status = rec.get(_REC_STATUS)
|
||||
if status in {"succeeded", "failed", "cancelled"}:
|
||||
self._terminal[task_id] = status
|
||||
except Exception:
|
||||
release_state_dir_lock(self._lock_handle)
|
||||
self._lock_handle = None
|
||||
raise
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# DurableTaskRunner Protocol
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def register(
|
||||
self,
|
||||
name: str,
|
||||
handler: Callable[[Mapping[str, Any]], Awaitable[None]],
|
||||
) -> None:
|
||||
if self._started:
|
||||
raise RuntimeError(
|
||||
f"InProcessTaskRunner.register({name!r}) called after the "
|
||||
"runner started scheduling tasks — register all handlers at "
|
||||
"host startup, before serving traffic, to avoid silently "
|
||||
"reordering in-flight work."
|
||||
)
|
||||
if name in self._handlers:
|
||||
logger.warning("InProcessTaskRunner: replacing handler registered under %r", name)
|
||||
self._handlers[name] = handler
|
||||
|
||||
async def schedule(
|
||||
self,
|
||||
name: str,
|
||||
payload: Mapping[str, Any],
|
||||
*,
|
||||
retry_policy: RetryPolicy | None = None,
|
||||
) -> TaskHandle:
|
||||
if name not in self._handlers:
|
||||
raise KeyError(
|
||||
f"InProcessTaskRunner.schedule({name!r}): no handler "
|
||||
"registered under this name. Call register(name, handler) "
|
||||
"at host startup before scheduling."
|
||||
)
|
||||
|
||||
self._started = True
|
||||
policy = retry_policy or self._default_retry_policy
|
||||
task_id = uuid.uuid4().hex
|
||||
handle = TaskHandle(task_id=task_id, name=name)
|
||||
|
||||
# Persist the record (when state_dir is set) BEFORE we spawn the
|
||||
# asyncio task — if the persistence write fails we surface it as
|
||||
# a synchronous error from ``schedule`` rather than silently
|
||||
# downgrading to in-memory.
|
||||
if self._cache is not None:
|
||||
record = self._build_record(name, dict(payload), policy)
|
||||
self._validate_picklable(record)
|
||||
self._cache.set(task_id, record)
|
||||
|
||||
# When persisted, wrap the payload so handler-side mutations
|
||||
# (e.g. ``payload["echo_done"] = True``) flow back to disk.
|
||||
runtime_payload: Mapping[str, Any]
|
||||
if self._cache is not None:
|
||||
captured_task_id = task_id
|
||||
|
||||
def _persist_cb(new_payload: Mapping[str, Any]) -> None:
|
||||
self._update_record_payload(captured_task_id, new_payload)
|
||||
|
||||
runtime_payload = _PersistedPayloadDict(payload, _persist_cb)
|
||||
else:
|
||||
runtime_payload = payload
|
||||
|
||||
handler = self._handlers[name]
|
||||
task = asyncio.create_task(
|
||||
self._run_with_retry(handle, handler, runtime_payload, policy),
|
||||
name=f"hosting.task[{name}]:{task_id}",
|
||||
)
|
||||
self._pending[task_id] = task
|
||||
|
||||
def _on_done(_t: asyncio.Task[None], tid: str = task_id) -> None:
|
||||
self._pending.pop(tid, None)
|
||||
|
||||
task.add_done_callback(_on_done)
|
||||
return handle
|
||||
|
||||
async def get(self, handle: TaskHandle) -> TaskStatus | None:
|
||||
if handle.task_id in self._pending:
|
||||
task = self._pending[handle.task_id]
|
||||
if task.cancelled():
|
||||
return "cancelled"
|
||||
return "running"
|
||||
# In-memory terminal mirror covers both pure-memory and
|
||||
# disk-persistent runs (we re-hydrate on cache open).
|
||||
if handle.task_id in self._terminal:
|
||||
return self._terminal[handle.task_id]
|
||||
# Disk fallback for very-aged task ids that left the in-memory
|
||||
# mirror but still have a record on disk (extremely unlikely
|
||||
# given that we re-hydrate all terminals at open, but defensive).
|
||||
if self._cache is not None:
|
||||
rec_obj: Any = self._cache.get(handle.task_id)
|
||||
if isinstance(rec_obj, dict):
|
||||
rec = cast(dict[str, Any], rec_obj)
|
||||
status = rec.get(_REC_STATUS)
|
||||
# Records on disk only live in one of four states:
|
||||
# ``pending`` (queued or in-flight — resume picks these
|
||||
# up) or one of the terminals. There is no transient
|
||||
# ``running`` status; the in-flight asyncio task is
|
||||
# observable via ``_pending`` only inside its own
|
||||
# process.
|
||||
if status in {"succeeded", "failed", "cancelled", "pending"}:
|
||||
return cast(TaskStatus, status)
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Resume — replay persisted pending records on startup
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def resume(self) -> int:
|
||||
"""Re-schedule pending tasks persisted by a previous process.
|
||||
|
||||
Walks the cache for records in ``"pending"`` status, looks up
|
||||
their handler in :attr:`_handlers`, and re-creates an
|
||||
:class:`asyncio.Task` for each — preserving the persisted
|
||||
attempt count so retry budgets resume mid-way through their
|
||||
backoff schedule.
|
||||
|
||||
Records whose handler is no longer registered are marked
|
||||
``"failed"`` with a clear reason in the log; they will not be
|
||||
retried again. Records that fail to deserialise (corrupted
|
||||
sqlite row, schema drift, …) are quarantined: their entry is
|
||||
removed from the cache and the task id is logged. Both classes
|
||||
of error are non-fatal — the host should boot even when a
|
||||
small number of legacy records can't be replayed.
|
||||
|
||||
Returns the number of records successfully re-scheduled.
|
||||
|
||||
Called automatically from :class:`AgentFrameworkHost`'s lifespan
|
||||
startup hook when the runner is host-owned. Callers driving the
|
||||
runner directly (tests, bespoke ASGI setups) MUST call this
|
||||
once after registering handlers and before serving traffic.
|
||||
"""
|
||||
if self._cache is None:
|
||||
return 0
|
||||
|
||||
# Mark started so subsequent register() calls fail loudly — we
|
||||
# don't want handler swaps after replay begins.
|
||||
self._started = True
|
||||
|
||||
replayed = 0
|
||||
# iterkeys returns a live view; we copy to a list because we may
|
||||
# delete entries inside the loop (quarantine / drop-on-missing-handler).
|
||||
task_ids: list[str] = [str(k) for k in self._cache.iterkeys() if k != _TERMINAL_ORDER_KEY]
|
||||
for task_id in task_ids:
|
||||
rec_obj: Any
|
||||
try:
|
||||
rec_obj = self._cache.get(task_id)
|
||||
except Exception:
|
||||
logger.exception("InProcessTaskRunner.resume: failed to read record %s; quarantining", task_id)
|
||||
with contextlib.suppress(KeyError):
|
||||
del self._cache[task_id]
|
||||
continue
|
||||
if not isinstance(rec_obj, dict) or _REC_STATUS not in rec_obj:
|
||||
logger.warning("InProcessTaskRunner.resume: record %s is not a task dict; quarantining", task_id)
|
||||
with contextlib.suppress(KeyError):
|
||||
del self._cache[task_id]
|
||||
continue
|
||||
rec = cast(dict[str, Any], rec_obj)
|
||||
status = rec[_REC_STATUS]
|
||||
if status != "pending":
|
||||
continue
|
||||
|
||||
handler_name = rec.get(_REC_HANDLER_NAME)
|
||||
if not isinstance(handler_name, str) or handler_name not in self._handlers:
|
||||
logger.warning(
|
||||
"InProcessTaskRunner.resume: no handler registered for record %s (handler=%r); marking failed",
|
||||
task_id,
|
||||
handler_name,
|
||||
)
|
||||
self._mark_terminal(task_id, "failed")
|
||||
continue
|
||||
handler = self._handlers[handler_name]
|
||||
|
||||
policy_value = rec.get(_REC_RETRY_POLICY) or self._default_retry_policy
|
||||
if not isinstance(policy_value, RetryPolicy):
|
||||
# Legacy / corrupt entry — fall back to the default rather
|
||||
# than failing the whole resume.
|
||||
policy_value = self._default_retry_policy
|
||||
policy: RetryPolicy = policy_value
|
||||
payload_value: Any = rec.get(_REC_PAYLOAD) or {}
|
||||
payload: dict[str, Any]
|
||||
if isinstance(payload_value, dict):
|
||||
payload = cast(dict[str, Any], payload_value)
|
||||
elif hasattr(payload_value, "keys"):
|
||||
payload = dict(cast(Mapping[str, Any], payload_value))
|
||||
else:
|
||||
payload = {}
|
||||
|
||||
name_value = rec.get(_REC_NAME, handler_name)
|
||||
handle = TaskHandle(task_id=task_id, name=str(name_value))
|
||||
attempts_value = rec.get(_REC_ATTEMPTS, 0)
|
||||
attempts_completed = int(attempts_value or 0)
|
||||
|
||||
def _make_resume_persist_cb(tid: str) -> Callable[[Mapping[str, Any]], None]:
|
||||
def _cb(new_payload: Mapping[str, Any]) -> None:
|
||||
self._update_record_payload(tid, new_payload)
|
||||
|
||||
return _cb
|
||||
|
||||
runtime_payload = _PersistedPayloadDict(payload, _make_resume_persist_cb(task_id))
|
||||
|
||||
task = asyncio.create_task(
|
||||
self._run_with_retry(handle, handler, runtime_payload, policy, _resume_from_attempt=attempts_completed),
|
||||
name=f"hosting.task[{handle.name}]:{task_id}(resumed)",
|
||||
)
|
||||
self._pending[task_id] = task
|
||||
|
||||
def _on_done(_t: asyncio.Task[None], tid: str = task_id) -> None:
|
||||
self._pending.pop(tid, None)
|
||||
|
||||
task.add_done_callback(_on_done)
|
||||
replayed += 1
|
||||
|
||||
if replayed:
|
||||
logger.info(
|
||||
"InProcessTaskRunner.resume: re-scheduled %d pending task(s) from %s", replayed, self._state_dir
|
||||
)
|
||||
return replayed
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Lifecycle helper (the host calls this from ``on_shutdown``)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def shutdown(self, *, timeout: float | None = None) -> None:
|
||||
"""Wait briefly for pending tasks to drain, then cancel anything still running.
|
||||
|
||||
Called by the host on ``on_shutdown`` so a graceful shutdown does
|
||||
not orphan in-flight push retries. Channels may legitimately
|
||||
schedule a final push from their own shutdown callback (e.g. a
|
||||
goodbye message); the runner therefore *waits* up to
|
||||
``timeout`` seconds (default: the runner's
|
||||
``shutdown_grace_seconds`` configured at construction) for the
|
||||
in-flight set to finish on its own before cancelling stragglers.
|
||||
Tasks that don't honour cancellation within the same window are
|
||||
abandoned — the runner makes no synchronous durability claim,
|
||||
so cleanup is best-effort.
|
||||
|
||||
When ``state_dir`` is set, tasks that didn't drain are left in
|
||||
``"pending"`` status on disk so the next process replays them
|
||||
via :meth:`resume`. The disk cache is closed and the
|
||||
single-owner lock is released regardless of drain outcome.
|
||||
"""
|
||||
self._shutting_down = True
|
||||
try:
|
||||
if self._pending:
|
||||
grace = timeout if timeout is not None else self._shutdown_grace_seconds
|
||||
tasks = list(self._pending.values())
|
||||
# Phase 1 — wait for natural completion within the grace window.
|
||||
if grace > 0:
|
||||
await asyncio.wait(tasks, timeout=grace)
|
||||
# Phase 2 — cancel anything still pending, then wait briefly for
|
||||
# cancellation to propagate.
|
||||
still_pending = [t for t in tasks if not t.done()]
|
||||
if still_pending:
|
||||
logger.info(
|
||||
"InProcessTaskRunner.shutdown: %d task(s) still running after %.2fs grace; cancelling",
|
||||
len(still_pending),
|
||||
grace,
|
||||
)
|
||||
for task in still_pending:
|
||||
task.cancel()
|
||||
cancellation_window = max(grace, 1.0)
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(*still_pending, return_exceptions=True),
|
||||
timeout=cancellation_window,
|
||||
)
|
||||
except (TimeoutError, asyncio.TimeoutError):
|
||||
logger.warning(
|
||||
"InProcessTaskRunner.shutdown: %d task(s) did not exit within %.2fs "
|
||||
"of cancellation; abandoning",
|
||||
sum(not t.done() for t in still_pending),
|
||||
cancellation_window,
|
||||
)
|
||||
finally:
|
||||
# Release disk resources after the in-flight set has been
|
||||
# given a chance to drain — tasks that mutate the payload
|
||||
# mid-shutdown will fail to persist after this point, which
|
||||
# is the correct behaviour (the next process will replay
|
||||
# from whatever the last fully-committed state was).
|
||||
if self._cache is not None:
|
||||
try:
|
||||
self._cache.close()
|
||||
except Exception: # pragma: no cover - close errors aren't actionable
|
||||
logger.exception("InProcessTaskRunner.shutdown: failed to close cache cleanly")
|
||||
self._cache = None
|
||||
if self._lock_handle is not None:
|
||||
release_state_dir_lock(self._lock_handle)
|
||||
self._lock_handle = None
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Internals — retry loop
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def _run_with_retry(
|
||||
self,
|
||||
handle: TaskHandle,
|
||||
handler: Callable[[Mapping[str, Any]], Awaitable[None]],
|
||||
payload: Mapping[str, Any],
|
||||
policy: RetryPolicy,
|
||||
*,
|
||||
_resume_from_attempt: int = 0,
|
||||
) -> None:
|
||||
delay = policy.initial_backoff_seconds
|
||||
attempt = _resume_from_attempt
|
||||
try:
|
||||
while True:
|
||||
attempt += 1
|
||||
# Persist the attempt counter BEFORE we invoke the
|
||||
# handler so a crash mid-handler doesn't lose the fact
|
||||
# that we tried — replay sees the bumped counter and
|
||||
# respects the original retry budget. Trade-off: a
|
||||
# crash before the external call is made still consumes
|
||||
# one attempt (at-most-once semantics around the bump);
|
||||
# we document this as best-effort across crashes.
|
||||
self._update_record_attempts(handle.task_id, attempt)
|
||||
|
||||
try:
|
||||
await handler(payload)
|
||||
except asyncio.CancelledError:
|
||||
# On a graceful shutdown of a disk-persistent runner
|
||||
# we deliberately *don't* mark the record terminal —
|
||||
# ``resume()`` will pick it up on the next boot and
|
||||
# replay it with the persisted attempt counter. For
|
||||
# in-memory runners (no cache) there's nothing to
|
||||
# resume from, so we still mark ``cancelled`` so
|
||||
# callers holding the handle can observe the
|
||||
# outcome.
|
||||
if not (self._shutting_down and self._cache is not None):
|
||||
self._mark_terminal(handle.task_id, "cancelled")
|
||||
raise
|
||||
except Exception as exc:
|
||||
if attempt >= policy.max_attempts:
|
||||
logger.exception(
|
||||
"InProcessTaskRunner: task %s (%s) failed after %d attempts",
|
||||
handle.name,
|
||||
handle.task_id,
|
||||
attempt,
|
||||
)
|
||||
self._mark_terminal(handle.task_id, "failed")
|
||||
return
|
||||
logger.warning(
|
||||
"InProcessTaskRunner: task %s (%s) attempt %d/%d failed (%s); retrying in %.2fs",
|
||||
handle.name,
|
||||
handle.task_id,
|
||||
attempt,
|
||||
policy.max_attempts,
|
||||
exc,
|
||||
delay,
|
||||
)
|
||||
try:
|
||||
await asyncio.sleep(delay)
|
||||
except asyncio.CancelledError:
|
||||
if not (self._shutting_down and self._cache is not None):
|
||||
self._mark_terminal(handle.task_id, "cancelled")
|
||||
raise
|
||||
delay = min(delay * policy.backoff_multiplier, policy.max_backoff_seconds)
|
||||
else:
|
||||
self._mark_terminal(handle.task_id, "succeeded")
|
||||
return
|
||||
except asyncio.CancelledError:
|
||||
# Propagate so the outer ``asyncio.Task`` records cancellation
|
||||
# in its own state for any observer that holds the raw task.
|
||||
return
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Internals — record / disk helpers
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _build_record(
|
||||
self,
|
||||
name: str,
|
||||
payload: Mapping[str, Any],
|
||||
policy: RetryPolicy,
|
||||
) -> dict[str, Any]:
|
||||
"""Construct the on-disk record dict for a freshly-scheduled task."""
|
||||
return {
|
||||
_REC_HANDLER_NAME: name,
|
||||
_REC_NAME: name,
|
||||
_REC_PAYLOAD: dict(payload),
|
||||
_REC_RETRY_POLICY: policy,
|
||||
_REC_ATTEMPTS: 0,
|
||||
_REC_STATUS: "pending",
|
||||
_REC_CREATED_AT: time.time(),
|
||||
}
|
||||
|
||||
def _validate_picklable(self, record: Mapping[str, Any]) -> None:
|
||||
"""Pickle-probe a record at schedule time so misconfig is loud.
|
||||
|
||||
We only do this when the cache is open (i.e. persistence is on).
|
||||
The probe runs ``pickle.dumps`` on the record and raises a
|
||||
framework-typed :class:`PushPayloadNotPicklable` if it fails.
|
||||
Loud failure here is better than silent data loss after the
|
||||
next restart.
|
||||
"""
|
||||
try:
|
||||
pickle.dumps(record) # nosec B301 - dumps only, no untrusted load
|
||||
except Exception as exc:
|
||||
raise PushPayloadNotPicklable(
|
||||
"InProcessTaskRunner: scheduled task payload is not picklable; "
|
||||
"disk persistence (state_dir) requires payloads to round-trip "
|
||||
"through pickle. Common causes: a user-supplied response that "
|
||||
"embeds a live network client, asyncio.Lock, or generator. "
|
||||
f"Underlying pickle error: {exc!r}"
|
||||
) from exc
|
||||
|
||||
def _update_record_attempts(self, task_id: str, attempt: int) -> None:
|
||||
"""Bump the attempt counter on the persisted record (if any).
|
||||
|
||||
Status stays ``"pending"`` while the task is in-flight — there
|
||||
is no transient ``"running"`` status. This keeps the resume
|
||||
contract simple: anything ``"pending"`` on disk is a candidate
|
||||
for replay, whether it was never picked up or crashed mid-attempt.
|
||||
"""
|
||||
if self._cache is None:
|
||||
return
|
||||
rec = self._cache.get(task_id)
|
||||
if not isinstance(rec, dict):
|
||||
# Record was evicted / quarantined since schedule; nothing
|
||||
# to persist. The asyncio task continues — it just won't
|
||||
# be resumable on next boot.
|
||||
return
|
||||
rec[_REC_ATTEMPTS] = attempt
|
||||
try:
|
||||
self._cache.set(task_id, rec)
|
||||
except Exception: # pragma: no cover - cache write failures aren't actionable
|
||||
logger.exception("InProcessTaskRunner: failed to persist attempt counter for %s", task_id)
|
||||
|
||||
def _update_record_payload(self, task_id: str, new_payload: Mapping[str, Any]) -> None:
|
||||
"""Persist a handler-side payload mutation back to disk.
|
||||
|
||||
Called from :class:`_PersistedPayloadDict.__setitem__`. The whole
|
||||
payload mapping is re-written (the cache stores opaque pickled
|
||||
values, so partial-field updates aren't possible). Handler-side
|
||||
mutations on the runner's hot path are rare (today: only the
|
||||
``echo_done`` cursor) so the extra write is acceptable.
|
||||
"""
|
||||
if self._cache is None:
|
||||
return
|
||||
rec = self._cache.get(task_id)
|
||||
if not isinstance(rec, dict):
|
||||
return
|
||||
rec[_REC_PAYLOAD] = dict(new_payload)
|
||||
try:
|
||||
self._cache.set(task_id, rec)
|
||||
except Exception: # pragma: no cover - cache write failures aren't actionable
|
||||
logger.exception("InProcessTaskRunner: failed to persist payload mutation for %s", task_id)
|
||||
|
||||
def _mark_terminal(self, task_id: str, status: TaskStatus) -> None:
|
||||
"""Move a task to a terminal status, updating both memory and disk.
|
||||
|
||||
Records are first updated on disk (so a crash between the disk
|
||||
write and the in-memory write doesn't lose the terminal status),
|
||||
then mirrored to the in-memory cache, then FIFO-bounded.
|
||||
"""
|
||||
# Disk side first.
|
||||
if self._cache is not None:
|
||||
rec = self._cache.get(task_id)
|
||||
if isinstance(rec, dict):
|
||||
rec[_REC_STATUS] = status
|
||||
rec[_REC_TERMINAL_AT] = time.time()
|
||||
# Truncate heavy fields (payload, retry_policy) — once
|
||||
# the task is terminal we never need them again, and
|
||||
# keeping them around bloats disk on long-lived hosts.
|
||||
rec[_REC_PAYLOAD] = None
|
||||
rec[_REC_RETRY_POLICY] = None
|
||||
try:
|
||||
self._cache.set(task_id, rec)
|
||||
except Exception: # pragma: no cover
|
||||
logger.exception("InProcessTaskRunner: failed to persist terminal status for %s", task_id)
|
||||
|
||||
# In-memory side.
|
||||
if task_id not in self._terminal:
|
||||
self._terminal_order.append(task_id)
|
||||
self._terminal[task_id] = status
|
||||
|
||||
# FIFO-evict from BOTH layers once we exceed the cap.
|
||||
while len(self._terminal_order) > self._terminal_cache_size:
|
||||
evicted = self._terminal_order.pop(0)
|
||||
self._terminal.pop(evicted, None)
|
||||
if self._cache is not None:
|
||||
try:
|
||||
del self._cache[evicted]
|
||||
except KeyError:
|
||||
pass
|
||||
except Exception: # pragma: no cover
|
||||
logger.exception("InProcessTaskRunner: failed to evict %s from disk cache", evicted)
|
||||
|
||||
# Persist the new ordering list so a restart sees the same FIFO
|
||||
# ordering for further eviction decisions.
|
||||
if self._cache is not None:
|
||||
try:
|
||||
self._cache.set(_TERMINAL_ORDER_KEY, list(self._terminal_order))
|
||||
except Exception: # pragma: no cover
|
||||
logger.exception("InProcessTaskRunner: failed to persist terminal-order list")
|
||||
|
||||
|
||||
__all__ = ["InProcessTaskRunner"]
|
||||
@@ -1,49 +1,11 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Disk-backed wrappers for the host's in-memory state dicts.
|
||||
"""Disk-backed wrapper for the host's session-alias map.
|
||||
|
||||
The host keeps three in-process dictionaries that need to survive a
|
||||
process restart when the operator opts in to disk persistence:
|
||||
|
||||
- ``_session_aliases`` (``isolation_key -> active session_id``): rotated
|
||||
by :meth:`AgentFrameworkHost.reset_session`; without persistence a
|
||||
restart silently re-uses the pre-rotation session_id and the user sees
|
||||
history they were supposed to have walked away from.
|
||||
- ``_active`` (``isolation_key -> last-seen channel name``): drives
|
||||
:class:`ResponseTarget` ``.active`` fan-out; losing it on restart makes
|
||||
:class:`ResponseTarget.active` raise ``"no active channel"`` for every
|
||||
user the host has previously talked to.
|
||||
- ``_identities``
|
||||
(``isolation_key -> {channel_name -> ChannelIdentity}``): the per-user
|
||||
channel registry that powers :class:`ResponseTarget` ``.channel(name)``,
|
||||
``.channels([...])`` and ``.all_linked``; losing it on restart turns
|
||||
every linked-identity push target into a not-found.
|
||||
|
||||
Both wrappers are :class:`dict` subclasses so the rest of the host code
|
||||
doesn't need to know whether persistence is on or off; the only
|
||||
difference is that mutations are mirrored back to a
|
||||
:mod:`diskcache`-backed sqlite store. Reads stay fast because the
|
||||
in-memory copy is the source of truth — disk is purely a backing
|
||||
store for write-through and re-hydration.
|
||||
|
||||
Layout under ``<state_dir>/sessions/`` (the ``sessions`` component
|
||||
chosen because all three dicts share the same per-user-life cycle):
|
||||
|
||||
<state_dir>/sessions/
|
||||
.lock # single-owner lock (advisory)
|
||||
cache.db, … # diskcache sqlite files
|
||||
keyed by:
|
||||
"aliases:<isolation_key>" -> str (session_id)
|
||||
"active:<isolation_key>" -> str (channel name)
|
||||
"identities:<isolation_key>" -> dict[channel_name, ChannelIdentity]
|
||||
|
||||
Pickle is what diskcache uses by default; the wrappers do not impose
|
||||
their own serialisation. :class:`ChannelIdentity` is a frozen dataclass
|
||||
of plain scalars and so round-trips cleanly.
|
||||
|
||||
Everything in this module is internal. Public consumers should use
|
||||
:class:`AgentFrameworkHost(state_dir=...)` and let the host wire the
|
||||
wrappers up.
|
||||
``AgentFrameworkHost.reset_session(isolation_key)`` rotates future requests for
|
||||
that isolation key onto a new session id. Persisting the alias map lets that
|
||||
rotation survive a host restart without introducing cross-channel identity or
|
||||
delivery state into the core host.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -52,7 +14,7 @@ import logging
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import Any, TypeVar, cast
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from ._persistence import (
|
||||
acquire_state_dir_lock,
|
||||
@@ -62,27 +24,12 @@ from ._persistence import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_V = TypeVar("_V")
|
||||
|
||||
|
||||
# Key prefixes inside the shared sessions cache. Three logical maps live
|
||||
# in one diskcache so they share a single sqlite handle and a single
|
||||
# directory lock — opening multiple diskcaches against the same
|
||||
# directory is supported but doubles file-handle pressure and the
|
||||
# per-open lock acquisition cost.
|
||||
_ALIASES_PREFIX = "aliases:"
|
||||
_ACTIVE_PREFIX = "active:"
|
||||
_IDENTITIES_PREFIX = "identities:"
|
||||
|
||||
|
||||
class SessionsStateStore:
|
||||
"""One disk cache + lock shared by every host-side persisted dict.
|
||||
|
||||
The host constructs one of these per ``state_dir["sessions"]`` value
|
||||
and threads it into each :class:`_PersistedDict` it creates. Closing
|
||||
the store releases the lock and the cache handle.
|
||||
"""
|
||||
"""One disk cache + lock for host-side session aliases."""
|
||||
|
||||
def __init__(self, sessions_dir: str | os.PathLike[str]) -> None:
|
||||
self._sessions_dir: Path = Path(os.fspath(sessions_dir))
|
||||
@@ -97,22 +44,11 @@ class SessionsStateStore:
|
||||
|
||||
@property
|
||||
def cache(self) -> Any:
|
||||
"""Return the underlying :mod:`diskcache` Cache.
|
||||
|
||||
Intended for the wrapper classes in this module only. Callers
|
||||
outside the module should go through the typed wrappers — direct
|
||||
cache access bypasses the key-prefix discipline that keeps the
|
||||
three maps from colliding.
|
||||
"""
|
||||
"""Return the underlying :mod:`diskcache` Cache."""
|
||||
return self._cache
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the cache and release the directory lock.
|
||||
|
||||
Safe to call multiple times. The host invokes this from its
|
||||
lifespan shutdown hook so a second host can re-open the same
|
||||
``state_dir`` cleanly after the first exits.
|
||||
"""
|
||||
"""Close the cache and release the directory lock."""
|
||||
if self._cache is not None:
|
||||
try:
|
||||
self._cache.close()
|
||||
@@ -125,15 +61,7 @@ class SessionsStateStore:
|
||||
|
||||
|
||||
class _PersistedDict(dict[str, _V]):
|
||||
"""Drop-in :class:`dict` whose mutations mirror to a diskcache prefix.
|
||||
|
||||
Used for the host's flat ``str -> V`` dicts (``_session_aliases``
|
||||
and ``_active``). The in-memory copy is the source of truth for
|
||||
reads; writes update memory first and then mirror to disk so a
|
||||
crash between the two leaves the in-memory state correct (which is
|
||||
what subsequent reads will see anyway) and only loses the last
|
||||
not-yet-flushed value on next restart.
|
||||
"""
|
||||
"""Drop-in :class:`dict` whose mutations mirror to a diskcache prefix."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -144,26 +72,20 @@ class _PersistedDict(dict[str, _V]):
|
||||
super().__init__()
|
||||
self._store = store
|
||||
self._prefix = key_prefix
|
||||
# Rehydrate from disk into memory exactly once at construction.
|
||||
# Doing this here (rather than lazily) keeps the in-memory dict
|
||||
# behaviour consistent with the non-persisted code path —
|
||||
# ``len(host._session_aliases)`` reflects all known users from
|
||||
# the moment the host is constructed.
|
||||
cache: Any = store.cache
|
||||
for raw_key in cache.iterkeys():
|
||||
if not isinstance(raw_key, str) or not raw_key.startswith(key_prefix):
|
||||
continue
|
||||
value: Any
|
||||
try:
|
||||
value = cache.get(raw_key)
|
||||
value: Any = cache.get(raw_key)
|
||||
except Exception:
|
||||
logger.exception("SessionsStateStore: failed to rehydrate %s; skipping", raw_key)
|
||||
continue
|
||||
logical_key = raw_key[len(key_prefix) :]
|
||||
super().__setitem__(logical_key, value)
|
||||
if initial:
|
||||
for k, v in initial.items():
|
||||
self[k] = v
|
||||
for key, value in initial.items():
|
||||
self[key] = value
|
||||
|
||||
def __setitem__(self, key: str, value: _V) -> None:
|
||||
super().__setitem__(key, value)
|
||||
@@ -182,9 +104,7 @@ class _PersistedDict(dict[str, _V]):
|
||||
logger.exception("SessionsStateStore: failed to evict %s%s", self._prefix, key)
|
||||
|
||||
def pop(self, key: str, *args: Any) -> _V:
|
||||
# ``dict.pop`` doesn't go through ``__delitem__``, so we mirror
|
||||
# the disk side here explicitly. Forward the default sentinel
|
||||
# only when present so we match ``dict.pop`` semantics exactly.
|
||||
"""Mirror ``dict.pop`` to disk."""
|
||||
value: _V = super().pop(key, *args)
|
||||
try:
|
||||
del self._store.cache[self._prefix + key]
|
||||
@@ -195,16 +115,17 @@ class _PersistedDict(dict[str, _V]):
|
||||
return value
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Mirror ``dict.clear`` to disk."""
|
||||
keys = list(self.keys())
|
||||
super().clear()
|
||||
cache = self._store.cache
|
||||
for k in keys:
|
||||
for key in keys:
|
||||
try:
|
||||
del cache[self._prefix + k]
|
||||
del cache[self._prefix + key]
|
||||
except KeyError:
|
||||
pass
|
||||
except Exception: # pragma: no cover
|
||||
logger.exception("SessionsStateStore: failed to evict %s%s during clear", self._prefix, k)
|
||||
logger.exception("SessionsStateStore: failed to evict %s%s during clear", self._prefix, key)
|
||||
|
||||
def update( # type: ignore[override]
|
||||
self,
|
||||
@@ -212,191 +133,14 @@ class _PersistedDict(dict[str, _V]):
|
||||
/,
|
||||
**kwargs: _V,
|
||||
) -> None:
|
||||
# Defer to __setitem__ so every entry is mirrored to disk; the
|
||||
# default ``dict.update`` writes into the underlying storage
|
||||
# directly and would skip our persistence hook.
|
||||
"""Mirror ``dict.update`` to disk one item at a time."""
|
||||
if other is not None:
|
||||
for k in other:
|
||||
self[k] = other[k]
|
||||
for k, v in kwargs.items():
|
||||
self[k] = v
|
||||
for key in other:
|
||||
self[key] = other[key]
|
||||
for key, value in kwargs.items():
|
||||
self[key] = value
|
||||
|
||||
|
||||
class _PersistedNestedDict(dict[str, dict[str, _V]]):
|
||||
"""Disk-backed wrapper for the per-isolation-key identity map.
|
||||
|
||||
The host's ``_identities`` is a nested dict
|
||||
``isolation_key -> {channel_name -> ChannelIdentity}``. The whole
|
||||
inner dict for a given isolation_key is small (one entry per channel
|
||||
the user has appeared on), so we persist the inner dict as a single
|
||||
cache value rather than per-channel — fewer cache hits, simpler
|
||||
schema, no need for a separate sub-prefix.
|
||||
|
||||
To make mutations of the inner dict mirror to disk, ``__getitem__``
|
||||
returns a ``_NestedInnerProxy`` that mutates the parent's cache slot
|
||||
on each ``__setitem__`` / ``__delitem__``. The wrapper is purely
|
||||
additive — callers that pass a plain dict in via ``__setitem__`` get
|
||||
the same write-through behaviour for free.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: SessionsStateStore,
|
||||
key_prefix: str = _IDENTITIES_PREFIX,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._store = store
|
||||
self._prefix = key_prefix
|
||||
cache: Any = store.cache
|
||||
for raw_key in cache.iterkeys():
|
||||
if not isinstance(raw_key, str) or not raw_key.startswith(key_prefix):
|
||||
continue
|
||||
value: Any
|
||||
try:
|
||||
value = cache.get(raw_key)
|
||||
except Exception:
|
||||
logger.exception("SessionsStateStore: failed to rehydrate %s; skipping", raw_key)
|
||||
continue
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
inner_value = cast(dict[str, _V], value)
|
||||
logical_key = raw_key[len(key_prefix) :]
|
||||
# Wrap so caller-side mutations on the inner dict mirror back.
|
||||
inner: _NestedInnerProxy[_V] = _NestedInnerProxy(self, logical_key, inner_value)
|
||||
super().__setitem__(logical_key, inner)
|
||||
|
||||
def __setitem__(self, key: str, value: dict[str, _V]) -> None:
|
||||
# Wrap whatever the caller passes in so subsequent ``inner[ch] = ...``
|
||||
# mutations are mirrored to disk. We always wrap (even
|
||||
# ``_NestedInnerProxy`` inputs) so the proxy's ``_outer`` link
|
||||
# points at us rather than at any previous outer dict.
|
||||
wrapped = _NestedInnerProxy(self, key, dict(value))
|
||||
super().__setitem__(key, wrapped)
|
||||
self.persist_inner(key, dict(value))
|
||||
|
||||
def __delitem__(self, key: str) -> None:
|
||||
super().__delitem__(key)
|
||||
try:
|
||||
del self._store.cache[self._prefix + key]
|
||||
except KeyError:
|
||||
pass
|
||||
except Exception: # pragma: no cover
|
||||
logger.exception("SessionsStateStore: failed to evict %s%s", self._prefix, key)
|
||||
|
||||
def setdefault(self, key: str, default: dict[str, _V] | None = None) -> dict[str, _V]: # type: ignore[override]
|
||||
if key in self:
|
||||
return self[key]
|
||||
if default is None:
|
||||
default = {}
|
||||
self[key] = default
|
||||
return self[key]
|
||||
|
||||
def persist_inner(self, isolation_key: str, snapshot: Mapping[str, _V]) -> None:
|
||||
"""Write the full inner dict for ``isolation_key`` back to disk.
|
||||
|
||||
Called from :class:`_NestedInnerProxy` on every mutation and by
|
||||
:meth:`__setitem__` when a new outer key is added. A single
|
||||
write per change keeps the schema simple — there is no
|
||||
partial-row update — and is fine for the access pattern
|
||||
(mutations on the host's hot path are rare: identity registry
|
||||
writes are once-per-channel-per-user).
|
||||
"""
|
||||
try:
|
||||
self._store.cache.set(self._prefix + isolation_key, snapshot)
|
||||
except Exception: # pragma: no cover - cache write failures aren't actionable
|
||||
logger.exception(
|
||||
"SessionsStateStore: failed to persist identities for %s%s",
|
||||
self._prefix,
|
||||
isolation_key,
|
||||
)
|
||||
|
||||
|
||||
class _NestedInnerProxy(dict[str, _V]):
|
||||
"""Inner-dict proxy that mirrors mutations back to its outer.
|
||||
|
||||
Returned by :class:`_PersistedNestedDict.__getitem__` (via the
|
||||
rehydration / ``__setitem__`` wrap). When the channel-registry code
|
||||
does ``self._identities[ik][channel_name] = identity``, the
|
||||
``__setitem__`` on this proxy fires and re-writes the whole inner
|
||||
dict to disk via the parent's ``persist_inner``. Behavioural
|
||||
identity with ``dict`` is preserved otherwise (``len``, iteration,
|
||||
``__contains__``, …).
|
||||
"""
|
||||
|
||||
_outer: _PersistedNestedDict[_V]
|
||||
_key: str
|
||||
|
||||
__slots__ = ("_key", "_outer")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
outer: _PersistedNestedDict[_V],
|
||||
key: str,
|
||||
data: Mapping[str, _V],
|
||||
) -> None:
|
||||
super().__init__(data)
|
||||
# ``__slots__`` on a ``dict`` subclass requires the back-door —
|
||||
# CPython is lenient, PyPy is strict.
|
||||
object.__setattr__(self, "_outer", outer)
|
||||
object.__setattr__(self, "_key", key)
|
||||
|
||||
def __setitem__(self, key: str, value: _V) -> None:
|
||||
super().__setitem__(key, value)
|
||||
self._outer.persist_inner(self._key, dict(self))
|
||||
|
||||
def __delitem__(self, key: str) -> None:
|
||||
super().__delitem__(key)
|
||||
self._outer.persist_inner(self._key, dict(self))
|
||||
|
||||
def pop(self, key: str, *args: Any) -> _V:
|
||||
value: _V = super().pop(key, *args)
|
||||
self._outer.persist_inner(self._key, dict(self))
|
||||
return value
|
||||
|
||||
def clear(self) -> None:
|
||||
super().clear()
|
||||
self._outer.persist_inner(self._key, dict(self))
|
||||
|
||||
def update( # type: ignore[override]
|
||||
self,
|
||||
other: Mapping[str, _V] | None = None,
|
||||
/,
|
||||
**kwargs: _V,
|
||||
) -> None:
|
||||
if other is not None:
|
||||
for k in other:
|
||||
super().__setitem__(k, other[k])
|
||||
for k, v in kwargs.items():
|
||||
super().__setitem__(k, v)
|
||||
self._outer.persist_inner(self._key, dict(self))
|
||||
|
||||
|
||||
def build_session_dicts(
|
||||
store: SessionsStateStore,
|
||||
) -> tuple[
|
||||
_PersistedDict[str],
|
||||
_PersistedDict[str],
|
||||
_PersistedNestedDict[Any],
|
||||
]:
|
||||
"""Construct the three host-side persisted dicts against a single store.
|
||||
|
||||
Returns ``(session_aliases, active, identities)`` in the order the
|
||||
host assigns them, so the call site reads
|
||||
``self._session_aliases, self._active, self._identities = build_session_dicts(store)``.
|
||||
"""
|
||||
aliases: _PersistedDict[str] = _PersistedDict(store, _ALIASES_PREFIX)
|
||||
active: _PersistedDict[str] = _PersistedDict(store, _ACTIVE_PREFIX)
|
||||
identities: _PersistedNestedDict[Any] = _PersistedNestedDict(store)
|
||||
return aliases, active, identities
|
||||
|
||||
|
||||
# Re-export keys for tests / power users that want to inspect the cache.
|
||||
__all__ = [
|
||||
"_ACTIVE_PREFIX",
|
||||
"_ALIASES_PREFIX",
|
||||
"_IDENTITIES_PREFIX",
|
||||
"SessionsStateStore",
|
||||
"_PersistedDict",
|
||||
"_PersistedNestedDict",
|
||||
"build_session_dicts",
|
||||
]
|
||||
def build_session_aliases(store: SessionsStateStore) -> dict[str, str]:
|
||||
"""Return the disk-backed session-alias map for ``store``."""
|
||||
return _PersistedDict[str](store, _ALIASES_PREFIX)
|
||||
|
||||
@@ -11,12 +11,7 @@
|
||||
These types form the boundary between the host and individual channels.
|
||||
A channel parses its native payload, builds a :class:`ChannelRequest`, and
|
||||
hands it to :class:`ChannelContext.run` (or ``run_stream``) on the host.
|
||||
The host normalizes the request into a single agent invocation and either
|
||||
returns the result to the originating channel or fans out via
|
||||
:class:`ResponseTarget` to other channels that implement
|
||||
:class:`ChannelPush`.
|
||||
|
||||
See ``docs/specs/002-python-hosting-channels.md`` for the full design.
|
||||
The channel owns rendering the result back onto its originating protocol.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -24,16 +19,11 @@ from __future__ import annotations
|
||||
import os
|
||||
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Any, Generic, Literal, Protocol, TypedDict, TypeVar, cast, runtime_checkable
|
||||
from typing import TYPE_CHECKING, Any, Generic, Protocol, TypedDict, TypeVar, runtime_checkable
|
||||
|
||||
from agent_framework import (
|
||||
AgentResponse,
|
||||
AgentResponseUpdate,
|
||||
AgentRunInputs,
|
||||
ResponseStream,
|
||||
SupportsAgentRun,
|
||||
Workflow,
|
||||
)
|
||||
from starlette.routing import BaseRoute
|
||||
|
||||
@@ -41,11 +31,6 @@ if TYPE_CHECKING:
|
||||
from ._host import ChannelContext
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Channel-neutral request envelope
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class ChannelSession:
|
||||
"""Channel-supplied session hint.
|
||||
|
||||
@@ -59,15 +44,12 @@ class ChannelSession:
|
||||
|
||||
|
||||
class ChannelIdentity:
|
||||
"""Channel-native identity the host sees on each request.
|
||||
"""Channel-native identity metadata observed on a request.
|
||||
|
||||
Consumed by the host's identity registry. The host uses it for two things:
|
||||
|
||||
1. Recording the active channel for an ``isolation_key`` so
|
||||
``ResponseTarget.active`` resolves correctly.
|
||||
2. Telling :class:`ChannelPush` ``push`` recipients **where** in their
|
||||
native namespace to deliver — Telegram uses ``native_id`` as the
|
||||
chat id, Teams as the conversation/AAD id, etc.
|
||||
The simplified hosting core records this only on the persisted input
|
||||
message's ``additional_properties["hosting"]`` block and forwards it
|
||||
through run/response hooks. Cross-channel linking and recipient lookup are
|
||||
follow-up concerns, not part of the v1 host contract.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -81,176 +63,6 @@ class ChannelIdentity:
|
||||
self.attributes: Mapping[str, Any] = attributes if attributes is not None else dict()
|
||||
|
||||
|
||||
class ResponseTargetKind(str, Enum):
|
||||
"""Discriminator for :class:`ResponseTarget` variants."""
|
||||
|
||||
ORIGINATING = "originating"
|
||||
ACTIVE = "active"
|
||||
CHANNELS = "channels"
|
||||
ALL_LINKED = "all_linked"
|
||||
IDENTITIES = "identities"
|
||||
NONE = "none"
|
||||
|
||||
|
||||
class ResponseTarget:
|
||||
"""Per-request directive controlling **where** the host delivers the agent reply.
|
||||
|
||||
Independent of ``session_mode``. Construct via the classmethod helpers or
|
||||
use the module-level singletons rather than touching ``kind`` directly.
|
||||
Variants:
|
||||
|
||||
- ``ResponseTarget.originating`` (default) — synchronous response on the
|
||||
originating channel only.
|
||||
- ``ResponseTarget.active`` — push to the channel most recently observed
|
||||
for the resolved ``isolation_key``.
|
||||
- ``ResponseTarget.channel("teams")`` / ``.channels([...])`` — push to
|
||||
one or more named destinations. Each entry is either a bare channel
|
||||
name (host resolves the native id from its identity registry) or a
|
||||
``"channel:native_id"`` token (used verbatim). The pseudo-name
|
||||
``"originating"`` includes the originating channel in the fan-out.
|
||||
- ``ResponseTarget.identity(ChannelIdentity)`` /
|
||||
``.identities([ChannelIdentity, ...])`` — push to one or more
|
||||
**fully-specified identities**. Preferred over the ``"channel:native_id"``
|
||||
string variant when the destination needs ``identity.attributes``
|
||||
preserved (Teams conversation/thread metadata, Slack channel+thread,
|
||||
Bot Framework service-url, etc.).
|
||||
- ``ResponseTarget.all_linked`` — push to every channel where the
|
||||
resolved ``isolation_key`` has been observed.
|
||||
- ``ResponseTarget.none`` — background-only; in the prototype this just
|
||||
suppresses the originating reply (no ``ContinuationToken`` yet).
|
||||
|
||||
Instances are intended to be treated as immutable; the singletons are
|
||||
shared across the process.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
kind: ResponseTargetKind = ResponseTargetKind.ORIGINATING,
|
||||
targets: tuple[str, ...] = (),
|
||||
identities: tuple[ChannelIdentity, ...] = (),
|
||||
*,
|
||||
echo_input: bool = False,
|
||||
) -> None:
|
||||
self.kind = kind
|
||||
self.targets = targets
|
||||
# Stored under a non-clashing name so the ``identities``
|
||||
# *classmethod* (the public builder) can coexist with the
|
||||
# value accessor (the ``identities`` property below). At
|
||||
# runtime instance attributes shadow class attributes anyway,
|
||||
# but type checkers see the classmethod and reject reassignment.
|
||||
self._target_identities: tuple[ChannelIdentity, ...] = tuple(identities)
|
||||
# When True, the host first pushes the originating user message
|
||||
# to every non-originating destination (so end-user apps observing
|
||||
# those channels can keep their UI in sync) before pushing the
|
||||
# agent response. Defaults to False — opt-in only, because not
|
||||
# every channel knows how to render ``role="user"`` content
|
||||
# gracefully on its own surface.
|
||||
self.echo_input = echo_input
|
||||
|
||||
@property
|
||||
def target_identities(self) -> tuple[ChannelIdentity, ...]:
|
||||
"""Destination identities for ``kind == IDENTITIES`` targets.
|
||||
|
||||
Public name distinct from the :meth:`identities` classmethod
|
||||
builder. Empty for non-``IDENTITIES`` kinds.
|
||||
"""
|
||||
return self._target_identities
|
||||
|
||||
# -- builders ---------------------------------------------------------- #
|
||||
|
||||
@classmethod
|
||||
def channel(cls, name: str, *, echo_input: bool = False) -> ResponseTarget:
|
||||
"""Target a single named destination channel."""
|
||||
return cls(kind=ResponseTargetKind.CHANNELS, targets=(name,), echo_input=echo_input)
|
||||
|
||||
@classmethod
|
||||
def channels(cls, names: Sequence[str], *, echo_input: bool = False) -> ResponseTarget:
|
||||
"""Target an explicit list of destination channels."""
|
||||
return cls(kind=ResponseTargetKind.CHANNELS, targets=tuple(names), echo_input=echo_input)
|
||||
|
||||
@classmethod
|
||||
def identity(cls, identity: ChannelIdentity, *, echo_input: bool = False) -> ResponseTarget:
|
||||
"""Target a single fully-specified :class:`ChannelIdentity`.
|
||||
|
||||
Preferred over the ``"channel:native_id"`` string token in
|
||||
:meth:`channels` when ``identity.attributes`` carries metadata the
|
||||
destination channel needs (Teams conversation/thread ids and
|
||||
service-url, Slack channel + thread, Bot Framework activity-locator
|
||||
fields, etc.). The host pushes to the named identity verbatim
|
||||
without consulting its own identity registry.
|
||||
"""
|
||||
return cls(kind=ResponseTargetKind.IDENTITIES, identities=(identity,), echo_input=echo_input)
|
||||
|
||||
@classmethod
|
||||
def identities(cls, identities: Sequence[ChannelIdentity], *, echo_input: bool = False) -> ResponseTarget:
|
||||
"""Target an explicit list of fully-specified :class:`ChannelIdentity` objects.
|
||||
|
||||
See :meth:`identity` for the single-destination variant.
|
||||
"""
|
||||
return cls(kind=ResponseTargetKind.IDENTITIES, identities=tuple(identities), echo_input=echo_input)
|
||||
|
||||
# -- value semantics --------------------------------------------------- #
|
||||
# ``ResponseTarget`` is treated as immutable, so two instances with the
|
||||
# same ``kind`` + ``targets`` + ``identities`` + ``echo_input`` are
|
||||
# interchangeable. Tests and channel parsers compare instances with
|
||||
# ``==`` and use them as dict keys.
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
if not isinstance(other, ResponseTarget):
|
||||
return NotImplemented
|
||||
return (
|
||||
self.kind is other.kind
|
||||
and self.targets == other.targets
|
||||
and _identities_equal(self._target_identities, other._target_identities)
|
||||
and self.echo_input == other.echo_input
|
||||
)
|
||||
|
||||
def __hash__(self) -> int:
|
||||
# ``ChannelIdentity`` is not itself hashable (mutable attributes
|
||||
# mapping); fold the identifying triple so two ``identities``
|
||||
# tuples with the same channel/native_id/attributes content hash
|
||||
# the same.
|
||||
identities_key = tuple(
|
||||
(i.channel, i.native_id, tuple(sorted(i.attributes.items()))) for i in self._target_identities
|
||||
)
|
||||
return hash((self.kind, self.targets, identities_key, self.echo_input))
|
||||
|
||||
def __repr__(self) -> str:
|
||||
suffix = ", echo_input=True" if self.echo_input else ""
|
||||
if self.kind is ResponseTargetKind.CHANNELS:
|
||||
return f"ResponseTarget.channels({list(self.targets)!r}{suffix})"
|
||||
if self.kind is ResponseTargetKind.IDENTITIES:
|
||||
return f"ResponseTarget.identities({list(self._target_identities)!r}{suffix})"
|
||||
return f"ResponseTarget.{self.kind.value}{suffix}"
|
||||
|
||||
|
||||
def _identities_equal(left: tuple[ChannelIdentity, ...], right: tuple[ChannelIdentity, ...]) -> bool:
|
||||
"""Structural-equality helper for ``ResponseTarget.identities`` comparisons.
|
||||
|
||||
``ChannelIdentity`` is a plain class without ``__eq__``, so ``tuple`` /
|
||||
``list`` comparisons fall back to identity equality which is too strict
|
||||
for value-typed ``ResponseTarget`` callers (two equivalent identity
|
||||
tuples produced independently would otherwise compare unequal).
|
||||
"""
|
||||
if len(left) != len(right):
|
||||
return False
|
||||
for a, b in zip(left, right, strict=True):
|
||||
if a.channel != b.channel or a.native_id != b.native_id:
|
||||
return False
|
||||
if dict(a.attributes) != dict(b.attributes):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# Module-level singletons so callers can write ``ResponseTarget.originating``
|
||||
# (matching the spec's classmethod-style notation) without juggling Python's
|
||||
# no-zero-arg-classmethod-property limitation.
|
||||
ResponseTarget.originating = ResponseTarget(kind=ResponseTargetKind.ORIGINATING) # type: ignore[attr-defined]
|
||||
ResponseTarget.active = ResponseTarget(kind=ResponseTargetKind.ACTIVE) # type: ignore[attr-defined]
|
||||
ResponseTarget.all_linked = ResponseTarget(kind=ResponseTargetKind.ALL_LINKED) # type: ignore[attr-defined]
|
||||
ResponseTarget.none = ResponseTarget(kind=ResponseTargetKind.NONE) # type: ignore[attr-defined]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChannelRequest:
|
||||
"""Uniform invocation envelope every channel produces from its native payload.
|
||||
@@ -260,16 +72,15 @@ class ChannelRequest:
|
||||
"""
|
||||
|
||||
channel: str
|
||||
operation: str # e.g. "message.create", "command.invoke"
|
||||
operation: str
|
||||
input: AgentRunInputs
|
||||
session: ChannelSession | None = None
|
||||
options: Mapping[str, Any] | None = None
|
||||
session_mode: str = "auto" # "auto" | "required" | "disabled"
|
||||
session_mode: str = "auto"
|
||||
metadata: Mapping[str, Any] = field(default_factory=lambda: {})
|
||||
attributes: Mapping[str, Any] = field(default_factory=lambda: {})
|
||||
stream: bool = False
|
||||
identity: ChannelIdentity | None = None
|
||||
response_target: ResponseTarget = field(default_factory=lambda: ResponseTarget.originating) # type: ignore[attr-defined]
|
||||
|
||||
|
||||
class ChannelCommand:
|
||||
@@ -335,42 +146,11 @@ TResult = TypeVar("TResult")
|
||||
|
||||
|
||||
class HostedRunResult(Generic[TResult]):
|
||||
r"""Channel-neutral envelope around the target's full-fidelity result.
|
||||
"""Channel-neutral envelope around the target's full-fidelity result.
|
||||
|
||||
Carries the underlying execution payload **unchanged** so channels
|
||||
(and developer-supplied ``response_hook``\\s) can read everything the
|
||||
target produced — full multi-modal contents, structured ``value``,
|
||||
``usage_details``, ``response_id``, workflow per-executor outputs,
|
||||
final ``WorkflowRunState``, etc.
|
||||
|
||||
``result`` is generic in ``TResult`` so callers retain static typing:
|
||||
|
||||
* Agent targets always produce
|
||||
``HostedRunResult[AgentResponse]`` — channels read
|
||||
``result.messages``, ``result.value``, ``result.usage_details``, …
|
||||
directly.
|
||||
* Workflow targets produce ``HostedRunResult[WorkflowRunResult]``
|
||||
today (``Workflow`` is not itself generic, so the static narrowing
|
||||
is only as tight as ``Workflow.run``'s return). Channels iterate
|
||||
``result.get_outputs()`` and inspect ``result.get_final_state()``
|
||||
to render workflow-specific UX. When a host author drives the
|
||||
workflow themselves and knows the final-output type, they may
|
||||
narrow to ``HostedRunResult[MyOutput]`` in their own
|
||||
``response_hook`` signatures.
|
||||
* The echo-input phase synthesises an ``HostedRunResult[AgentResponse]``
|
||||
wrapping the originating user turn so the same per-destination
|
||||
delivery machinery applies.
|
||||
|
||||
The optional ``session`` slot carries the resolved
|
||||
:class:`~agent_framework.AgentSession` the host bound to this
|
||||
invocation (``None`` for workflow targets, which do not own session
|
||||
state in the agent sense). Channels that want to surface session
|
||||
metadata (e.g. echo the resolved isolation key into a response
|
||||
header) read it here.
|
||||
|
||||
Treat instances as immutable: the host clones per-destination before
|
||||
invoking a per-channel ``response_hook`` so one channel's transform
|
||||
cannot perturb the payload another destination observes.
|
||||
The host does not flatten or pre-shape the target output. Channels and
|
||||
response hooks read the underlying result type directly and serialize the
|
||||
subset their wire format can carry.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -388,575 +168,45 @@ class HostedRunResult(Generic[TResult]):
|
||||
result: TResult | _Unset = _UNSET,
|
||||
session: Any | _Unset | None = _UNSET,
|
||||
) -> HostedRunResult[TResult]:
|
||||
"""Return a shallow copy with the supplied fields overridden.
|
||||
|
||||
Used by the host's delivery layer to clone the envelope before
|
||||
applying a per-destination ``response_hook``, so one channel's
|
||||
transform cannot mutate the payload another destination sees.
|
||||
The clone is shallow — channels that need to mutate
|
||||
``result.messages`` (or any other nested mutable container) are
|
||||
responsible for deep-cloning that container themselves.
|
||||
"""
|
||||
"""Return a shallow copy with the supplied fields overridden."""
|
||||
new: HostedRunResult[TResult] = HostedRunResult.__new__(HostedRunResult) # pyright: ignore[reportUnknownVariableType]
|
||||
new.result = self.result if isinstance(result, _Unset) else result
|
||||
new.session = self.session if isinstance(session, _Unset) else session
|
||||
return new
|
||||
|
||||
|
||||
class DurableTaskPayloadMode(str, Enum):
|
||||
"""How a :class:`DurableTaskRunner` consumes scheduled-task payloads.
|
||||
|
||||
Used by the host's startup validator to pair a runner's persistence
|
||||
expectations with the channels' push-codec capabilities. Adapter packages
|
||||
pick the right value for their backing store.
|
||||
|
||||
* ``OBJECT`` — the runner accepts live Python objects in the payload.
|
||||
No serialization is required; the host's
|
||||
:class:`InProcessTaskRunner` is the canonical example. Suitable for
|
||||
``runtime_mode="long_running"`` deployments where the runner shares
|
||||
address space with the producer.
|
||||
* ``JSON`` — the runner persists the payload (database, durable queue,
|
||||
Foundry scheduled-task store, …) and replays it after a process
|
||||
restart. Payloads MUST be JSON-serializable, which constrains what
|
||||
the host can put on the wire. The host validates at construction
|
||||
that every push-capable channel exposes a
|
||||
:class:`ChannelPushCodec` (so :class:`HostedRunResult` payloads can
|
||||
be reduced to a JSON envelope before scheduling).
|
||||
"""
|
||||
|
||||
OBJECT = "object"
|
||||
JSON = "json"
|
||||
|
||||
|
||||
# A push-codec implementation reduces the ``(result, request, identity)``
|
||||
# triple a destination channel will receive into a JSON-safe envelope that
|
||||
# a durable :class:`DurableTaskRunner` can persist, and reconstructs the
|
||||
# rendering inputs on the consumer side. The host *invokes* the codec
|
||||
# during scheduling; the destination channel implements it (the channel
|
||||
# knows what shape of payload it can render).
|
||||
#
|
||||
# Channels with no push codec are usable only with object-mode runners
|
||||
# (the default :class:`InProcessTaskRunner`) — the host validates this at
|
||||
# construction so the mismatch surfaces eagerly rather than on first push.
|
||||
class ChannelPushCodec(Protocol):
|
||||
"""Optional capability: serialise the push envelope for a durable task runner.
|
||||
|
||||
Implementations live on the destination channel (alongside ``push``)
|
||||
as a duck-typed ``push_codec`` attribute. The host's
|
||||
:meth:`_deliver_response` invokes :meth:`encode` once per scheduled
|
||||
push (in JSON-mode runner deployments) to produce a JSON-safe
|
||||
envelope for the runner; the handler calls :meth:`decode`
|
||||
immediately before invoking :meth:`ChannelPush.push`. Object-mode
|
||||
runners (the default in-process runner) bypass the codec entirely
|
||||
and pass live references through verbatim.
|
||||
|
||||
Encoded envelopes MUST be JSON-serialisable
|
||||
(``dict``/``list``/``str``/``int``/``float``/``bool``/``None``).
|
||||
Channels that cannot satisfy this for some inputs (e.g. arbitrary
|
||||
workflow result objects without a stable schema) SHOULD raise a
|
||||
typed :class:`PushPayloadNotSerializable` from :meth:`encode`
|
||||
rather than return a best-effort representation; the host surfaces
|
||||
that as a schedule-time error and the destination is treated as
|
||||
skipped (other destinations still get their chance).
|
||||
"""
|
||||
|
||||
async def encode(
|
||||
self,
|
||||
*,
|
||||
result: HostedRunResult[Any],
|
||||
request: ChannelRequest,
|
||||
identity: ChannelIdentity,
|
||||
echo_result: HostedRunResult[Any] | None,
|
||||
) -> Mapping[str, Any]:
|
||||
"""Project the in-memory push triple into a JSON-safe envelope."""
|
||||
...
|
||||
|
||||
async def decode(
|
||||
self,
|
||||
envelope: Mapping[str, Any],
|
||||
) -> tuple[HostedRunResult[Any], ChannelRequest, ChannelIdentity, HostedRunResult[Any] | None]:
|
||||
"""Reconstruct ``(result, request, identity, echo_result)`` from an envelope."""
|
||||
...
|
||||
|
||||
|
||||
class PushPayloadNotSerializable(RuntimeError):
|
||||
"""Raised by a :class:`ChannelPushCodec` when the payload cannot be serialised.
|
||||
|
||||
Channels raise this from :meth:`ChannelPushCodec.encode` when the
|
||||
inbound :class:`HostedRunResult` carries content the codec has no
|
||||
JSON projection for (e.g. an arbitrary workflow result with no
|
||||
declared schema). The host surfaces the error eagerly at schedule
|
||||
time rather than letting the runner discover it after persisting
|
||||
a half-formed envelope.
|
||||
"""
|
||||
|
||||
|
||||
class PushPayloadNotPicklable(RuntimeError):
|
||||
"""Raised when a disk-persistent runner cannot pickle a scheduled task payload.
|
||||
|
||||
The in-process runner falls back to pickle when ``state_dir`` is set
|
||||
so a long-running host can resume in-flight pushes across restarts.
|
||||
Most :class:`HostedRunResult` payloads (frozen dataclasses wrapping
|
||||
:class:`AgentResponse` or workflow output) pickle without issue, but
|
||||
a user-supplied workflow result or response hook may embed an
|
||||
unpickleable object (live network client, ``asyncio.Lock``, generator).
|
||||
The runner raises this at schedule time so the misconfig is loud
|
||||
rather than silently downgrading to no-persistence.
|
||||
"""
|
||||
|
||||
|
||||
class HostStatePaths(TypedDict, total=False):
|
||||
"""Per-component disk paths for host-managed state.
|
||||
|
||||
Pass an instance of this typed dict to
|
||||
:class:`~agent_framework_hosting._host.AgentFrameworkHost`'s
|
||||
``state_dir`` parameter when you want to place individual components
|
||||
on different volumes — for example, a fast local SSD for the runner
|
||||
task queue and a network-attached durable volume for session state
|
||||
that needs to survive container moves.
|
||||
|
||||
All keys are optional (``total=False``): unset components fall back
|
||||
to in-memory storage (or, for ``checkpoints``, to no checkpoint
|
||||
persistence). Pass a single ``str``/``PathLike`` to ``state_dir``
|
||||
instead to get the default subfolder layout
|
||||
(``state_dir/runner/``, ``state_dir/sessions/``,
|
||||
``state_dir/checkpoints/``, ``state_dir/links/``).
|
||||
|
||||
Future components (continuations, ledger) will be added as additional
|
||||
keys in subsequent releases.
|
||||
Only session aliases and workflow checkpoints remain in the simplified
|
||||
host. Linking stores, active-channel maps, identity registries, and runner
|
||||
queues are follow-up concerns.
|
||||
"""
|
||||
|
||||
runner: str | os.PathLike[str]
|
||||
"""Where :class:`~agent_framework_hosting._runner.InProcessTaskRunner`
|
||||
persists its pending-task queue and bounded terminal-status cache.
|
||||
Required for in-flight push retries to survive process restarts."""
|
||||
|
||||
sessions: str | os.PathLike[str]
|
||||
"""Where the host persists session aliases (from
|
||||
:meth:`AgentFrameworkHost.reset_session`), the per-isolation-key
|
||||
identity registry, and the last-active-channel map. Required for
|
||||
``ResponseTarget.active``/``.channel``/``.all_linked`` to find
|
||||
destinations after a restart, and for ``reset_session`` rotations
|
||||
to survive a restart."""
|
||||
"""Where the host persists session aliases created by ``reset_session``."""
|
||||
|
||||
checkpoints: str | os.PathLike[str]
|
||||
"""Where the host persists workflow checkpoints for ``Workflow``
|
||||
targets. Equivalent to passing ``checkpoint_location=<this path>``
|
||||
directly: the host wraps it in a per-isolation-key
|
||||
:class:`~agent_framework.FileCheckpointStorage`. Ignored when the
|
||||
target is a ``SupportsAgentRun`` agent (a warning is emitted if you
|
||||
set it explicitly via the mapping form). Pass the legacy
|
||||
``checkpoint_location`` parameter instead when you need to supply a
|
||||
:class:`~agent_framework.CheckpointStorage` instance — it takes
|
||||
precedence over this key."""
|
||||
|
||||
links: str | os.PathLike[str]
|
||||
"""Where identity-linker implementations persist their link store:
|
||||
pending link challenges/grants, channel-native identity to linked
|
||||
isolation-key mappings, and verified-claim metadata. The core host
|
||||
does not impose a storage format; concrete :class:`IdentityLinker`
|
||||
implementations that support host-provided persistence receive this
|
||||
path via ``configure_link_store_path``. If a linker manages its own
|
||||
persistence, omit this key or configure that linker directly."""
|
||||
"""Where the host persists workflow checkpoints for ``Workflow`` targets."""
|
||||
|
||||
|
||||
# A transform hook runs over each AgentResponseUpdate as the channel consumes
|
||||
# the stream. It can return a replacement update, ``None`` to drop the update,
|
||||
# or be async. Channels apply it during iteration so that channel-specific
|
||||
# concerns (e.g. masking, redaction, formatting for the wire) live close to
|
||||
# the channel rather than on the agent.
|
||||
ChannelStreamTransformHook = Callable[
|
||||
ChannelStreamUpdateHook = Callable[
|
||||
[AgentResponseUpdate],
|
||||
"AgentResponseUpdate | Awaitable[AgentResponseUpdate | None] | None",
|
||||
]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Channel run hook
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
# Run hooks accept the channel-built ``ChannelRequest`` and return a
|
||||
# (possibly modified) replacement. Channels invoke the hook with both the
|
||||
# request and the channel-side context as keyword arguments — the call
|
||||
# convention is ``await hook(request, target=..., protocol_request=...)``.
|
||||
#
|
||||
# The ergonomic minimum for a hook implementation is therefore a function
|
||||
# accepting ``request`` positionally plus ``**kwargs`` and returning a
|
||||
# (possibly mutated) :class:`ChannelRequest`. Hooks that need the agent
|
||||
# target or the raw channel-native payload pull them off the keyword
|
||||
# arguments by name (``target`` / ``protocol_request``).
|
||||
#
|
||||
# ``protocol_request`` is the raw, channel-native payload the channel
|
||||
# parsed (the JSON body for Responses, the Telegram ``Update`` dict, the
|
||||
# Bot Framework ``Activity`` for Teams). Use it when the hook needs a
|
||||
# field the channel did not lift onto ``ChannelRequest`` (e.g. OpenAI's
|
||||
# ``safety_identifier``, Teams' ``from.aadObjectId``, …).
|
||||
ChannelRunHook = Callable[..., "Awaitable[ChannelRequest] | ChannelRequest"]
|
||||
|
||||
|
||||
async def apply_run_hook(
|
||||
hook: ChannelRunHook,
|
||||
request: ChannelRequest,
|
||||
*,
|
||||
target: SupportsAgentRun | Workflow,
|
||||
protocol_request: Any | None,
|
||||
) -> ChannelRequest:
|
||||
"""Channel-side helper to invoke a :data:`ChannelRunHook` with the standard kwargs.
|
||||
|
||||
Channels call this rather than calling the hook directly so the
|
||||
invocation convention (``request`` positional, ``target`` /
|
||||
``protocol_request`` keyword) is enforced in one place.
|
||||
"""
|
||||
result = hook(request, target=target, protocol_request=protocol_request)
|
||||
if isinstance(result, Awaitable):
|
||||
return await result
|
||||
return result
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Channel response hook
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class ChannelResponseContext:
|
||||
"""Per-destination context handed to a :data:`ChannelResponseHook`.
|
||||
|
||||
Response hooks run on the *output* side of the host pipeline, after
|
||||
the agent / workflow has produced a :class:`HostedRunResult` but
|
||||
before the destination channel serialises it to its wire format.
|
||||
Hooks may need to make decisions based on *where* the payload is
|
||||
headed — e.g. flatten multi-modal output to text for a text-only
|
||||
destination, or pick which content variant to deliver to a card-
|
||||
capable channel. The context captures that information without
|
||||
forcing hooks to parse stringly destination tokens.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
request: ChannelRequest,
|
||||
channel_name: str,
|
||||
destination_identity: ChannelIdentity | None,
|
||||
originating: bool,
|
||||
is_echo: bool = False,
|
||||
) -> None:
|
||||
self.request = request
|
||||
self.channel_name = channel_name
|
||||
# ``None`` when the originating channel is rendering its own reply
|
||||
# (no push identity needed for "respond on the wire you came in
|
||||
# on") or when the destination is named without a known native id.
|
||||
self.destination_identity = destination_identity
|
||||
# True when this hook invocation is for the originating channel's
|
||||
# synchronous reply. False for non-originating push targets.
|
||||
self.originating = originating
|
||||
# True when the payload being shaped is the user-message echo
|
||||
# rather than the agent response (only happens when
|
||||
# ``ResponseTarget.echo_input`` is set).
|
||||
self.is_echo = is_echo
|
||||
|
||||
|
||||
# Response hooks accept the :class:`HostedRunResult` the host has assembled
|
||||
# and return a (possibly modified) replacement. Channels invoke the hook
|
||||
# with both the payload and the per-destination
|
||||
# :class:`ChannelResponseContext` as keyword arguments — the call
|
||||
# convention is ``await hook(result, context=...)``.
|
||||
#
|
||||
# The ergonomic minimum for a hook implementation is a function accepting
|
||||
# ``result`` positionally plus ``**kwargs`` and returning a (possibly
|
||||
# rewritten) :class:`HostedRunResult`. Hooks that need to branch on the
|
||||
# destination read it off the ``context`` keyword argument.
|
||||
#
|
||||
# ``HostedRunResult`` is generic in the underlying ``result`` type; the
|
||||
# hook callable signature stays ``Any``-typed so a single
|
||||
# ``response_hook`` attribute on a channel can serve both agent
|
||||
# (``HostedRunResult[AgentResponse]``) and workflow
|
||||
# (``HostedRunResult[WorkflowRunResult]``) payloads — channels narrow
|
||||
# at hook entry if they need static checking.
|
||||
ChannelResponseHook = Callable[..., "Awaitable[HostedRunResult[Any]] | HostedRunResult[Any]"]
|
||||
|
||||
|
||||
async def apply_response_hook(
|
||||
hook: ChannelResponseHook,
|
||||
result: HostedRunResult[Any],
|
||||
*,
|
||||
context: ChannelResponseContext,
|
||||
) -> HostedRunResult[Any]:
|
||||
"""Channel-side helper to invoke a :data:`ChannelResponseHook` with the standard kwargs.
|
||||
|
||||
Channels (and the host's delivery layer) call this rather than calling
|
||||
the hook directly so the invocation convention (``result`` positional,
|
||||
``context`` keyword) is enforced in one place.
|
||||
"""
|
||||
out = hook(result, context=context)
|
||||
if isinstance(out, Awaitable):
|
||||
return await out
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Channel protocols
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Channel(Protocol):
|
||||
"""A pluggable adapter that exposes one transport on the host.
|
||||
|
||||
Channels publish their routes, commands, and lifecycle callbacks via
|
||||
:meth:`contribute`. The host mounts them under the channel's ``path``
|
||||
(or at the app root when ``path == ""``) and gives the channel a
|
||||
:class:`ChannelContext` so it can call back into the host to invoke
|
||||
the agent target and deliver responses.
|
||||
"""
|
||||
"""A pluggable adapter that exposes one transport on the host."""
|
||||
|
||||
name: str
|
||||
path: str # default endpoint path (e.g. "/responses"); use "" to mount contributed routes at the app root
|
||||
path: str
|
||||
|
||||
def contribute(self, context: ChannelContext) -> ChannelContribution: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ChannelPush(Protocol):
|
||||
r"""Optional capability: a channel that can deliver outbound messages without a prior request.
|
||||
|
||||
Per SPEC-002 (req #13), channels that can do proactive delivery
|
||||
(Telegram bot proactive message, Teams proactive bot message,
|
||||
webhook callbacks, SSE broadcasts) implement ``push`` on top of the
|
||||
base :class:`Channel` protocol. Channels without push can only be
|
||||
addressed as the ``originating`` :class:`ResponseTarget`.
|
||||
|
||||
Distinguishing user echoes from agent replies
|
||||
---------------------------------------------
|
||||
When the originating :class:`ResponseTarget` opts in to
|
||||
``echo_input=True``, the host pushes the user's input message to
|
||||
each non-originating destination **before** the agent reply. Both
|
||||
pushes go through the same ``push(identity, payload)`` entry point;
|
||||
the channel distinguishes them by inspecting the role on the
|
||||
payload's underlying :class:`~agent_framework.Message`\\(s):
|
||||
|
||||
* ``payload.result.messages[i].role == "user"`` → the echo phase
|
||||
(originating user's turn mirrored onto this destination so the
|
||||
channel's UX can stay coherent with the user's actual prompt).
|
||||
Channels that cannot impersonate the user (most chat bots can
|
||||
only send AS the bot) typically render echoes as a quoted /
|
||||
prefixed block, drop them, or skip them via a
|
||||
``response_hook`` — see below.
|
||||
* ``payload.result.messages[i].role == "assistant"`` → the agent's
|
||||
reply.
|
||||
|
||||
Channels that want to branch on phase WITHOUT inspecting roles can
|
||||
instead expose a ``response_hook`` attribute on the channel
|
||||
instance: the host calls the hook with a
|
||||
:class:`ChannelResponseContext` whose ``is_echo`` flag carries the
|
||||
same phase information explicitly, and the hook returns a
|
||||
(possibly rewritten) :class:`HostedRunResult` that the host then
|
||||
hands to ``push``. The hook seam is duck-typed and intentionally
|
||||
NOT part of this Protocol so adding hook support to an existing
|
||||
channel never breaks its public contract.
|
||||
"""
|
||||
|
||||
name: str
|
||||
|
||||
async def push(self, identity: ChannelIdentity, payload: HostedRunResult[Any]) -> None: ...
|
||||
|
||||
|
||||
async def apply_channel_response_hook(
|
||||
channel: Channel | ChannelPush,
|
||||
result: HostedRunResult[Any],
|
||||
*,
|
||||
request: ChannelRequest,
|
||||
originating: bool,
|
||||
destination_identity: ChannelIdentity | None = None,
|
||||
is_echo: bool = False,
|
||||
clone: bool = False,
|
||||
) -> HostedRunResult[Any]:
|
||||
"""Apply a channel's optional response hook with the standard context.
|
||||
|
||||
Channels and the host call this helper when they need to shape a
|
||||
:class:`HostedRunResult` for one destination. The helper centralizes the
|
||||
response-hook convention: hooks are discovered from a duck-typed
|
||||
``response_hook`` attribute, called through :func:`apply_response_hook`,
|
||||
and receive a :class:`ChannelResponseContext` that identifies the channel,
|
||||
destination identity, originating-vs-push phase, and echo phase.
|
||||
|
||||
Args:
|
||||
channel: Channel whose ``response_hook`` attribute may shape the payload.
|
||||
result: Hosted run result to pass to the hook.
|
||||
request: Originating channel request.
|
||||
originating: Whether this is the originating channel's synchronous reply.
|
||||
destination_identity: Destination identity for non-originating pushes, or
|
||||
``None`` for originating replies.
|
||||
is_echo: Whether the payload is an echo of the user input.
|
||||
clone: Whether to shallow-clone ``result`` before applying the hook.
|
||||
|
||||
Returns:
|
||||
The original, cloned, or hook-shaped hosted run result.
|
||||
"""
|
||||
shaped = result.replace() if clone else result
|
||||
hook = cast(ChannelResponseHook | None, getattr(channel, "response_hook", None))
|
||||
if not callable(hook):
|
||||
return shaped
|
||||
context = ChannelResponseContext(
|
||||
request=request,
|
||||
channel_name=channel.name,
|
||||
destination_identity=destination_identity,
|
||||
originating=originating,
|
||||
is_echo=is_echo,
|
||||
)
|
||||
return await apply_response_hook(hook, shaped, context=context)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Durable task runner — pluggable seam for non-originating push fan-out and
|
||||
# (in v1 fast-follow) background runs. See spec §"Durable task runner".
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RetryPolicy:
|
||||
"""Retry contract a :class:`DurableTaskRunner` honours per scheduled task.
|
||||
|
||||
Defaults are deliberately conservative — five attempts on a 1s/2x/60s
|
||||
exponential backoff — so a transient channel outage (Telegram returning
|
||||
502, Activity Protocol token refresh) is rerouted to retry without the
|
||||
operator wiring anything. Adapter backends (TaskHub, Foundry durable
|
||||
tasks) MAY translate this into their native retry primitive; the
|
||||
in-process runner implements it directly via ``asyncio.sleep``.
|
||||
"""
|
||||
|
||||
max_attempts: int = 5
|
||||
initial_backoff_seconds: float = 1.0
|
||||
backoff_multiplier: float = 2.0
|
||||
max_backoff_seconds: float = 60.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskHandle:
|
||||
"""Opaque, runner-issued handle for a scheduled task.
|
||||
|
||||
Callers receive one of these from :meth:`DurableTaskRunner.schedule` and
|
||||
pass it back to :meth:`DurableTaskRunner.get` to poll status. ``task_id``
|
||||
is opaque — its shape is implementation-defined (UUID for the in-process
|
||||
runner, instance id for TaskHub, scheduled-task arn for Foundry). The
|
||||
``name`` mirrors the handler name supplied to :meth:`schedule` so the
|
||||
caller does not have to track it separately.
|
||||
"""
|
||||
|
||||
task_id: str
|
||||
name: str
|
||||
|
||||
|
||||
TaskStatus = Literal["scheduled", "running", "succeeded", "failed", "cancelled"]
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DurableTaskRunner(Protocol):
|
||||
"""Pluggable seam the host uses to schedule out-of-band work.
|
||||
|
||||
The host registers a single internal handler — ``"hosting.push"`` — at
|
||||
startup; each non-originating push destination becomes a
|
||||
``runner.schedule("hosting.push", payload)`` call. The handler resolves
|
||||
the destination channel, runs its ``response_hook`` (if any), and calls
|
||||
:meth:`ChannelPush.push`. Failures inside the handler are caught by the
|
||||
runner, retried per the supplied :class:`RetryPolicy`, and ultimately
|
||||
marked terminal-failed when ``max_attempts`` is exhausted.
|
||||
|
||||
Two implementations ship in the framework: an in-process default
|
||||
(``InProcessTaskRunner``, asyncio + bounded retry, no cross-restart
|
||||
persistence) suitable for ``runtime_mode="long_running"`` deployments,
|
||||
plus adapter packages (``agent-framework-hosting-durabletask``, a future
|
||||
Foundry adapter) for ``runtime_mode="ephemeral"`` deployments that need
|
||||
cross-restart durability.
|
||||
|
||||
Adapters MUST publish their ``payload_mode`` so the host's startup
|
||||
validator can pair runner persistence expectations with channel
|
||||
push-codec capabilities. Object-mode runners accept live Python
|
||||
references in the payload (the in-process default does this for
|
||||
speed); JSON-mode runners persist payloads across process restarts
|
||||
and therefore require every push-capable channel to expose a
|
||||
:class:`ChannelPushCodec`.
|
||||
"""
|
||||
|
||||
# Adapter classes set this explicitly; the host inspects it at
|
||||
# construction time. Default is conservative ("object") so a runner
|
||||
# that omits the attribute is treated as in-process-only and does
|
||||
# not silently impose a JSON requirement on channels.
|
||||
payload_mode: DurableTaskPayloadMode
|
||||
|
||||
def register(
|
||||
self,
|
||||
name: str,
|
||||
handler: Callable[[Mapping[str, Any]], Awaitable[None]],
|
||||
) -> None:
|
||||
"""Register a named handler the runner will invoke when a task fires.
|
||||
|
||||
Re-registering under the same name replaces the previous handler.
|
||||
Implementations SHOULD raise :class:`RuntimeError` if called after
|
||||
the runner has been started, to avoid silent reorderings of in-flight
|
||||
work; the in-process runner enforces this.
|
||||
"""
|
||||
...
|
||||
|
||||
async def schedule(
|
||||
self,
|
||||
name: str,
|
||||
payload: Mapping[str, Any],
|
||||
*,
|
||||
retry_policy: RetryPolicy | None = None,
|
||||
) -> TaskHandle:
|
||||
"""Schedule a previously-registered handler invocation.
|
||||
|
||||
``name`` MUST match a name previously passed to :meth:`register`. The
|
||||
``payload`` is forwarded verbatim to the handler; implementations
|
||||
MUST treat it as opaque (no introspection, no normalization).
|
||||
``retry_policy`` overrides the runner's default for this task only;
|
||||
``None`` means "use the runner-wide default".
|
||||
|
||||
Returns a :class:`TaskHandle` the caller may use with :meth:`get` to
|
||||
poll status. Returning the handle MUST NOT wait for the task to run
|
||||
— scheduling is fire-and-forget from the caller's perspective.
|
||||
"""
|
||||
...
|
||||
|
||||
async def get(self, handle: TaskHandle) -> TaskStatus | None:
|
||||
"""Return the current status of a scheduled task.
|
||||
|
||||
Returns ``None`` if the runner no longer has any record of the task
|
||||
(e.g. it was scheduled in a prior process and the runner has no
|
||||
persistent backing). Otherwise one of the :data:`TaskStatus` values.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentResponse",
|
||||
"AgentResponseUpdate",
|
||||
"Channel",
|
||||
"ChannelCommand",
|
||||
"ChannelCommandContext",
|
||||
"ChannelContribution",
|
||||
"ChannelIdentity",
|
||||
"ChannelPush",
|
||||
"ChannelPushCodec",
|
||||
"ChannelRequest",
|
||||
"ChannelResponseContext",
|
||||
"ChannelResponseHook",
|
||||
"ChannelRunHook",
|
||||
"ChannelSession",
|
||||
"ChannelStreamTransformHook",
|
||||
"DurableTaskPayloadMode",
|
||||
"DurableTaskRunner",
|
||||
"HostStatePaths",
|
||||
"HostedRunResult",
|
||||
"PushPayloadNotPicklable",
|
||||
"PushPayloadNotSerializable",
|
||||
"ResponseStream",
|
||||
"ResponseTarget",
|
||||
"ResponseTargetKind",
|
||||
"RetryPolicy",
|
||||
"TaskHandle",
|
||||
"TaskStatus",
|
||||
"apply_channel_response_hook",
|
||||
"apply_response_hook",
|
||||
"apply_run_hook",
|
||||
]
|
||||
|
||||
@@ -1,580 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for the authorization and identity-linking seam."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Collection
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework_hosting import (
|
||||
AgentFrameworkHost,
|
||||
AllOfAllowlists,
|
||||
AllowAll,
|
||||
Allowed,
|
||||
AllowlistDecision,
|
||||
AnyOfAllowlists,
|
||||
AuthorizationContext,
|
||||
AuthPolicy,
|
||||
CallableAllowlist,
|
||||
ChannelConfigurationError,
|
||||
ChannelContext,
|
||||
ChannelContribution,
|
||||
ChannelIdentity,
|
||||
Denied,
|
||||
LinkChallenge,
|
||||
LinkedClaimAllowlist,
|
||||
LinkedIdentity,
|
||||
LinkRequired,
|
||||
NativeIdAllowlist,
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Fakes #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class _ChannelStub:
|
||||
name: str = "stub"
|
||||
path: str = "/stub"
|
||||
require_link: bool = False
|
||||
allowlist: Any = "inherit"
|
||||
emits_verified_claims: bool = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
name: str = "stub",
|
||||
require_link: bool = False,
|
||||
allowlist: Any = "inherit",
|
||||
emits_verified_claims: bool = False,
|
||||
) -> None:
|
||||
self.name = name
|
||||
self.path = f"/{name}"
|
||||
self.require_link = require_link
|
||||
self.allowlist = allowlist
|
||||
self.emits_verified_claims = emits_verified_claims
|
||||
|
||||
def contribute(self, context: ChannelContext) -> ChannelContribution:
|
||||
return ChannelContribution(routes=[])
|
||||
|
||||
|
||||
class _AgentStub:
|
||||
"""Bare minimum target — the validators run during ``__init__``,
|
||||
not on first request, so the target is never actually invoked."""
|
||||
|
||||
async def run(self, *args: Any, **kwargs: Any) -> Any: # pragma: no cover
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class _StaticLinker:
|
||||
"""Test linker returning either a linked identity or a challenge."""
|
||||
|
||||
def __init__(self, result: LinkedIdentity | LinkChallenge) -> None:
|
||||
self.result = result
|
||||
self.calls: list[ChannelIdentity] = []
|
||||
|
||||
async def resolve(self, identity: ChannelIdentity) -> LinkedIdentity | LinkChallenge:
|
||||
self.calls.append(identity)
|
||||
return self.result
|
||||
|
||||
|
||||
def _ctx_pre_link(channel: str = "telegram", native_id: str = "42") -> AuthorizationContext:
|
||||
return AuthorizationContext(
|
||||
identity=ChannelIdentity(channel=channel, native_id=native_id),
|
||||
phase="pre_link",
|
||||
)
|
||||
|
||||
|
||||
def _ctx_post_link(claims: dict[str, str] | None = None) -> AuthorizationContext:
|
||||
return AuthorizationContext(
|
||||
identity=ChannelIdentity(channel="telegram", native_id="42"),
|
||||
phase="post_link",
|
||||
isolation_key="alice",
|
||||
verified_claims=claims or {},
|
||||
claim_source="linker",
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Built-in allowlists #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestAllowAll:
|
||||
async def test_allows_both_phases(self) -> None:
|
||||
a = AllowAll()
|
||||
assert await a.evaluate(_ctx_pre_link()) is AllowlistDecision.ALLOW
|
||||
assert await a.evaluate(_ctx_post_link()) is AllowlistDecision.ALLOW
|
||||
|
||||
def test_does_not_require_linked_claims(self) -> None:
|
||||
assert AllowAll().requires_linked_claims is False
|
||||
|
||||
|
||||
class TestNativeIdAllowlist:
|
||||
async def test_allows_listed_id(self) -> None:
|
||||
a = NativeIdAllowlist({"42", "99"})
|
||||
assert await a.evaluate(_ctx_pre_link(native_id="42")) is AllowlistDecision.ALLOW
|
||||
|
||||
async def test_denies_unlisted_id(self) -> None:
|
||||
a = NativeIdAllowlist({"42"})
|
||||
assert await a.evaluate(_ctx_pre_link(native_id="99")) is AllowlistDecision.DENY
|
||||
|
||||
async def test_channel_filter_abstains_for_other_channels(self) -> None:
|
||||
# The native-id list is scoped to "telegram" — a request from
|
||||
# another channel should ABSTAIN so a combinator can give a
|
||||
# parallel allowlist a chance to ALLOW.
|
||||
a = NativeIdAllowlist({"42"}, channel="telegram")
|
||||
assert await a.evaluate(_ctx_pre_link(channel="slack", native_id="42")) is AllowlistDecision.ABSTAIN
|
||||
|
||||
async def test_channel_filter_evaluates_matching_channel(self) -> None:
|
||||
a = NativeIdAllowlist({"42"}, channel="telegram")
|
||||
assert await a.evaluate(_ctx_pre_link(channel="telegram", native_id="42")) is AllowlistDecision.ALLOW
|
||||
assert await a.evaluate(_ctx_pre_link(channel="telegram", native_id="99")) is AllowlistDecision.DENY
|
||||
|
||||
async def test_async_loader_caches_after_first_call(self) -> None:
|
||||
# The loader should run once; subsequent ``evaluate`` calls hit
|
||||
# the cache so a slow / costly source isn't re-queried per
|
||||
# message.
|
||||
calls = {"n": 0}
|
||||
|
||||
async def loader() -> Collection[str]:
|
||||
calls["n"] += 1
|
||||
return {"42"}
|
||||
|
||||
a = NativeIdAllowlist(loader)
|
||||
assert await a.evaluate(_ctx_pre_link(native_id="42")) is AllowlistDecision.ALLOW
|
||||
assert await a.evaluate(_ctx_pre_link(native_id="42")) is AllowlistDecision.ALLOW
|
||||
assert calls["n"] == 1
|
||||
|
||||
|
||||
class TestLinkedClaimAllowlist:
|
||||
"""Claim allowlists abstain pre-link and decide once claims are available."""
|
||||
|
||||
def test_declares_requires_linked_claims(self) -> None:
|
||||
a = LinkedClaimAllowlist("oid", ["abc"])
|
||||
assert a.requires_linked_claims is True
|
||||
|
||||
async def test_pre_link_abstains(self) -> None:
|
||||
a = LinkedClaimAllowlist("oid", ["abc"])
|
||||
assert await a.evaluate(_ctx_pre_link()) is AllowlistDecision.ABSTAIN
|
||||
|
||||
async def test_post_link_allows_matching_claim(self) -> None:
|
||||
a = LinkedClaimAllowlist("oid", ["abc"])
|
||||
assert await a.evaluate(_ctx_post_link({"oid": "abc"})) is AllowlistDecision.ALLOW
|
||||
|
||||
async def test_post_link_allows_matching_multi_value_claim(self) -> None:
|
||||
a = LinkedClaimAllowlist("groups", ["admins"])
|
||||
ctx = AuthorizationContext(
|
||||
identity=ChannelIdentity(channel="telegram", native_id="42"),
|
||||
phase="post_link",
|
||||
isolation_key="alice",
|
||||
verified_claims={"groups": ("users", "admins")},
|
||||
claim_source="linker",
|
||||
)
|
||||
assert await a.evaluate(ctx) is AllowlistDecision.ALLOW
|
||||
|
||||
async def test_post_link_denies_missing_or_nonmatching_claim(self) -> None:
|
||||
a = LinkedClaimAllowlist("oid", ["abc"])
|
||||
assert await a.evaluate(_ctx_post_link({"oid": "def"})) is AllowlistDecision.DENY
|
||||
assert await a.evaluate(_ctx_post_link({"tid": "abc"})) is AllowlistDecision.DENY
|
||||
|
||||
|
||||
class TestAnyOfAllowlists:
|
||||
async def test_any_allow_wins(self) -> None:
|
||||
a = AnyOfAllowlists(NativeIdAllowlist({"42"}), NativeIdAllowlist({"99"}))
|
||||
# native_id=42 → first ALLOWs, short-circuit.
|
||||
assert await a.evaluate(_ctx_pre_link(native_id="42")) is AllowlistDecision.ALLOW
|
||||
|
||||
async def test_all_deny_yields_deny(self) -> None:
|
||||
# Both lists deny native_id=7.
|
||||
a = AnyOfAllowlists(NativeIdAllowlist({"42"}), NativeIdAllowlist({"99"}))
|
||||
assert await a.evaluate(_ctx_pre_link(native_id="7")) is AllowlistDecision.DENY
|
||||
|
||||
async def test_abstain_when_no_decision(self) -> None:
|
||||
# Channel-scoped lists both ABSTAIN on a "slack" request.
|
||||
a = AnyOfAllowlists(
|
||||
NativeIdAllowlist({"42"}, channel="telegram"),
|
||||
NativeIdAllowlist({"99"}, channel="teams"),
|
||||
)
|
||||
assert await a.evaluate(_ctx_pre_link(channel="slack", native_id="42")) is AllowlistDecision.ABSTAIN
|
||||
|
||||
async def test_empty_is_abstain(self) -> None:
|
||||
# No children → ABSTAIN (not DENY) to avoid silent deny-all.
|
||||
a = AnyOfAllowlists()
|
||||
assert await a.evaluate(_ctx_pre_link()) is AllowlistDecision.ABSTAIN
|
||||
|
||||
def test_propagates_requires_linked_claims(self) -> None:
|
||||
a = AnyOfAllowlists(NativeIdAllowlist({"42"}), LinkedClaimAllowlist("oid", []))
|
||||
assert a.requires_linked_claims is True
|
||||
|
||||
|
||||
class TestAllOfAllowlists:
|
||||
async def test_any_deny_short_circuits(self) -> None:
|
||||
a = AllOfAllowlists(NativeIdAllowlist({"42"}), NativeIdAllowlist({"99"}))
|
||||
assert await a.evaluate(_ctx_pre_link(native_id="42")) is AllowlistDecision.DENY
|
||||
|
||||
async def test_all_allow_yields_allow(self) -> None:
|
||||
a = AllOfAllowlists(NativeIdAllowlist({"42"}), NativeIdAllowlist({"42", "99"}))
|
||||
assert await a.evaluate(_ctx_pre_link(native_id="42")) is AllowlistDecision.ALLOW
|
||||
|
||||
async def test_abstain_when_no_deny_but_no_unanimous_allow(self) -> None:
|
||||
a = AllOfAllowlists(
|
||||
NativeIdAllowlist({"42"}, channel="telegram"),
|
||||
NativeIdAllowlist({"42"}, channel="teams"),
|
||||
)
|
||||
# ABSTAIN from teams (different channel), ALLOW from telegram → ABSTAIN.
|
||||
assert await a.evaluate(_ctx_pre_link(channel="telegram", native_id="42")) is AllowlistDecision.ABSTAIN
|
||||
|
||||
async def test_empty_is_abstain(self) -> None:
|
||||
a = AllOfAllowlists()
|
||||
assert await a.evaluate(_ctx_pre_link()) is AllowlistDecision.ABSTAIN
|
||||
|
||||
|
||||
class TestCallableAllowlist:
|
||||
async def test_wraps_async_fn(self) -> None:
|
||||
async def fn(ctx: AuthorizationContext) -> AllowlistDecision:
|
||||
if ctx.identity.native_id == "42":
|
||||
return AllowlistDecision.ALLOW
|
||||
return AllowlistDecision.DENY
|
||||
|
||||
a = CallableAllowlist(fn)
|
||||
assert await a.evaluate(_ctx_pre_link(native_id="42")) is AllowlistDecision.ALLOW
|
||||
assert await a.evaluate(_ctx_pre_link(native_id="99")) is AllowlistDecision.DENY
|
||||
|
||||
def test_requires_linked_claims_passthrough(self) -> None:
|
||||
async def fn(_: AuthorizationContext) -> AllowlistDecision: # pragma: no cover
|
||||
return AllowlistDecision.ALLOW
|
||||
|
||||
a = CallableAllowlist(fn, requires_linked_claims=True)
|
||||
assert a.requires_linked_claims is True
|
||||
|
||||
|
||||
class TestAuthPolicy:
|
||||
async def test_factory_helpers_return_working_allowlists(self) -> None:
|
||||
assert await AuthPolicy.open().evaluate(_ctx_pre_link()) is AllowlistDecision.ALLOW
|
||||
assert await AuthPolicy.native_ids({"42"}).evaluate(_ctx_pre_link()) is AllowlistDecision.ALLOW
|
||||
assert await AuthPolicy.linked_claim("oid", {"abc"}).evaluate(_ctx_post_link({"oid": "abc"})) is (
|
||||
AllowlistDecision.ALLOW
|
||||
)
|
||||
|
||||
async def test_custom_factory(self) -> None:
|
||||
async def fn(_: AuthorizationContext) -> AllowlistDecision:
|
||||
return AllowlistDecision.ALLOW
|
||||
|
||||
policy = AuthPolicy.custom(fn, requires_linked_claims=True)
|
||||
assert policy.requires_linked_claims is True
|
||||
assert await policy.evaluate(_ctx_pre_link()) is AllowlistDecision.ALLOW
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Host configuration validator #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestChannelAuthorizationValidator:
|
||||
"""The host's startup validator catches three classes of misconfig
|
||||
so they fail at construction rather than silently denying every
|
||||
user at runtime."""
|
||||
|
||||
def test_require_link_without_linker_raises(self) -> None:
|
||||
# ``require_link=True`` with no linker would silently reject
|
||||
# every request — caught at construction.
|
||||
with pytest.raises(ChannelConfigurationError, match="identity_linker"):
|
||||
AgentFrameworkHost(
|
||||
target=_AgentStub(),
|
||||
channels=[_ChannelStub(require_link=True)],
|
||||
)
|
||||
|
||||
def test_require_link_with_linker_passes(self) -> None:
|
||||
host = AgentFrameworkHost(
|
||||
target=_AgentStub(),
|
||||
channels=[_ChannelStub(require_link=True)],
|
||||
identity_linker=_StaticLinker(LinkedIdentity("alice", {"oid": "abc"})),
|
||||
)
|
||||
assert host.runtime_mode == "long_running"
|
||||
|
||||
def test_linked_claim_allowlist_without_claim_source_raises(self) -> None:
|
||||
# The channel has no ``require_link=True`` AND doesn't emit
|
||||
# claims natively → the allowlist would always DENY / ABSTAIN.
|
||||
with pytest.raises(ChannelConfigurationError, match="verified IdP claims"):
|
||||
AgentFrameworkHost(
|
||||
target=_AgentStub(),
|
||||
channels=[_ChannelStub(allowlist=LinkedClaimAllowlist("oid", []))],
|
||||
)
|
||||
|
||||
def test_linked_claim_allowlist_with_native_claim_source_passes(self) -> None:
|
||||
# When the channel declares ``emits_verified_claims=True``
|
||||
# (e.g. Activity Protocol with AAD bearer) the validator
|
||||
# accepts the LinkedClaimAllowlist without needing a linker.
|
||||
host = AgentFrameworkHost(
|
||||
target=_AgentStub(),
|
||||
channels=[
|
||||
_ChannelStub(
|
||||
allowlist=LinkedClaimAllowlist("oid", ["abc"]),
|
||||
emits_verified_claims=True,
|
||||
)
|
||||
],
|
||||
)
|
||||
assert host.default_allowlist is None
|
||||
|
||||
def test_linked_claim_allowlist_with_require_link_and_linker_passes(self) -> None:
|
||||
host = AgentFrameworkHost(
|
||||
target=_AgentStub(),
|
||||
channels=[_ChannelStub(require_link=True, allowlist=LinkedClaimAllowlist("oid", ["abc"]))],
|
||||
identity_linker=_StaticLinker(LinkedIdentity("alice", {"oid": "abc"})),
|
||||
)
|
||||
assert host.runtime_mode == "long_running"
|
||||
|
||||
def test_native_id_allowlist_unknown_channel_raises(self) -> None:
|
||||
with pytest.raises(ChannelConfigurationError, match="unknown channel 'mystery'"):
|
||||
AgentFrameworkHost(
|
||||
target=_AgentStub(),
|
||||
channels=[_ChannelStub(allowlist=NativeIdAllowlist({"42"}, channel="mystery"))],
|
||||
)
|
||||
|
||||
def test_native_id_allowlist_known_channel_passes(self) -> None:
|
||||
# A channel-scoped native list pointing at a peer channel is
|
||||
# the supported way to compose per-channel allowlists.
|
||||
host = AgentFrameworkHost(
|
||||
target=_AgentStub(),
|
||||
channels=[
|
||||
_ChannelStub(name="telegram", allowlist=NativeIdAllowlist({"42"}, channel="telegram")),
|
||||
_ChannelStub(name="slack"),
|
||||
],
|
||||
)
|
||||
assert host.runtime_mode == "long_running"
|
||||
|
||||
def test_default_allowlist_applies_to_inheriting_channel(self) -> None:
|
||||
# ``allowlist="inherit"`` (the default) picks up the host-level
|
||||
# ``default_allowlist``. This is the "lock down a whole bot in
|
||||
# one place" ergonomic.
|
||||
host = AgentFrameworkHost(
|
||||
target=_AgentStub(),
|
||||
channels=[_ChannelStub(name="telegram")],
|
||||
default_allowlist=NativeIdAllowlist({"42"}),
|
||||
)
|
||||
# The default flowed through; channel sees the host's allowlist.
|
||||
assert host.default_allowlist is not None
|
||||
|
||||
def test_explicit_none_carve_out_overrides_default(self) -> None:
|
||||
# ``allowlist=None`` on a channel explicitly opts out of the
|
||||
# host default — useful for a public endpoint inside an
|
||||
# otherwise locked-down host.
|
||||
host = AgentFrameworkHost(
|
||||
target=_AgentStub(),
|
||||
channels=[_ChannelStub(name="public", allowlist=None)],
|
||||
default_allowlist=NativeIdAllowlist({"42"}),
|
||||
)
|
||||
# Construction succeeded; the validator did not raise.
|
||||
assert host.default_allowlist is not None
|
||||
|
||||
def test_combinator_with_unknown_nested_channel_raises(self) -> None:
|
||||
# The validator walks ``AnyOfAllowlists`` / ``AllOfAllowlists``
|
||||
# so a typo'd channel name nested under a combinator is still
|
||||
# caught at construction.
|
||||
with pytest.raises(ChannelConfigurationError, match="unknown channel 'typo'"):
|
||||
AgentFrameworkHost(
|
||||
target=_AgentStub(),
|
||||
channels=[
|
||||
_ChannelStub(
|
||||
allowlist=AnyOfAllowlists(
|
||||
NativeIdAllowlist({"42"}, channel="stub"),
|
||||
NativeIdAllowlist({"99"}, channel="typo"),
|
||||
)
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# host.authorize pipeline #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestHostAuthorize:
|
||||
"""Host authorization pipeline across open, native-id, and linked-claim profiles."""
|
||||
|
||||
def _host(self) -> AgentFrameworkHost:
|
||||
return AgentFrameworkHost(target=_AgentStub(), channels=[_ChannelStub()])
|
||||
|
||||
async def test_open_profile_returns_allowed_with_auto_isolation_key(self) -> None:
|
||||
host = self._host()
|
||||
outcome = await host.authorize(ChannelIdentity(channel="telegram", native_id="42"))
|
||||
assert isinstance(outcome, Allowed)
|
||||
assert outcome.isolation_key == "telegram:42"
|
||||
|
||||
async def test_native_allowlist_allows_listed_id(self) -> None:
|
||||
host = self._host()
|
||||
outcome = await host.authorize(
|
||||
ChannelIdentity(channel="telegram", native_id="42"),
|
||||
allowlist=NativeIdAllowlist({"42"}),
|
||||
)
|
||||
assert isinstance(outcome, Allowed)
|
||||
assert outcome.isolation_key == "telegram:42"
|
||||
|
||||
async def test_native_allowlist_denies_unlisted_id(self) -> None:
|
||||
host = self._host()
|
||||
outcome = await host.authorize(
|
||||
ChannelIdentity(channel="telegram", native_id="99"),
|
||||
allowlist=NativeIdAllowlist({"42"}),
|
||||
)
|
||||
assert isinstance(outcome, Denied)
|
||||
assert outcome.reason_code == "allowlist_denied_pre_link"
|
||||
assert outcome.user_message is not None
|
||||
# The bland default leaks neither tenant nor list size.
|
||||
assert "telegram" not in (outcome.user_message or "")
|
||||
|
||||
async def test_abstain_with_claim_requirement_yields_link_required_message(self) -> None:
|
||||
# Without a linker and without channel-emitted claims, a claim-required
|
||||
# allowlist cannot make progress and the host returns a safe denial.
|
||||
async def abstain(_: AuthorizationContext) -> AllowlistDecision:
|
||||
return AllowlistDecision.ABSTAIN
|
||||
|
||||
host = AgentFrameworkHost(
|
||||
target=_AgentStub(),
|
||||
channels=[_ChannelStub(emits_verified_claims=True)],
|
||||
)
|
||||
outcome = await host.authorize(
|
||||
ChannelIdentity(channel="telegram", native_id="42"),
|
||||
allowlist=CallableAllowlist(abstain, requires_linked_claims=True),
|
||||
)
|
||||
assert isinstance(outcome, Denied)
|
||||
assert outcome.reason_code == "allowlist_requires_link"
|
||||
|
||||
async def test_abstain_without_claim_requirement_falls_through_to_allowed(self) -> None:
|
||||
async def abstain(_: AuthorizationContext) -> AllowlistDecision:
|
||||
return AllowlistDecision.ABSTAIN
|
||||
|
||||
host = self._host()
|
||||
outcome = await host.authorize(
|
||||
ChannelIdentity(channel="telegram", native_id="42"),
|
||||
allowlist=CallableAllowlist(abstain),
|
||||
)
|
||||
assert isinstance(outcome, Allowed)
|
||||
|
||||
async def test_auto_issue_returns_existing_key_when_known(self) -> None:
|
||||
# When an identity has already been observed, the auto-issued
|
||||
# key matches the existing one rather than coining a fresh
|
||||
# token. This is the linker-free equivalent of identity resolution.
|
||||
host = self._host()
|
||||
host._identities["alice"] = {"telegram": ChannelIdentity(channel="telegram", native_id="42")}
|
||||
outcome = await host.authorize(ChannelIdentity(channel="telegram", native_id="42"))
|
||||
assert isinstance(outcome, Allowed)
|
||||
assert outcome.isolation_key == "alice"
|
||||
|
||||
async def test_verified_claims_propagate_to_context(self) -> None:
|
||||
# Channels that natively carry verified claims (e.g. Activity
|
||||
# Protocol bearer with AAD oid) pass them through to
|
||||
# ``authorize`` — the allowlist sees them on the
|
||||
# ``AuthorizationContext``.
|
||||
seen: list[AuthorizationContext] = []
|
||||
|
||||
async def capture(ctx: AuthorizationContext) -> AllowlistDecision:
|
||||
seen.append(ctx)
|
||||
return AllowlistDecision.ALLOW
|
||||
|
||||
host = self._host()
|
||||
await host.authorize(
|
||||
ChannelIdentity(channel="telegram", native_id="42"),
|
||||
allowlist=CallableAllowlist(capture),
|
||||
verified_claims={"oid": "abc"},
|
||||
)
|
||||
assert len(seen) == 1
|
||||
assert seen[0].claim_source == "channel"
|
||||
assert dict(seen[0].verified_claims) == {"oid": "abc"}
|
||||
|
||||
async def test_require_link_returns_challenge_when_unlinked(self) -> None:
|
||||
challenge = LinkChallenge("c1", url="https://login.example/c1")
|
||||
linker = _StaticLinker(challenge)
|
||||
host = AgentFrameworkHost(
|
||||
target=_AgentStub(),
|
||||
channels=[_ChannelStub(require_link=True)],
|
||||
identity_linker=linker,
|
||||
)
|
||||
outcome = await host.authorize(
|
||||
ChannelIdentity(channel="telegram", native_id="42"),
|
||||
require_link=True,
|
||||
)
|
||||
assert isinstance(outcome, LinkRequired)
|
||||
assert outcome.challenge is challenge
|
||||
assert [call.native_id for call in linker.calls] == ["42"]
|
||||
|
||||
async def test_require_link_returns_linked_identity_when_resolved(self) -> None:
|
||||
linked = LinkedIdentity("entra:abc", {"oid": "abc"})
|
||||
linker = _StaticLinker(linked)
|
||||
host = AgentFrameworkHost(
|
||||
target=_AgentStub(),
|
||||
channels=[_ChannelStub(require_link=True)],
|
||||
identity_linker=linker,
|
||||
)
|
||||
outcome = await host.authorize(
|
||||
ChannelIdentity(channel="telegram", native_id="42"),
|
||||
require_link=True,
|
||||
)
|
||||
assert isinstance(outcome, Allowed)
|
||||
assert outcome.isolation_key == "entra:abc"
|
||||
assert dict(outcome.verified_claims) == {"oid": "abc"}
|
||||
assert outcome.claim_source == "linker"
|
||||
# authorize() is decision-only; identity registry writes remain on
|
||||
# the request execution path.
|
||||
assert host._identities == {}
|
||||
|
||||
async def test_linked_claim_allowlist_with_linker_allows_matching_claim(self) -> None:
|
||||
host = AgentFrameworkHost(
|
||||
target=_AgentStub(),
|
||||
channels=[_ChannelStub(require_link=True, allowlist=LinkedClaimAllowlist("oid", ["abc"]))],
|
||||
identity_linker=_StaticLinker(LinkedIdentity("entra:abc", {"oid": "abc"})),
|
||||
)
|
||||
outcome = await host.authorize(
|
||||
ChannelIdentity(channel="telegram", native_id="42"),
|
||||
require_link=True,
|
||||
allowlist=LinkedClaimAllowlist("oid", ["abc"]),
|
||||
)
|
||||
assert isinstance(outcome, Allowed)
|
||||
assert outcome.isolation_key == "entra:abc"
|
||||
|
||||
async def test_linked_claim_allowlist_with_linker_denies_nonmatching_claim(self) -> None:
|
||||
host = AgentFrameworkHost(
|
||||
target=_AgentStub(),
|
||||
channels=[_ChannelStub(require_link=True, allowlist=LinkedClaimAllowlist("oid", ["abc"]))],
|
||||
identity_linker=_StaticLinker(LinkedIdentity("entra:def", {"oid": "def"})),
|
||||
)
|
||||
outcome = await host.authorize(
|
||||
ChannelIdentity(channel="telegram", native_id="42"),
|
||||
require_link=True,
|
||||
allowlist=LinkedClaimAllowlist("oid", ["abc"]),
|
||||
)
|
||||
assert isinstance(outcome, Denied)
|
||||
assert outcome.reason_code == "allowlist_denied_post_link"
|
||||
|
||||
async def test_linked_claim_allowlist_with_linker_returns_challenge_when_unlinked(self) -> None:
|
||||
challenge = LinkChallenge("c1")
|
||||
host = AgentFrameworkHost(
|
||||
target=_AgentStub(),
|
||||
channels=[_ChannelStub(require_link=True, allowlist=LinkedClaimAllowlist("oid", ["abc"]))],
|
||||
identity_linker=_StaticLinker(challenge),
|
||||
)
|
||||
outcome = await host.authorize(
|
||||
ChannelIdentity(channel="telegram", native_id="42"),
|
||||
require_link=True,
|
||||
allowlist=LinkedClaimAllowlist("oid", ["abc"]),
|
||||
)
|
||||
assert isinstance(outcome, LinkRequired)
|
||||
assert outcome.challenge is challenge
|
||||
|
||||
async def test_linked_claim_allowlist_uses_channel_verified_claims_without_linker(self) -> None:
|
||||
host = AgentFrameworkHost(
|
||||
target=_AgentStub(),
|
||||
channels=[_ChannelStub(emits_verified_claims=True, allowlist=LinkedClaimAllowlist("oid", ["abc"]))],
|
||||
)
|
||||
outcome = await host.authorize(
|
||||
ChannelIdentity(channel="activity", native_id="aad-user"),
|
||||
allowlist=LinkedClaimAllowlist("oid", ["abc"]),
|
||||
verified_claims={"oid": "abc"},
|
||||
)
|
||||
assert isinstance(outcome, Allowed)
|
||||
assert outcome.isolation_key == "activity:aad-user"
|
||||
assert outcome.claim_source == "channel"
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
@@ -21,16 +21,9 @@ from agent_framework_hosting import (
|
||||
ChannelContext,
|
||||
ChannelContribution,
|
||||
ChannelIdentity,
|
||||
ChannelPush,
|
||||
ChannelRequest,
|
||||
ChannelSession,
|
||||
DurableTaskPayloadMode,
|
||||
DurableTaskRunner,
|
||||
HostedRunResult,
|
||||
ResponseTarget,
|
||||
RetryPolicy,
|
||||
TaskHandle,
|
||||
TaskStatus,
|
||||
)
|
||||
|
||||
|
||||
@@ -75,27 +68,33 @@ class _FakeAgent:
|
||||
self.created_sessions.append(s)
|
||||
return s
|
||||
|
||||
async def run(self, messages: Any = None, *, stream: bool = False, session: Any = None, **kwargs: Any) -> Any:
|
||||
def run(self, messages: Any = None, *, stream: bool = False, session: Any = None, **kwargs: Any) -> Any:
|
||||
self.calls.append({"messages": messages, "stream": stream, "session": session, "kwargs": kwargs})
|
||||
if stream: # pragma: no cover - not used by these tests
|
||||
if stream:
|
||||
updates = [AgentResponseUpdate(contents=[Content.from_text(text=self._reply)], role="assistant")]
|
||||
|
||||
async def _gen() -> AsyncIterator[Any]:
|
||||
yield self._reply
|
||||
async def _gen() -> AsyncIterator[AgentResponseUpdate]:
|
||||
for update in updates:
|
||||
yield update
|
||||
|
||||
return _gen()
|
||||
return _FakeAgentResponse(text=self._reply)
|
||||
async def _finalize(items: Sequence[AgentResponseUpdate]) -> AgentResponse: # noqa: RUF029
|
||||
return AgentResponse.from_updates(items)
|
||||
|
||||
return ResponseStream[AgentResponseUpdate, AgentResponse](_gen(), finalizer=_finalize)
|
||||
|
||||
async def _coro() -> _FakeAgentResponse:
|
||||
return _FakeAgentResponse(text=self._reply)
|
||||
|
||||
return _coro()
|
||||
|
||||
|
||||
class _RecordingChannel:
|
||||
"""Minimal :class:`Channel` + :class:`ChannelPush` for routing tests."""
|
||||
"""Minimal :class:`Channel` for host tests."""
|
||||
|
||||
def __init__(self, name: str = "fake", path: str = "/fake", supports_push: bool = True) -> None:
|
||||
def __init__(self, name: str = "fake", path: str = "/fake") -> None:
|
||||
self.name = name
|
||||
self.path = path
|
||||
self.context: ChannelContext | None = None
|
||||
self.pushes: list[tuple[ChannelIdentity, HostedRunResult[Any]]] = []
|
||||
self._push_raises: Exception | None = None
|
||||
self._supports_push = supports_push
|
||||
# Provide a single trivial route so contribute() exercises the endpoint path.
|
||||
self._routes: Sequence[BaseRoute] = (Route("/ping", _ping),)
|
||||
|
||||
@@ -103,88 +102,6 @@ class _RecordingChannel:
|
||||
self.context = context
|
||||
return ChannelContribution(routes=self._routes)
|
||||
|
||||
async def push(self, identity: ChannelIdentity, payload: HostedRunResult[Any]) -> None:
|
||||
if self._push_raises is not None:
|
||||
raise self._push_raises
|
||||
self.pushes.append((identity, payload))
|
||||
|
||||
|
||||
class _NoPushChannel:
|
||||
"""A channel that does NOT implement :class:`ChannelPush`."""
|
||||
|
||||
def __init__(self, name: str = "nopush", path: str = "/nopush") -> None:
|
||||
self.name = name
|
||||
self.path = path
|
||||
|
||||
def contribute(self, context: ChannelContext) -> ChannelContribution:
|
||||
return ChannelContribution()
|
||||
|
||||
|
||||
class _SyncTaskRunner(DurableTaskRunner):
|
||||
"""A :class:`DurableTaskRunner` that runs handlers inline.
|
||||
|
||||
Tests of the delivery routing want deterministic, synchronous
|
||||
behaviour. The real :class:`InProcessTaskRunner` schedules via
|
||||
``asyncio.create_task`` so push side effects only land *after*
|
||||
the test has yielded control — awkward for assertions that read
|
||||
a channel's recorded pushes immediately after
|
||||
:meth:`ChannelContext.deliver_response` returns.
|
||||
|
||||
Two knobs control failure handling:
|
||||
|
||||
- ``schedule_raises``: when set, every call to :meth:`schedule`
|
||||
raises this exception. Mimics a host-side outage (the durable
|
||||
backend is unreachable).
|
||||
- ``swallow_handler_errors`` (default ``True``): when the
|
||||
handler raises, the error is recorded in
|
||||
:attr:`handler_errors` but :meth:`schedule` still returns
|
||||
successfully — matching the real durable contract that
|
||||
"scheduled" is a separate signal from "delivered". Set to
|
||||
``False`` to surface handler exceptions through
|
||||
:meth:`schedule` for the few tests that want to assert on
|
||||
handler-raised failures inline.
|
||||
"""
|
||||
|
||||
def __init__(self, *, swallow_handler_errors: bool = True) -> None:
|
||||
self._handlers: dict[str, Callable[[Mapping[str, Any]], Awaitable[None]]] = {}
|
||||
self.scheduled: list[tuple[str, Mapping[str, Any]]] = []
|
||||
self.handler_errors: list[BaseException] = []
|
||||
self.schedule_raises: BaseException | None = None
|
||||
self.swallow_handler_errors = swallow_handler_errors
|
||||
|
||||
# Default object-mode matches the real ``InProcessTaskRunner`` —
|
||||
# tests that want to exercise the JSON-mode path override this on
|
||||
# the instance.
|
||||
payload_mode = DurableTaskPayloadMode.OBJECT
|
||||
|
||||
def register(
|
||||
self,
|
||||
name: str,
|
||||
handler: Callable[[Mapping[str, Any]], Awaitable[None]],
|
||||
) -> None:
|
||||
self._handlers[name] = handler
|
||||
|
||||
async def schedule(
|
||||
self,
|
||||
name: str,
|
||||
payload: Mapping[str, Any],
|
||||
*,
|
||||
retry_policy: RetryPolicy | None = None,
|
||||
) -> TaskHandle:
|
||||
if self.schedule_raises is not None:
|
||||
raise self.schedule_raises
|
||||
self.scheduled.append((name, payload))
|
||||
try:
|
||||
await self._handlers[name](payload)
|
||||
except Exception as exc:
|
||||
self.handler_errors.append(exc)
|
||||
if not self.swallow_handler_errors:
|
||||
raise
|
||||
return TaskHandle(task_id=f"sync-{len(self.scheduled)}", name=name)
|
||||
|
||||
async def get(self, handle: TaskHandle) -> TaskStatus | None: # pragma: no cover - unused
|
||||
return "succeeded"
|
||||
|
||||
|
||||
def _assistant_response(text: str) -> AgentResponse:
|
||||
"""Build a one-message ``AgentResponse`` to use as a ``HostedRunResult.result``."""
|
||||
@@ -227,7 +144,6 @@ class TestHostWiring:
|
||||
def test_channel_is_recognized(self) -> None:
|
||||
ch = _RecordingChannel()
|
||||
assert isinstance(ch, Channel)
|
||||
assert isinstance(ch, ChannelPush)
|
||||
|
||||
def test_app_mounts_channel_routes_under_path(self) -> None:
|
||||
agent = _FakeAgent()
|
||||
@@ -313,10 +229,6 @@ class TestHostInvoke:
|
||||
"native_id": "user:1",
|
||||
"attributes": {},
|
||||
}
|
||||
assert msg.additional_properties["hosting"]["response_target"] == {
|
||||
"kind": "originating",
|
||||
"targets": [],
|
||||
}
|
||||
|
||||
async def test_invoke_caches_session_per_isolation_key(self) -> None:
|
||||
agent = _FakeAgent()
|
||||
@@ -398,6 +310,56 @@ class TestHostInvoke:
|
||||
assert agent.calls[0]["kwargs"]["options"] == {"temperature": 0.4}
|
||||
|
||||
|
||||
class TestHostOwnedHooks:
|
||||
async def test_context_run_applies_run_hook_before_invocation(self) -> None:
|
||||
agent = _FakeAgent()
|
||||
ch = _RecordingChannel()
|
||||
host = AgentFrameworkHost(target=agent, channels=[ch])
|
||||
_ = host.app
|
||||
assert ch.context is not None
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def hook(request: ChannelRequest, **kwargs: Any) -> ChannelRequest:
|
||||
captured["target"] = kwargs["target"]
|
||||
captured["protocol_request"] = kwargs["protocol_request"]
|
||||
return ChannelRequest(
|
||||
channel=request.channel,
|
||||
operation=request.operation,
|
||||
input="rewritten",
|
||||
session=request.session,
|
||||
)
|
||||
|
||||
req = ChannelRequest(channel=ch.name, operation="op", input="original", session=ChannelSession("alice"))
|
||||
await ch.context.run(req, run_hook=hook, protocol_request={"raw": True})
|
||||
|
||||
assert captured["target"] is agent
|
||||
assert captured["protocol_request"] == {"raw": True}
|
||||
assert agent.calls[0]["messages"].text == "rewritten"
|
||||
|
||||
async def test_context_run_stream_applies_run_hook_before_opening_stream(self) -> None:
|
||||
agent = _FakeAgent()
|
||||
ch = _RecordingChannel()
|
||||
host = AgentFrameworkHost(target=agent, channels=[ch])
|
||||
_ = host.app
|
||||
assert ch.context is not None
|
||||
|
||||
def hook(request: ChannelRequest, **_: Any) -> ChannelRequest:
|
||||
return ChannelRequest(channel=request.channel, operation=request.operation, input="streamed")
|
||||
|
||||
stream = await ch.context.run_stream(
|
||||
ChannelRequest(channel=ch.name, operation="op", input="original"),
|
||||
run_hook=hook,
|
||||
stream_update_hook=lambda update: AgentResponseUpdate(
|
||||
contents=[Content.from_text(text=update.text.upper())],
|
||||
role="assistant",
|
||||
),
|
||||
)
|
||||
|
||||
chunks = [update.text async for update in stream]
|
||||
assert chunks == ["OK"]
|
||||
assert agent.calls[0]["messages"].text == "streamed"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Workflow target #
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -436,7 +398,7 @@ class TestHostWorkflowTarget:
|
||||
assert ch.context is not None
|
||||
|
||||
req = ChannelRequest(channel="fake", operation="message.create", input="hi")
|
||||
stream = ch.context.run_stream(req)
|
||||
stream = await ch.context.run_stream(req)
|
||||
|
||||
updates: list[AgentResponseUpdate] = []
|
||||
async for update in stream:
|
||||
@@ -464,7 +426,7 @@ class TestHostWorkflowTarget:
|
||||
assert ch.context is not None
|
||||
|
||||
req = ChannelRequest(channel="fake", operation="message.create", input="x")
|
||||
stream = ch.context.run_stream(req)
|
||||
stream = await ch.context.run_stream(req)
|
||||
|
||||
chunks: list[str] = []
|
||||
async for update in stream:
|
||||
@@ -561,7 +523,7 @@ class TestHostWorkflowCheckpointing:
|
||||
input="hi",
|
||||
session=ChannelSession(isolation_key="bob"),
|
||||
)
|
||||
stream = ch.context.run_stream(req)
|
||||
stream = await ch.context.run_stream(req)
|
||||
async for _ in stream:
|
||||
pass
|
||||
await stream.get_final_response()
|
||||
@@ -702,520 +664,6 @@ class TestHostWorkflowCheckpointingPathTraversal:
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Delivery routing #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _make_host_with_two_channels(
|
||||
*,
|
||||
runner: DurableTaskRunner | None = None,
|
||||
) -> tuple[AgentFrameworkHost, _RecordingChannel, _RecordingChannel, ChannelContext, _SyncTaskRunner]:
|
||||
agent = _FakeAgent()
|
||||
a = _RecordingChannel(name="responses", path="/r")
|
||||
b = _RecordingChannel(name="telegram", path="/t")
|
||||
sync_runner = runner if isinstance(runner, _SyncTaskRunner) else _SyncTaskRunner()
|
||||
host = AgentFrameworkHost(
|
||||
target=agent,
|
||||
channels=[a, b],
|
||||
durable_task_runner=runner or sync_runner,
|
||||
)
|
||||
_ = host.app
|
||||
assert a.context is not None
|
||||
return host, a, b, a.context, sync_runner
|
||||
|
||||
|
||||
def _record_identity_on(host: AgentFrameworkHost, isolation_key: str, channel: str, native_id: str) -> None:
|
||||
"""Pre-seed the host's identity registry by running a request."""
|
||||
host._identities.setdefault(isolation_key, {})[channel] = ChannelIdentity(channel=channel, native_id=native_id)
|
||||
host._active[isolation_key] = channel
|
||||
|
||||
|
||||
class TestDeliverResponse:
|
||||
"""Delivery routing — the originating channel learns whether to render
|
||||
on its own wire from the ``bool`` return; everything else
|
||||
(scheduled tasks, schedule-time failures, skip reasons) lives in
|
||||
the runner's own log. Tests assert the bool plus observable
|
||||
state on the sync runner fake (``scheduled``, ``handler_errors``)
|
||||
and on the destination channels (``pushes``)."""
|
||||
|
||||
async def test_originating_returns_true(self) -> None:
|
||||
_, _, _, ctx, runner = _make_host_with_two_channels()
|
||||
req = ChannelRequest(channel="responses", operation="op", input="x")
|
||||
include_originating = await ctx.deliver_response(req, _make_reply("reply"))
|
||||
assert include_originating is True
|
||||
assert runner.scheduled == []
|
||||
|
||||
async def test_none_suppresses_everything(self) -> None:
|
||||
_, _, _, ctx, runner = _make_host_with_two_channels()
|
||||
req = ChannelRequest(
|
||||
channel="responses",
|
||||
operation="op",
|
||||
input="x",
|
||||
response_target=ResponseTarget.none, # type: ignore[attr-defined]
|
||||
)
|
||||
include_originating = await ctx.deliver_response(req, _make_reply("reply"))
|
||||
assert include_originating is False
|
||||
assert runner.scheduled == []
|
||||
|
||||
async def test_active_pushes_to_other_channel(self) -> None:
|
||||
host, _a, b, ctx, runner = _make_host_with_two_channels()
|
||||
# Alice was last seen on telegram.
|
||||
_record_identity_on(host, "alice", "telegram", "42")
|
||||
# Now she sends a message via responses; ResponseTarget.active should
|
||||
# push to telegram, not back to responses.
|
||||
req = ChannelRequest(
|
||||
channel="responses",
|
||||
operation="op",
|
||||
input="x",
|
||||
session=ChannelSession(isolation_key="alice"),
|
||||
response_target=ResponseTarget.active, # type: ignore[attr-defined]
|
||||
)
|
||||
include_originating = await ctx.deliver_response(req, _make_reply("reply"))
|
||||
assert include_originating is False
|
||||
assert len(runner.scheduled) == 1
|
||||
assert b.pushes and b.pushes[0][0].native_id == "42"
|
||||
|
||||
async def test_active_falls_back_to_originating_when_self(self) -> None:
|
||||
host, _a, _b, ctx, runner = _make_host_with_two_channels()
|
||||
_record_identity_on(host, "alice", "responses", "user:1")
|
||||
req = ChannelRequest(
|
||||
channel="responses",
|
||||
operation="op",
|
||||
input="x",
|
||||
session=ChannelSession(isolation_key="alice"),
|
||||
response_target=ResponseTarget.active, # type: ignore[attr-defined]
|
||||
)
|
||||
include_originating = await ctx.deliver_response(req, _make_reply("reply"))
|
||||
assert include_originating is True
|
||||
assert runner.scheduled == []
|
||||
|
||||
async def test_channels_with_unknown_identity_falls_back_to_originating(self) -> None:
|
||||
_, _, _, ctx, runner = _make_host_with_two_channels()
|
||||
# No prior identity seeded for telegram on alice.
|
||||
req = ChannelRequest(
|
||||
channel="responses",
|
||||
operation="op",
|
||||
input="x",
|
||||
session=ChannelSession(isolation_key="alice"),
|
||||
response_target=ResponseTarget.channel("telegram"),
|
||||
)
|
||||
include_originating = await ctx.deliver_response(req, _make_reply("reply"))
|
||||
# Skipped at resolution → fallback to originating so the user
|
||||
# still gets a reply.
|
||||
assert include_originating is True
|
||||
assert runner.scheduled == []
|
||||
|
||||
async def test_channels_with_explicit_native_id_token(self) -> None:
|
||||
_, _, b, ctx, runner = _make_host_with_two_channels()
|
||||
req = ChannelRequest(
|
||||
channel="responses",
|
||||
operation="op",
|
||||
input="x",
|
||||
response_target=ResponseTarget.channel("telegram:99"),
|
||||
)
|
||||
include_originating = await ctx.deliver_response(req, _make_reply("reply"))
|
||||
assert include_originating is False
|
||||
assert len(runner.scheduled) == 1
|
||||
assert b.pushes[0][0].native_id == "99"
|
||||
|
||||
async def test_channels_originating_pseudo_includes_origin(self) -> None:
|
||||
host, _a, _b, ctx, runner = _make_host_with_two_channels()
|
||||
_record_identity_on(host, "alice", "telegram", "42")
|
||||
req = ChannelRequest(
|
||||
channel="responses",
|
||||
operation="op",
|
||||
input="x",
|
||||
session=ChannelSession(isolation_key="alice"),
|
||||
response_target=ResponseTarget.channels(["originating", "telegram"]),
|
||||
)
|
||||
include_originating = await ctx.deliver_response(req, _make_reply("reply"))
|
||||
assert include_originating is True
|
||||
assert len(runner.scheduled) == 1
|
||||
|
||||
async def test_channels_unknown_channel_name_falls_back(self) -> None:
|
||||
_, _, _, ctx, runner = _make_host_with_two_channels()
|
||||
req = ChannelRequest(
|
||||
channel="responses",
|
||||
operation="op",
|
||||
input="x",
|
||||
response_target=ResponseTarget.channel("nope"),
|
||||
)
|
||||
include_originating = await ctx.deliver_response(req, _make_reply("reply"))
|
||||
assert include_originating is True # fallback
|
||||
assert runner.scheduled == []
|
||||
|
||||
async def test_no_push_capability_falls_back(self) -> None:
|
||||
agent = _FakeAgent()
|
||||
a = _RecordingChannel(name="responses", path="/r")
|
||||
b = _NoPushChannel(name="nopush", path="/n")
|
||||
host = AgentFrameworkHost(target=agent, channels=[a, b])
|
||||
_ = host.app
|
||||
assert a.context is not None
|
||||
# Pre-seed identity on the no-push channel so we get past the
|
||||
# identity check and hit the ChannelPush check.
|
||||
host._identities.setdefault("alice", {})["nopush"] = ChannelIdentity(channel="nopush", native_id="42")
|
||||
req = ChannelRequest(
|
||||
channel="responses",
|
||||
operation="op",
|
||||
input="x",
|
||||
session=ChannelSession(isolation_key="alice"),
|
||||
response_target=ResponseTarget.channel("nopush"),
|
||||
)
|
||||
include_originating = await a.context.deliver_response(req, _make_reply("reply"))
|
||||
assert include_originating is True # fallback
|
||||
|
||||
async def test_all_linked_pushes_to_every_other_channel(self) -> None:
|
||||
host, _a, b, ctx, runner = _make_host_with_two_channels()
|
||||
# Alice on responses (originating) and telegram.
|
||||
host._identities.setdefault("alice", {})
|
||||
host._identities["alice"]["responses"] = ChannelIdentity(channel="responses", native_id="user:1")
|
||||
host._identities["alice"]["telegram"] = ChannelIdentity(channel="telegram", native_id="42")
|
||||
req = ChannelRequest(
|
||||
channel="responses",
|
||||
operation="op",
|
||||
input="x",
|
||||
session=ChannelSession(isolation_key="alice"),
|
||||
response_target=ResponseTarget.all_linked, # type: ignore[attr-defined]
|
||||
)
|
||||
include_originating = await ctx.deliver_response(req, _make_reply("reply"))
|
||||
assert include_originating is True
|
||||
assert len(runner.scheduled) == 1
|
||||
assert b.pushes and b.pushes[0][1].result.text == "reply"
|
||||
|
||||
async def test_all_linked_no_other_channels_falls_back(self) -> None:
|
||||
_host, _a, _b, ctx, runner = _make_host_with_two_channels()
|
||||
req = ChannelRequest(
|
||||
channel="responses",
|
||||
operation="op",
|
||||
input="x",
|
||||
session=ChannelSession(isolation_key="alice"),
|
||||
response_target=ResponseTarget.all_linked, # type: ignore[attr-defined]
|
||||
)
|
||||
include_originating = await ctx.deliver_response(req, _make_reply("reply"))
|
||||
assert include_originating is True
|
||||
assert runner.scheduled == []
|
||||
|
||||
async def test_identities_variant_preserves_attributes(self) -> None:
|
||||
"""``ResponseTarget.identities([...])`` plumbs full
|
||||
:class:`ChannelIdentity` objects through resolution, preserving
|
||||
``attributes`` for destination channels that need conversation/
|
||||
thread metadata (Teams, Slack, Bot Framework)."""
|
||||
_, _, b, ctx, runner = _make_host_with_two_channels()
|
||||
ident = ChannelIdentity(
|
||||
channel="telegram",
|
||||
native_id="42",
|
||||
attributes={"thread_id": "t1", "service_url": "https://x"},
|
||||
)
|
||||
req = ChannelRequest(
|
||||
channel="responses",
|
||||
operation="op",
|
||||
input="x",
|
||||
response_target=ResponseTarget.identity(ident),
|
||||
)
|
||||
include_originating = await ctx.deliver_response(req, _make_reply("reply"))
|
||||
assert include_originating is False
|
||||
assert len(runner.scheduled) == 1
|
||||
# The destination identity arrived at push with attributes intact.
|
||||
pushed_identity = b.pushes[0][0]
|
||||
assert pushed_identity.native_id == "42"
|
||||
assert dict(pushed_identity.attributes) == {"thread_id": "t1", "service_url": "https://x"}
|
||||
|
||||
async def test_identities_pointing_to_originating_includes_origin(self) -> None:
|
||||
"""An identity whose channel matches the originating channel
|
||||
folds into ``include_originating`` rather than double-delivering
|
||||
via push."""
|
||||
_, _, _, ctx, runner = _make_host_with_two_channels()
|
||||
ident = ChannelIdentity(channel="responses", native_id="user:1")
|
||||
req = ChannelRequest(
|
||||
channel="responses",
|
||||
operation="op",
|
||||
input="x",
|
||||
response_target=ResponseTarget.identities([ident]),
|
||||
)
|
||||
include_originating = await ctx.deliver_response(req, _make_reply("reply"))
|
||||
assert include_originating is True
|
||||
assert runner.scheduled == []
|
||||
|
||||
async def test_handler_exception_does_not_change_return_value(self) -> None:
|
||||
"""When ``ChannelPush.push`` raises *inside the runner handler*
|
||||
the originating channel still sees the same return value —
|
||||
``DurableTaskRunner.schedule`` accepted the work, and downstream
|
||||
delivery outcome is owned by the runner (it logs and retries
|
||||
per the configured ``RetryPolicy``)."""
|
||||
host, _a, b, ctx, runner = _make_host_with_two_channels()
|
||||
b._push_raises = RuntimeError("boom") # type: ignore[attr-defined]
|
||||
host._identities.setdefault("alice", {})["telegram"] = ChannelIdentity(channel="telegram", native_id="42")
|
||||
req = ChannelRequest(
|
||||
channel="responses",
|
||||
operation="op",
|
||||
input="x",
|
||||
session=ChannelSession(isolation_key="alice"),
|
||||
response_target=ResponseTarget.channel("telegram"),
|
||||
)
|
||||
include_originating = await ctx.deliver_response(req, _make_reply("reply"))
|
||||
# Schedule succeeded → the return value is unaffected by a
|
||||
# downstream handler failure.
|
||||
assert include_originating is False
|
||||
assert len(runner.scheduled) == 1
|
||||
# Handler raised — runner captured the error (the real runner
|
||||
# would retry it; the sync fake records it).
|
||||
assert runner.handler_errors and isinstance(runner.handler_errors[0], RuntimeError)
|
||||
assert str(runner.handler_errors[0]) == "boom"
|
||||
|
||||
async def test_schedule_exception_falls_back_to_originating(self) -> None:
|
||||
"""When :meth:`DurableTaskRunner.schedule` itself raises (the
|
||||
runner backend is unreachable) the destination is treated as
|
||||
skipped — same outcome as any other resolution-time drop. The
|
||||
host's fall-back-to-originating rule then ensures the user
|
||||
still gets a reply rather than being left without one."""
|
||||
host, _a, _b, ctx, runner = _make_host_with_two_channels()
|
||||
runner.schedule_raises = RuntimeError("runner backend unreachable")
|
||||
host._identities.setdefault("alice", {})["telegram"] = ChannelIdentity(channel="telegram", native_id="42")
|
||||
req = ChannelRequest(
|
||||
channel="responses",
|
||||
operation="op",
|
||||
input="x",
|
||||
session=ChannelSession(isolation_key="alice"),
|
||||
response_target=ResponseTarget.channel("telegram"),
|
||||
)
|
||||
include_originating = await ctx.deliver_response(req, _make_reply("reply"))
|
||||
# Schedule raised → no scheduled tasks, fall back to originating.
|
||||
assert runner.scheduled == []
|
||||
assert include_originating is True
|
||||
|
||||
async def test_echo_input_pushes_user_message_then_response(self) -> None:
|
||||
"""``echo_input=True`` triggers two pushes per destination,
|
||||
bundled into the same scheduled task: the originating user
|
||||
message first, then the agent reply. Channels downstream of a
|
||||
workflow that emits to multiple channels need this to keep
|
||||
their UI state coherent with the user's actual prompt."""
|
||||
host, _a, b, ctx, runner = _make_host_with_two_channels()
|
||||
host._identities.setdefault("alice", {})["telegram"] = ChannelIdentity(channel="telegram", native_id="42")
|
||||
req = ChannelRequest(
|
||||
channel="responses",
|
||||
operation="op",
|
||||
input="hello there",
|
||||
session=ChannelSession(isolation_key="alice"),
|
||||
response_target=ResponseTarget.channel("telegram", echo_input=True),
|
||||
)
|
||||
include_originating = await ctx.deliver_response(req, _make_reply("reply"))
|
||||
assert include_originating is False
|
||||
# One scheduled task per destination; the handler does echo then response inline.
|
||||
assert len(runner.scheduled) == 1
|
||||
_, payload = runner.scheduled[0]
|
||||
assert payload["echo_result"] is not None
|
||||
# Two pushes landed on the channel: echo first, then response.
|
||||
assert len(b.pushes) == 2
|
||||
echo_identity, echo_payload = b.pushes[0]
|
||||
assert echo_identity.native_id == "42"
|
||||
assert echo_payload.result.text == "hello there"
|
||||
assert str(echo_payload.result.messages[0].role) == "user"
|
||||
resp_identity, resp_payload = b.pushes[1]
|
||||
assert resp_identity.native_id == "42"
|
||||
assert resp_payload.result.text == "reply"
|
||||
assert str(resp_payload.result.messages[0].role) == "assistant"
|
||||
|
||||
async def test_echo_input_failure_does_not_block_response(self) -> None:
|
||||
"""An echo push that raises inside the handler is logged and
|
||||
swallowed; the response push must still be attempted on the
|
||||
same destination so the user-visible failure mode is
|
||||
"response delivered without echo" rather than "no response at
|
||||
all"."""
|
||||
agent = _FakeAgent()
|
||||
a = _RecordingChannel(name="responses", path="/r")
|
||||
b = _RecordingChannel(name="telegram", path="/t")
|
||||
runner = _SyncTaskRunner()
|
||||
host = AgentFrameworkHost(target=agent, channels=[a, b], durable_task_runner=runner)
|
||||
_ = host.app
|
||||
assert a.context is not None
|
||||
|
||||
host._identities.setdefault("alice", {})["telegram"] = ChannelIdentity(channel="telegram", native_id="42")
|
||||
|
||||
# Make the FIRST push (echo) raise, but the SECOND (response) succeed.
|
||||
calls = {"n": 0}
|
||||
real_push = b.push
|
||||
|
||||
async def flaky_push(identity: ChannelIdentity, payload: HostedRunResult[Any]) -> None:
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
raise RuntimeError("echo down")
|
||||
await real_push(identity, payload)
|
||||
|
||||
b.push = flaky_push # type: ignore[method-assign]
|
||||
|
||||
req = ChannelRequest(
|
||||
channel="responses",
|
||||
operation="op",
|
||||
input="hi",
|
||||
session=ChannelSession(isolation_key="alice"),
|
||||
response_target=ResponseTarget.channel("telegram", echo_input=True),
|
||||
)
|
||||
include_originating = await a.context.deliver_response(req, _make_reply("reply"))
|
||||
# Schedule succeeded; handler swallowed the echo failure and
|
||||
# the response push landed on the channel.
|
||||
assert include_originating is False
|
||||
assert b.pushes and b.pushes[0][1].result.text == "reply"
|
||||
# Handler did not raise (echo failure was swallowed inside
|
||||
# the handler), so the runner saw no error.
|
||||
assert runner.handler_errors == []
|
||||
|
||||
async def test_echo_idempotent_on_retry(self) -> None:
|
||||
"""When the response push fails on a retried task, the handler
|
||||
must NOT re-deliver the echo if a prior attempt already
|
||||
succeeded. The ``echo_done`` cursor on the payload mapping is
|
||||
the host's idempotency primitive; this test invokes the
|
||||
handler directly twice with the same payload to exercise the
|
||||
retry semantics."""
|
||||
host, _a, b, ctx, runner = _make_host_with_two_channels()
|
||||
host._identities.setdefault("alice", {})["telegram"] = ChannelIdentity(channel="telegram", native_id="42")
|
||||
req = ChannelRequest(
|
||||
channel="responses",
|
||||
operation="op",
|
||||
input="hi",
|
||||
session=ChannelSession(isolation_key="alice"),
|
||||
response_target=ResponseTarget.channel("telegram", echo_input=True),
|
||||
)
|
||||
# First scheduled invocation — echo + response both succeed.
|
||||
await ctx.deliver_response(req, _make_reply("reply"))
|
||||
assert len(b.pushes) == 2 # echo + response
|
||||
# Simulate a retry: invoke the handler again with the same
|
||||
# payload mapping (the in-process runner reuses the mapping
|
||||
# across retries). After the first run ``echo_done`` was
|
||||
# mutated to ``True``; the second run must skip the echo.
|
||||
_, payload = runner.scheduled[0]
|
||||
assert payload["echo_done"] is True
|
||||
await host._handle_push_task(payload)
|
||||
# Only one more push (the response) — the echo was skipped.
|
||||
assert len(b.pushes) == 3
|
||||
assert str(b.pushes[2][1].result.messages[0].role) == "assistant"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Response hook + multi-modal payload + clone-on-fan-out #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestResponseHookFanOut:
|
||||
async def test_response_hook_applied_per_destination(self) -> None:
|
||||
"""Channels with a ``response_hook`` attribute see their hook
|
||||
applied before push, with a ``ChannelResponseContext`` carrying
|
||||
the destination identity, the originating request, and an
|
||||
``is_echo`` flag."""
|
||||
agent = _FakeAgent()
|
||||
a = _RecordingChannel(name="responses", path="/r")
|
||||
b = _RecordingChannel(name="telegram", path="/t")
|
||||
|
||||
seen: list[tuple[str, str, bool]] = []
|
||||
|
||||
async def telegram_hook(
|
||||
result: HostedRunResult[AgentResponse],
|
||||
*,
|
||||
context: Any,
|
||||
**_: Any,
|
||||
) -> HostedRunResult[AgentResponse]:
|
||||
seen.append((context.channel_name, context.destination_identity.native_id, context.is_echo))
|
||||
return result.replace(
|
||||
result=AgentResponse(
|
||||
messages=[Message(role="assistant", contents=[Content.from_text("[hooked] " + result.result.text)])]
|
||||
),
|
||||
)
|
||||
|
||||
b.response_hook = telegram_hook # type: ignore[attr-defined]
|
||||
host = AgentFrameworkHost(target=agent, channels=[a, b], durable_task_runner=_SyncTaskRunner())
|
||||
_ = host.app
|
||||
assert a.context is not None
|
||||
|
||||
host._identities.setdefault("alice", {})["telegram"] = ChannelIdentity(channel="telegram", native_id="42")
|
||||
req = ChannelRequest(
|
||||
channel="responses",
|
||||
operation="op",
|
||||
input="hi",
|
||||
session=ChannelSession(isolation_key="alice"),
|
||||
response_target=ResponseTarget.channel("telegram"),
|
||||
)
|
||||
report = await a.context.deliver_response(req, _make_reply("reply"))
|
||||
assert report is False
|
||||
# The pushed payload reflects the hook's transform.
|
||||
assert b.pushes[0][1].result.text == "[hooked] reply"
|
||||
assert seen == [("telegram", "42", False)]
|
||||
|
||||
async def test_response_hook_mutation_isolated_per_destination(self) -> None:
|
||||
"""A hook that rebinds ``result`` on its payload must NOT affect
|
||||
the payload another destination sees. The host clones the
|
||||
envelope before each hook invocation so a per-destination
|
||||
:meth:`HostedRunResult.replace` cannot leak across destinations."""
|
||||
agent = _FakeAgent()
|
||||
a = _RecordingChannel(name="responses", path="/r")
|
||||
b = _RecordingChannel(name="telegram", path="/t")
|
||||
c = _RecordingChannel(name="extra", path="/x")
|
||||
|
||||
async def hook_that_rebinds(result: HostedRunResult[AgentResponse], **_: Any) -> HostedRunResult[AgentResponse]:
|
||||
# Naughty hook: rebind ``result`` to a fresh AgentResponse.
|
||||
# Host's per-destination clone via ``replace()`` makes this safe
|
||||
# for sibling destinations.
|
||||
return result.replace(result=AgentResponse(messages=[]))
|
||||
|
||||
b.response_hook = hook_that_rebinds # type: ignore[attr-defined]
|
||||
host = AgentFrameworkHost(target=agent, channels=[a, b, c], durable_task_runner=_SyncTaskRunner())
|
||||
_ = host.app
|
||||
assert a.context is not None
|
||||
|
||||
host._identities.setdefault("alice", {})["telegram"] = ChannelIdentity(channel="telegram", native_id="42")
|
||||
host._identities["alice"]["extra"] = ChannelIdentity(channel="extra", native_id="9")
|
||||
|
||||
original = _make_reply("reply")
|
||||
original_result_snapshot = original.result
|
||||
|
||||
req = ChannelRequest(
|
||||
channel="responses",
|
||||
operation="op",
|
||||
input="hi",
|
||||
session=ChannelSession(isolation_key="alice"),
|
||||
response_target=ResponseTarget.channels(["telegram", "extra"]),
|
||||
)
|
||||
report = await a.context.deliver_response(req, original)
|
||||
assert report is False
|
||||
# The rebind on the telegram clone must not have touched the
|
||||
# original envelope, nor the extra channel's view.
|
||||
assert original.result is original_result_snapshot
|
||||
# ``extra`` channel saw the original-shaped payload.
|
||||
extra_push = next(p for p in c.pushes)
|
||||
assert extra_push[1].result.text == "reply"
|
||||
|
||||
async def test_response_hook_fires_on_echo_with_is_echo_true(self) -> None:
|
||||
"""When ``echo_input`` is set, the channel's response_hook fires
|
||||
TWICE per destination — once for the echo (is_echo=True), once
|
||||
for the response (is_echo=False)."""
|
||||
agent = _FakeAgent()
|
||||
a = _RecordingChannel(name="responses", path="/r")
|
||||
b = _RecordingChannel(name="telegram", path="/t")
|
||||
|
||||
phases: list[bool] = []
|
||||
|
||||
async def telegram_hook(
|
||||
result: HostedRunResult[AgentResponse], *, context: Any, **_: Any
|
||||
) -> HostedRunResult[AgentResponse]:
|
||||
phases.append(context.is_echo)
|
||||
return result
|
||||
|
||||
b.response_hook = telegram_hook # type: ignore[attr-defined]
|
||||
host = AgentFrameworkHost(target=agent, channels=[a, b], durable_task_runner=_SyncTaskRunner())
|
||||
_ = host.app
|
||||
assert a.context is not None
|
||||
|
||||
host._identities.setdefault("alice", {})["telegram"] = ChannelIdentity(channel="telegram", native_id="42")
|
||||
req = ChannelRequest(
|
||||
channel="responses",
|
||||
operation="op",
|
||||
input="hi",
|
||||
session=ChannelSession(isolation_key="alice"),
|
||||
response_target=ResponseTarget.channel("telegram", echo_input=True),
|
||||
)
|
||||
await a.context.deliver_response(req, _make_reply("reply"))
|
||||
assert phases == [True, False]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# HostedRunResult — generic typed envelope #
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -1522,7 +970,7 @@ class TestBindRequestContext:
|
||||
stream=True,
|
||||
attributes={"response_id": "resp_stream"},
|
||||
)
|
||||
stream = ch.context.run_stream(req)
|
||||
stream = await ch.context.run_stream(req)
|
||||
|
||||
# As soon as run_stream returns, the binding must already be open
|
||||
# so any provider work that happens during iteration sees it.
|
||||
@@ -1574,7 +1022,7 @@ class TestBoundResponseStream:
|
||||
stream=True,
|
||||
attributes={"response_id": "resp_get_final"},
|
||||
)
|
||||
stream = ch.context.run_stream(req)
|
||||
stream = await ch.context.run_stream(req)
|
||||
# Skip iteration and go straight to ``get_final_response``;
|
||||
# the adapter must drain the inner stream itself and close
|
||||
# the binding in ``finally``.
|
||||
@@ -1599,7 +1047,7 @@ class TestBoundResponseStream:
|
||||
stream=True,
|
||||
attributes={"response_id": "resp_idem"},
|
||||
)
|
||||
stream = ch.context.run_stream(req)
|
||||
stream = await ch.context.run_stream(req)
|
||||
async for _u in stream:
|
||||
pass
|
||||
# Iteration's finally already closed; an explicit ``aclose``
|
||||
@@ -1627,7 +1075,7 @@ class TestBoundResponseStream:
|
||||
stream=True,
|
||||
attributes={"response_id": "resp_abandon"},
|
||||
)
|
||||
stream = ch.context.run_stream(req)
|
||||
stream = await ch.context.run_stream(req)
|
||||
await stream.aclose() # type: ignore[attr-defined]
|
||||
|
||||
# Binding released without iterating.
|
||||
@@ -1655,7 +1103,7 @@ class TestBoundResponseStream:
|
||||
stream=True,
|
||||
attributes={"response_id": "resp_getattr"},
|
||||
)
|
||||
stream = ch.context.run_stream(req)
|
||||
stream = await ch.context.run_stream(req)
|
||||
# ``with_result_hook`` is a real method on ``ResponseStream``;
|
||||
# if forwarding broke this would AttributeError.
|
||||
try:
|
||||
@@ -1682,7 +1130,7 @@ class TestBoundResponseStream:
|
||||
stream=True,
|
||||
attributes={"response_id": "resp_await"},
|
||||
)
|
||||
stream = ch.context.run_stream(req)
|
||||
stream = await ch.context.run_stream(req)
|
||||
final = await stream # exercises __await__
|
||||
assert final.text == "chunk-1chunk-2"
|
||||
names = [n for n, _ in prov.events]
|
||||
|
||||
@@ -1,32 +1,19 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for ``state_dir`` wired through :class:`AgentFrameworkHost`."""
|
||||
"""Tests for narrowed ``state_dir`` support in :class:`AgentFrameworkHost`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework_hosting import (
|
||||
AgentFrameworkHost,
|
||||
ChannelContext,
|
||||
ChannelContribution,
|
||||
ChannelIdentity,
|
||||
LinkChallenge,
|
||||
)
|
||||
from agent_framework_hosting import AgentFrameworkHost, ChannelContext, ChannelContribution
|
||||
|
||||
# Skip the whole module when the optional disk extra isn't installed.
|
||||
pytest.importorskip("diskcache")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Test helpers #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class _AgentStub:
|
||||
"""Bare-minimum SupportsAgentRun stub for host construction."""
|
||||
|
||||
@@ -42,65 +29,22 @@ class _ChannelStub:
|
||||
return ChannelContribution()
|
||||
|
||||
|
||||
class _NonConfigurableLinker:
|
||||
async def resolve(self, _identity: ChannelIdentity) -> LinkChallenge:
|
||||
return LinkChallenge("link")
|
||||
|
||||
|
||||
class _ConfigurableLinker:
|
||||
def __init__(self) -> None:
|
||||
self.configured_path: Path | None = None
|
||||
|
||||
def configure_link_store_path(self, path: str | Path) -> None:
|
||||
self.configured_path = Path(path)
|
||||
|
||||
async def resolve(self, _identity: ChannelIdentity) -> LinkChallenge:
|
||||
return LinkChallenge("link")
|
||||
|
||||
|
||||
def _close_host_disk(host: AgentFrameworkHost) -> None:
|
||||
"""Mirror the lifespan shutdown ordering for tests that simulate restart.
|
||||
|
||||
The real shutdown order is ``runner.shutdown()`` → ``sessions_store.close()``;
|
||||
both release their advisory file locks so a second host can take ownership.
|
||||
"""
|
||||
runner = host._durable_task_runner
|
||||
try:
|
||||
asyncio.get_event_loop().run_until_complete(runner.shutdown(timeout=1.0))
|
||||
except RuntimeError:
|
||||
# No running loop; spin up a throw-away one.
|
||||
asyncio.run(runner.shutdown(timeout=1.0))
|
||||
"""Release any session-alias store held by ``host``."""
|
||||
if host._sessions_store is not None:
|
||||
host._sessions_store.close()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# state_dir=None preserves the in-memory contract #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_state_dir_none_keeps_plain_dicts(tmp_path: Path) -> None:
|
||||
"""No store, no sessions persistence, no files written."""
|
||||
def test_state_dir_none_keeps_plain_alias_dict(tmp_path: Path) -> None:
|
||||
"""No store, no alias persistence, no files written."""
|
||||
host = AgentFrameworkHost(target=_AgentStub(), channels=[_ChannelStub()])
|
||||
try:
|
||||
assert host._sessions_store is None
|
||||
assert isinstance(host._session_aliases, dict)
|
||||
assert isinstance(host._active, dict)
|
||||
assert isinstance(host._identities, dict)
|
||||
# No accidental disk writes anywhere under tmp_path.
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
finally:
|
||||
# Nothing to close.
|
||||
pass
|
||||
assert host._sessions_store is None
|
||||
assert isinstance(host._session_aliases, dict)
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Single string state_dir creates default subfolders #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_string_state_dir_creates_subfolders(tmp_path: Path) -> None:
|
||||
"""Passing a single path expands to ``runner/`` and ``sessions/``."""
|
||||
def test_string_state_dir_creates_sessions_subfolder_only(tmp_path: Path) -> None:
|
||||
"""Passing a single path expands to ``sessions/`` plus lazy checkpoint path."""
|
||||
host = AgentFrameworkHost(
|
||||
target=_AgentStub(),
|
||||
channels=[_ChannelStub()],
|
||||
@@ -108,100 +52,42 @@ def test_string_state_dir_creates_subfolders(tmp_path: Path) -> None:
|
||||
)
|
||||
try:
|
||||
assert host._sessions_store is not None
|
||||
assert (tmp_path / "runner").is_dir()
|
||||
assert (tmp_path / "sessions").is_dir()
|
||||
assert not (tmp_path / "runner").exists()
|
||||
assert not (tmp_path / "links").exists()
|
||||
# Checkpoint path is derived but not created for agent targets.
|
||||
assert not (tmp_path / "checkpoints").exists()
|
||||
finally:
|
||||
_close_host_disk(host)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Per-component override via HostStatePaths-shaped dict #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_per_component_paths(tmp_path: Path) -> None:
|
||||
"""Dict form lets the caller route components to different roots."""
|
||||
runner_dir = tmp_path / "tasks"
|
||||
def test_per_component_session_path(tmp_path: Path) -> None:
|
||||
"""Dict form lets callers route session aliases to a specific root."""
|
||||
sessions_dir = tmp_path / "state"
|
||||
host = AgentFrameworkHost(
|
||||
target=_AgentStub(),
|
||||
channels=[_ChannelStub()],
|
||||
state_dir={"runner": runner_dir, "sessions": sessions_dir},
|
||||
state_dir={"sessions": sessions_dir},
|
||||
)
|
||||
try:
|
||||
assert runner_dir.is_dir()
|
||||
assert sessions_dir.is_dir()
|
||||
# Default subfolders should NOT exist when the caller provides
|
||||
# explicit overrides.
|
||||
assert not (tmp_path / "runner").is_dir() or runner_dir == (tmp_path / "runner")
|
||||
assert not (tmp_path / "sessions").is_dir() or sessions_dir == (tmp_path / "sessions")
|
||||
assert host._sessions_store is not None
|
||||
assert host._checkpoint_location is None
|
||||
finally:
|
||||
_close_host_disk(host)
|
||||
|
||||
|
||||
def test_unknown_component_key_raises(tmp_path: Path) -> None:
|
||||
"""Misspelled keys should fail loudly so the user catches typos."""
|
||||
@pytest.mark.parametrize("key", ["runner", "links", "active", "identities"])
|
||||
def test_removed_state_dir_component_keys_raise(tmp_path: Path, key: str) -> None:
|
||||
"""Obsolete follow-up components should fail loudly instead of becoming no-ops."""
|
||||
with pytest.raises(ValueError, match="unknown"):
|
||||
AgentFrameworkHost(
|
||||
target=_AgentStub(),
|
||||
channels=[_ChannelStub()],
|
||||
state_dir={"runnerr": tmp_path / "x"}, # type: ignore[dict-item]
|
||||
state_dir={key: tmp_path / key}, # type: ignore[dict-item]
|
||||
)
|
||||
|
||||
|
||||
def test_links_state_path_configures_compatible_identity_linker(tmp_path: Path) -> None:
|
||||
"""``state_dir['links']`` is offered to linkers that accept host-owned persistence."""
|
||||
linker = _ConfigurableLinker()
|
||||
host = AgentFrameworkHost(
|
||||
target=_AgentStub(),
|
||||
channels=[_ChannelStub()],
|
||||
identity_linker=linker,
|
||||
state_dir=tmp_path,
|
||||
)
|
||||
try:
|
||||
assert linker.configured_path == tmp_path / "links"
|
||||
finally:
|
||||
_close_host_disk(host)
|
||||
|
||||
|
||||
def test_explicit_links_state_path_without_linker_warns(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Explicit ``links`` path with no linker is almost certainly dead config."""
|
||||
with caplog.at_level("WARNING", logger="agent_framework.hosting"):
|
||||
host = AgentFrameworkHost(
|
||||
target=_AgentStub(),
|
||||
channels=[_ChannelStub()],
|
||||
state_dir={"links": tmp_path / "links"},
|
||||
)
|
||||
try:
|
||||
assert any(
|
||||
"state_dir['links']" in rec.message and "no identity_linker" in rec.message for rec in caplog.records
|
||||
)
|
||||
finally:
|
||||
_close_host_disk(host)
|
||||
|
||||
|
||||
def test_links_state_path_with_nonconfigurable_linker_warns(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""A linker that owns its persistence directly gets a clear warning."""
|
||||
with caplog.at_level("WARNING", logger="agent_framework.hosting"):
|
||||
host = AgentFrameworkHost(
|
||||
target=_AgentStub(),
|
||||
channels=[_ChannelStub()],
|
||||
identity_linker=_NonConfigurableLinker(),
|
||||
state_dir={"links": tmp_path / "links"},
|
||||
)
|
||||
try:
|
||||
assert any(
|
||||
"state_dir['links']" in rec.message and "SupportsLinkStorePath" in rec.message for rec in caplog.records
|
||||
)
|
||||
finally:
|
||||
_close_host_disk(host)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Session bookkeeping survives a host restart #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_session_aliases_survive_restart(tmp_path: Path) -> None:
|
||||
"""Aliases written on host #1 must be visible to host #2."""
|
||||
state_dir = tmp_path / "state"
|
||||
@@ -219,84 +105,6 @@ def test_session_aliases_survive_restart(tmp_path: Path) -> None:
|
||||
_close_host_disk(host2)
|
||||
|
||||
|
||||
def test_active_channel_survives_restart(tmp_path: Path) -> None:
|
||||
"""``_active`` must round-trip through the store."""
|
||||
state_dir = tmp_path / "state"
|
||||
|
||||
host1 = AgentFrameworkHost(target=_AgentStub(), channels=[_ChannelStub()], state_dir=state_dir)
|
||||
host1._active["user-1"] = "telegram"
|
||||
host1._active["user-2"] = "responses"
|
||||
_close_host_disk(host1)
|
||||
|
||||
host2 = AgentFrameworkHost(target=_AgentStub(), channels=[_ChannelStub()], state_dir=state_dir)
|
||||
try:
|
||||
assert host2._active["user-1"] == "telegram"
|
||||
assert host2._active["user-2"] == "responses"
|
||||
finally:
|
||||
_close_host_disk(host2)
|
||||
|
||||
|
||||
def test_identities_nested_mutation_survives_restart(tmp_path: Path) -> None:
|
||||
"""Setting ``self._identities[ik][channel] = identity`` must persist.
|
||||
|
||||
This exercises the proxy-inner-dict ``__setitem__`` write-through path,
|
||||
not just the outer-key replacement path.
|
||||
"""
|
||||
state_dir = tmp_path / "state"
|
||||
|
||||
host1 = AgentFrameworkHost(target=_AgentStub(), channels=[_ChannelStub()], state_dir=state_dir)
|
||||
ident_tg = ChannelIdentity("telegram", "tg-123", {"username": "alice"})
|
||||
ident_rsp = ChannelIdentity("responses", "rsp-456")
|
||||
# Mirrors the host-internal path in ``_register_identity``.
|
||||
host1._identities.setdefault("user-1", {})["telegram"] = ident_tg
|
||||
host1._identities.setdefault("user-1", {})["responses"] = ident_rsp
|
||||
host1._identities.setdefault("user-2", {})["telegram"] = ChannelIdentity("telegram", "tg-789")
|
||||
_close_host_disk(host1)
|
||||
|
||||
host2 = AgentFrameworkHost(target=_AgentStub(), channels=[_ChannelStub()], state_dir=state_dir)
|
||||
try:
|
||||
u1 = host2._identities["user-1"]
|
||||
assert set(u1.keys()) == {"telegram", "responses"}
|
||||
assert u1["telegram"].native_id == "tg-123"
|
||||
assert u1["telegram"].attributes["username"] == "alice"
|
||||
assert u1["responses"].native_id == "rsp-456"
|
||||
assert host2._identities["user-2"]["telegram"].native_id == "tg-789"
|
||||
finally:
|
||||
_close_host_disk(host2)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Explicit durable_task_runner + state_dir['runner'] warns #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_explicit_runner_with_runner_state_warns(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Caller-owned runner + state_dir['runner'] → ignore + warn."""
|
||||
from agent_framework_hosting import InProcessTaskRunner
|
||||
|
||||
user_runner = InProcessTaskRunner()
|
||||
try:
|
||||
with caplog.at_level("WARNING"):
|
||||
host = AgentFrameworkHost(
|
||||
target=_AgentStub(),
|
||||
channels=[_ChannelStub()],
|
||||
durable_task_runner=user_runner,
|
||||
allow_in_process_runner=True,
|
||||
state_dir={"runner": tmp_path / "runner"},
|
||||
)
|
||||
assert any("state_dir['runner']" in rec.message for rec in caplog.records)
|
||||
# Sessions store wasn't requested, so still None.
|
||||
assert host._sessions_store is None
|
||||
finally:
|
||||
# user_runner has no disk state, so nothing else to clean up.
|
||||
pass
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Workflow checkpoint integration #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _build_simple_workflow() -> Any:
|
||||
"""Build a no-op workflow for checkpoint-wiring tests."""
|
||||
from tests._workflow_fixtures import build_upper_workflow
|
||||
@@ -313,7 +121,6 @@ def test_single_path_state_dir_wires_workflow_checkpoints(tmp_path: Path) -> Non
|
||||
state_dir=tmp_path,
|
||||
)
|
||||
try:
|
||||
# Checkpoint location is derived from the single state_dir.
|
||||
assert host._checkpoint_location == tmp_path / "checkpoints"
|
||||
finally:
|
||||
_close_host_disk(host)
|
||||
@@ -330,7 +137,6 @@ def test_mapping_state_dir_checkpoints_key_wires_workflow_checkpoints(tmp_path:
|
||||
)
|
||||
try:
|
||||
assert host._checkpoint_location == ckpt_dir
|
||||
# No diskcache components were requested.
|
||||
assert host._sessions_store is None
|
||||
finally:
|
||||
_close_host_disk(host)
|
||||
@@ -342,9 +148,7 @@ def test_mapping_state_dir_omits_checkpoints_for_workflow(tmp_path: Path) -> Non
|
||||
host = AgentFrameworkHost(
|
||||
target=workflow,
|
||||
channels=[_ChannelStub()],
|
||||
# No 'checkpoints' key → no checkpoint persistence even though
|
||||
# other components are persisted.
|
||||
state_dir={"runner": tmp_path / "r", "sessions": tmp_path / "s"},
|
||||
state_dir={"sessions": tmp_path / "s"},
|
||||
)
|
||||
try:
|
||||
assert host._checkpoint_location is None
|
||||
@@ -381,7 +185,6 @@ def test_state_dir_checkpoints_for_agent_target_silent_for_single_path(tmp_path:
|
||||
)
|
||||
try:
|
||||
assert host._checkpoint_location is None
|
||||
# ``checkpoints/`` subfolder is not eagerly created (no consumer).
|
||||
assert not (tmp_path / "checkpoints").exists()
|
||||
finally:
|
||||
_close_host_disk(host)
|
||||
@@ -390,7 +193,7 @@ def test_state_dir_checkpoints_for_agent_target_silent_for_single_path(tmp_path:
|
||||
def test_state_dir_checkpoints_for_agent_target_warns_when_explicit(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Mapping form with ``checkpoints`` + agent target → warn (dead config)."""
|
||||
"""Mapping form with ``checkpoints`` + agent target → warn."""
|
||||
with caplog.at_level("WARNING", logger="agent_framework.hosting"):
|
||||
host = AgentFrameworkHost(
|
||||
target=_AgentStub(),
|
||||
|
||||
@@ -16,6 +16,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.routing import BaseRoute, Route
|
||||
@@ -168,7 +169,22 @@ def _make_host_with_probe() -> tuple[object, _IsolationProbeChannel]:
|
||||
|
||||
|
||||
class TestIsolationMiddlewareEndToEnd:
|
||||
def test_both_headers_lifted_into_contextvar(self) -> None:
|
||||
def test_headers_ignored_outside_foundry_environment(self) -> None:
|
||||
host, probe = _make_host_with_probe()
|
||||
with TestClient(host.app) as client: # type: ignore[attr-defined]
|
||||
r = client.get(
|
||||
"/probe",
|
||||
headers={
|
||||
ISOLATION_HEADER_USER: "alice-uid",
|
||||
ISOLATION_HEADER_CHAT: "general-cid",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"user": None, "chat": None, "_present": False}
|
||||
assert probe.captured == [None]
|
||||
|
||||
def test_both_headers_lifted_into_contextvar(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1")
|
||||
host, probe = _make_host_with_probe()
|
||||
with TestClient(host.app) as client: # type: ignore[attr-defined]
|
||||
r = client.get(
|
||||
@@ -186,15 +202,17 @@ class TestIsolationMiddlewareEndToEnd:
|
||||
assert captured.user_key == "alice-uid"
|
||||
assert captured.chat_key == "general-cid"
|
||||
|
||||
def test_only_user_header_lifted(self) -> None:
|
||||
def test_only_user_header_lifted(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""One-header-only branch: the middleware still binds (chat=None)."""
|
||||
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1")
|
||||
host, probe = _make_host_with_probe()
|
||||
with TestClient(host.app) as client: # type: ignore[attr-defined]
|
||||
r = client.get("/probe", headers={ISOLATION_HEADER_USER: "alice-uid"})
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"user": "alice-uid", "chat": None}
|
||||
|
||||
def test_only_chat_header_lifted(self) -> None:
|
||||
def test_only_chat_header_lifted(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1")
|
||||
host, probe = _make_host_with_probe()
|
||||
with TestClient(host.app) as client: # type: ignore[attr-defined]
|
||||
r = client.get("/probe", headers={ISOLATION_HEADER_CHAT: "general-cid"})
|
||||
@@ -213,9 +231,10 @@ class TestIsolationMiddlewareEndToEnd:
|
||||
assert r.json() == {"user": None, "chat": None, "_present": False}
|
||||
assert probe.captured == [None]
|
||||
|
||||
def test_empty_header_value_treated_as_absent(self) -> None:
|
||||
def test_empty_header_value_treated_as_absent(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A header that's present but empty must not bind an empty key —
|
||||
``IsolationContext`` rejects empty strings on the read side."""
|
||||
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1")
|
||||
host, probe = _make_host_with_probe()
|
||||
with TestClient(host.app) as client: # type: ignore[attr-defined]
|
||||
r = client.get(
|
||||
@@ -229,10 +248,11 @@ class TestIsolationMiddlewareEndToEnd:
|
||||
# Empty user header decodes to None; chat key stays bound.
|
||||
assert r.json() == {"user": None, "chat": "general-cid"}
|
||||
|
||||
def test_contextvar_resets_after_request(self) -> None:
|
||||
def test_contextvar_resets_after_request(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The middleware must call ``reset_current_isolation_keys`` in
|
||||
a ``finally`` so per-request state never leaks across requests
|
||||
or back into the calling thread's context."""
|
||||
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1")
|
||||
host, probe = _make_host_with_probe()
|
||||
with TestClient(host.app) as client: # type: ignore[attr-defined]
|
||||
r1 = client.get("/probe", headers={ISOLATION_HEADER_USER: "alice-uid"})
|
||||
@@ -245,9 +265,10 @@ class TestIsolationMiddlewareEndToEnd:
|
||||
r2 = client.get("/probe")
|
||||
assert r2.json() == {"user": None, "chat": None, "_present": False}
|
||||
|
||||
def test_concurrent_requests_get_isolated_contextvars(self) -> None:
|
||||
def test_concurrent_requests_get_isolated_contextvars(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Different requests run in different async contexts; binding
|
||||
from request A must NOT leak into a concurrent request B."""
|
||||
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1")
|
||||
host, probe = _make_host_with_probe()
|
||||
|
||||
async def _drive() -> None:
|
||||
|
||||
@@ -1,333 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for :class:`InProcessTaskRunner` and runtime-mode auto-detection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework_hosting import (
|
||||
AgentFrameworkHost,
|
||||
ChannelContext,
|
||||
ChannelContribution,
|
||||
DurableTaskPayloadMode,
|
||||
InProcessTaskRunner,
|
||||
RetryPolicy,
|
||||
TaskHandle,
|
||||
)
|
||||
from agent_framework_hosting._host import _detect_runtime_mode
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Test helpers #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class _AgentStub:
|
||||
"""Bare-minimum SupportsAgentRun stub for host construction."""
|
||||
|
||||
async def run(self, *_args: Any, **_kwargs: Any) -> None: # pragma: no cover - unused
|
||||
return None
|
||||
|
||||
|
||||
class _ChannelStub:
|
||||
name = "stub"
|
||||
path = "/stub"
|
||||
|
||||
def contribute(self, _context: ChannelContext) -> ChannelContribution:
|
||||
return ChannelContribution()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Runtime-mode auto-detection #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestRuntimeModeDetection:
|
||||
"""``_detect_runtime_mode`` is pure: tests pass a synthetic env so
|
||||
they never depend on the test runner's environment. Auto-detected
|
||||
mode + matched marker drive the per-host startup banner so operators
|
||||
can confirm the host is running in the expected shape."""
|
||||
|
||||
def test_no_markers_defaults_to_long_running(self) -> None:
|
||||
mode, marker = _detect_runtime_mode(env={})
|
||||
assert mode == "long_running"
|
||||
assert marker is None
|
||||
|
||||
def test_foundry_marker_selects_ephemeral(self) -> None:
|
||||
mode, marker = _detect_runtime_mode(env={"FOUNDRY_HOSTING_ENVIRONMENT": "production"})
|
||||
assert mode == "ephemeral"
|
||||
assert marker == "FOUNDRY_HOSTING_ENVIRONMENT"
|
||||
|
||||
def test_azure_functions_marker_selects_ephemeral(self) -> None:
|
||||
mode, marker = _detect_runtime_mode(env={"AZURE_FUNCTIONS_ENVIRONMENT": "Development"})
|
||||
assert mode == "ephemeral"
|
||||
assert marker == "AZURE_FUNCTIONS_ENVIRONMENT"
|
||||
|
||||
def test_lambda_marker_selects_ephemeral(self) -> None:
|
||||
mode, marker = _detect_runtime_mode(env={"AWS_LAMBDA_FUNCTION_NAME": "my-fn"})
|
||||
assert mode == "ephemeral"
|
||||
assert marker == "AWS_LAMBDA_FUNCTION_NAME"
|
||||
|
||||
def test_empty_marker_value_ignored(self) -> None:
|
||||
# Empty-string env var should not count as "set" — Foundry's
|
||||
# template uses unset-or-empty as "not deployed".
|
||||
mode, marker = _detect_runtime_mode(env={"FOUNDRY_HOSTING_ENVIRONMENT": ""})
|
||||
assert mode == "long_running"
|
||||
assert marker is None
|
||||
|
||||
|
||||
class TestHostRuntimeMode:
|
||||
"""``runtime_mode`` ctor argument overrides auto-detect; ``None``
|
||||
triggers auto-detect. The detected mode is exposed via the
|
||||
``runtime_mode`` property for operator inspection (and is logged at
|
||||
startup via ``_log_startup``)."""
|
||||
|
||||
def test_explicit_long_running(self) -> None:
|
||||
host = AgentFrameworkHost(
|
||||
target=_AgentStub(),
|
||||
channels=[_ChannelStub()],
|
||||
runtime_mode="long_running",
|
||||
)
|
||||
assert host.runtime_mode == "long_running"
|
||||
|
||||
def test_explicit_ephemeral_with_default_runner_raises(self) -> None:
|
||||
# Default runner is in-process and not durable. Ephemeral
|
||||
# deployments would silently lose pushes on scale-to-zero, so
|
||||
# the host refuses the combination at construction unless the
|
||||
# operator opts in explicitly via ``allow_in_process_runner``.
|
||||
with pytest.raises(RuntimeError, match="ephemeral"):
|
||||
AgentFrameworkHost(
|
||||
target=_AgentStub(),
|
||||
channels=[_ChannelStub()],
|
||||
runtime_mode="ephemeral",
|
||||
)
|
||||
|
||||
def test_explicit_ephemeral_with_in_process_opt_in_warns(self, caplog: pytest.LogCaptureFixture) -> None:
|
||||
# The opt-in escape hatch keeps the old warn-and-proceed
|
||||
# behaviour for local-dev / smoke-test scenarios that genuinely
|
||||
# want ephemeral runtime semantics without a real durable
|
||||
# backend.
|
||||
with caplog.at_level("WARNING", logger="agent_framework.hosting"):
|
||||
host = AgentFrameworkHost(
|
||||
target=_AgentStub(),
|
||||
channels=[_ChannelStub()],
|
||||
runtime_mode="ephemeral",
|
||||
allow_in_process_runner=True,
|
||||
)
|
||||
assert host.runtime_mode == "ephemeral"
|
||||
assert any("ephemeral" in r.getMessage() and "InProcessTaskRunner" in r.getMessage() for r in caplog.records)
|
||||
|
||||
def test_explicit_ephemeral_with_supplied_runner_does_not_warn(self, caplog: pytest.LogCaptureFixture) -> None:
|
||||
runner = InProcessTaskRunner()
|
||||
with caplog.at_level("WARNING", logger="agent_framework.hosting"):
|
||||
host = AgentFrameworkHost(
|
||||
target=_AgentStub(),
|
||||
channels=[_ChannelStub()],
|
||||
runtime_mode="ephemeral",
|
||||
durable_task_runner=runner,
|
||||
)
|
||||
# No warning — operator opted into a specific runner.
|
||||
assert host.runtime_mode == "ephemeral"
|
||||
assert host.durable_task_runner is runner
|
||||
assert not any("ephemeral" in r.getMessage() for r in caplog.records)
|
||||
|
||||
def test_auto_detect_ephemeral_raises_without_opt_in(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Auto-detected ephemeral flows through the same strict gate.
|
||||
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "production")
|
||||
with pytest.raises(RuntimeError, match="ephemeral"):
|
||||
AgentFrameworkHost(target=_AgentStub(), channels=[_ChannelStub()])
|
||||
|
||||
def test_auto_detect_ephemeral_with_opt_in_proceeds(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "production")
|
||||
host = AgentFrameworkHost(
|
||||
target=_AgentStub(),
|
||||
channels=[_ChannelStub()],
|
||||
allow_in_process_runner=True,
|
||||
)
|
||||
assert host.runtime_mode == "ephemeral"
|
||||
|
||||
def test_default_runner_is_in_process_task_runner(self) -> None:
|
||||
host = AgentFrameworkHost(target=_AgentStub(), channels=[_ChannelStub()])
|
||||
assert isinstance(host.durable_task_runner, InProcessTaskRunner)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# InProcessTaskRunner #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestInProcessTaskRunner:
|
||||
async def test_schedule_runs_handler_and_records_succeeded(self) -> None:
|
||||
runner = InProcessTaskRunner()
|
||||
seen: list[Mapping[str, Any]] = []
|
||||
|
||||
async def handler(payload: Mapping[str, Any]) -> None:
|
||||
seen.append(payload)
|
||||
|
||||
runner.register("ping", handler)
|
||||
handle = await runner.schedule("ping", {"x": 1})
|
||||
# ``schedule`` returns immediately; the task runs on the loop.
|
||||
# Drain explicitly via ``shutdown`` to flush in-flight work,
|
||||
# then assert.
|
||||
await _drain(runner, handle)
|
||||
assert seen == [{"x": 1}]
|
||||
assert await runner.get(handle) == "succeeded"
|
||||
|
||||
async def test_unknown_handler_raises_keyerror(self) -> None:
|
||||
runner = InProcessTaskRunner()
|
||||
with pytest.raises(KeyError):
|
||||
await runner.schedule("missing", {})
|
||||
|
||||
async def test_register_after_start_raises(self) -> None:
|
||||
runner = InProcessTaskRunner()
|
||||
|
||||
async def noop(_p: Mapping[str, Any]) -> None:
|
||||
return None
|
||||
|
||||
runner.register("x", noop)
|
||||
handle = await runner.schedule("x", {})
|
||||
await _drain(runner, handle)
|
||||
# Re-registering after the runner has started scheduling is
|
||||
# rejected so in-flight tasks can't have their handler swapped
|
||||
# out from under them.
|
||||
with pytest.raises(RuntimeError, match="register"):
|
||||
runner.register("y", noop)
|
||||
|
||||
async def test_handler_retried_then_succeeds(self) -> None:
|
||||
runner = InProcessTaskRunner()
|
||||
attempts = {"n": 0}
|
||||
|
||||
async def flaky(_p: Mapping[str, Any]) -> None:
|
||||
attempts["n"] += 1
|
||||
if attempts["n"] < 3:
|
||||
raise RuntimeError(f"attempt {attempts['n']}")
|
||||
|
||||
runner.register("flaky", flaky)
|
||||
# Tight retry policy so the test doesn't sleep visibly.
|
||||
policy = RetryPolicy(max_attempts=5, initial_backoff_seconds=0.001, max_backoff_seconds=0.005)
|
||||
handle = await runner.schedule("flaky", {}, retry_policy=policy)
|
||||
await _drain(runner, handle)
|
||||
assert attempts["n"] == 3
|
||||
assert await runner.get(handle) == "succeeded"
|
||||
|
||||
async def test_handler_failure_records_failed_after_max_attempts(self) -> None:
|
||||
runner = InProcessTaskRunner()
|
||||
|
||||
async def always_fails(_p: Mapping[str, Any]) -> None:
|
||||
raise RuntimeError("nope")
|
||||
|
||||
runner.register("doomed", always_fails)
|
||||
policy = RetryPolicy(max_attempts=2, initial_backoff_seconds=0.001)
|
||||
handle = await runner.schedule("doomed", {}, retry_policy=policy)
|
||||
await _drain(runner, handle)
|
||||
assert await runner.get(handle) == "failed"
|
||||
|
||||
async def test_shutdown_cancels_pending_tasks(self) -> None:
|
||||
runner = InProcessTaskRunner()
|
||||
started = asyncio.Event()
|
||||
cancelled = asyncio.Event()
|
||||
|
||||
async def long_running(_p: Mapping[str, Any]) -> None:
|
||||
started.set()
|
||||
try:
|
||||
# Sleep longer than the test wait so shutdown can cancel.
|
||||
await asyncio.sleep(5)
|
||||
except asyncio.CancelledError:
|
||||
cancelled.set()
|
||||
raise
|
||||
|
||||
runner.register("long", long_running)
|
||||
handle = await runner.schedule("long", {})
|
||||
await asyncio.wait_for(started.wait(), timeout=1.0)
|
||||
await runner.shutdown(timeout=1.0)
|
||||
assert cancelled.is_set()
|
||||
assert await runner.get(handle) == "cancelled"
|
||||
|
||||
async def test_shutdown_grace_drain_does_not_cancel_finishing_tasks(self) -> None:
|
||||
"""A short-lived task that completes within the grace window
|
||||
must NOT receive a cancellation. The grace-period drain is the
|
||||
graceful-shutdown contract — channels with goodbye-message
|
||||
flushes rely on it."""
|
||||
runner = InProcessTaskRunner()
|
||||
cancelled = asyncio.Event()
|
||||
completed = asyncio.Event()
|
||||
|
||||
async def quick(_p: Mapping[str, Any]) -> None:
|
||||
try:
|
||||
await asyncio.sleep(0.05)
|
||||
except asyncio.CancelledError:
|
||||
cancelled.set()
|
||||
raise
|
||||
completed.set()
|
||||
|
||||
runner.register("quick", quick)
|
||||
handle = await runner.schedule("quick", {})
|
||||
# Shutdown with a generous grace window relative to the task duration.
|
||||
await runner.shutdown(timeout=1.0)
|
||||
assert completed.is_set()
|
||||
assert not cancelled.is_set()
|
||||
assert await runner.get(handle) == "succeeded"
|
||||
|
||||
async def test_get_returns_none_for_unknown_handle(self) -> None:
|
||||
runner = InProcessTaskRunner()
|
||||
handle = TaskHandle(task_id="never-scheduled", name="x")
|
||||
assert await runner.get(handle) is None
|
||||
|
||||
async def test_terminal_cache_evicts_oldest(self) -> None:
|
||||
# Cache size of 2: drain three tasks in sequence, the first
|
||||
# should age out by the time the third's terminal lands.
|
||||
runner = InProcessTaskRunner(terminal_cache_size=2)
|
||||
|
||||
async def noop(_p: Mapping[str, Any]) -> None:
|
||||
return None
|
||||
|
||||
runner.register("noop", noop)
|
||||
h1 = await runner.schedule("noop", {})
|
||||
await _drain(runner, h1)
|
||||
h2 = await runner.schedule("noop", {})
|
||||
await _drain(runner, h2)
|
||||
h3 = await runner.schedule("noop", {})
|
||||
await _drain(runner, h3)
|
||||
# Oldest handle's terminal status should be evicted by now.
|
||||
assert await runner.get(h1) is None
|
||||
assert await runner.get(h2) == "succeeded"
|
||||
assert await runner.get(h3) == "succeeded"
|
||||
|
||||
async def test_shutdown_is_safe_when_no_tasks_pending(self) -> None:
|
||||
runner = InProcessTaskRunner()
|
||||
# No-op shouldn't raise.
|
||||
await runner.shutdown()
|
||||
|
||||
def test_payload_mode_defaults_to_object(self) -> None:
|
||||
# The in-process runner passes live Python references through
|
||||
# the payload — the host wires this attribute into its codec
|
||||
# validator at startup. Durable adapters that persist payloads
|
||||
# must override this to ``JSON`` so the host refuses to ship
|
||||
# un-serialisable references.
|
||||
runner = InProcessTaskRunner()
|
||||
assert runner.payload_mode == DurableTaskPayloadMode.OBJECT
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Helpers #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
async def _drain(runner: InProcessTaskRunner, handle: TaskHandle, *, timeout: float = 1.0) -> None:
|
||||
"""Wait for ``handle`` to reach a terminal state.
|
||||
|
||||
Polls ``get`` rather than reaching into runner internals so we exercise the
|
||||
public surface from the test side too.
|
||||
"""
|
||||
deadline = asyncio.get_event_loop().time() + timeout
|
||||
while True:
|
||||
status = await runner.get(handle)
|
||||
if status in ("succeeded", "failed", "cancelled"):
|
||||
return
|
||||
if asyncio.get_event_loop().time() > deadline:
|
||||
raise AssertionError(f"task {handle.task_id} did not reach terminal in {timeout}s; status={status}")
|
||||
await asyncio.sleep(0.01)
|
||||
@@ -1,278 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for :class:`InProcessTaskRunner` disk persistence (``state_dir``)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework_hosting import (
|
||||
InProcessTaskRunner,
|
||||
PushPayloadNotPicklable,
|
||||
RetryPolicy,
|
||||
)
|
||||
|
||||
# Skip the whole module if the optional diskcache dependency isn't installed.
|
||||
pytest.importorskip("diskcache")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# state_dir=None preserves today's purely in-memory contract #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
async def test_state_dir_none_is_pure_memory(tmp_path: Path) -> None:
|
||||
"""No directory creation / no lock file when state_dir is omitted."""
|
||||
runner = InProcessTaskRunner()
|
||||
calls: list[Mapping[str, Any]] = []
|
||||
|
||||
async def handler(payload: Mapping[str, Any]) -> None:
|
||||
calls.append(payload)
|
||||
|
||||
runner.register("echo", handler)
|
||||
handle = await runner.schedule("echo", {"k": "v"})
|
||||
|
||||
# Wait for completion.
|
||||
for _ in range(50):
|
||||
if (await runner.get(handle)) == "succeeded":
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
assert calls == [{"k": "v"}]
|
||||
assert await runner.get(handle) == "succeeded"
|
||||
# Confirm we didn't accidentally write to disk.
|
||||
assert not (tmp_path / ".lock").exists()
|
||||
|
||||
await runner.shutdown()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Lock contention — two runners on the same dir refuse to coexist #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
async def test_two_runners_one_state_dir_raise(tmp_path: Path) -> None:
|
||||
"""Second runner construction must fail loudly, not silently corrupt."""
|
||||
state_dir = tmp_path / "runner"
|
||||
first = InProcessTaskRunner(state_dir=state_dir)
|
||||
try:
|
||||
with pytest.raises(RuntimeError, match="state lock"):
|
||||
InProcessTaskRunner(state_dir=state_dir)
|
||||
finally:
|
||||
await first.shutdown()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Pickle failure raises eagerly, never silently downgrades #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
async def test_unpickleable_payload_raises(tmp_path: Path) -> None:
|
||||
"""Schedule must refuse payloads that can't survive a restart."""
|
||||
runner = InProcessTaskRunner(state_dir=tmp_path / "runner")
|
||||
|
||||
async def handler(_: Mapping[str, Any]) -> None: ...
|
||||
|
||||
runner.register("echo", handler)
|
||||
# Local lambdas / closures are the canonical unpicklable values.
|
||||
with pytest.raises(PushPayloadNotPicklable):
|
||||
await runner.schedule("echo", {"callback": lambda: None})
|
||||
await runner.shutdown()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Resume — pending records replay on next process #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
async def test_pending_record_replays_on_resume(tmp_path: Path) -> None:
|
||||
"""Simulate a crash: first runner schedules but never starts running."""
|
||||
state_dir = tmp_path / "runner"
|
||||
|
||||
# Process 1 — schedule a task, then "die" before the asyncio loop runs it.
|
||||
runner1 = InProcessTaskRunner(state_dir=state_dir)
|
||||
blocked = asyncio.Event()
|
||||
|
||||
async def slow(_: Mapping[str, Any]) -> None:
|
||||
# Sleep so the task is observably still in flight when we shutdown.
|
||||
await blocked.wait()
|
||||
|
||||
runner1.register("slow", slow)
|
||||
handle = await runner1.schedule("slow", {"work": 1})
|
||||
# Force a hard shutdown — leaves the in-flight task in 'pending' on disk.
|
||||
await runner1.shutdown(timeout=0.1)
|
||||
|
||||
# Process 2 — fresh runner against same state_dir, register the handler,
|
||||
# call resume. We expect the persisted record to be re-scheduled.
|
||||
runner2 = InProcessTaskRunner(state_dir=state_dir)
|
||||
seen: list[Mapping[str, Any]] = []
|
||||
|
||||
async def slow_resumed(payload: Mapping[str, Any]) -> None:
|
||||
seen.append(dict(payload))
|
||||
|
||||
runner2.register("slow", slow_resumed)
|
||||
replayed = await runner2.resume()
|
||||
assert replayed == 1
|
||||
|
||||
# Give the resumed task time to run.
|
||||
for _ in range(50):
|
||||
if seen:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
assert seen == [{"work": 1}]
|
||||
# Status is observable via the original handle.
|
||||
assert await runner2.get(handle) == "succeeded"
|
||||
|
||||
await runner2.shutdown()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# echo_done cursor survives restart #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
async def test_payload_mutation_survives_restart(tmp_path: Path) -> None:
|
||||
"""Handler-side payload mutations (echo_done) round-trip through disk."""
|
||||
state_dir = tmp_path / "runner"
|
||||
runner1 = InProcessTaskRunner(state_dir=state_dir)
|
||||
|
||||
# Handler sets echo_done and then blocks forever (simulating mid-flight crash).
|
||||
handler_progress = asyncio.Event()
|
||||
|
||||
async def half_done(payload: Mapping[str, Any]) -> None:
|
||||
# Mutate the payload to mark first phase complete.
|
||||
payload["echo_done"] = True # type: ignore[index]
|
||||
handler_progress.set()
|
||||
# Sleep indefinitely so the asyncio task is still running at shutdown.
|
||||
await asyncio.Event().wait()
|
||||
|
||||
runner1.register("two_phase", half_done)
|
||||
handle = await runner1.schedule("two_phase", {"echo_done": False, "k": "v"})
|
||||
await handler_progress.wait()
|
||||
await runner1.shutdown(timeout=0.1)
|
||||
|
||||
# Process 2 — replay; the handler now sees echo_done=True from disk.
|
||||
runner2 = InProcessTaskRunner(state_dir=state_dir)
|
||||
observed: list[bool] = []
|
||||
|
||||
async def two_phase_resumed(payload: Mapping[str, Any]) -> None:
|
||||
observed.append(bool(payload.get("echo_done")))
|
||||
|
||||
runner2.register("two_phase", two_phase_resumed)
|
||||
await runner2.resume()
|
||||
|
||||
for _ in range(50):
|
||||
if observed:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
assert observed == [True]
|
||||
# And the resumed task ran to completion.
|
||||
assert await runner2.get(handle) == "succeeded"
|
||||
|
||||
await runner2.shutdown()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Resume gracefully handles missing handler / corrupt entries #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
async def test_resume_with_missing_handler_marks_failed(tmp_path: Path) -> None:
|
||||
"""A persisted record whose handler is no longer registered is marked failed."""
|
||||
state_dir = tmp_path / "runner"
|
||||
|
||||
runner1 = InProcessTaskRunner(state_dir=state_dir)
|
||||
|
||||
async def will_be_removed(_: Mapping[str, Any]) -> None:
|
||||
await asyncio.Event().wait()
|
||||
|
||||
runner1.register("ghost", will_be_removed)
|
||||
handle = await runner1.schedule("ghost", {})
|
||||
await runner1.shutdown(timeout=0.1)
|
||||
|
||||
# Process 2 — never registers "ghost".
|
||||
runner2 = InProcessTaskRunner(state_dir=state_dir)
|
||||
replayed = await runner2.resume()
|
||||
assert replayed == 0
|
||||
# The record is moved to terminal 'failed'.
|
||||
assert await runner2.get(handle) == "failed"
|
||||
await runner2.shutdown()
|
||||
|
||||
|
||||
async def test_resume_quarantines_corrupt_entries(tmp_path: Path) -> None:
|
||||
"""A non-dict on-disk entry must be quarantined, not crash resume."""
|
||||
import diskcache # noqa: PLC0415 - lazy import to keep module-import cheap
|
||||
|
||||
state_dir = tmp_path / "runner"
|
||||
state_dir.mkdir(parents=True, exist_ok=True)
|
||||
# Pre-populate the cache with a junk entry.
|
||||
cache = diskcache.Cache(str(state_dir))
|
||||
cache.set("bad-task-id", "this is not a dict")
|
||||
cache.close()
|
||||
|
||||
runner = InProcessTaskRunner(state_dir=state_dir)
|
||||
# resume() must not raise even with a corrupt entry on disk.
|
||||
replayed = await runner.resume()
|
||||
assert replayed == 0
|
||||
await runner.shutdown()
|
||||
|
||||
# The corrupt entry should have been removed.
|
||||
cache2 = diskcache.Cache(str(state_dir))
|
||||
assert "bad-task-id" not in cache2
|
||||
cache2.close()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Retry attempt counter persists across resume #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
async def test_attempt_counter_persists_across_resume(tmp_path: Path) -> None:
|
||||
"""A handler that crashes mid-attempt resumes with the consumed budget."""
|
||||
state_dir = tmp_path / "runner"
|
||||
policy = RetryPolicy(max_attempts=3, initial_backoff_seconds=0.01, backoff_multiplier=1.0)
|
||||
|
||||
# Process 1 — schedule, fail once, shutdown before retry settles.
|
||||
runner1 = InProcessTaskRunner(state_dir=state_dir, default_retry_policy=policy)
|
||||
attempts_seen_p1 = 0
|
||||
|
||||
async def flaky(_: Mapping[str, Any]) -> None:
|
||||
nonlocal attempts_seen_p1
|
||||
attempts_seen_p1 += 1
|
||||
raise RuntimeError("boom-1")
|
||||
|
||||
runner1.register("flaky", flaky)
|
||||
handle = await runner1.schedule("flaky", {})
|
||||
# Let it attempt twice (waste 2 of 3 budgeted retries), then crash-shutdown.
|
||||
for _ in range(50):
|
||||
if attempts_seen_p1 >= 2:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
await runner1.shutdown(timeout=0.05)
|
||||
|
||||
# Process 2 — resume; only 1 attempt left in the budget. Confirm we don't
|
||||
# re-grant the full retry budget.
|
||||
runner2 = InProcessTaskRunner(state_dir=state_dir, default_retry_policy=policy)
|
||||
attempts_seen_p2 = 0
|
||||
|
||||
async def flaky_resumed(_: Mapping[str, Any]) -> None:
|
||||
nonlocal attempts_seen_p2
|
||||
attempts_seen_p2 += 1
|
||||
raise RuntimeError("boom-2")
|
||||
|
||||
runner2.register("flaky", flaky_resumed)
|
||||
await runner2.resume()
|
||||
# Wait for the resumed task to consume its remaining attempts and fail terminally.
|
||||
for _ in range(100):
|
||||
if (await runner2.get(handle)) == "failed":
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
assert await runner2.get(handle) == "failed"
|
||||
# Original consumed 2 attempts; we should have allowed at most max_attempts-2=1
|
||||
# more in process 2.
|
||||
assert attempts_seen_p2 <= 1
|
||||
await runner2.shutdown()
|
||||
@@ -4,63 +4,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from agent_framework_hosting import (
|
||||
ChannelContribution,
|
||||
ChannelIdentity,
|
||||
ChannelRequest,
|
||||
ChannelResponseContext,
|
||||
ChannelSession,
|
||||
DurableTaskPayloadMode,
|
||||
HostedRunResult,
|
||||
ResponseTarget,
|
||||
ResponseTargetKind,
|
||||
apply_channel_response_hook,
|
||||
apply_run_hook,
|
||||
)
|
||||
|
||||
|
||||
class TestResponseTarget:
|
||||
def test_originating_default_singleton(self) -> None:
|
||||
target = ResponseTarget.originating # type: ignore[attr-defined]
|
||||
assert target.kind is ResponseTargetKind.ORIGINATING
|
||||
assert target.targets == ()
|
||||
|
||||
def test_active_singleton(self) -> None:
|
||||
target = ResponseTarget.active # type: ignore[attr-defined]
|
||||
assert target.kind is ResponseTargetKind.ACTIVE
|
||||
assert target.targets == ()
|
||||
|
||||
def test_all_linked_singleton(self) -> None:
|
||||
target = ResponseTarget.all_linked # type: ignore[attr-defined]
|
||||
assert target.kind is ResponseTargetKind.ALL_LINKED
|
||||
|
||||
def test_none_singleton(self) -> None:
|
||||
target = ResponseTarget.none # type: ignore[attr-defined]
|
||||
assert target.kind is ResponseTargetKind.NONE
|
||||
|
||||
def test_channel_builder_single(self) -> None:
|
||||
target = ResponseTarget.channel("teams")
|
||||
assert target.kind is ResponseTargetKind.CHANNELS
|
||||
assert target.targets == ("teams",)
|
||||
|
||||
def test_channels_builder_list(self) -> None:
|
||||
target = ResponseTarget.channels(["teams", "telegram", "originating"])
|
||||
assert target.kind is ResponseTargetKind.CHANNELS
|
||||
assert target.targets == ("teams", "telegram", "originating")
|
||||
|
||||
def test_channels_builder_accepts_tuple(self) -> None:
|
||||
target = ResponseTarget.channels(("a", "b"))
|
||||
assert target.targets == ("a", "b")
|
||||
|
||||
def test_target_is_hashable(self) -> None:
|
||||
# Plain class — hashing falls back to identity, which is fine here:
|
||||
# the two keys below are different instances (singleton vs builder).
|
||||
d = {ResponseTarget.originating: 1, ResponseTarget.channel("t"): 2} # type: ignore[attr-defined]
|
||||
assert len(d) == 2
|
||||
|
||||
|
||||
class TestChannelRequest:
|
||||
def test_required_fields_only(self) -> None:
|
||||
req = ChannelRequest(channel="responses", operation="message.create", input="hi")
|
||||
@@ -74,17 +24,6 @@ class TestChannelRequest:
|
||||
assert req.attributes == {}
|
||||
assert req.stream is False
|
||||
assert req.identity is None
|
||||
# Default response target is the originating singleton.
|
||||
assert req.response_target.kind is ResponseTargetKind.ORIGINATING
|
||||
|
||||
def test_default_response_target_is_originating_singleton(self) -> None:
|
||||
# Every new request shares the module-level ``originating`` singleton
|
||||
# by default — instances are intended to be treated as immutable, so
|
||||
# sharing is safe and avoids per-request allocation.
|
||||
a = ChannelRequest(channel="a", operation="op", input="x")
|
||||
b = ChannelRequest(channel="b", operation="op", input="y")
|
||||
assert a.response_target is ResponseTarget.originating # type: ignore[attr-defined]
|
||||
assert a.response_target is b.response_target
|
||||
|
||||
def test_with_session_and_identity(self) -> None:
|
||||
req = ChannelRequest(
|
||||
@@ -93,14 +32,12 @@ class TestChannelRequest:
|
||||
input="hi",
|
||||
session=ChannelSession(isolation_key="user:42"),
|
||||
identity=ChannelIdentity(channel="telegram", native_id="42"),
|
||||
response_target=ResponseTarget.active, # type: ignore[attr-defined]
|
||||
)
|
||||
assert req.session is not None
|
||||
assert req.session.isolation_key == "user:42"
|
||||
assert req.identity is not None
|
||||
assert req.identity.channel == "telegram"
|
||||
assert req.identity.native_id == "42"
|
||||
assert req.response_target.kind is ResponseTargetKind.ACTIVE
|
||||
|
||||
|
||||
class TestChannelIdentity:
|
||||
@@ -111,228 +48,3 @@ class TestChannelIdentity:
|
||||
def test_attributes_passthrough(self) -> None:
|
||||
ident = ChannelIdentity(channel="teams", native_id="abc", attributes={"role": "user"})
|
||||
assert dict(ident.attributes) == {"role": "user"}
|
||||
|
||||
|
||||
class _DummyTarget:
|
||||
"""Stand-in for the ``SupportsAgentRun | Workflow`` arg `apply_run_hook` forwards.
|
||||
|
||||
`apply_run_hook` doesn't introspect the target — it just forwards
|
||||
it as a kwarg to the user's hook — so a bare class is enough.
|
||||
"""
|
||||
|
||||
|
||||
class _DummyChannel:
|
||||
name = "dummy"
|
||||
path = "/dummy"
|
||||
|
||||
def contribute(self, _context: Any) -> ChannelContribution:
|
||||
return ChannelContribution()
|
||||
|
||||
|
||||
class TestApplyChannelResponseHook:
|
||||
async def test_originating_hook_receives_standard_context(self) -> None:
|
||||
request = ChannelRequest(channel="discord", operation="message.create", input="hi")
|
||||
payload = HostedRunResult("original")
|
||||
captured: list[ChannelResponseContext] = []
|
||||
|
||||
async def hook(
|
||||
result: HostedRunResult[Any],
|
||||
*,
|
||||
context: ChannelResponseContext,
|
||||
) -> HostedRunResult[Any]:
|
||||
captured.append(context)
|
||||
return result.replace(result="hooked")
|
||||
|
||||
channel = _DummyChannel()
|
||||
channel.response_hook = hook # type: ignore[attr-defined]
|
||||
|
||||
shaped = await apply_channel_response_hook(channel, payload, request=request, originating=True)
|
||||
|
||||
assert shaped.result == "hooked"
|
||||
assert captured[0].request is request
|
||||
assert captured[0].channel_name == "dummy"
|
||||
assert captured[0].destination_identity is None
|
||||
assert captured[0].originating is True
|
||||
assert captured[0].is_echo is False
|
||||
|
||||
async def test_non_originating_hook_can_clone_before_shaping(self) -> None:
|
||||
request = ChannelRequest(channel="responses", operation="message.create", input="hi")
|
||||
identity = ChannelIdentity(channel="dummy", native_id="user-1")
|
||||
payload = HostedRunResult("original")
|
||||
seen_payloads: list[HostedRunResult[Any]] = []
|
||||
seen_contexts: list[ChannelResponseContext] = []
|
||||
|
||||
def hook(
|
||||
result: HostedRunResult[Any],
|
||||
*,
|
||||
context: ChannelResponseContext,
|
||||
) -> HostedRunResult[Any]:
|
||||
seen_payloads.append(result)
|
||||
seen_contexts.append(context)
|
||||
return result.replace(result="hooked")
|
||||
|
||||
channel = _DummyChannel()
|
||||
channel.response_hook = hook # type: ignore[attr-defined]
|
||||
|
||||
shaped = await apply_channel_response_hook(
|
||||
channel,
|
||||
payload,
|
||||
request=request,
|
||||
destination_identity=identity,
|
||||
originating=False,
|
||||
is_echo=True,
|
||||
clone=True,
|
||||
)
|
||||
|
||||
assert seen_payloads[0] is not payload
|
||||
assert shaped.result == "hooked"
|
||||
assert seen_contexts[0].destination_identity is identity
|
||||
assert seen_contexts[0].originating is False
|
||||
assert seen_contexts[0].is_echo is True
|
||||
|
||||
async def test_missing_hook_returns_payload_or_clone(self) -> None:
|
||||
request = ChannelRequest(channel="responses", operation="message.create", input="hi")
|
||||
payload = HostedRunResult("original")
|
||||
channel = _DummyChannel()
|
||||
|
||||
same = await apply_channel_response_hook(channel, payload, request=request, originating=True)
|
||||
cloned = await apply_channel_response_hook(channel, payload, request=request, originating=True, clone=True)
|
||||
|
||||
assert same is payload
|
||||
assert cloned is not payload
|
||||
assert cloned.result == payload.result
|
||||
|
||||
|
||||
class TestApplyRunHook:
|
||||
"""`apply_run_hook` is the channel-side helper that invokes a
|
||||
`ChannelRunHook` with the standard kwargs (`request` positional,
|
||||
`target` / `protocol_request` keyword). Channels call this rather
|
||||
than calling the hook directly so the convention is enforced in
|
||||
one place. Cover both branching paths (sync vs async hook return)
|
||||
and assert kwargs forwarding so a regression that drops `target`
|
||||
or `protocol_request` is caught."""
|
||||
|
||||
async def test_sync_hook_returning_modified_request(self) -> None:
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def hook(request: ChannelRequest, **kwargs: Any) -> ChannelRequest:
|
||||
# Snapshot the kwargs for the assertion below, then return a
|
||||
# NEW request so we also verify the helper passes the
|
||||
# replacement straight through (no merging / mutation).
|
||||
captured["target"] = kwargs.get("target")
|
||||
captured["protocol_request"] = kwargs.get("protocol_request")
|
||||
return ChannelRequest(channel=request.channel, operation="HOOK_TOUCHED", input=request.input)
|
||||
|
||||
original = ChannelRequest(channel="responses", operation="op", input="hi")
|
||||
target = _DummyTarget()
|
||||
proto = {"raw": "payload"}
|
||||
|
||||
result = await apply_run_hook(hook, original, target=target, protocol_request=proto)
|
||||
|
||||
assert result is not original
|
||||
assert result.operation == "HOOK_TOUCHED"
|
||||
assert captured["target"] is target
|
||||
assert captured["protocol_request"] is proto
|
||||
|
||||
async def test_async_hook_returning_modified_request(self) -> None:
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def hook(request: ChannelRequest, **kwargs: Any) -> ChannelRequest:
|
||||
captured["target"] = kwargs.get("target")
|
||||
captured["protocol_request"] = kwargs.get("protocol_request")
|
||||
# Return an awaitable result to exercise the async branch
|
||||
# (`isinstance(result, Awaitable) → await it`).
|
||||
return ChannelRequest(channel=request.channel, operation="ASYNC_HOOK", input=request.input)
|
||||
|
||||
original = ChannelRequest(channel="telegram", operation="op", input="hi")
|
||||
target = _DummyTarget()
|
||||
proto = {"update_id": 42}
|
||||
|
||||
result = await apply_run_hook(hook, original, target=target, protocol_request=proto)
|
||||
|
||||
assert result.operation == "ASYNC_HOOK"
|
||||
assert captured["target"] is target
|
||||
assert captured["protocol_request"] is proto
|
||||
|
||||
async def test_protocol_request_can_be_none(self) -> None:
|
||||
"""Channels that don't have a raw protocol payload (e.g. CLI / test
|
||||
harness invocations) pass ``protocol_request=None``; the helper
|
||||
forwards it as-is so hooks can ``if protocol_request is None`` to
|
||||
gate channel-specific logic."""
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def hook(request: ChannelRequest, **kwargs: Any) -> ChannelRequest:
|
||||
captured["protocol_request"] = kwargs.get("protocol_request")
|
||||
captured["protocol_request_in_kwargs"] = "protocol_request" in kwargs
|
||||
return request
|
||||
|
||||
await apply_run_hook(
|
||||
hook,
|
||||
ChannelRequest(channel="x", operation="op", input="hi"),
|
||||
target=_DummyTarget(),
|
||||
protocol_request=None,
|
||||
)
|
||||
|
||||
assert captured["protocol_request"] is None
|
||||
assert captured["protocol_request_in_kwargs"] is True
|
||||
|
||||
|
||||
class TestDurableTaskPayloadMode:
|
||||
"""``DurableTaskPayloadMode`` distinguishes object-mode (in-process,
|
||||
live references) from JSON-mode (durable persistence, channel codec
|
||||
required) runners. The host's startup validator uses the value to
|
||||
refuse misconfigured deployments."""
|
||||
|
||||
def test_enum_values(self) -> None:
|
||||
assert DurableTaskPayloadMode.OBJECT.value == "object"
|
||||
assert DurableTaskPayloadMode.JSON.value == "json"
|
||||
# Both members; no surprise additions until we ship a third
|
||||
# adapter style.
|
||||
assert set(DurableTaskPayloadMode) == {DurableTaskPayloadMode.OBJECT, DurableTaskPayloadMode.JSON}
|
||||
|
||||
|
||||
class TestResponseTargetIdentities:
|
||||
"""``ResponseTarget.identity``/``.identities`` carry full
|
||||
:class:`ChannelIdentity` objects (incl. attributes) so destination
|
||||
channels that need conversation/thread metadata (Teams, Slack, Bot
|
||||
Framework) don't have to encode it through string tokens."""
|
||||
|
||||
def test_identity_single(self) -> None:
|
||||
ident = ChannelIdentity(channel="teams", native_id="user@contoso", attributes={"tenant_id": "abc"})
|
||||
target = ResponseTarget.identity(ident)
|
||||
assert target.kind is ResponseTargetKind.IDENTITIES
|
||||
assert len(target.target_identities) == 1
|
||||
assert target.target_identities[0].channel == "teams"
|
||||
assert target.target_identities[0].native_id == "user@contoso"
|
||||
assert dict(target.target_identities[0].attributes) == {"tenant_id": "abc"}
|
||||
|
||||
def test_identities_list_preserves_attributes(self) -> None:
|
||||
ident_a = ChannelIdentity(channel="teams", native_id="u1", attributes={"thread": "t1"})
|
||||
ident_b = ChannelIdentity(channel="slack", native_id="u2", attributes={"channel_id": "c2"})
|
||||
target = ResponseTarget.identities([ident_a, ident_b])
|
||||
assert target.kind is ResponseTargetKind.IDENTITIES
|
||||
assert len(target.target_identities) == 2
|
||||
assert dict(target.target_identities[0].attributes) == {"thread": "t1"}
|
||||
assert dict(target.target_identities[1].attributes) == {"channel_id": "c2"}
|
||||
|
||||
def test_identity_value_equality_matches_on_attributes(self) -> None:
|
||||
# Two ``ResponseTarget.identity`` values built independently
|
||||
# compare equal when the underlying ``ChannelIdentity`` content
|
||||
# matches — important because tests and channel parsers use
|
||||
# ``==`` on targets.
|
||||
ident_a = ChannelIdentity(channel="teams", native_id="u1", attributes={"thread": "t1"})
|
||||
ident_b = ChannelIdentity(channel="teams", native_id="u1", attributes={"thread": "t1"})
|
||||
assert ResponseTarget.identity(ident_a) == ResponseTarget.identity(ident_b)
|
||||
# Different attributes → not equal.
|
||||
ident_c = ChannelIdentity(channel="teams", native_id="u1", attributes={"thread": "t2"})
|
||||
assert ResponseTarget.identity(ident_a) != ResponseTarget.identity(ident_c)
|
||||
|
||||
def test_identity_repr_includes_targets(self) -> None:
|
||||
ident = ChannelIdentity(channel="teams", native_id="u1")
|
||||
rep = repr(ResponseTarget.identity(ident))
|
||||
assert "ResponseTarget.identities" in rep
|
||||
|
||||
def test_identity_echo_input_flag(self) -> None:
|
||||
ident = ChannelIdentity(channel="teams", native_id="u1")
|
||||
target = ResponseTarget.identity(ident, echo_input=True)
|
||||
assert target.echo_input is True
|
||||
|
||||
Reference in New Issue
Block a user