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
@@ -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",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user