Python: add agent-framework-hosting core package (#5638)

* feat(hosting): add agent-framework-hosting core package

New ``agent-framework-hosting`` package implementing ADR 0026 / SPEC-002:
the channel-neutral host that lets a single ``Agent`` (or ``Workflow``)
fan out across multiple wire protocols ("channels") behind one Starlette
ASGI app.

Surface (re-exported from ``agent_framework_hosting``):

- ``AgentFrameworkHost`` — wraps a hostable target, mounts channels onto
  an ASGI app, owns per-isolation-key ``AgentSession`` reuse, threads
  request context (``response_id`` / ``previous_response_id``) into
  context providers via an ``ExitStack`` of ``bind_request_context``
  calls, and exposes an opt-in Hypercorn ``serve()`` helper (extra
  ``[serve]``).
- ``Channel`` protocol + ``ChannelContribution`` — the surface a channel
  package implements (routes, lifespans, identity hooks, …).
- ``ChannelRequest`` / ``ChannelSession`` / ``ChannelIdentity`` /
  ``ChannelPush`` / ``ChannelCommand[Context]`` / ``ChannelRunHook`` /
  ``ChannelStreamTransformHook`` / ``DeliveryReport`` /
  ``HostedRunResult`` / ``ResponseTarget`` / ``ResponseTargetKind`` /
  ``apply_run_hook`` — channel-side dataclasses + helpers.
- ``IsolationKeys`` + ``ISOLATION_HEADER_USER`` / ``..._CHAT`` +
  ``get/set/reset_current_isolation_keys`` — the host's ASGI middleware
  reads the ``x-agent-{user,chat}-isolation-key`` headers off each
  inbound request and exposes them to the agent stack via a
  ``ContextVar`` so storage-side providers (e.g.
  ``FoundryHostedAgentHistoryProvider``) can apply per-tenant
  partitioning without channels having to forward anything.

Includes 45 unit tests covering the host, channel contributions,
isolation contextvar, and shared types. Registers the package in
``python/pyproject.toml`` ``[tool.uv.sources]`` and adds the matching
pyright ``executionEnvironments`` entry for tests.

Hypercorn is an optional dependency (``[serve]`` extra); the soft import
in ``serve()`` is annotated for pyright since it isn't on the default
install.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(hosting): address PR-2 review comments

Source-code changes
- _suppress_already_consumed: narrow contract — RuntimeError now logs
  at WARNING with exc_info; non-RuntimeError still logs at exception().
  Docstring clarifies that any non-clean teardown is observable.
- _BoundResponseStream: add aclose() and route __await__ through
  get_final_response() so the binding is always released — fixes
  contextvar leak when channels abandon the stream or use the
  await-the-stream convenience.
- Lifespan: aggregate startup/shutdown callback errors; every callback
  runs, all failures are logged with their qualname, and the first
  error is re-raised so Starlette still aborts boot.
- _build_run_kwargs: switch session-cache write to dict.setdefault so
  concurrent racers cannot orphan a session if create_session ever
  yields.
- _deliver_response: introduce DeliveryReport.failed for push outages
  vs explicit "no link" drops; an outage no longer triggers an
  originating fallback so the channel can decide degraded behaviour.

Test additions
- tests/test_isolation.py (new): full coverage of IsolationKeys, the
  contextvar helpers, header constants, and end-to-end ASGI
  middleware lift / reset / passthrough.
- tests/test_host.py: TestBindRequestContext, TestBoundResponseStream
  (aclose / __await__ / __getattr__ forwarding / double-close
  idempotency), TestWrapInputListMessages (list[Message] LAST
  precedence), TestLifespanAggregation (startup + shutdown).
- tests/test_types.py: TestApplyRunHook (sync/async/None), and
  TestDeliveryReport (new failed field).
- Updated test_push_exception_marks_skipped ->
  test_push_exception_lands_in_failed_no_fallback to match the new
  delivery contract.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(hosting): address PR-2 round-2 review comments

- Refactor workflow checkpoint restoration into shared helpers
  (_restore_workflow_checkpoint for blocking; the streaming sibling
  drains the rehydration stream) so the blocking and streaming paths
  rehydrate identically — clarifies the previously inline _maybe_restore
  by hoisting the pattern next to the blocking call site.
- Document that blocking workflow output is text-only by design;
  richer modalities ride the streaming AgentResponseUpdate channel,
  which preserves all content parts.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* review: address PR-4 _host.py round 2 feedback

These review comments were filed on PR-4 (#5640) but target lines that
live in the hosting-core package (PR-2 / #5638), so the fixes land here
and PR-4's stack will pick them up on rebase.

- _suppress_already_consumed: narrow the RuntimeError catch to the two
  documented benign messages (`Inner stream not available`, `Event loop
  is closed`); any other RuntimeError now logs at ERROR with a full
  traceback so executor bugs / runner-context state errors / checkpoint
  RuntimeErrors during the post-run flush no longer masquerade as
  benign cleanup noise. Still no propagation (we're in an
  async-generator finally during teardown) — see the docstring.
- _restore_workflow_checkpoint{,_streaming}: log a WARNING when a
  non-None latest checkpoint drains to zero events, so a stale or
  partially-written checkpoint_id surfaces as an operator signal
  instead of a silent state-loss.

(The `deliver_response` "no destinations resolvable" vs "every
destination errored" concern raised in 3198268038 is already addressed
by the existing `failed` vs `skipped` distinction surfaced through
`DeliveryReport.failed` — see lines 1080-1102 and the
`DeliveryReport` docstring.)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(hosting): reject path-traversal patterns in checkpoint isolation_key

The host's `_resolve_checkpoint_storage` joined `request.session.isolation_key`
directly into the configured `checkpoint_location`. The key is caller-
controlled — sourced from inbound headers (`x-agent-{user,chat}-isolation-key`
injected by the Foundry runtime), from channel-supplied derivations such as
`telegram:<chat_id>` / `entra:<oid>`, or from values set by a channel
`run_hook`. A value like `../../../etc/foo` or an absolute path would let
the resulting checkpoint directory escape the configured root (CWE-22).
This matches the path-traversal class fixed upstream in #5851 for the
foundry_hosting checkpoint storage.

New `_checkpoint_path_for_isolation_key(root, isolation_key)` helper:

- Uses a denylist (not allowlist) so legitimate namespaced keys
  (`telegram:42`, `entra:abc-def`) continue to pass through unmodified.
- Rejects path separators (`/`, `\`), NUL, all-dot reductions (`.`, `..`,
  `...`, ...), absolute paths (`os.path.isabs`), and drive-letter prefixes
  (`os.path.splitdrive` plus an explicit `^[A-Za-z]:` check so payloads
  crafted on a POSIX host still fail closed if the resulting directory
  ever round-trips to Windows storage).
- After joining, resolves both sides and verifies
  `target.is_relative_to(root)` as defence-in-depth.

`_resolve_checkpoint_storage` now logs a WARNING and returns `None` for
invalid keys rather than crashing the request — checkpointing is best-
effort and we prefer dropping it to letting one malformed key abort an
otherwise valid agent run.

Tests:

- `TestCheckpointPathForIsolationKey` exercises the helper directly with
  legitimate keys (alphanumeric, `:`-namespaced, dotted, 200-char), all
  rejected traversal patterns from #5851's MSRC repro list, and
  non-string input.
- `TestHostWorkflowCheckpointingPathTraversal` verifies the end-to-end
  request path: a traversal key (`../escape`) and an in-key separator
  (`evil/sub`) both produce a successful agent response with no files
  written under `checkpoint_location`, and the traversal case logs a
  WARNING citing `isolation_key`.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(hosting): address PR-2 round-3 review feedback + add response hooks

Round-3 review comment fixes:

- _types.py: drop the _EMPTY_MAPPING sentinel; ChannelIdentity.attributes
  uses plain dict() as the default — simpler, no extra symbol to track.
- _host.py: drop the local `import asyncio` + `from typing import cast as
  _cast` inside `serve()`; rely on the module-level imports.
- _host.py: switch `_log_incoming` to structured `extra={...}` payloads
  for both INFO and DEBUG so log aggregators get queryable fields.
- _host.py: delete `_flat_context_providers` and stop descending into a
  `.providers` attribute. Aggregator providers (AggregateContextProvider /
  ContextProviderBase) are responsible for forwarding `response_context`
  to their children themselves; the host treats whatever
  `agent.context_providers` exposes as the final, flat list.
- _host.py: stop collapsing agent / workflow output to text. `_invoke`
  forwards `AgentResponse.messages` (and `raw_response`) on the
  `HostedRunResult`. `_invoke_workflow` builds a per-event message list
  via a new `_workflow_output_to_messages` helper that preserves
  AgentResponse / AgentResponseUpdate / Message / Content branches and
  falls back to text only for arbitrary objects.
- _host.py: `_workflow_event_to_update` carries Content payloads through
  unchanged so multi-modal workflow outputs (images, function-call
  metadata, ...) survive into channels.

New features (per design discussion in the PR thread):

- HostedRunResult: rebuilt around `messages: list[Message]` with
  `.text` / `.contents` as projections, a `raw_response` slot for the
  underlying AgentResponse, and a `replace(messages=..., raw_response=...)`
  clone helper used by the delivery layer for per-destination isolation.
  The `HostedRunResult(text="...")` ctor is preserved as a back-compat
  shim that synthesises a single assistant text message.
- ResponseTarget: gain `echo_input: bool = False` (also exposed on
  `.channel(name, *, echo_input=...)` / `.channels([...], *, echo_input=...)`).
  When set, the host pushes the originating user message to each
  non-originating destination before the agent reply. Channels can
  filter or transform echoes via their response_hook.
- DeliveryReport: add `echoed` / `echo_failed` tuples to surface
  per-destination outcomes of the new echo phase. Echo failures do not
  abort the corresponding response push on the same destination.
- ChannelResponseHook + ChannelResponseContext + apply_response_hook:
  duck-typed `response_hook` attribute on channels for per-destination
  post-processing. Receives a clone of the HostedRunResult and a
  context carrying the request, channel name, destination identity,
  originating flag, and `is_echo` phase flag. Channels stay
  modality-aware (text-only wires flatten via the hook; card-capable
  channels render structured contents directly).
- _deliver_response: clone-before-hook fan-out so a hook mutating one
  channel's payload cannot leak into another destination's view.

Tests:

- Update _FakeAgentResponse to expose `.messages` (single assistant text
  message synthesised from `text`) so existing tests pass unchanged on
  the new multi-modal _invoke path.
- Replace the obsolete `test_bind_descends_one_level_into_providers_attribute`
  with a regression guard asserting the host does NOT descend into
  `.providers` (matches new contract).
- New tests for HostedRunResult multi-modal preservation, echo_input
  fan-out with success + failure, response_hook applied per destination,
  per-destination mutation isolation, and is_echo phase observability.

Docs:

- spec 002: rewrite Canonical flow with the new input → run_hook → host
  → target → wrap → per-destination clone → response_hook → push
  pipeline; document multi-modality contract and per-destination
  cloning; add `echo_input` row to ResponseTarget table; rewrite
  HostedRunResult/HostedStreamResult row; add ChannelResponseHook /
  ChannelResponseContext / apply_response_hook table; log decisions
  Q28 (no host-side text collapse), Q29 (duck-typed response_hook),
  Q30 (opt-in `echo_input` on ResponseTarget).
- ADR 0026: add ChannelResponseHook + multi-modality bullets;
  surface `echo_input` on the ResponseTarget bullet.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(hosting): drop HostedRunResult(text=...) back-compat shim; use from_text()

Pre-release cleanup — no released callers to break, so consolidate on one
canonical entry point plus a classmethod for the ergonomic
single-text-message case:

- HostedRunResult.__init__ takes ``messages`` positionally (required); no
  more ``text=`` kwarg overload, no more "synthesise an empty message
  when no args" path.
- New HostedRunResult.from_text(text, *, role="assistant", raw_response=None)
  classmethod for the common "wrap a single text content as one message"
  case (tests, channels emitting plain strings, the echo-input phase
  wrapping a user's text turn).
- ``_build_echo_payload`` uses ``HostedRunResult.from_text(raw, role="user")``
  for the ``str`` and fallback branches; the other branches use the plain
  ctor with explicit ``Message`` lists.
- Tests rewritten to use ``from_text("reply")`` everywhere
  ``HostedRunResult(text="reply")`` appeared. Added an explicit
  ``test_from_text_role_kwarg_overrides_default`` regression guard.
- spec 002: HostedRunResult row updated to describe the
  ``from_text(text, *, role="assistant")`` classmethod instead of the
  removed back-compat shim.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* refactor(hosting-core): reshape HostedRunResult into generic typed envelope

Replace the flattened multi-modal HostedRunResult (carrying
messages/raw_response/.text projections) with a typed generic
envelope around the target's full-fidelity output:

  class HostedRunResult(Generic[TResult]):
      result: TResult
      session: AgentSession | None

- Agent targets produce HostedRunResult[AgentResponse]; channels
  read result.messages, result.text, result.value, result.response_id,
  result.usage_details directly off the underlying response.
- Workflow targets produce HostedRunResult[WorkflowRunResult];
  channels iterate result.get_outputs() and inspect
  result.get_final_state() themselves (the host no longer collapses
  workflow outputs onto a synthesised message list).
- The echo-input phase synthesises a HostedRunResult[AgentResponse]
  wrapping the user's turn so the same per-destination delivery
  machinery applies.
- replace() is now {result, session} only; the host's clone is
  shallow — channels that need to mutate result itself are
  responsible for their own deep copy.

Rationale: the earlier shape pre-shaped target output (collapsing
workflows onto a Message list, losing per-executor outputs, final
state, and structured value affordances). Carrying the target output
unchanged keeps the host modality-agnostic, gives channel authors
static typing where they want it, and removes 30+ lines of
host-side projection helpers.

Also updates ADR 0026 + spec 002 (Q3, Q28, Q29 amended; new Q31
captures the generic-envelope decision and rationale).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs(hosting-core): document echo vs response distinction for push channels

The host already encodes the echo-vs-response phase via the
underlying Message.role on the pushed HostedRunResult:

- echo phase: payload.result.messages[*].role == "user"
- response phase: payload.result.messages[*].role == "assistant"

Both pushes go through the same ChannelPush.push(identity, payload)
entry point. Channels distinguish either by inspecting role (which
works for any push-capable channel) or — when a response_hook is
wired — by branching on ChannelResponseContext.is_echo directly.

Expand the ChannelPush Protocol docstring to make this discoverable
for channel implementers (esp. chat bots that cannot impersonate
the user on their wire and need to render echoes as quoted /
prefixed blocks rather than as bot replies).

Mirror the explanation into the spec's echo_input section.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs(hosting-core): fix quickstart to use current Agent API

ChatAgent was renamed to Agent and the preferred construction pattern
is client.as_agent(...). Also drop the sibling channel import so the
snippet imports only modules declared as dependencies of this package;
point readers at the sibling packages instead.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test(hosting-core): drop redundant @pytest.mark.asyncio decorators

asyncio_mode = "auto" is configured in pyproject.toml, so individual
@pytest.mark.asyncio decorators are unnecessary.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs(hosting): add authorization profiles + IdentityAllowlist seam to ADR/spec

Composes `require_link` + `allowlist` into three named profiles (open,
forced-link, allowlist) with the allowlist itself keyed on either the
channel-native id (pre-link) or a verified IdP claim (post-link), plus
`AnyOf`/`AllOf` combinators for mixed setups. Lifts the design into
an explicit host seam (`host.authorize(...)` → `AuthorizationOutcome`
of `Allowed` / `LinkRequired` / `Denied`) instead of leaving each
channel to roll its own.

Key contract bits:
- Tri-state `AllowlistDecision` (ALLOW / DENY / ABSTAIN) so claim-based
  lists can ABSTAIN until claims are available without composition
  silently flipping that into DENY.
- `AuthorizationContext` carries explicit `phase` + `claim_source`
  so allowlists can tell pre-link from post-link without overloading
  `verified_claims is None`.
- Channel-side `allowlist: ... | Literal["inherit"] | None` with an
  explicit inheritance sentinel, so the host-level `default_allowlist`
  is opt-out, not opt-in.
- Construction-time validator rejects silent-deny configurations
  (`LinkedClaimAllowlist` without a claim source) with a typed
  `ChannelConfigurationError`.
- Group-chat denial mirrors the existing `LinkChallenge` DM-redirect
  pattern; only the redacted `user_message` reaches the wire,
  structured `log_details` stay in telemetry.

Ships in two waves: the Protocol + `NativeIdAllowlist` + config
validator land with the next core PR ahead of the linker; the full
pipeline + `LinkedClaimAllowlist` enforcement land with the
`IdentityLinker` core PR.

Updates: ADR 0026 (summary bullet + conceptual-API table row + resolved
Q16), spec 002 (new req #22, renumbered v1 fast-follow #23..#29 and
stretch #30..#31, new "Authorization profiles and the IdentityAllowlist
seam" subsection, inbound-ownership row, resolved Q32, follow-up entry).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(hosting): add DurableTaskRunner seam + runtime_mode auto-detect

Introduces the explicit long-running vs ephemeral runtime distinction
and a generic DurableTaskRunner Protocol that owns non-originating
push dispatch — collapsing the previous deliveries[] per-destination
state machine, SupportsDeliveryTracking provider capability, and
Foundry update_item service ask down to a single immutable
intended_targets[] write on the message.

Spec / ADR:
- New §"Runtime modes" with auto-detect markers + defaults matrix.
- Rewrites §"Delivery tracking" → §"Intended targets + durable
  delivery": intent-only on the message, operational state lives in
  the runner.
- New §"Durable task runner" defining DurableTaskRunner / RetryPolicy
  / TaskHandle / TaskStatus.
- Drops §SupportsDeliveryTracking and §Foundry update_item gap.
- Resolved Qs: 12, 18, 21, 26 revised; new 17/18/19 (ADR) and
  33/34/35 (spec).

Code:
- New _runner.py with InProcessTaskRunner (asyncio + bounded retry,
  bounded terminal-status cache, register-after-start guard,
  shutdown drain).
- _host.py: runtime_mode + durable_task_runner ctor params;
  auto-detect via FOUNDRY_HOSTING_ENVIRONMENT /
  AZURE_FUNCTIONS_ENVIRONMENT / AWS_LAMBDA_FUNCTION_NAME;
  HOSTING_PUSH_TASK_NAME handler registered eagerly so
  _deliver_response can be called outside the lifespan;
  _handle_push_task does echo-then-response inline per destination;
  _deliver_response now schedules one task per destination via the
  runner (DeliveryReport.pushed = scheduled; .failed = schedule-time
  outage only).
- _types.py: new DurableTaskRunner Protocol + RetryPolicy /
  TaskHandle / TaskStatus; DeliveryReport drops echoed /
  echo_failed (echo outcome owned by the runner).
- __init__.py exports the new public surface.

Tests: 132 passing, 90% coverage. New test_runner.py covers
InProcessTaskRunner success/retry/terminal-failure/cancellation/
register-after-start, runtime-mode auto-detect with synthetic env,
and the warning-on-ephemeral-without-runner path. test_host.py
delivery tests use a sync runner fake for deterministic assertions
and validate the new "schedule succeeded vs runner backend
unreachable" semantics.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(hosting): rubber-duck round-5 — strict ephemeral, codec seam, allowlist Wave-1, drop DeliveryReport

Adopts the rubber-duck-approved package of changes from the round-5
review of PR #5638 (modulo DeliveryReport.failed — the value type is
removed entirely now that durable delivery covers the failure
surface, per user direction).

Code:
- Drop DeliveryReport value type; host-internal _deliver_response
  returns bool. Failure observability is now logs (in-process) /
  runner backend (durable adapters).
- Strict ephemeral default: ephemeral runtime_mode with the default
  in-process runner raises RuntimeError; opt-in via
  allow_in_process_runner=True (warns).
- ChannelPushCodec Protocol + DurableTaskPayloadMode enum +
  _validate_runner_codec_pairing so JSON-mode runners can be safely
  paired with channels via codecs; _handle_push_task accepts both
  object- and JSON-envelope shapes.
- ResponseTarget.identity(...) / .identities([...]) builders +
  IDENTITIES kind for explicit caller-supplied recipients; field
  rename identities → _target_identities (private) with a
  target_identities property to resolve the classmethod collision.
- Intent-only audit: _annotate_intended_targets writes
  hosting.intended_targets / skipped_targets / includes_originating /
  originating_channel onto assistant messages — single immutable
  write per the runner-owned operational-state model.
- InProcessTaskRunner: 2-phase drain on shutdown
  (shutdown_grace_seconds, default 5.0) so a clean shutdown does not
  abandon work mid-retry; payload_mode = OBJECT class-level.
- Echo idempotency: _handle_push_task tracks an echo_done cursor on
  runner-owned task state so a retry that fires after the echo
  phase succeeded does not double-echo.

Wave-1 authorization seam (full landing):
- New _authorization.py with AllowlistDecision tri-state,
  AuthorizationContext, IdentityAllowlist Protocol, AllowAll /
  NativeIdAllowlist (with async loader cache + channel-scope ABSTAIN) /
  LinkedClaimAllowlist (raise-until-Wave-2) / AnyOfAllowlists /
  AllOfAllowlists / CallableAllowlist built-ins, Allowed /
  LinkRequired / Denied outcomes, ChannelConfigurationError.
- Host(default_allowlist=..., identity_linker=...) + per-channel
  allowlist parameter with 'inherit' / None semantics.
- _validate_channel_authorization enforces all three rules at
  construction: claim-source requirement, linker presence for
  require_link=True (elevated from no-op — must not ship
  unenforced), and NativeIdAllowlist(channel=...) typo detection.
  Combinator-walking via _flatten_allowlists catches nested
  misconfigs.
- host.authorize(...) for the native-id pipeline: open path returns
  Allowed with auto-issued <channel>:<native_id> isolation key (or
  the existing key when the identity has been seen); ABSTAIN on a
  claim-required allowlist maps to
  Denied(reason_code='allowlist_requires_link') until Wave 2 wires
  the linker to convert it to LinkRequired.

Spec / ADR:
- docs/specs/002-python-hosting-channels.md: Wave-1 status updated
  to reflect the linker-presence rule elevation and the
  host.authorize landing; new sub-sections (codec contract, drain,
  echo cursor); Qs 18 / 21 DeliveryReport references purged; new
  resolved Qs 36–40 covering the strict-ephemeral default, codec
  contract, DeliveryReport removal, echo cursor, and drain.
- docs/decisions/0026-hosting-channels.md: Q12 DeliveryReport
  reference purged; Q16 updated to reflect Wave-1 landing; new
  resolved Qs 20 (codec contract) + 21 (strict ephemeral / drain /
  echo cursor).

Tests:
- New tests/test_authorization.py (35 cases) covering every Wave-1
  built-in, the three validator rules, combinator decision
  semantics, and host.authorize across open / allow / deny /
  abstain-with-claim-dep / abstain-without-claim-dep paths plus
  existing-key reuse and verified-claims propagation.
- tests/test_host.py: TestDeliverResponse rewritten for the bool
  return + runner.scheduled-count assertions; new tests for
  IDENTITIES variant + echo idempotency.
- tests/test_runner.py: strict-ephemeral now expects RuntimeError;
  allow_in_process_runner opt-in tests; shutdown drain test;
  payload_mode default test.
- tests/test_types.py: TestDeliveryReport removed; new
  TestDurableTaskPayloadMode + TestResponseTargetIdentities.

Validation: 178 tests pass, 91% coverage, fmt + lint + pyright +
mypy clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs(hosting): add mermaid flow diagrams to ADR, spec, README

Insert the 10 hosting flow diagrams reviewed in
python/.user/hosting-diagrams.md into the public docs:

- README: runtime topology (1a) + cross-link to the spec for the
  richer set.
- ADR: runtime topology, channel contribution shape, and authorization
  decision (1a, 1b, 3) at the end of 'Conceptual API shape'.
- Spec: all 10 diagrams — 1a/1b at the top of API Surface, 2 in
  Canonical flow, 3 in Authorization profiles, 4-7 in Scenarios 6-8,
  8 in Codec contract, 9 in Echo idempotency, 10 in Scenario 9.

Doc-only; no API or behaviour change.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(hosting): add opt-in disk persistence via state_dir

Long-running hosts (always-on container, single-VM bot, local dev) lose
state on every restart today. Add an opt-in disk persistence layer under
a new `state_dir` constructor parameter on `AgentFrameworkHost` that
survives process restarts without taking on a heavyweight database
dependency.

Backed by `diskcache` (installed via the new `[disk]` optional extra).
An OS-level advisory file lock guarantees single-owner semantics so two
hosts pointed at the same directory cannot double-execute scheduled
pushes.

What persists when `state_dir` is set:

- Pending durable-task records — scheduled-but-not-yet-completed pushes
  replay on the next host startup via `InProcessTaskRunner.resume()`.
  Records that crashed mid-attempt resume with the already-consumed
  retry budget (no full-budget re-grant).
- `_session_aliases` — per-isolation-key session-id rewrites.
- `_active` — most-recently-active channel per isolation key.
- `_identities` — `ChannelIdentity` rows for fan-out targeting,
  including nested mutations of the form
  `self._identities[ik][channel] = identity`.

The `state_dir` parameter accepts any of:

- `None` — today's purely in-memory behaviour.
- `str` / `PathLike` — single root; host auto-creates `runner/` and
  `sessions/` subfolders.
- `HostStatePaths` TypedDict / plain mapping — per-component overrides
  routed to different roots. Unknown keys raise `ValueError` to surface
  typos early.

Unpicklable push payloads raise `PushPayloadNotPicklable` eagerly from
`schedule()` so issues surface at the call site rather than on the
next restart. Corrupt on-disk records are quarantined-and-logged; the
runner never crashes on resume.

Live `AgentSession` objects stay in memory and are rehydrated lazily
by the history provider on the next turn.

- New modules: `_persistence.py` (lock + normalisation),
  `_state_store.py` (session-bookkeeping store).
- Runner rewrite: 4-state model (`pending` / `succeeded` / `failed`
  / `cancelled`); the transient `running` state was a bug that caused
  resume to skip records that crashed mid-handler.
- New tests: `test_runner_disk.py` (8 tests), `test_host_disk.py` (8
  tests). 194 passed total. pyright + mypy + ruff clean.
- README: new "Optional disk persistence" section with code samples.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(hosting): add checkpoints to state_dir + fix host docstring

Three related polish changes on top of the disk-persistence landing:

1. Extend `state_dir` to cover workflow checkpoints. Adds
   `checkpoints` as a third `HostStatePaths` key. Single-path form
   (`state_dir="/foo"`) now also auto-derives `/foo/checkpoints/`
   for workflow targets (equivalent to passing
   `checkpoint_location="/foo/checkpoints"`). The mapping form lets
   workflow callers opt out by omitting the key, or route checkpoints
   to a different volume.

   Conflict / precedence rules:
   * Explicit `checkpoint_location` always wins over the state_dir
     derived path; a warning surfaces the double-config.
   * Single-path `state_dir` + non-Workflow target → checkpoints path
     silently ignored (no eager directory creation either).
   * Mapping form with `checkpoints` + non-Workflow target → warn
     (almost certainly dead config).
   * Derived path with a workflow that already has its own
     `checkpoint_storage` → same `RuntimeError` as the explicit
     parameter triggers, so ownership stays unambiguous.

   Checkpoint persistence uses `FileCheckpointStorage` from the
   framework core — no extra dependency. Only `runner` and
   `sessions` require the `[disk]` extra.

2. Move `AgentFrameworkHost.__init__` parameter docs from `Args:` to
   `Keyword Args:` for every parameter after the `*`. Only `target`
   remains under `Args:`. Brings the docstring in line with the
   actual signature (the params have always been keyword-only).

3. `HostStatePaths` already existed as a TypedDict but did not cover
   `checkpoints`; updated to document the new key with the same
   per-attribute docstring style as `runner` / `sessions` so editors
   can surface help on the keys.

Validation: 201 tests pass (was 194; +7 checkpoint integration tests
in test_host_disk.py). pyright + mypy + ruff + bandit clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(hosting): add core IdentityLinker authorization seam

Fold the core IdentityLinker pieces into the hosting-core PR so the
authorization surface no longer has a deferred Wave-2 placeholder.
Provider-specific linkers (for example Entra OAuth helpers) can now plug
into core without core depending on an IdP SDK.

Core additions:
- Add LinkChallenge, LinkedIdentity, LinkResolution, and IdentityLinker.
  IdentityLinker.resolve(identity) is a single-call decision that returns
  either a linked identity with verified claims or a challenge the channel
  can render.
- Enable LinkedClaimAllowlist end-to-end. It now abstains pre-link and
  allows/denies post-link against verified claims, including multi-valued
  claims such as groups.
- Add AuthPolicy factories for common allowlist shapes.
- Extend Allowed with verified_claims and claim_source for audit/telemetry
  without requiring callers to re-derive how the decision was made.

Host behavior:
- identity_linker is now typed as IdentityLinker | None.
- authorize() supports open, native-id, forced-link, and linked-claim
  profiles end-to-end.
- require_link=True resolves via the linker and returns LinkRequired when
  the identity is not linked.
- claim-based allowlists use channel-emitted verified_claims when present,
  or linker-resolved claims otherwise.
- authorize() remains decision-only and does not mutate _identities/_active;
  identity registry writes remain on the actual request execution path.

Docs/tests:
- Remove Wave-1/Wave-2 language from core/spec/ADR surfaces touched here.
- Update the spec/ADR to describe the core linker seam and provider-specific
  linker packages.
- Add authorization tests for linker challenges, linked identities, linked
  claim allowlists, channel-emitted claims, AuthPolicy factories, and the
  no-mutation contract.

Validation: 214 tests pass, pyright/mypy/ruff clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(hosting): add link-store path to state_dir

Identity linking introduces host-adjacent state that needs the same state_dir treatment as runner, session, and checkpoint state. Add a links component to the host state paths so applications and linker packages have a typed, discoverable persistence location.

Changes:
- Extend HostStatePaths with links and include it in state_dir normalization (state_dir/links/ for the single-path form).
- Add SupportsLinkStorePath, an optional protocol for identity linkers that accept a host-provided link-store path.
- AgentFrameworkHost now offers state_dir links to compatible linkers, warns when an explicit links path is supplied without a linker, and warns when the configured linker manages persistence directly instead of implementing SupportsLinkStorePath.
- Update README and spec text to document the link-store component and clarify that concrete linkers still own the storage format.
- Add disk-state tests for compatible, missing, and non-configurable linkers.

Validation: 217 tests pass, pyright/mypy/ruff clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Eduard van Valkenburg
2026-05-22 14:55:56 +02:00
committed by GitHub
co-authored by Copilot
parent e666cdc7c8
commit 0cb9b52a4b
24 changed files with 10619 additions and 107 deletions
+117 -6
View File
@@ -110,10 +110,12 @@ We will introduce a new hosting core distribution package per language. The full
- **Hostable target** — may be either an **agent** (per-language agent execution seam) or a **workflow** (per-language workflow execution seam). The host detects the kind and dispatches; channels are unchanged.
- **Channel**, **`ChannelContext`**, **`ChannelRequest`**, **`ChannelSession`**, **`ChannelContribution`**, **`ChannelCommand`** — the channel-authoring surface. Defined in Terminology.
- **`ChannelRunHook`** — the developer's runtime escape hatch over the uniform `ChannelRequest` envelope. Channels translate their native protocol payload into `ChannelRequest`; the hook then runs **after** that translation and **before** target invocation, receiving and returning a `ChannelRequest`. Examples (illustrative): reshaping a chat message into a workflow's typed input, dropping/injecting `ChatOptions` fields, enforcing required options, overriding `session_mode` / `response_target`.
- **`IdentityResolver`** + **`IdentityLinker`** — the channel-neutral identity stack. Resolver maps channel-native ids to `isolation_key`; linker runs the **link/connect ceremony** (OAuth / MFA / signed one-time code) so a new channel can join an existing `isolation_key`. The host owns the routes and short-lived state the linker needs; channels surface entry points. Channels may declare `require_link=True` to enforce "authenticate before chatting", and the linker stores verified IdP claims (e.g. Entra ID `oid`) so subsequent channels that supply the same claim are auto-merged onto the same `isolation_key` without a second ceremony.
- **`ResponseTarget`** + **`ChannelPush`** + **`ContinuationToken`** + **active channel** — the response-delivery stack. `ResponseTarget` decouples *where* a response is delivered from *where* it originated; `ChannelPush` is the optional channel capability used for non-`originating` delivery; `ContinuationToken` makes background runs first-class with a stable id and status; the host tracks last-seen `(isolation_key, channel)` to resolve `response_target="active"`.
- **`ChannelResponseHook`** — the *outbound* counterpart to `ChannelRunHook`. Applied per destination after target invocation, before the channel pushes the result onto its wire. Receives the `HostedRunResult` and a `ChannelResponseContext` (request, channel name, destination identity, originating flag, `is_echo` phase flag) and returns a (possibly transformed) `HostedRunResult`. Used for channel-side projections of the target's output: a text-only wire reads `result.result.text` (for agent targets) or projects `result.result.get_outputs()` into a single text turn (for workflow targets); a card-capable channel iterates the underlying contents; a workflow result with a typed final output can be rebound to a channel-friendly `AgentResponse` via `result.replace(result=...)`. Hooks are stored as a `response_hook` attribute on the channel instance — duck-typed, not part of the `Channel` Protocol, so adding hook support to a new channel package never breaks the Protocol contract. The host clones the `HostedRunResult` envelope per destination before invoking the hook so one channel's `replace(result=...)` cannot leak into another's payload.
- **`HostedRunResult[TResult]` is a generic typed envelope around the target's full-fidelity output.** For agent targets `TResult` narrows to `AgentResponse` (channels read `result.messages`, `result.value`, `result.usage_details`, `result.response_id`, … directly); for workflow targets to `WorkflowRunResult` (channels iterate `result.get_outputs()` / inspect `result.get_final_state()`). The host never collapses or pre-shapes — multi-modality and structured outputs survive end-to-end. The envelope also carries the resolved `session: AgentSession | None` (None for workflows, which do not own session state in the agent sense). Channels decide what subset their wire can carry through their `response_hook` and their native serializer.
- **`IdentityResolver`** + **`IdentityLinker`** + **`IdentityAllowlist`** — the channel-neutral identity stack. Resolver maps channel-native ids to `isolation_key`; linker runs the **link/connect ceremony** (OAuth / MFA / signed one-time code) so a new channel can join an existing `isolation_key`. The host owns the routes and short-lived state the linker needs; channels surface entry points. `IdentityAllowlist` is the **authorization** seam, orthogonal to linking: combined with the per-channel `require_link: bool` it produces three named profiles — **open** (default), **forced-link** (any authenticated identity), and **allowlist** (native-id list, IdP-claim list, or composition of both). Decisions are tri-state (`ALLOW` / `DENY` / `ABSTAIN`) so the host can run the allowlist twice — once with the raw channel identity and again with linker-emitted claims — and compose multiple lists (`AnyOfAllowlists`, `AllOfAllowlists`) without one list's missing information silently denying the request. The host runs a startup validator that rejects silent-deny-everyone configurations (e.g. a claim-based allowlist with no source of verified claims). Channels may declare `require_link=True` to enforce "authenticate before chatting", and the linker stores verified IdP claims (e.g. Entra ID `oid`) so subsequent channels that supply the same claim are auto-merged onto the same `isolation_key` without a second ceremony.
- **`ResponseTarget`** + **`ChannelPush`** + **`ContinuationToken`** + **active channel** — the response-delivery stack. `ResponseTarget` decouples *where* a response is delivered from *where* it originated; `ChannelPush` is the optional channel capability used for non-`originating` delivery; `ContinuationToken` makes background runs first-class with a stable id and status; the host tracks last-seen `(isolation_key, channel)` to resolve `response_target="active"`. `ResponseTarget` constructors that name destinations accept `echo_input=True` to also push the originating user message onto each non-originating destination before the agent reply — keeps the destination channel's UI coherent with the user's actual turn when the host orchestrates cross-channel delivery.
- **`confidentiality_tier`** + **`LinkPolicy`** — the multi-tier-on-one-host stack. `confidentiality_tier` is an opaque per-channel label; `LinkPolicy` is the host-level decision over which channel pairs may share an `isolation_key` (link) and which may push to one another (deliver). Built-in `DenyAllLinks` enforces "share a target, never share a session"; running multiple hosts is always a valid alternative.
- **Persisted delivery envelope** — assistant messages stored by the host carry a `deliveries[]` array on `Message.additional_properties["hosting"]` capturing the resolved destination set (per-destination `status`, `attempts`, timestamps, `last_error`, channel-issued `delivery_id`). This is the data model for **audit** ("which destinations did this response actually reach?") and for **replay** ("Telegram was offline; resend to that user when it comes back"). The replay *mechanism* is out of scope for v1; the data model is committed to so providers (especially the Foundry-backed Responses store) and operators can build on it. Live in-place updates require an opt-in `SupportsDeliveryTracking` provider capability; append-only providers degrade to write-once at completion.
- **Intent-only delivery envelope + pluggable `DurableTaskRunner`** — assistant messages stored by the host carry an `intended_targets[]` array on `Message.additional_properties["hosting"]` capturing the resolved destination set (after `ResponseTarget` + `LinkPolicy` filtering). The write is **immutable** — a single record of intent, never mutated post-push. Per-destination operational state (attempts, retries, last error, success timestamp, channel-issued id) lives in a pluggable `DurableTaskRunner` (`register` / `schedule` / `get`) that the host uses to fan out non-originating pushes. Built-in `InProcessTaskRunner` (asyncio + bounded retry) is the default for `long_running` deployments; adapter packages (`agent-framework-hosting-durabletask`, future Foundry adapter) plug in for `ephemeral` deployments. Replay is a property of the configured runner — native for durable adapters, not supported for in-process. This eliminates the earlier `pending`/`delivered`/`failed`/`skipped` state machine, the `SupportsDeliveryTracking` provider capability, and the Foundry `update_item` service ask.
- **Caller-supplied vs. host-tracked session carriage** — channels split into two families based on whether the upstream protocol carries a per-conversation key on every request. *Caller-supplied* channels (Responses' `previous_response_id`, Invocations, A2A, MCP) parse it into `ChannelSession.key` and let the caller branch threads by sending fresh ids. *Host-tracked* channels (Telegram, Activity Protocol via Azure Bot Service — Teams/Web Chat/Slack/…— WhatsApp) carry only a stable identity and rely on the host's per-`isolation_key` session alias plus a `host.reset_session(...)` `/new`-style command. The split is invisible to the agent target and explains why `reset_session` and aliasing exist at all (host-tracked channels have no other way to start a fresh thread). Anonymous vs. identified is an orthogonal axis; identity is supplied by the channel, the resolver, or both.
- **Multi-user surfaces are first-class.** Telegram groups, supergroups, forum topics, and Activity Protocol multi-user `conversationType`s (`groupChat`, `channel`) are designed-in from v1 — not retrofitted. The contract enforces a clean separation of **user identity** (`ChannelIdentity.native_id` = `from.id` / `from.aadObjectId`) and **conversation locator** (`ChannelRequest.conversation_id` = `chat.id` (+ optional `message_thread_id` / `replyToId`)). Channel implementations expose a `conversation_scope` option (`per_user`, `per_user_per_conversation` (default in groups), `per_conversation`) and an `accept_in_group` addressing rule (`mention_only` (default), `command_only`, `mention_or_command`, `all`) so the bot does not respond to every message in a group and so a single user's group context does not leak into their DM by default. Linker challenge messages (OAuth URL / one-time code) MUST redirect to the user's DM in group contexts.
- **Built-in channels** — own their protocol-defined relative routes under default mount roots (`/responses/v1`, `/invocations/invoke`, `/telegram/webhook`) without the app author spelling those out.
@@ -154,8 +156,11 @@ The top-level user experience should look the same conceptually in every languag
| Per-request hook | `ChannelRunHook = Callable[..., ChannelRequest \| Awaitable[ChannelRequest]]` invoked as `hook(request, *, target=..., protocol_request=...)` | `Func<ChannelRequest, ChannelRunHookKwargs, ValueTask<ChannelRequest>>` / delegate with named extras |
| Identity resolver | `IdentityResolver = Callable[[ChannelIdentity], str \| None]` | `IIdentityResolver` (returns `isolation_key`) |
| Identity linker | `IdentityLinker` Protocol with `begin(...)` / `complete(...)` plus `routes()` for callback / verification endpoints | `IIdentityLinker` interface with begin/complete + route contributions |
| Authorization policy | `require_link: bool` + `allowlist: IdentityAllowlist \| Literal["inherit"] \| None` on each channel; built-in allowlists `AllowAll`, `NativeIdAllowlist`, `LinkedClaimAllowlist`, `AnyOfAllowlists`, `AllOfAllowlists`, `CallableAllowlist`; host seam `host.authorize(identity, *, require_link, allowlist, verified_claims) -> AuthorizationOutcome` (`Allowed` \| `LinkRequired` \| `Denied`) with tri-state `AllowlistDecision` (`ALLOW` / `DENY` / `ABSTAIN`); named factories on `AuthPolicy` (`.open()` / `.require_link()` / `.native_allowlist(...)` / `.linked_claim_allowlist(...)`) | `IIdentityAllowlist.EvaluateAsync(AuthorizationContext)` returning `AllowlistDecision`; built-ins `AllowAll`, `NativeIdAllowlist`, `LinkedClaimAllowlist`, `AnyOfAllowlists`, `AllOfAllowlists`; `IAgentFrameworkHost.AuthorizeAsync(...)` returning `AuthorizationOutcome` discriminated union with `Allowed` / `LinkRequired` / `Denied` variants |
| Response routing | `ChannelRequest.response_target = ResponseTarget.originating \| .active \| .channel("activity") \| .all_linked \| .none`; channels expose `ChannelPush` if they can deliver proactively | `ChannelRequest.ResponseTarget` discriminated union; `IChannelPush` interface for proactive delivery |
| Background runs | `ContinuationToken` returned by `host.run_in_background(request)`; channels may return it as their protocol response and/or expose a poll route | `ContinuationToken` record + `HostStateStore` for persistence (file-based default; pluggable Cosmos / SQL / Redis) |
| Runtime mode | `runtime_mode: Literal["long_running", "ephemeral"] \| None = None` on `AgentFrameworkHost`; `None` triggers auto-detect via deployment env markers (`FOUNDRY_HOSTING_ENVIRONMENT`, `AZURE_FUNCTIONS_ENVIRONMENT`, `AWS_LAMBDA_FUNCTION_NAME`); falls back to `"long_running"`. Advisory — drives defaults for `HostStateStore` / `DurableTaskRunner` / identity-link state. | `RuntimeMode` enum on `AgentFrameworkHostBuilder` with the same auto-detection contract |
| Durable task runner | `DurableTaskRunner` Protocol (`register` / `schedule` / `get`) on the host; built-in `InProcessTaskRunner` (asyncio + bounded retry); adapter packages plug TaskHub / Foundry / SQLite backends. Used internally for non-originating push fan-out; in v1 fast-follow shared with background-run plumbing. | `IDurableTaskRunner` interface with the same register/schedule/get triple; built-in in-process runner; adapter packages mirror the Python set |
| Confidentiality tier on a channel | `Channel.confidentiality_tier: str \| None` (opaque) | `IChannel.ConfidentialityTier { get; }` (opaque string) |
| Link / delivery policy | `LinkPolicy = Callable[[LinkPolicyContext], bool]` with built-ins `AllowAllLinks`, `SameConfidentialityTierOnly`, `ExplicitAllowList`, `DenyAllLinks` | `ILinkPolicy.IsAllowed(LinkPolicyContext)` with the same set of built-in implementations |
| Command descriptor | `ChannelCommand` dataclass | `ChannelCommand` record |
@@ -165,6 +170,105 @@ Built-in channels own the default mapping from each protocol's request model int
The full Python API surface — exact types, fields, default routes, code samples — is specified in the companion Python spec. A future .NET spec captures the .NET-idiomatic API surface for the same model.
#### Runtime topology
How the pieces wire at runtime. Channels contribute routes to the host's app; inbound traffic splits at the parse step into a command dispatch (handled in the channel) or a message that flows through `host.authorize` → target invocation → response delivery. Non-originating destinations go through the configured `DurableTaskRunner`; the originating channel is rendered synchronously.
```mermaid
graph LR
Caller[External caller /<br/>messaging app]
subgraph Host[AgentFrameworkHost]
direction TB
ASGI[Starlette / ASP.NET Core app]
Router[Channel router]
Parse{parse →<br/>command or<br/>message?}
Auth[host.authorize]
Resolver[IdentityResolver]
Delivery[_deliver_response]
Push[_handle_push_task]
end
Channels[Channels<br/>Responses · Invocations ·<br/>Telegram · Activity ·<br/>IdentityLinker]
CmdHandler[CommandHandler<br/>via ChannelCommandContext]
Target[(Agent or Workflow)]
Runner[DurableTaskRunner]
StateStore[(HostStateStore)]
Caller --> ASGI
ASGI --> Router
Router --> Parse
Parse -- /command --> CmdHandler
Parse -- message --> Auth
CmdHandler -- ctx.run --> Auth
CmdHandler -- local reply --> Channels
Auth --> Resolver
Resolver --> StateStore
Auth --> Target
Target --> Delivery
Delivery -- originating sync --> Channels
Delivery -- non-originating --> Runner
Runner --> Push
Push --> Channels
Channels --> ASGI
```
#### Channel contribution shape
Every channel exposes the same three contribution slots (all optional except `routes`). The host duck-types each slot and stitches them in at construction.
```mermaid
graph LR
subgraph C[ConcreteChannel<br/>e.g. TelegramChannel]
direction TB
Routes[routes:<br/>webhook / poller / API endpoints<br/>→ Starlette router]
Commands[commands: Sequence ChannelCommand<br/>name · description · handle ·<br/>scopes · locales · expose_in_ui]
Push[ChannelPush.push<br/>+ optional ChannelPushCodec<br/>+ optional response_hook]
end
Host[Host]
Native[Platform native catalog<br/>Telegram set_my_commands ·<br/>Teams app manifest · …]
Dispatch[CommandHandler dispatch]
Delivery[Originating sync delivery<br/>+ runner-scheduled fan-out]
Routes -- contribute at startup --> Host
Commands -- startup projection --> Native
Commands -- runtime dispatch --> Dispatch
Push -- driven by --> Delivery
```
#### Authorization decision
`require_link` and `allowlist` are orthogonal axes. The `require_link` gate runs first against the current link state from `StateStore`; an unlinked identity on a `require_link=True` channel returns `LinkRequired` regardless of allowlist. A claim-dependent allowlist that has not yet seen claims returns `ABSTAIN` from `evaluate` and is converted into a `LinkRequired` outcome so the user gets a link prompt rather than a silent deny.
```mermaid
flowchart TB
Start([authorize identity,<br/>require_link, allowlist])
Linked{identity already<br/>linked?<br/>StateStore lookup}
Required{require_link?}
OpenPath{allowlist is None?}
Resolve[/isolation_key:<br/>linked → existing,<br/>else auto-issue channel:native_id/]
Evaluate[/allowlist.evaluate context/]
Decision{decision}
Abstain{requires_linked_claims?}
Allowed([Allowed isolation_key])
DeniedPre([Denied<br/>allowlist_denied_pre_link])
LinkReq([LinkRequired<br/>via configured linker])
Start --> Linked
Linked -- yes --> OpenPath
Linked -- no --> Required
Required -- yes --> LinkReq
Required -- no --> OpenPath
OpenPath -- yes --> Resolve --> Allowed
OpenPath -- no --> Evaluate --> Decision
Decision -- ALLOW --> Resolve
Decision -- DENY --> DeniedPre
Decision -- ABSTAIN --> Abstain
Abstain -- yes --> LinkReq
Abstain -- no --> Resolve
```
## Terminology
These terms are language-neutral and shared between Python and .NET implementations. Each language realizes them with idiomatic types and naming.
@@ -285,9 +389,15 @@ The decision is validated when, in each implementing language:
| 9 | Where do issued link grants live? | **File storage for v1**, leveraging Hosted Agents' isolated, persistent per-instance file storage. Resolved together with Q11. |
| 10 | Should the identity resolver be invoked per channel or once on the host with `(channel_id, native_id)`? | **Host-level resolver receiving `(channel_id, native_id)`** so cross-channel decisions stay in one place. Per-channel overrides remain a future option if real cases emerge. |
| 11 | Where does the continuation-token store live? At-rest format and TTL? | Same as Q9 — **file storage for v1** (`FileHostStateStore` under `./.af-hosting/continuations/`, atomic JSON-per-token writes, 24h default TTL on completed entries). Shares the host-level `HostStateStore` contract with link grants and last-seen records. Pluggable Cosmos / SQL / Redis adapters tracked in spec req #23. |
| 12 | Contract for `ChannelPush` failure (offline destination, opt-out, expired token)? | Default: **fall back to the originating channel**, recorded on the persisted `deliveries[]` array with telemetry. Per-request override via `run_hook`. (This already matches the spec; cross-check the wording.) |
| 13 | Should `response_target="active"` use a time window? Behavior on expiry? | Yes — configurable `active_window_seconds` on the host (suggested default **300 s**). On expiry, fall back to `originating`, then to `all_linked`. Recorded on `deliveries[]`. Per-request override via `run_hook`. |
| 12 | Contract for `ChannelPush` failure (offline destination, opt-out, expired token)? | **Retry handled by the configured `DurableTaskRunner` per its `RetryPolicy`.** The host registers a single internal `"hosting.push"` handler at startup; each non-originating destination becomes a `runner.schedule("hosting.push", payload)` call. Failures inside the handler are caught by the runner, retried with backoff, and ultimately marked terminal-failed when `max_attempts` is exhausted. Downstream push outcomes live in the runner's own log — there is no per-destination return surface. The earlier `DeliveryReport` value type has been removed; the host's internal `_deliver_response` helper returns `bool` (whether any work was scheduled) for the originating channel. Per-request override via `run_hook`. See the Python spec's [Intended targets + durable delivery](../specs/002-python-hosting-channels.md#intended-targets--durable-delivery) and [Durable task runner](../specs/002-python-hosting-channels.md#durable-task-runner). |
| 13 | Should `response_target="active"` use a time window? Behavior on expiry? | Yes — configurable `active_window_seconds` on the host (suggested default **300 s**). On expiry, fall back to `originating`, then to `all_linked`. Per-request override via `run_hook`. |
| 15 | Should `Channel.confidentiality_tier` stay opaque or become an ordered enum? | **Keep as opaque string.** Apps define their own taxonomy. Built-in policies do equality / set membership checks only — no ordered-comparison policy is shipped. |
| 16 | Should authorization (per-channel allowlist) ship as a single `auth_mode` enum or as two orthogonal parameters? | **Two orthogonal parameters (`require_link: bool` + `allowlist: IdentityAllowlist`)** plus named `AuthPolicy` factories for ergonomics. A single enum cannot express the Mixed profile (native ids bypass auth, everyone else is funneled into linking) without sub-parameters that defeat the point. Composition uses a **tri-state `AllowlistDecision` (`ALLOW` / `DENY` / `ABSTAIN`)** so claim-based allowlists can `ABSTAIN` until claims are available without that being read as a denial. `LinkedClaimAllowlist` use without a source of verified claims is rejected at host startup via a typed `ChannelConfigurationError` — silent deny-everyone is the worst possible default and is not allowed. The core PR includes the channel-neutral pieces: `IdentityAllowlist` Protocol, `AllowlistDecision`, built-ins (`AllowAll` / `NativeIdAllowlist` / `LinkedClaimAllowlist` / combinators / `CallableAllowlist`), `IdentityLinker` Protocol, `LinkedIdentity`, `LinkChallenge`, `AuthPolicy` factories, `Allowed` / `LinkRequired` / `Denied` outcomes, `Host(default_allowlist=..., identity_linker=...)` + per-channel `allowlist`, three-rule construction validator (`require_link=True` without a linker now raises), and `host.authorize(...)` for open, native-id, and linked-claim profiles. Provider-specific linkers (for example Entra OAuth helpers) ship as separate packages. See the Python spec's [Authorization profiles section](../specs/002-python-hosting-channels.md#authorization-profiles-and-the-identityallowlist-seam) for full mechanics. |
| 17 | How does the host decide long-running vs ephemeral runtime, and is that distinction enforced? | **Single `runtime_mode` parameter, advisory, auto-detected by default.** `None` (the default) inspects known deployment markers (`FOUNDRY_HOSTING_ENVIRONMENT`, `AZURE_FUNCTIONS_ENVIRONMENT`, `AWS_LAMBDA_FUNCTION_NAME`) and picks `"ephemeral"` on the first hit; otherwise falls back to `"long_running"` (sensible local-dev / always-on default). The mode drives the *default selection* of seams that have FHA-shaped vs container-shaped defaults — `HostStateStore`, `DurableTaskRunner`, identity-link state — but every choice remains independently overridable. Detected mode is logged at startup so misdetection is visible. See the Python spec's [Runtime modes](../specs/002-python-hosting-channels.md#runtime-modes). |
| 18 | How does delivery to non-originating destinations actually happen, and what is the retry / replay contract? | **Out-of-band via a pluggable `DurableTaskRunner`.** The host registers an internal `"hosting.push"` handler at startup; each non-originating destination becomes a `runner.schedule("hosting.push", payload)` call. The originating destination (when `ResponseTarget` includes it) is **still rendered synchronously** on the originating channel's wire — only fan-out goes through the runner. Default runner is `InProcessTaskRunner` (asyncio + bounded retry, no cross-restart persistence — suitable for `long_running`). Durable adapter packages (`agent-framework-hosting-durabletask`, future Foundry adapter) plug into the same Protocol for `ephemeral` deployments. Replay across host restarts is a property of the configured runner (native for durable adapters; not supported for the in-process runner). See the Python spec's [Durable task runner](../specs/002-python-hosting-channels.md#durable-task-runner). |
| 19 | What is the audit shape on the assistant message — full per-destination state machine, or intent only? | **Intent only.** `Message.additional_properties["hosting"]["intended_targets"]` is a single immutable write that records the resolved destination set (after `ResponseTarget` + `LinkPolicy` filtering). Operational state — attempt count, last error, success timestamp, channel-issued id — lives in the `DurableTaskRunner` and is observed via the runner's backend. This eliminates the earlier per-destination `pending`/`delivered`/`failed`/`skipped` state machine, the `SupportsDeliveryTracking` provider capability, and the Foundry `update_item` service ask. See the Python spec's [Intended targets + durable delivery](../specs/002-python-hosting-channels.md#intended-targets--durable-delivery). |
| 20 | What is the wire contract between `DurableTaskRunner` and push-capable channels when the runner is out-of-process? | **A two-piece contract.** Each `DurableTaskRunner` declares its `payload_mode` (`OBJECT` for in-process pass-by-reference; `JSON` for runners that round-trip through JSON). Push-capable channels that ship non-JSON-native payloads expose a `ChannelPushCodec` (`encode` / `decode`). At construction the host validates the pairing and refuses a `JSON`-mode runner paired with codec-less push channels (`ChannelConfigurationError`). The push handler accepts both `OBJECT` and `JSON` envelope shapes so the same handler serves both runner backends. See the Python spec's [Codec contract for durable serialisation](../specs/002-python-hosting-channels.md#codec-contract-for-durable-serialisation). |
| 21 | What is the operational contract for `runtime_mode="ephemeral"` without a configured durable runner, and for clean shutdown of the in-process runner? | **Strict ephemeral by default + 2-phase drain.** `ephemeral` + default (in-process) runner raises `RuntimeError` at construction unless `allow_in_process_runner=True` is opted in (warning logged) — silently using the in-process runner in an ephemeral environment would drop in-flight pushes on the next scale-to-zero. For `long_running`, `InProcessTaskRunner` ships a `shutdown_grace_seconds` window (default `5.0`) that lets in-flight retries finish before cancellation; `CancelledError` from the cancellation phase is swallowed as the expected shutdown shape. When `echo_input=True`, the push task carries an `echo_done` cursor in runner-owned state so a retry that fires after the echo succeeded does not double-echo. See the Python spec's [Durable task runner](../specs/002-python-hosting-channels.md#durable-task-runner). |
## Decisions-driven follow-ups
@@ -297,10 +407,11 @@ These are spec-body / sample / code edits implied by the resolutions above, **ou
- **Q7** — spec, `ChannelCommand` reference, and the Telegram channel design need optional `scopes` and `locales` fields with clear "channels free to ignore" semantics.
- **Q8** — ✅ Done in spec rev. Req #24 lists only `OAuthIdentityLinker` and `OneTimeCodeIdentityLinker`; the linker-helper table and the OAuth scenario no longer reference `MfaIdentityLinker`.
- **Q9 + Q11** — ✅ Resolved in spec rev. Spec req #23 now names the seam **`HostStateStore`** with a v1 default of `FileHostStateStore` (atomic JSON writes under `./.af-hosting/`), so continuation tokens, link grants, and last-seen records all survive single-node restarts. Pluggable Cosmos / SQL / Redis adapters remain v1 fast follow.
- **Q12** — verify the spec's `ChannelPush` failure narrative includes "recorded on `deliveries[]`" alongside "telemetry warning"; tighten if needed.
- **Q12** — ✅ Resolved by the durable-delivery seam (Q18). The `ChannelPush` failure narrative is now "retry per `RetryPolicy` via the runner, observe in the runner's backend"; no separate `deliveries[]` annotation required.
- **Q13** — add `active_window_seconds` (default 300 s) to the host config surface and document the `originating``all_linked` fallback chain.
- **Q14** — explicitly document the **swappable WS codec** property in the Responses channel section (host contract does not depend on the framing) so the spec stays valid as upstream OpenAI evolves.
- **Q15** — confirm the spec consistently treats `confidentiality_tier` as an opaque string and that no built-in policy assumes an ordered hierarchy.
- **Q17 / Q18 / Q19** — ✅ Spec text added: new top-level §[Runtime modes](../specs/002-python-hosting-channels.md#runtime-modes), rewritten §[Intended targets + durable delivery](../specs/002-python-hosting-channels.md#intended-targets--durable-delivery), new §[Durable task runner](../specs/002-python-hosting-channels.md#durable-task-runner). Core code lands `DurableTaskRunner` Protocol + `InProcessTaskRunner` + `runtime_mode` constructor parameter + auto-detection in this PR; durable runner adapters (`agent-framework-hosting-durabletask`, Foundry adapter) ship as separate follow-up packages.
## More Information
File diff suppressed because it is too large Load Diff