Compare commits

...
Author SHA1 Message Date
c91b88f217 Python: add agent-framework-hosting-mcp channel (#6305)
* feat(python): add agent-framework-hosting-mcp channel

Add a hosting channel that exposes the host target (agent or workflow)
as a single Model Context Protocol tool over Streamable HTTP. The tool
invocation routes through the host pipeline (ChannelContext.run/
run_stream) so sessions, linking, and run/response hooks apply. Maps the
MCP request context to a ChannelSession isolation key and ChannelIdentity,
and forwards streaming output as MCP progress notifications.

Includes tests, README, and workspace registration.

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

* Address MCP hosting channel review feedback

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-12 12:25:43 +02:00
5534198142 Python: add agent-framework-hosting-a2a channel (#6306)
* feat(python): add agent-framework-hosting-a2a channel

Add a hosting channel that exposes the host target (agent or workflow)
as a peer agent over the Agent-to-Agent (A2A) protocol (JSON-RPC plus a
served agent card). Requests are handled by a host-routed
HostAgentExecutor that drives the host pipeline (ChannelContext.run/
run_stream) instead of wrapping the target directly, so sessions,
linking, and run/response hooks apply. Maps the A2A conversation/context
id to a ChannelSession isolation key and the caller to a ChannelIdentity;
streaming emits incremental task artifacts.

Includes tests, README, and workspace registration.

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

* Address A2A hosting channel review feedback

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-12 12:20:54 +02:00
36ce0950e4 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>
2026-06-12 08:34:08 +02:00
e5a6e35843 Python: feat(python): cross-channel hosting improvements (endpoint paths, Activity push, Telegram/Teams fixes) (#6307)
* Update hosting channel endpoint paths

Treat channel paths as concrete endpoint paths so built-in channels can be mounted at their defaults or at the app root without sample-specific subclasses. Update docs, tests, and the Foundry Telegram Invocations sample accordingly.

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

* Add push support to ActivityProtocolChannel

Implement the ChannelPush protocol so the Activity Protocol channel can
receive cross-channel fan-out (ResponseTarget.all_linked) and echo_input
replay as a non-originating destination:

- Add push() that reconstructs a proactive Bot Framework activity (bot/user
  swap) from the stored conversation reference and POSTs it to
  /v3/conversations/{id}/activities.
- Record a ChannelIdentity (service_url, conversation, bot, user, channel_id,
  locale) on ChannelRequest.identity so the host registers the channel under
  its isolation key for fan-out resolution.
- Route the streaming path through deliver_response so Activity-originated
  turns broadcast like Telegram/Discord.
- Add tests for push delivery, service_url validation, ChannelPush instance
  check, and inbound identity recording.

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

* Don't delete Telegram webhook on shutdown by default

The TelegramChannel deleted its webhook on shutdown in webhook mode. During
a rolling redeploy the new revision registers the webhook on startup, then
the old revision's shutdown deletes it, silently breaking inbound delivery
until the next boot. setWebhook is overwriting/idempotent, so startup
re-asserts the webhook every boot and no teardown is needed.

Add a delete_webhook_on_shutdown flag (default False) so teardown is opt-in
for ephemeral deployments, and leave the webhook in place otherwise.

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

* Fix Activity channel streaming on non-Teams channels (405 on updateActivity)

The Activity Protocol channel streamed replies the Teams way: POST a
placeholder, then PUT-edit it as tokens arrive. Only Teams supports the
updateActivity REST op; Web Chat, Direct Line and the Emulator return
405 Method Not Allowed on the PUT, so the user saw only the placeholder.

Gate the placeholder+edit flow on edit-capable channels (msteams). Other
channels now buffer the stream and POST a single final message, mirroring
the non-streaming path's fan-out and response-hook semantics. Also add a
defensive 405 fallback inside the Teams edit loop so an unexpected 405
can never strand the user on the placeholder.

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

* fix(hosting-activity-protocol): don't parse Teams inline attachment content as a URI

Teams message activities include a text/html attachment whose inline
`content` is raw HTML (not a URL). _parse_activity fell back to
`attachment["content"]` and passed it to Content.from_uri, raising
ContentError ("URI must contain a scheme") and failing the whole turn,
so Teams users got no response.

Only treat `contentUrl` as a URI, require an absolute scheme, and skip
unparseable attachments defensively instead of failing the message.

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

* feat(hosting-activity-protocol): native slash-command dispatch for Teams/Activity

Add a commands= parameter to ActivityProtocolChannel that intercepts a
leading /command (after stripping the bot's own @mention) and dispatches
to ChannelCommand handlers, mirroring the Telegram channel. Unknown
commands fall through to the agent. The channel run_hook is applied to
command requests so handlers observe the same resolved isolation key as
ordinary messages, and handler errors are swallowed (200, no Bot Service
retry of non-idempotent commands).

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

* feat(hosting): silent attributed Telegram echoes + Teams markdown rendering

- hosting-telegram: send cross-channel input echoes with disable_notification
  (silent) and detect echo payloads so they aren't re-broadcast.
- hosting-activity-protocol: render outbound + push activities as textFormat
  'markdown' so Teams shows formatted replies (enables per-channel variants).

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

* fix(hosting-activity-protocol): address PR #6307 review feedback

Consult the host delivery pipeline even for empty streamed replies so
ResponseTarget.none is honoured and non-originating fan-out is consulted
instead of always emitting an originating "(no response)" message. Applies
to both the progressive-edit (Teams) and buffered (Web Chat/Direct Line)
streaming paths.

Re-validate service_url against the allow-list in push(): the identity is
read from a persisted store and push runs out-of-band, so the captured
service_url must be re-checked before a bearer token is sent.

Adds tests for empty-stream host consultation/suppression on both streaming
paths and for push rejecting a disallowed service_url.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-03 16:37:03 +02:00
e8c22caaeb Python: add agent-framework-hosting-discord channel (#6081)
* Add Discord hosting channel

Add an alpha agent-framework-hosting-discord package backed by Discord HTTP Interactions. The channel verifies signed slash-command requests, registers commands, runs hosted agents and ChannelCommand handlers, supports originating response hooks, streams by editing the original interaction response, and can push through Discord channel ids.

Factor standard channel response-hook context application into hosting core so both host fan-out and originating channel replies use one helper.

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

* Address Discord review chunking feedback

Ensure Discord command replies are chunked and streaming preview edits stay under Discord's content limit while final streamed replies continue through the chunked reply path.

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

* small fix in init

* updated lock

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 15:53:23 +02:00
6b822853eb Python: add hosting Channels sample apps (#5645)
* samples(hosting): add hosting Channels sample apps under samples/04-hosting/af-hosting

Adds five end-to-end sample apps under
``python/samples/04-hosting/af-hosting/`` that exercise the
``agent-framework-hosting`` Channels stack from the simplest single-channel
case up to a multi-channel deployment with cross-channel identity linking.

Samples (ordered by complexity)
-------------------------------

* ``foundry_hosted_agent/`` — minimal Responses + Invocations host with a
  Foundry-backed agent and ``FoundryHostedAgentHistoryProvider``.
  ``agd``-deployable; bundles a ``Dockerfile`` and
  ``scripts/vendor-packages.sh`` that copies workspace packages into
  ``_vendor/`` for self-contained builds. ``_vendor/`` is gitignored.
* ``local_responses/`` — single-channel Responses host with a
  ``run_hook`` that strips caller-supplied options and forces a
  reasoning preset. Demonstrates the hook seam over the uniform
  ``ChannelRequest`` envelope.
* ``local_responses_workflow/`` — Responses + Invocations exposing a
  three-agent workflow with per-conversation checkpoint storage.
* ``local_telegram/`` — Responses + Telegram with a ``@tool``,
  ``FileHistoryProvider``, hooks, and a ``ResponseTarget`` multicast
  variant (``call_server_multicast.py``) that pushes a single Responses
  reply to a separate Telegram chat.
* ``local_identity_link/`` — full surface: Responses + Invocations +
  Telegram + Activity Protocol (Teams) + the ``EntraIdentityLinkChannel``
  sidecar. Resolves per-channel ids onto a single Entra object id so a
  user's history follows them across surfaces.

Notes
-----

* Samples that use Telegram/Teams via Activity Protocol depend on the
  renamed ``agent-framework-hosting-activity-protocol`` package (see the
  PR-5 series).
* All samples use ``[tool.uv.sources]`` editable workspace deps, except
  ``foundry_hosted_agent/`` which uses the ``./_vendor/`` self-contained
  layout for ``azd`` Docker builds.
* Each sample includes a ``README.md`` with run instructions and an
  ``app.py`` ASGI entrypoint plus a ``call_server.py`` client harness.

Depends on the prior hosting PRs (foundry-hosted-agent refactor +
hosting-core + the per-channel packages). After those merge, this
branch can be rebased onto ``main`` cleanly.

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

* samples(hosting): point sample deps at the feature/python-hosting GitHub branch

Switches every sample's ``[tool.uv.sources]`` from in-monorepo
editable path deps (which only resolve when running inside the
agent-framework workspace) to git refs targeting the
``feature/python-hosting`` branch on
``microsoft/agent-framework``. Samples now install standalone outside
the monorepo while the ``agent-framework-hosting*`` packages are still
pre-PyPI; once they publish, the ``[tool.uv.sources]`` block can be
dropped and the declared deps resolve from PyPI.

Cleanup
-------

* Drops ``foundry_hosted_agent/scripts/vendor-packages.sh``,
  ``_vendor/`` from ``.gitignore``, the ``hooks.prepackage`` block in
  ``azure.yaml`` and the ``COPY _vendor/`` step in the Dockerfile —
  vendoring is no longer needed because git refs make the deps
  network-resolvable from any context.
* Drops obsolete ``workspace.pyproject.toml`` reference and ``scripts/``
  / ``workspace.pyproject.toml`` entries from
  ``Dockerfile.dockerignore``.
* Updates the foundry sample's Dockerfile to ``uv sync --no-dev``
  (no ``--frozen``) so it locks fresh against the GitHub-hosted deps
  at build time.
* Drops every committed ``uv.lock`` because the resolver needs network
  access to ``feature/python-hosting`` to lock — they regenerate the
  first time a user runs ``uv sync`` after the branch lands.
* Refreshes the per-sample READMEs to mention the GitHub install path
  instead of "in-tree workspace packages".

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

* samples(hosting): address PR #5645 review comments

- foundry_hosted_agent/call_server.py: replace hard-coded
  project_endpoint and service_session_id with FOUNDRY_PROJECT_ENDPOINT,
  FOUNDRY_HOSTED_AGENT_NAME, and optional FOUNDRY_HOSTED_SESSION_ID
  environment variables. Session-id is now optional so the sample
  exercises the new-conversation path by default.

- local_identity_link/app.py:
  * make_telegram_hook: apply the reasoning bump regardless of
    identity-link state (the previous early-return on linked chats
    silently dropped the high-effort preset for the very flow the
    sample exists to demonstrate).
  * make_responses_hook: add a prominent DEV-ONLY warning that the
    client-supplied entra_oid shortcut bypasses identity verification
    and must be replaced by a JWT validator in production.
  * /link command: early-return when chat_id is missing instead of
    minting an authorize URL keyed on "telegram:None" (which would
    poison the link store with a binding any future chat_id-less
    update would collapse onto).
  * Switch ENTRA_CERT_PATH / ENTRA_CERT_PASSWORD env vars to the
    longer ENTRA_CERTIFICATE_PATH / ENTRA_CERTIFICATE_PASSWORD names
    that the README already documents.
  * channels: Sequence[Channel] -> list[Channel] (the next line
    appends, which a Sequence type doesn't expose).

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

* chore(hosting-samples): apply sample formatting

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

* fix(hosting-samples): guard command input text

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 14:57:46 +02:00
fe89da15b6 Python: add agent-framework-hosting-entra identity-link helpers (#5644)
* feat(hosting-entra): add Entra (Azure AD) identity-linking channel

New ``agent-framework-hosting-entra`` package implementing a Microsoft
Entra OAuth-based identity-linking channel for the Hosting framework.
Mounts a small set of routes (``/entra/login``, ``/entra/callback``,
``/entra/whoami``) that walk a user through an Entra/Azure AD
authorization-code flow and stick the resulting verified identity
(``oid`` / ``email`` / ``tid``) onto the host's identity table so
later requests on any other channel (Responses, Telegram, …) can be
linked to the same user.

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

- ``EntraChannel`` -- concrete ``Channel`` implementation. Owns the
  three Starlette routes, signs/verifies short-lived ``state`` tokens
  to bind the round-trip to the originating channel, exchanges the
  authorization code for an ID token via MSAL, and writes the
  verified identity into the host's identity store via the standard
  ``ChannelIdentity`` plumbing so cross-channel push (e.g. send a
  Telegram message to the user who completed the link from
  Responses) works without the channels having to coordinate
  directly.
- 14 unit tests covering route wiring, ``state`` issue / verify,
  callback exchange happy + failure paths, and identity-store write.

Registers the package in ``python/pyproject.toml``
``[tool.uv.sources]`` and adds the matching pyright
``executionEnvironments`` entry. Stacks on PR-2 (Hosting core);
independent of PR-3 / PR-4 / PR-6.

The cross-channel sample (``local_identity_link/``) that demonstrates
this end-to-end alongside Responses + Telegram lands in PR-8 (samples).

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

* fix(hosting-entra): close IDOR + reflected-XSS + open-redirect on the OAuth flow

Three SECURITY-CRITICAL fixes flagged in round-2 review.

1. IDOR on /auth/start (3198518308). Without authentication the
   endpoint accepted (channel, channel_id) from the query string and
   bound *whoever signed in* to that pair. An attacker could bind
   their own Entra oid to a victim's per-channel id (e.g.
   `telegram:<victim_chat_id>`), redirecting all of the victim's
   future inbound traffic to the attacker's isolation key.

   Fix: introduce link_token_secret + mint_start_url(channel, id, ...).
   When set, /auth/start requires `exp` + `sig` (HMAC-SHA256 over
   `channel|channel_id|expires_at`) before issuing the redirect.
   Channels that hand out start URLs (a Telegram /link command after
   verifying the inbound webhook signature) call mint_start_url so
   the token proves the (channel, id) pair was authorised by the
   channel that owns the surface. Unsigned mode is opt-in and logs a
   loud WARNING at startup *and* on every accepted request.

2. Reflected XSS on /auth/callback (3198520256, 3198527896). `error`,
   `error_description`, channel_key (from the unauthenticated /start
   query), and `upn` (from a Graph response) flowed straight into the
   text/html response body unescaped. With the IDOR above, an
   attacker could stash `<script>` payloads in `channel` or `id` and
   serve them from the auth host's origin (full XSS on the auth
   surface — cookies/storage of anything else mounted there).

   Fix: html.escape() every value before HTML output.

3. Open redirect on `return_to` (3198524746). Accepted any URL.

   Fix: `_validate_return_to` allows only relative paths starting
   with `/` (and not `//`) or absolute URLs whose host equals the
   configured `public_base_url` host. Validated at /start mint time
   AND defensively re-validated at /callback before redirect.

12 new tests cover signed-token rejection (missing/forged/expired),
mint helper requirements, startup warning visibility, XSS escaping
on both error and success paths, and the open-redirect allowlist
(external rejected, relative accepted, same-origin accepted,
protocol-relative `//evil.example/` rejected).

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

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

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

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 14:47:36 +02:00
cdea9fa956 Python: add agent-framework-hosting-activity-protocol channel (#5641)
* feat(hosting-activity-protocol): rename Bot Framework channel to ActivityProtocolChannel

The existing Bot-Framework-via-Azure-Bot-Service channel was previously
shipped under the name ``hosting-teams`` / ``TeamsChannel``. That name
is misleading for what the channel actually does -- it speaks the Bot
Framework Activity Protocol against Azure Bot Service, which fans out
across MS Teams, Slack, Webex, Telegram-via-Bot-Service, etc., and does
not provide any Teams-specific affordances.

This PR renames the package atomically and frees the ``hosting-teams``
name for a future Teams-native channel built on
``microsoft-teams-apps`` (PR-5b, spec req #28).

Renames (all in one commit):

- Package: ``agent-framework-hosting-teams`` ->
  ``agent-framework-hosting-activity-protocol``
- Module: ``agent_framework_hosting_teams`` ->
  ``agent_framework_hosting_activity_protocol``
- Channel class: ``TeamsChannel`` -> ``ActivityProtocolChannel``
- Helper: ``teams_isolation_key`` -> ``activity_protocol_isolation_key``
  (isolation key prefix ``teams:`` -> ``activity:``)
- Channel name: ``"teams"`` -> ``"activity"``; default mount path
  ``/teams`` -> ``/activity``
- Internal helper: ``_parse_teams_activity`` -> ``_parse_activity``
- Worker task name + a couple of error strings updated for consistency

Updates README.md and the module docstring to call out:

- this is the channel-neutral Activity Protocol channel,
- it surfaces what every Bot-Service-connected channel has in common
  (text in / text out),
- a forthcoming ``agent-framework-hosting-teams`` package will layer
  Teams-specific affordances (adaptive cards, message extensions,
  dialogs, SSO, ...) on the same Bot Service transport.

Workspace: registers ``agent-framework-hosting-activity-protocol`` in
``python/pyproject.toml`` and adds the matching pyright
``executionEnvironments`` entry.

Behavior is unchanged. Pyright + mypy clean, 11 tests pass.

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

* review: address PR-5 round 2 feedback

- security (#3198327004): add `service_url_allowed_hosts` constructor
  option (default `botframework.com` + `smba.trafficmanager.net`) and
  reject inbound activities whose `serviceUrl` host falls outside it
  with HTTP 400 — without this gate a malicious caller could redirect
  outbound replies (and the attached bearer token) to an
  attacker-controlled host
- security (#3198324219): add `inbound_auth_validator` async callback;
  log a loud WARNING at startup when no validator AND no operator
  reverse-proxy is configured so the dev-mode bypass cannot
  accidentally ship to production. Document the contract: prototype
  intentionally does not ship JWT validation (out of scope); operators
  must plug a validator or terminate auth in front of the channel
- retry semantics (#3198328746): distinguish transient outbound
  failures (httpx network errors, non-2xx from Bot Service) — return
  502 so Bot Service retries — from deterministic agent failures —
  return 200 so Bot Service does not retry the same broken activity
  in a loop
- bug (#3198330424): fix the placeholder-failure deadlock. When
  `send_initial_placeholder` fails, `activity_id` stays `None`, the
  edit-worker loop exit condition (`accumulated == last_sent`) is
  unreachable while no PUT is possible, and the worker would deadlock
  on `wake.wait()` forever after `worker_done` is set. Now: skip the
  worker entirely on placeholder failure and POST a single final
  activity at the end with whatever accumulated
- tests (#3198334465, #3187178091, #3198336045): add coverage for
  - `_is_service_url_allowed` allow/deny matrix + webhook 400 on
    disallowed serviceUrl
  - `inbound_auth_validator` allow/deny/raises paths
  - outbound `Authorization: Bearer <token>` header presence in
    production mode and absence in dev mode
  - the streaming path (`_stream_to_conversation`): placeholder +
    final edit, placeholder-failure fallback (with timeout guard
    against deadlock regression), and empty-stream `(no response)`
    placeholder replacement
  - retry-signal differentiation: outbound `httpx.ConnectError` →
    502; deterministic `ValueError` from the agent → 200

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

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

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

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

* feat(hosting-activity-protocol): add response hooks

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

* docs(hosting-activity-protocol): mark constructor keyword args

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 14:37:18 +02:00
f0b9ab6733 Python: add agent-framework-hosting-telegram channel (#5643)
* feat(hosting-telegram): add Telegram channel package

New ``agent-framework-hosting-telegram`` package implementing the
Telegram Bot API channel for the Hosting framework. Mounts a webhook
endpoint (``POST /telegram/webhook``) and an in-process polling loop
onto an ``AgentFrameworkHost`` and translates Telegram ``Update``
payloads to/from the channel-neutral ``ChannelRequest`` /
``HostedRunResult`` plumbing.

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

- ``TelegramChannel`` -- concrete ``Channel`` implementation. Owns the
  webhook route + an optional ``getUpdates`` long-polling lifespan,
  parses Telegram ``Update``s into ``ChannelRequest`` (text, photo,
  document, voice, callback_query, …), runs the optional
  ``ChannelRunHook``, calls back into the ``ChannelContext`` to invoke
  the agent target, and posts the response back via
  ``sendMessage`` / ``sendChatAction`` / ``answerCallbackQuery`` on the
  Telegram Bot API. Honours ``DeliveryReport.include_originating`` so
  cross-channel pushes can target the originating Telegram chat
  without double-acking.
- Native fields the channel doesn't lift onto ``ChannelRequest`` (e.g.
  ``chat.type``, ``message.message_id``, ``callback_query.data``) are
  attached to ``ChannelRequest.attributes`` so a ``ChannelRunHook``
  can pick them up via the standard ``protocol_request=`` kwarg.
- 13 unit tests covering route wiring, ``Update`` parsing across the
  common content shapes, hook composition, and originating vs
  non-originating delivery branches.

Registers the package in ``python/pyproject.toml``
``[tool.uv.sources]`` and adds the matching pyright
``executionEnvironments`` entry. Stacks on PR-2 (Hosting core);
independent of PR-3 / PR-4.

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

* fix(hosting-telegram): preserve in-chat ordering, ack-before-run, drain shutdown

- Replace per-update task fan-out with per-chat asyncio.Queue + worker.
  Telegram only guarantees update ordering up to getUpdates; the
  previous code spawned one task per update, which broke ordering for
  adjacent updates in the same chat. Updates are now serialised per
  chat_id (so /start then "what's the weather" can't race) while
  different chats still process in parallel.

- Webhook handler now acks (200) immediately and runs the agent in
  the per-chat worker. Telegram redelivers any update the webhook
  doesn't 200 within ~60 seconds, so a streamed agent reply that runs
  longer than that previously triggered a retry storm and duplicate
  replies.

- _on_shutdown now drains everything: poll task → per-chat workers →
  webhook-spawned dispatcher tasks (the new ack-before-run path), then
  deletes the webhook + closes the HTTP client. Previously webhook
  tasks were not tracked at all, so an in-flight agent invocation
  could leak past app shutdown.

- _enqueue_update extracts chat_id from message / edited_message /
  callback_query; updates with no resolvable chat fall back to a
  one-shot dispatcher task that's still tracked in _update_tasks for
  shutdown.

- Webhook handler now also returns 400 on malformed JSON / non-object
  payloads instead of crashing the request.

4 new tests cover per-chat serial ordering, parallel-across-chats
isolation, ack-before-run latency, and shutdown drain.

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

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

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

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

* fix(hosting-telegram): adapt push tests to hosted run result wrapper

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

* feat(hosting-telegram): add response hooks

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 14:28:30 +02:00
cb1d4a6ee5 Python: add agent-framework-hosting-invocations channel (#5640)
* feat(hosting-invocations): add Invocations channel package

New ``agent-framework-hosting-invocations`` package implementing the
"Invocations" HTTP channel for the Hosting framework -- a lightweight
JSON-over-HTTP shape (``POST /invocations``) for callers that want a
single request/response without committing to the full OpenAI Responses
envelope. Mounts onto an ``AgentFrameworkHost`` like any other channel.

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

- ``InvocationsChannel`` -- concrete ``Channel`` implementation. Owns
  the Starlette route, parses inbound JSON into a ``ChannelRequest``
  (``input`` / ``session`` / ``metadata`` / ``options``), runs the
  optional ``ChannelRunHook``, calls back into the ``ChannelContext``
  to invoke the agent target, and returns a flat JSON envelope (or an
  SSE stream when ``stream=true``).
- 8 unit tests covering route wiring, isolation-key passthrough, hook
  composition, sync vs streaming paths, and ack-only behaviour for
  non-originating ``DeliveryReport``s.

Registers the package in ``python/pyproject.toml`` ``[tool.uv.sources]``
and adds the matching pyright ``executionEnvironments`` entry.

Independent of PR-3 (Responses); both depend only on PR-2 (Hosting
core).

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

* review: address PR-4 round 2 feedback

- expand `_stream` docstring to call out the HTTP-200 + `event: error`
  SSE contract (status committed before generator runs; hard failures
  surface as the first SSE frame, not an HTTP code)
- split chunked text on full-line terminators via `splitlines()` so
  embedded `\r` / `\r\n` no longer leak into `data:` framing on the
  wire, breaking EventSource consumers
- on `get_final_response()` failure, emit `event: error` instead of
  silently swallowing — finalize is what triggers
  history-provider persistence on the agent side, so a 5xx /
  disk-full / context-provider error must reach the client
- add tests covering `stream_transform_hook` (rewrite, drop, async),
  CRLF-in-chunk framing, and the finalize-error → no-`[DONE]` contract

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

* docs(hosting-invocations): rename stale ChatMessage docstring reference to Message

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

* fix(hosting-invocations): adapt to hosted run result wrapper

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

* feat(hosting-invocations): add response hooks

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 14:08:34 +02:00
d75f55ee2c Python: add agent-framework-hosting-responses channel (#5639)
* feat(hosting-responses): add OpenAI Responses-shaped channel package

New ``agent-framework-hosting-responses`` package implementing the
OpenAI Responses-shaped HTTP channel for the Hosting framework. Mounts
``POST /responses`` (and a ``/responses/{response_id}`` GET) onto an
``AgentFrameworkHost`` and translates the OpenAI Responses wire shape
to/from the channel-neutral ``ChannelRequest`` / ``HostedRunResult``
plumbing.

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

- ``ResponsesChannel`` -- concrete ``Channel`` implementation. Owns the
  Starlette route(s), parses inbound JSON into ``ChannelRequest``, runs
  the optional ``ChannelRunHook``, calls back into the
  ``ChannelContext`` to invoke the agent target, builds Responses
  envelopes (sync JSON or SSE), and respects
  ``DeliveryReport.include_originating`` so cross-channel push routes
  only ack to the originating Responses caller.
- The minted ``response_id`` is propagated via the host's ContextVar
  machinery so storage-side history providers (e.g.
  ``FoundryHostedAgentHistoryProvider``) persist envelopes against the
  same id the channel returns.
- 48 unit tests covering route wiring, parsing of each Responses input
  shape, hook composition, sync vs streaming paths, and originating
  vs non-originating delivery branches.

Registers the package in ``python/pyproject.toml`` ``[tool.uv.sources]``
and adds the matching pyright ``executionEnvironments`` entry.

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

* review: address PR-3 round 2 feedback

- consume IsolationKeys.chat_key from the host-bound contextvar instead
  of the raw `x-agent-chat-isolation-key` header off the wire so the
  host's ASGI isolation middleware (or any operator-supplied
  replacement) is the authoritative point at which the caller is
  authenticated and the bucket key is established
- expand `response_id_factory` docstring to call out partition
  co-location vs. partition-ownership enforcement: the channel forwards
  `previous_response_id` as a hint to the factory; the storage layer
  validates the embedded partition against the bound user/chat
  isolation keys
- on mid-stream failure, call `deliver_response` with the accumulated
  text before emitting `response.failed` so host-side history /
  push-channel state stays consistent with the partial deltas the
  client already saw

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

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

ChatAgent was renamed to Agent and ChatMessage to Message. Update the
README quickstart to use client.as_agent(...) and refresh the stale
docstring reference in _channel.py.

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

* fix(hosting-responses): adapt to hosted run result wrapper

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

* feat(hosting-responses): add response hooks

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

* fix(hosting-responses): keep instructions in chat options

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 13:56:43 +02:00
4c317eb7cf Python: refactor FoundryHostedAgentHistoryProvider onto Foundry SDK (#5637)
* refactor(foundry_hosting): build FoundryHostedAgentHistoryProvider on azure.ai.agentserver SDK

Rebuilds the Foundry hosted-agent history provider on top of
``azure.ai.agentserver``'s ``FoundryStorageProvider`` instead of the
in-house ``_HttpStorageBackend``. Splits the monolithic ``_responses.py``
into focused modules:

- ``_history_provider.py`` — new ``FoundryHostedAgentHistoryProvider``
  that talks to the SDK's ``FoundryStorageProvider``, threads
  ``response_id`` / ``previous_response_id`` through ``ContextVar``s via
  ``bind_request_context``, and lifts host-bound isolation keys
  (``x-agent-{user,chat}-isolation-key``) from the optional
  ``agent_framework_hosting`` package into a provider-local
  ``IsolationContext`` so the storage layer carries the correct
  partition keys without channels having to know about them.
- ``_shared.py`` — extracts all SDK ``Item`` / ``OutputItem`` ↔
  framework ``Message`` conversion helpers into one place so both
  ``_responses.py`` and the new history provider can share them.
  Restores ``_convert_file_data`` for inline ``input_file`` payloads,
  and the hosted-MCP routing for ``custom_tool_call_output`` items
  whose ``call_id`` carries the ``mcp_*`` prefix.
- ``_ids.py`` — shared id helpers.
- ``_responses.py`` — shrinks ~700 lines, re-exports converters for
  back-compat with existing tests.
- ``tests/test_history_provider.py`` — exercises the new provider
  against a fake SDK backend; the host-isolation test is gated on the
  optional ``agent_framework_hosting`` import.

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

* feat(foundry_hosting): add local_storage_root for file-based dev history

Adds an optional `local_storage_root: str | Path | None` parameter to
`FoundryHostedAgentHistoryProvider`. When set and the provider is
running outside a Foundry Hosted Agent container, conversations are
persisted to JSONL files via `agent_framework.FileHistoryProvider`
laid out as:

  {root}/{user_key or '~none'}/{chat_key or '~none'}/{session_id}.jsonl

Hosted mode (FOUNDRY_HOSTING_ENVIRONMENT set) ignores the option with a
one-time INFO log so Foundry storage always wins on the platform. The
in-memory fallback is unchanged when the option is omitted.

Path safety: isolation segments are validated against the same character
allowlist FileHistoryProvider uses for session-id stems and
base64-url-encoded with a reserved "~iso-" prefix when unsafe. "~none"
sentinel for missing keys can never collide with a real isolation key
(real keys starting with "~" are encoded). The resolved target dir is
also re-checked to be inside the configured root.

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

* fix(foundry_hosting): address PR-1 review comments

- _shared.py:_capture_raw narrows `except Exception` to `except TypeError`
  and emits a WARNING with traceback so the lossy fallback to a
  synthesized round-trip is observable. Mirrors the reviewer suggestion.

- _history_provider.py:save_messages narrows `except Exception` to
  `except FoundryStorageError` so only storage-validation failures
  (4xx/5xx, opaque server errors) are swallowed. Network / TLS / auth
  / payload-builder bugs propagate so the caller can retry / alert.
  Adds an instance-level `failed_writes` counter operators can poll
  for silent-drop visibility.

- _history_provider.py id-stamping loop: drops the
  `contextlib.suppress(AttributeError, TypeError)` around
  `item.id = new_id` so SDK contract changes surface in the test
  suite instead of silently corrupting the chain (the storage backend
  rejects the entire `create_response` with HTTP 500 when synthetic
  prefix-based ids leak through). `import contextlib` removed.

- tests:
  * Unit-cover `foundry_response_id` / `foundry_response_id_factory` /
    `foundry_item_id` so SDK `IdGenerator` contract changes are caught
    locally.
  * Cover the `save_messages` wire payload: required-by-storage fields
    (`background`, `parallel_tool_calls`, `instructions`,
    `agent_reference`), env-var-driven stamping (`FOUNDRY_AGENT_NAME` /
    `FOUNDRY_AGENT_VERSION` / `FOUNDRY_AGENT_SESSION_ID` /
    `MODEL_DEPLOYMENT_NAME` with `AZURE_AI_MODEL_DEPLOYMENT_NAME`
    fallback), and the rule that `model` / `agent_session_id` /
    `agent_reference.version` are omitted (not stamped to `None`) when
    their env vars are unset.
  * Cover the `FOUNDRY_AGENT_SESSION_ID` last-resort chain anchor on
    both the get and save paths, including the prefix gate that blocks
    non-`caresp_*`/`resp_*` values from reaching storage, and the
    precedence rule that a host binding wins over the env.
  * Replace the old `test_save_messages_swallows_backend_errors` with
    two tests asserting the new contract: storage errors are swallowed
    and bump `failed_writes`; everything else propagates and leaves the
    counter at zero.

141 unit tests pass; mypy + pyright + ruff clean.

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

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

- Hosted detection now delegates to AgentConfig.from_env().is_hosted so
  a future Foundry SDK rename of FOUNDRY_HOSTING_ENVIRONMENT propagates
  automatically; drop the local _ENV_FOUNDRY_HOSTING_ENVIRONMENT
  constant.
- Drop the FOUNDRY_AGENT_SESSION_ID fallback in both get_messages and
  save_messages: per the SDK it identifies the *container instance*,
  not the conversation, so chaining off it would silently merge
  unrelated conversations across container restarts. The host-bound
  previous_response_id (set by ResponsesChannel) is the only
  authoritative anchor; the env value is still stamped into the
  persisted envelope's agent_session_id for operator correlation.
- Update module docstring + replace TestFoundryAgentSessionIdAnchor
  with assertions for the new contract (env var ignored as anchor,
  still stamped onto persisted envelope, host binding wins).

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

* refactor(foundry_hosting): reconcile with upstream main (#5851, #5666)

Brings the FoundryHostedAgentHistoryProvider refactor branch back into
sync with the foundry_hosting changes that have landed on upstream
main since PR-1 was opened:

* #5851 (path traversal in checkpoint storage, CWE-22).
  The workflow-host code in ``_responses.py`` builds a
  ``FileCheckpointStorage`` from a caller-controlled ``context_id``
  (``previous_response_id`` / ``conversation_id`` / ``response_id``).
  Switch both call sites to route through
  ``_checkpoint_storage_for_context``, which rejects separators,
  NUL bytes, drive letters, absolute paths, and all-dot segments,
  and enforces ``is_relative_to(root)`` before any directory is
  created.

* #5666 (function approval flow).
  Make the SDK-Item → AF-Message conversion helpers in ``_shared.py``
  async and accept an optional ``approval_storage`` keyword:

  - ``_items_to_messages`` / ``_item_to_message`` /
    ``_item_to_message_inner``
  - ``_output_items_to_messages`` / ``_output_item_to_message`` /
    ``_output_item_to_message_inner``

  For ``mcp_approval_request`` / ``mcp_approval_response`` items the
  helpers now load the original function-call Content from the
  approval storage (via ``ApprovalStorage.load_approval_request``)
  instead of synthesising a placeholder. This matches upstream
  semantics and lets approval round-trips reconstruct the real
  payload.

  The ``ApprovalStorage`` Protocol moves to ``_shared.py`` so the
  conversion helpers can reference it without pulling in
  ``_responses.py`` (which would create a circular import). The
  concrete ``InMemoryFunctionApprovalStorage`` and
  ``FileBasedFunctionApprovalStorage`` stay in ``_responses.py``
  next to the host that owns them, and re-export
  ``ApprovalStorage`` from ``_shared`` for compatibility.

  The workflow-host streaming path passes its own
  ``self._approval_storage`` into ``_to_outputs`` so approval
  requests are saved at emit time.

* Bump ``_history_provider.FoundryHostedAgentHistoryProvider.get_messages``
  to ``await`` the now-async ``_output_items_to_messages`` call.

No public API change beyond the new keyword-only ``approval_storage``
parameter on the four conversion entry points.

Validation:
- uv run poe check-packages -P foundry_hosting (lint + pyright clean)
- uv run poe mypy -P foundry_hosting (clean)
- uv run poe test -P foundry_hosting (183 passed, 1 skipped)

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-22 15:42:06 +02:00
0cb9b52a4b 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>
2026-05-22 14:55:56 +02:00
eavanvalkenburgandCopilot e666cdc7c8 docs: renumber hosting channels ADR
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-22 14:01:17 +02:00
25692a17a8 Python: Channel spec (#5549)
* first iteration of channel spec

* added deny link setup

* clarify invocation hook role and dedupe ADR/spec

ADR 0026:
- Tighten Decision Outcome Summary so each concept is mentioned once;
  defer full definitions to the Terminology section.
- Update ChannelInvocationHook bullet to match the clarified gap #7
  language (uniform ChannelRequest envelope, hook timing, illustrative
  examples).
- Drop Decision Drivers bullets that just restated Business Goals;
  cross-link to the goals section instead.
- Replace the More Information bullet list with a pointer to Non-Goals.

Spec 002:
- Trim requirement #21 to point at the canonical LinkPolicy section
  instead of restating the full contract.
- Add a #linkpolicy-and-trust_level subsection anchor for cross-refs.
- Trim the Terminology LinkPolicy entry's two-hosts caveat (canonical
  version stays in the Key Types section).

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

* updated adr and spec

* Update hosting channels ADR and spec

- Document FoundryHostedAgentHistoryProvider roundtrip of additional_properties namespaces via the agent_framework container key on stored OutputItems.
- Add Foundry storage gap subsection capturing the update_item service ask required for post-push delivery_tracking[] mutation.
- Triage open questions: 18 resolved (now in a Resolved Questions decisions log), 3 notes-updated, 6 unchanged. Capture spec-body follow-ups implied by the resolutions in a new Decisions-driven follow-ups subsection.

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

* Refine hosting ADR + spec: A2A/MCP-tool channels, store-parameter matrix, open-question pass

- Surface A2A and MCP-tool channels as explicitly designed-in but fast-follow work after the first Responses + Invocations + Telegram release. Updated ADR business goals, non-goals, and More Information; added spec reqs #25 (A2AChannel) and #26 (MCPToolChannel) under v1 Fast Follow; renumbered the WhatsApp/Teams entry to #27.
- New 'The Responses store parameter' subsection in the spec: 2x3 destination matrix making explicit that 'store' has no canonical meaning at the hosted-agent layer — the developer decides what it maps to across service-side, hosted-agent storage, and caller-side. Includes design properties on forwarding-vs-mapping, per-deployment documentation responsibility, and richer storage vocabulary via OpenAI's extra_body.
- Fixed contradicting spec text that previously claimed ResponsesChannel maps store=False to session_mode=disabled by default; updated channel options table, session_mode terminology entry, and Scenario 3 prose/comment to match the new model.
- Renamed FoundryHistoryProvider -> FoundryHostedAgentHistoryProvider throughout the spec (9 occurrences) so the name reinforces the intended hosted-agent use case.
- ADR open-questions pass: walked through all 15 entries with the user. 13 resolved (moved to a new 'Resolved Questions (decisions log)' table), 2 kept open with refined wording (Q6 'Channel' GA name, Q14 Responses WS subprotocol). Added a 'Decisions-driven follow-ups' bullet list capturing the spec-body / sample edits implied by the resolutions.

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

* Hosting ADR + spec: rename Teams channel to Activity Protocol, add multi-user conversation design

- Rename the planned Teams channel to ActivityChannel (package agent-framework-hosting-activity). Promoted to req #27 (v1 fast follow) alongside A2A and MCP-tool, with native translations from Activity Protocol objects to AF types so the contract is explicit rather than implicit through Invocations. Channel sits behind Azure Bot Service, which fronts Teams / Web Chat / Slack / etc. Naming reserves a TeamsChannel name for any future direct-to-Teams transport that bypasses Bot Service (now stretch req #28 with WhatsApp). ResponseTarget channel ids and JSON examples updated from "teams" to "activity". Appendix B updated to acknowledge that ActivityChannel deliberately reuses the Bot Service connector model (the no-connector stance applies to the rest of the channel set).

- Add first-class design for multi-user surfaces (Telegram groups / supergroups / forum topics; Activity Protocol groupChat and team channels). Cleanly separate user identity (ChannelIdentity.native_id = from.id / from.aadObjectId) from conversation locator (ChannelRequest.conversation_id = chat.id (+ message_thread_id / replyToId)). New per-channel options: conversation_scope (per_user / per_user_per_conversation (default in groups) / per_conversation) and accept_in_group addressing rule (mention_only (default) / command_only / mention_or_command / all). Specifies originating reply must include conversation + thread locator, ChannelPush behavior in groups, link-ceremony privacy (challenges redirected to user DMs), and the Activity-channel mapping for personal / groupChat / channel conversationType plus Teams replyToId threading. Broadcast Telegram Channels and adaptive-card Invoke activity flows scoped as fast follow.

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

* docs(hosting): rename RunHandle → ContinuationToken; HostStateStore (file-based v1); align agentserver dependency posture

- Rename RunHandle → ContinuationToken (opaque URL-safe `token` field) throughout
  ADR + spec; update routes to /{continuation_token}; spec out equivalent
  continuation-token support for the Invocations channel (Q20 done).
- Introduce HostStateStore as the single persistence seam for host-execution
  metadata (continuation tokens, identity-link grants, last-seen records).
  V1 default: FileHostStateStore (atomic JSON-per-record under ./.af-hosting/,
  per-namespace TTLs) — background runs and link grants now survive host
  restarts. InMemoryHostStateStore for tests; pluggable Cosmos / SQL / Redis
  remain v1 fast follow under req #23. Closes Q9, Q11, Q14.
- Drop blanket "no agentserver dependency" claims. Hosting core is still
  independent of agentserver, but channel packages MAY consume lower-level
  building blocks (notably the Foundry response-store SDK that
  FoundryHostedAgentHistoryProvider builds on).

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

* docs(hosting): swap Scenarios 6 and 7 so the linker comes before cross-channel continuity

Scenario 6 (cross-channel continuity) previously forward-referenced Scenario 7
(linker) twice, since continuity depends on the link/merge ceremony. Invert the
order so the linker scenario establishes the mechanism first and the continuity
scenario builds on it. Update internal cross-references, the require_link
section anchor, and Scenario 8's prerequisites/comment to match. Also tightened
the new Scenario 7's closing note to point at HostStateStore (file-based
default) for cross-host continuity, and dropped a stale MfaIdentityLinker
reference from the linker variants paragraph (Q13 dropped MFA from phase 1).

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

* docs(hosting): rewrite Scenario 7 as trusted-relay + add ResponseTarget.identities

The previous Scenario 7 (cross-channel chat continuity) implied two independent
auto-issued isolation_keys would converge by themselves — they don't, that
needs a linker. Replace with a more realistic and complementary scenario:
a trusted server-side application backend exposes Responses + Telegram against
the same agent and uses extra_body to carry app-internal identity hints
(app_user_id, push_to_telegram_chat_id) that a Responses run_hook translates
into both an isolation_key promotion and a push to a known Telegram chat.
Includes a closing variant pointing back at Scenario 6's linker for the
no-app-table flow.

Adds the ResponseTarget.identities([ChannelIdentity(...)]) variant to the
type table and req #12 to support 'caller already knows the channel-native
recipient' delivery without going through the link store. Bypasses the link
store but still consults LinkPolicy per delivery.

Drops MfaIdentityLinker references from req #11, req #24, and the linker
helpers table (Q13 had already dropped MFA from phase 1; the spec body just
hadn't caught up). Marks ADR Q8 follow-up done.

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

* docs(hosting): wire FileCheckpointStorage into Scenario 9 + show resume-from-checkpoint flow

Scenario 9 now builds the workflow with a FileCheckpointStorage so executor
frames are persisted across runs, and demonstrates how the run_hook surfaces
a caller-supplied resume_from_checkpoint into request.attributes so the host's
workflow dispatch can pass it to Workflow.run(checkpoint_id=...). Closing
paragraph clarifies that CheckpointStorage is workflow-runtime state, kept
structurally separate from HostStateStore and ContextProvider — three
protocols that MAY share a backend but stay independently typed.

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

* docs(hosting): emphasize result richness in Scenario 10 (channels are not limited to result.text)

Add a 'Result is rich, not just text' callout under the channel-authoring
sample. Inventories the typed Contents on the underlying AgentRunResult
(TextContent, DataContent, UriContent, FunctionCallContent /
FunctionResultContent, HostedFile/VectorStoreContent, UsageContent,
TextReasoningContent, ErrorContent + additional_properties), the typed
structured output via result.value, and shows concrete examples per channel
shape: Telegram (MarkdownV2 + sendPhoto/sendAudio + inline keyboards),
Responses (full content-list round-trip), chat UI (GFM/HTML +
collapsible tool/reasoning panels), voice (TTS + earcons), typed RPC
(result.value first). result.text is positioned as a convenience for
single-string channels, not the contract.

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

* spec: add TeamsChannel (microsoft/teams.py) as fast-follow req #28

Add a Teams-native channel package built on the MIT-licensed
microsoft/teams.py SDK as fast-follow alongside the generic
ActivityChannel (req #27). Where ActivityChannel targets the
generic Activity Protocol surface, TeamsChannel exploits
Teams-specific affordances the generic protocol does not surface
natively: Adaptive Cards (typed builder), streamed replies,
AI-generated badge, feedback controls + form, suggested-prompt
chips, inline citations, modal Dialogs, Message Extensions
(action / search / link unfurling), proactive / targeted /
threaded messages, and SSO via MSAL.

Mounts the SDK's App into the host's Starlette app via a custom
HttpServerAdapter; reuses the same host-tracked-session family
as ActivityChannel (from.aadObjectId -> ChannelIdentity). The
SDK already ships a 'Build an agent using Microsoft Agent
Framework' guide so the integration story is direct.

Renumber the WhatsApp / direct-to-Teams stretch item to req #29
and clarify its 'direct-to-Teams' placeholder is a future
transport that bypasses both Bot Service and the teams.py SDK.

Add the SDK to Dependencies & Commitment Status as a proposed
runtime dep of agent-framework-hosting-teams.

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

* spec: clarify direct-to-Teams stretch as speculative (no Bot Service)

Split the WhatsApp + direct-to-Teams stretch entry into two
distinct items and reword the direct-to-Teams item to be honest
about its current feasibility:

- It MUST not rely on Azure Bot Service (otherwise it is just
  ActivityChannel / TeamsChannel under a different name).
- No such transport is publicly available today: Graph chat APIs
  and microsoft/teams.py both ultimately route through Bot Service
  for the bot-as-conversation-participant pattern.
- The slot is kept on the roadmap to preserve the naming line in
  case Microsoft ships a Bot-Service-free transport (native Teams
  REST/RPC, a Graph subscription strong enough to drive both
  inbound and outbound message flow, ...).
- Reaffirm TeamsChannel (req #28) as the canonical Teams channel
  until then.

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

* spec: clarify TeamsChannel still rides on Bot Service in v1; add audience table

Make explicit that TeamsChannel (req #28) uses Azure Bot Service
in v1 — the microsoft/teams.py SDK is a higher-level Pythonic
wrapper over the same Activity Protocol pipeline that
ActivityChannel exposes raw. The difference is what the developer
writes against, not the network path. A Bot-Service-free Teams
transport is not currently possible and stays tracked as the
speculative req #30.

Add the ActivityChannel vs TeamsChannel audience comparison table
to req #28 so the choice is obvious to readers:
- ActivityChannel: maximum portability across all Bot Service-fronted channels.
- TeamsChannel: Teams-first deployments wanting Cards / Dialogs /
  Message Extensions / citations / feedback / suggested prompts /
  SSO out of the box.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-22 13:54:13 +02:00
112 changed files with 19772 additions and 783 deletions
+145
View File
@@ -0,0 +1,145 @@
---
status: proposed
contact: eavanvalkenburg
date: 2026-06-11
deciders: eavanvalkenburg
---
# Python minimal hosting core and pluggable channels
## Context and Problem Statement
Agent Framework has several protocol-specific hosting surfaces. App authors who want one agent or workflow on multiple protocols must compose servers, routes, middleware, session handling, and lifecycle code by hand.
We will introduce a small Python hosting core that owns the common server shape and leaves protocol details inside channel packages. The first public contract must be intentionally narrow so Python can ship a base contract before adding identity linking, proactive delivery, or multicast behavior. Other language implementations may reuse the same conceptual boundary, but this ADR records the Python decision.
## Decision Drivers
- Keep the first host easy to explain: one app, one hostable target, one or more channels.
- Reuse Agent Framework's existing agent, workflow, session, history, and checkpoint primitives.
- Let channel packages own protocol parsing, protocol responses, authentication details, and native command surfaces.
- Make session continuity explicit through a channel-supplied `ChannelSession(isolation_key=...)`.
- Avoid approving cross-channel identity and delivery semantics before their safety model is reviewed.
## Considered Options
1. Keep only protocol-specific hosts.
2. Ship a large hosting core with identity linking, authorization, background delivery, active-channel routing, and multicast in v1.
3. Ship a minimal host/channel core now and track linking/multicast as follow-up work.
### Keep only protocol-specific hosts
- Good: no new abstraction or package surface.
- Neutral: each protocol can continue evolving independently.
- Bad: every multi-channel app still has to compose servers, lifecycle, and session handling by hand.
### Ship the large cross-channel host in v1
- Good: the richest cross-channel scenarios are available immediately.
- Neutral: the host becomes the natural place to demonstrate identity and delivery policy.
- Bad: v1 becomes a security-sensitive identity and delivery system before the safety model is reviewed.
### Ship the minimal core now
- Good: the host/channel boundary can be implemented, tested, and explained without solving linking and durable delivery at the same time.
- Neutral: apps that need richer behavior must build it locally or wait for ADR-0028 follow-up work.
- Bad: proactive delivery and multicast scenarios are deliberately absent from v1.
## Decision Outcome
Chosen option: **minimal host/channel core now, follow-up enhancements later**.
`AgentFrameworkHost` owns:
- one application object,
- one hostable target (`SupportsAgentRun` agent-compatible object or a `Workflow`), and
- one or more channels.
Channels own:
- contributed routes, middleware, commands, and lifecycle callbacks,
- protocol-native request parsing into `ChannelRequest`,
- protocol-native rendering of the originating response, and
- any channel-specific authentication or signature validation.
The host owns:
- route/lifecycle aggregation,
- invocation of the target,
- `ChannelSession(isolation_key=...)` to `AgentSession` resolution and caching,
- `reset_session(isolation_key=...)`,
- host-level middleware, including Foundry isolation middleware only when the Foundry hosting environment flag is present,
- invocation of per-channel hooks (`ChannelRunHook`, `ChannelResponseHook`, `ChannelStreamUpdateHook`), and
- workflow checkpoint wiring through an explicit `checkpoint_location`.
`ChannelIdentity`, when present, is request metadata only. In v1 it is not a linking, authorization, or delivery key.
### Trust boundary for `isolation_key`
The host treats `ChannelSession.isolation_key` as a session partition key, not as proof of identity. Channels or host middleware must authenticate and authorize any externally supplied value before passing it to the host. For example, a Responses caller must not be allowed to choose an arbitrary `previous_response_id` or header-derived key unless the platform or middleware has already established that the caller owns that conversation. The host deliberately does not infer that trust from the string itself.
### Hook ownership
Channels provide hook configuration and protocol-native context. The host invokes those hooks as part of the common invocation pipeline:
- `ChannelRunHook` runs after channel parsing and before target invocation.
- `ChannelResponseHook` runs after target invocation and before the originating channel serializes its response.
- `ChannelStreamUpdateHook` is applied by the host while the channel consumes streamed updates because streaming serialization is protocol-specific.
`ChannelStreamUpdateHook` is an update hook, not a final-response sanitizer. Channels that use it for redaction or filtering must also apply equivalent policy to any final response they render. Channels choose whether the response is streaming before run hooks execute.
This keeps hook call conventions centralized while leaving protocol payload parsing and response formatting in channel packages.
### State owned by v1
`state_dir` is limited to host-owned local files for reset-session aliases and workflow checkpoint path derivation. It does not store linked identities, active-channel state, response-routing state, continuation records, durable runner queues, or delivery attempts. Those storage concerns belong to ADR-0028.
## Non-goals for v1
The following are deliberately **not** part of the v1 contract:
- cross-channel identity linking (`IdentityLinker`, `local_identity_link`, or `agent-framework-hosting-entra`),
- identity allowlists or authorization policy (`IdentityAllowlist`, `AuthPolicy`),
- response routing beyond the originating channel (`ResponseTarget`, active channel, specific linked channel, `all_linked`),
- push or payload codecs (`ChannelPush`, `ChannelPushCodec`),
- background/continuation delivery,
- durable task runners (`DurableTaskRunner`, `InProcessTaskRunner`),
- retry/replay policy (`RetryPolicy`),
- fan-out, multicast, or all-linked delivery,
- confidentiality tiers and `LinkPolicy`, and
- a host-level multi-agent router.
These areas are follow-up enhancements covered by [ADR-0028](0028-hosting-linking-multicast-enhancements.md). They are not prerequisites for shipping or using the v1 host.
## Consequences
Positive:
- The host/channel model can be implemented and tested without designing a security-sensitive identity graph.
- Existing and new channel packages can share one Starlette app, middleware stack, lifecycle, and target invocation path.
- Session continuity is explicit and debuggable: two channels share history only when they produce the same `isolation_key`.
- Hook invocation is centralized in the host, so channels do not each invent the call convention.
Negative:
- Apps that need OAuth linking, allowlists, proactive messages, or multicast must continue to implement those behaviors outside the v1 host.
- Some richer cross-channel scenarios from the original design move to a separate decision and validation cycle.
- The host must document `isolation_key` trust clearly because it now provides the shared session boundary.
## Validation Gates
Before this ADR is accepted:
- A sample can expose one target on multiple channels with one `AgentFrameworkHost` and no handwritten Starlette route composition.
- Built-in channel tests prove that routes, commands, startup, and shutdown callbacks are contributed by channels and aggregated by the host.
- Session tests prove that identical `ChannelSession.isolation_key` values resolve to the same cached `AgentSession`, and `reset_session` rotates that mapping.
- Channel tests prove that each channel renders only its own originating response; there is no host-level push, multicast, or active-channel delivery path.
- Workflow tests or samples use an explicit `checkpoint_location`.
- Foundry isolation middleware is documented and covered by integration or contract tests, including the non-Foundry case where raw isolation headers are ignored.
- The v1 API and packages do not expose the removed symbols or packages listed in [Non-goals for v1](#non-goals-for-v1).
- The Python spec is updated to match this simplified contract and uses "public", "stable", or "released" terminology for Agent Framework APIs.
## More Information
- Python v1 specification: [SPEC-002](../specs/002-python-hosting-channels.md)
- Follow-up linking and multicast ADR: [ADR-0028](0028-hosting-linking-multicast-enhancements.md)
@@ -0,0 +1,132 @@
---
status: proposed
contact: eavanvalkenburg
date: 2026-06-11
deciders: eavanvalkenburg
---
# Hosting linking and multicast enhancements
## Context and Problem Statement
[ADR-0027](0027-hosting-channels.md) defines the minimal v1 hosting core: originating-channel responses, explicit `ChannelSession.isolation_key`, and no host-level identity linking, push, multicast, background delivery, or durable runners.
This ADR tracks the richer cross-channel behaviors that were removed from v1. These enhancements are **follow-up work** and are **not prerequisites** for shipping, using, or stabilizing the v1 host/channel core.
## Decision Drivers
- Cross-channel continuity must not create accidental cross-user, cross-tenant, or cross-channel data leaks.
- Non-originating delivery must be observable, idempotent, retryable, and supportable.
- Protocol payloads must remain channel-native while still being safe to persist and replay.
- App authors need opt-in policy controls, not hidden defaults.
- The enhancement stack should layer on top of the v1 host without reshaping the minimal channel contract.
## Enhancement Areas
The follow-up design should cover these capabilities together because they share identity, storage, delivery, and replay concerns:
- **Cross-channel identity linking** — a user can connect multiple `ChannelIdentity` values to one channel-neutral `isolation_key`.
- **Authorization and allowlist policy** — channels or hosts can require verified identity, allow specific native identities or claims, and deny unknown callers.
- **Non-originating response delivery** — a run can respond somewhere other than the request's originating protocol when explicitly configured.
- **Active-channel routing** — delivery can target the most recently observed linked channel for an `isolation_key`.
- **Multicast / all-linked delivery** — delivery can fan out to every linked channel or a selected set.
- **Background runs and continuation tokens** — long-running requests can return immediately and complete later, with a polling/status fallback.
- **Durable delivery runners** — delivery work can survive process restarts and support dead-letter handling.
- **Retry and replay semantics** — delivery attempts are bounded, deduplicated, and safe to replay.
- **Payload serialization** — channel-specific payloads can be persisted, redacted, versioned, and reconstructed without losing protocol fidelity.
Candidate API names from the broader design (`IdentityLinker`, `IdentityAllowlist`, `AuthPolicy`, `ResponseTarget`, `ChannelPush`, `ChannelPushCodec`, `DurableTaskRunner`, `InProcessTaskRunner`, `RetryPolicy`, `LinkPolicy`) remain design vocabulary for this ADR. They are not approved v1 APIs.
## Considered Options
### Option A — Leave all behavior to applications
Applications implement linking, authorization, push, retry, and serialization independently.
- Good: the hosting core stays very small.
- Neutral: advanced apps can still build what they need.
- Bad: every app must solve the same security and delivery problems, likely inconsistently.
### Option B — Add the full enhancement stack to v1
The first host release includes linking, authorization, active channel, multicast, background runs, durable runners, and codecs.
- Good: the original cross-channel experience is available immediately.
- Neutral: samples can demonstrate rich end-to-end flows.
- Bad: v1 becomes security-sensitive, storage-heavy, and harder to stabilize.
### Option C — Layer opt-in enhancement packages after v1
Ship the minimal host first, then add linking, authorization, and delivery packages behind explicit configuration.
- Good: v1 remains simple while leaving room for a reviewed, supportable enhancement stack.
- Neutral: apps that need advanced delivery wait for follow-up packages.
- Bad: the first release does not satisfy proactive or all-linked scenarios.
### Option D — Build only platform-specific integrations
Implement linking and proactive delivery separately in Telegram, Activity Protocol, Discord, and future channels.
- Good: each package can match its protocol exactly.
- Neutral: some shared abstractions may emerge later.
- Bad: cross-channel behavior becomes fragmented and hard to reason about.
## Decision Outcome
Proposed direction: **Option C — layered opt-in enhancement packages after v1**.
The minimal host remains the foundation. Follow-up packages may add linking, authorization, delivery, and durable execution, but must be explicitly enabled and must pass the validation gates below before becoming part of the public contract.
## Safety Requirements
### Threat model
The design must account for:
- spoofed channel-native identities,
- stolen or replayed link challenges,
- cross-tenant or cross-confidentiality data leakage,
- unsolicited proactive messages,
- malicious payloads persisted for replay,
- denial-of-service through fan-out or retry storms, and
- privacy leakage through logs, metrics, or support tooling.
Required mitigations include verified identity claims where available, signed and expiring link challenges, explicit user consent, per-channel capability checks, default-deny policy options, tenant partitioning, and uninformative denial messages on shared channels.
### Idempotency and replay
Exactly-once delivery is not a realistic guarantee. The design must provide:
- stable run, continuation, and delivery-attempt identifiers,
- channel-level idempotency keys where protocols support them,
- bounded retry with jitter and explicit terminal states,
- replay windows and expiration,
- duplicate suppression for persisted attempts, and
- clear semantics for "delivered", "accepted by platform", and "observed by user".
### Storage
Enhancement storage must stay distinct from v1 `AgentSession` history and workflow checkpoints unless an implementation deliberately backs them with the same physical store.
Stored data should be schema-versioned, minimized, encrypted or otherwise protected as appropriate, and partitioned by tenant/project. Link records, continuation records, active-channel state, delivery attempts, dead letters, and serialized payloads need independent TTL and deletion policies.
### Observability and support
The design must include structured logs, traces, and metrics for link attempts, authorization decisions, delivery scheduling, retries, replay, and dead-letter outcomes. Logs must avoid message content and sensitive identity claims by default. Operators need a way to inspect, revoke, replay, or purge stuck records safely.
## Validation Gates
Before these enhancements are accepted:
- A reviewed threat model covers identity linking, authorization, non-originating delivery, multicast, and replay.
- Cross-channel linking tests prove a verified identity can link two channels and that unlink/deny paths do not leak information.
- Authorization tests cover native-id allowlists, verified-claim allowlists, default-deny behavior, and misconfiguration failures.
- Delivery tests cover originating-only, specific-channel, active-channel, selected-channel, and all-linked routing.
- Background/continuation tests cover polling fallback, cancellation or expiration, process restart, retry, and dead-letter behavior.
- Codec tests prove payloads are versioned, redacted where needed, backward compatible, and rejected safely when unknown.
- Multicast tests prove fan-out is bounded, independently retried, and idempotent per destination.
- Observability tests or manual validation prove support operators can correlate a request to delivery attempts without exposing sensitive content.
## Relationship to ADR-0027
ADR-0027 remains valid without any of these enhancements. This ADR extends the hosting model only after the safety, storage, and support requirements above are satisfied.
+320
View File
@@ -0,0 +1,320 @@
---
status: proposed
contact: eavanvalkenburg
date: 2026-06-11
deciders: eavanvalkenburg
---
# Python hosting core and pluggable channels
## Scope
This specification is the Python implementation plan for [ADR-0027](../decisions/0027-hosting-channels.md). It documents the simplified v1 host/channel contract only.
The v1 contract is:
- `AgentFrameworkHost` owns one Starlette app, one hostable target, and one or more channels.
- A hostable target is either a `SupportsAgentRun`-compatible agent or a `Workflow`.
- Channels contribute routes, middleware, commands, and lifecycle callbacks.
- Channels parse protocol-native input into `ChannelRequest`.
- Channels render their own originating response.
- Session continuity is explicit: a channel supplies `ChannelSession(isolation_key=...)`, and the host resolves/caches an `AgentSession` for that key.
- The host invokes `ChannelRunHook` and `ChannelResponseHook`; channels provide hook configuration and protocol context.
The host does not link identities, route responses to other channels, run background continuations, or multicast in v1. Those enhancements are tracked in [ADR-0028](../decisions/0028-hosting-linking-multicast-enhancements.md).
## Goals
- Let an app expose one agent or workflow on multiple protocols without handwritten Starlette composition.
- Keep protocol parsing and response formatting inside channel packages.
- Provide one session-resolution path shared by all channels.
- Keep the channel authoring surface small enough for new channels to implement.
- Preserve full-fidelity agent and workflow results until a channel decides how to render them.
## Non-goals for v1
The following are removed from the v1 implementation pass:
- `IdentityLinker`, `IdentityAllowlist`, `AuthPolicy`, and `LinkPolicy`
- `ResponseTarget`, active-channel routing, `all_linked`, fan-out, and multicast
- `ChannelPush` and `ChannelPushCodec`
- `DurableTaskRunner`, `InProcessTaskRunner`, and `RetryPolicy`
- continuation tokens and background delivery
- confidentiality tiers
- `agent-framework-hosting-entra`
- `local_identity_link`
These are follow-up design topics, not hidden requirements of the v1 host.
## Packages
| Package | Import surface | Contents |
|---|---|---|
| `agent-framework-hosting` | `agent_framework_hosting` | `AgentFrameworkHost`, channel protocols, key request/result types, hooks, `reset_session`, state-path helpers. |
| `agent-framework-hosting-responses` | `agent_framework_hosting_responses` | `ResponsesChannel`. |
| `agent-framework-hosting-invocations` | `agent_framework_hosting_invocations` | `InvocationsChannel`. |
| `agent-framework-hosting-telegram` | `agent_framework_hosting_telegram` | `TelegramChannel` and Telegram command helpers. |
| `agent-framework-hosting-activity-protocol` | `agent_framework_hosting_activity_protocol` | `ActivityProtocolChannel` for Activity Protocol over Azure Bot Service. |
| `agent-framework-hosting-discord` | `agent_framework_hosting_discord` | `DiscordChannel` and Discord command/interaction helpers. |
| `agent-framework-foundry-hosting` | `agent_framework.foundry_hosting` | Foundry isolation middleware and Foundry-backed hosting helpers usable with the v1 host. |
Channel packages may depend on their native SDKs. The core hosting package should not depend on channel SDKs or on top-level legacy protocol hosts.
## Key Types
### `AgentFrameworkHost`
The host constructor accepts:
- `target`: one `SupportsAgentRun`-compatible object or one `Workflow`
- `channels`: one or more `Channel` instances
- optional Starlette middleware
- optional `state_dir`
- optional workflow `checkpoint_location`
The host exposes:
- `app`: the canonical Starlette ASGI application
- `serve(...)`: a convenience wrapper for local serving
- `reset_session(isolation_key: str)`: rotate the cached `AgentSession` for a host-tracked conversation
`state_dir` is narrowed to v1 host-owned local files only:
- session aliases (`isolation_key` to current `AgentSession` id), and
- workflow checkpoint paths when the app chooses the host-provided file layout.
It is not a store for identity links, continuations, active-channel state, delivery attempts, or multicast payloads.
Externally supplied isolation keys are trusted only after the channel or host middleware has authenticated and authorized the caller. The host uses `isolation_key` as a partition key; the string itself is not proof of identity or ownership.
### `Channel`
A channel implements a small protocol:
- declare a stable channel id/name,
- contribute routes, middleware, commands, and lifecycle callbacks,
- parse inbound protocol data into `ChannelRequest`,
- call the host through `ChannelContext.run(...)` or `ChannelContext.run_stream(...)`, and
- serialize the returned result to the originating protocol response.
Channels own protocol authentication, signature validation, native command registration, and protocol-specific error bodies.
### `ChannelContribution`
`ChannelContribution` is the channel's host-facing contribution:
- Starlette routes and optional middleware,
- native command descriptors,
- startup and shutdown callbacks, and
- any channel-local metadata needed by the package.
The host aggregates contributions but does not interpret protocol payloads.
### `ChannelRequest`
`ChannelRequest` is the host-neutral request envelope produced by a channel. It carries:
- target input,
- optional `ChannelSession`,
- optional `ChannelIdentity`,
- options and attributes produced by the channel, and
- request metadata useful to hooks and context providers.
The host may pass attributes through to context providers and middleware. Channels should treat attributes as a documented extension bag, not as a cross-channel delivery contract.
### `ChannelSession`
`ChannelSession(isolation_key=...)` is the only v1 session-continuity mechanism.
When a request contains an isolation key:
1. The host looks up or creates the cached `AgentSession` for that key.
2. The target runs with that `AgentSession` when the target is an agent.
3. `reset_session(isolation_key)` rotates the alias so the next request starts a new conversation.
If two channels produce the same isolation key on the same host, they share the same cached session. If they produce different keys, they do not share session state.
### `ChannelIdentity`
`ChannelIdentity` is optional request metadata such as channel id, native user id, tenant id, claims, or display attributes.
In v1, `ChannelIdentity` does not link channels, authorize callers, select delivery destinations, or imply that two identities should share an `AgentSession`. A channel that wants shared history must still produce the same `ChannelSession.isolation_key`.
### Hooks
Hooks are optional and channel-owned:
- `ChannelRunHook`: runs after channel parsing and before host invocation; returns the `ChannelRequest` to execute.
- `ChannelResponseHook`: runs after target completion and before the originating channel renders a one-shot response.
- `ChannelStreamUpdateHook`: the host applies it to streamed updates before the originating channel serializes the stream.
Common uses include adapting chat text into workflow inputs, enforcing deployment-specific options, flattening rich output for text-only protocols, or filtering streamed updates for a protocol. Stream update hooks are update-only; they do not automatically sanitize `get_final_response()` output. Channels choose their response transport from the parsed protocol request before invoking run hooks.
### `HostedRunResult`
`HostedRunResult[T]` wraps the target's full-fidelity result plus the resolved `AgentSession | None`.
- Agent targets produce `HostedRunResult[AgentResponse]`.
- Workflow targets produce `HostedRunResult[WorkflowRunResult]`.
The host does not flatten, filter, or translate the result. Each channel decides how much of the result its protocol can carry.
## Host Behavior
1. `AgentFrameworkHost` builds one Starlette app and asks each channel for its contribution.
2. A channel route receives a protocol-native request.
3. The channel validates/parses the native payload and creates `ChannelRequest`.
4. The channel passes the request, optional `ChannelRunHook`, and protocol-native context to the host.
5. The host invokes `ChannelRunHook`, if configured, and receives the prepared request.
6. The host resolves an `AgentSession` from `ChannelSession.isolation_key` when present.
7. The host invokes the agent or workflow target.
8. The host wraps the result in `HostedRunResult` or the streaming equivalent.
9. The host invokes `ChannelResponseHook`, if configured, for non-streaming/final response shaping.
10. The host applies stream update hooks while the channel consumes streams; the channel renders the originating protocol response.
There is no host-level route from one channel's request to another channel's response in v1.
## Workflow Checkpoints
Workflow checkpointing is explicit. Apps either configure checkpoint storage on the workflow itself or pass a `checkpoint_location` to the host so the workflow dispatch path can use the intended file location.
`state_dir` may provide a conventional location for workflow checkpoint files, but checkpointing is still opt-in and separate from agent session history. Checkpoints are workflow-runtime state, not channel state and not identity-link state.
## Foundry Isolation Middleware
V1 keeps Foundry isolation as middleware rather than as a channel-linking feature.
The middleware is installed only when the Foundry hosting environment flag is present. In that environment it reads Foundry-provided isolation values at the trusted hosting boundary, exposes them as read-only request context for Foundry-aware history or memory providers, and rejects unsafe session resumes when the live isolation context does not match persisted session context. Outside Foundry, raw isolation headers are ignored unless an app supplies its own trusted middleware.
This middleware does not create cross-channel identity links and does not authorize non-Foundry channels.
## Current Channels
### Responses
`ResponsesChannel` exposes the OpenAI-compatible Responses API shape. It maps request body fields such as input, options, and conversation identifiers into `ChannelRequest`, and it renders Responses-compatible one-shot or streaming responses.
Responses session continuity uses a channel-selected `isolation_key`, commonly derived from a response/conversation id, caller-provided session id, Foundry isolation context, or deployment-specific request metadata.
### Invocations
`InvocationsChannel` exposes an invocation endpoint for server-side callers and tools. It maps the request body into `ChannelRequest` and renders the invocation result on the same HTTP response.
Invocations is useful for typed workflow inputs because a `ChannelRunHook` can translate the request body into the workflow's expected input type.
### Telegram
`TelegramChannel` supports webhook or polling transport, native command registration, and message rendering back to the originating Telegram chat.
The channel chooses a default `isolation_key` from Telegram-native data such as chat id, user id, or a configured user/chat scope. A `/new` or equivalent command may call `reset_session` for that isolation key.
### Activity Protocol
`ActivityChannel` supports Activity Protocol requests, typically through Azure Bot Service for Teams, Web Chat, and other Bot Framework-fronted surfaces.
The channel maps incoming `Activity` objects to `ChannelRequest` and renders a reply activity to the originating conversation. Proactive Activity delivery, active-channel routing, and all-linked fan-out are not v1 host semantics.
### Discord
`DiscordChannel` supports Discord messages, slash commands, and interactions as channel-native input.
The channel maps Discord-native user, guild, channel, thread, and interaction data into `ChannelRequest` metadata and a configured `ChannelSession.isolation_key`. It renders the result to the originating Discord response path.
## High-level Samples
### One agent on Responses
```python
host = AgentFrameworkHost(
target=agent,
channels=[ResponsesChannel()],
)
app = host.app
```
### One agent on multiple channels
```python
host = AgentFrameworkHost(
target=agent,
channels=[
ResponsesChannel(),
InvocationsChannel(),
TelegramChannel(bot_token=os.environ["TELEGRAM_BOT_TOKEN"]),
],
)
host.serve(host="localhost", port=8000)
```
The host owns one Starlette app. Each channel contributes its own routes and renders its own response.
### Adapting a request before execution
```python
from dataclasses import replace
def enforce_options(request: ChannelRequest) -> ChannelRequest:
options = dict(request.options or {})
options["temperature"] = 0
return replace(request, options=options)
host = AgentFrameworkHost(
target=agent,
channels=[ResponsesChannel(run_hook=enforce_options)],
)
```
### Workflow with explicit checkpoints
```python
host = AgentFrameworkHost(
target=workflow,
channels=[InvocationsChannel(run_hook=adapt_to_workflow_input)],
checkpoint_location=Path("./.af-hosting/workflow_checkpoints"),
)
```
The hook adapts channel-native input to the workflow's typed input. Checkpoints use the explicit workflow checkpoint location, not identity-link or delivery storage.
### Message channel reset command
```python
async def new_chat(context):
if context.request.session is not None:
await context.host.reset_session(context.request.session.isolation_key)
await context.reply("Started a new conversation.")
```
Telegram, Activity Protocol, and Discord can expose equivalent native commands when their protocols support them.
## Follow-up Enhancements
See [ADR-0028](../decisions/0028-hosting-linking-multicast-enhancements.md) for the deferred design covering:
- cross-channel identity linking,
- authorization and allowlists,
- non-originating response delivery,
- active-channel routing,
- multicast and all-linked delivery,
- background runs and continuation tokens,
- durable delivery runners,
- retry/replay semantics, and
- payload serialization.
Those enhancements must layer on top of this v1 contract without requiring v1 users to adopt them.
## Validation Gates
The Python implementation should be considered complete when:
- a sample uses one `AgentFrameworkHost` with multiple channels and no manual Starlette route composition,
- each current channel has contract tests for route contribution, lifecycle, request parsing, hooks, and originating response rendering,
- session tests prove shared `isolation_key` values share an `AgentSession` and `reset_session` rotates it,
- workflow tests or samples use explicit `checkpoint_location`,
- Foundry isolation middleware is covered by integration or contract tests,
- no v1 package exposes the removed linking, multicast, durable-runner, or continuation APIs, and
- this spec and ADR-0027 remain aligned.
+15
View File
@@ -21,6 +21,21 @@ When making changes to a package, check if the following need updates:
- The package's `AGENTS.md` file (adding/removing/renaming public APIs, architecture changes, import path changes)
- The agent skills in `.github/skills/` if conventions, commands, or workflows change
At the end of every run, re-read `AGENTS.md` and the relevant skill files and
update any guidance that the conversation revealed to be out of date,
incomplete, or misleading (renamed files, changed commands, new conventions
the user confirmed, etc.). **Before adding a new principle or rule, ask the
user whether they want it captured as a durable principle** — do not invent
team norms from a single conversation without explicit confirmation.
## Terminology
- **Avoid "GA" for Agent Framework code.** Reserve *GA* for hosted services
(e.g. "the Foundry service is GA"). For Agent Framework packages, features,
and APIs use **"released"** or **"stable"** depending on context — these
match the feature-lifecycle stages documented in the
`python-feature-lifecycle` skill.
## Pull Request Description Guidance
When preparing a PR description:
+1
View File
@@ -34,6 +34,7 @@ Status is grouped into these buckets:
| `agent-framework-foundry-local` | `python/packages/foundry_local` | `beta` |
| `agent-framework-gemini` | `python/packages/gemini` | `alpha` |
| `agent-framework-github-copilot` | `python/packages/github_copilot` | `beta` |
| `agent-framework-hosting-discord` | `python/packages/hosting-discord` | `alpha` |
| `agent-framework-hyperlight` | `python/packages/hyperlight` | `beta` |
| `agent-framework-lab` | `python/packages/lab` | `beta` |
| `agent-framework-mem0` | `python/packages/mem0` | `beta` |
@@ -2,6 +2,16 @@
import importlib.metadata
from ._history_provider import (
FoundryHostedAgentHistoryProvider,
bind_request_context,
get_current_request_context,
)
from ._ids import (
foundry_item_id,
foundry_response_id,
foundry_response_id_factory,
)
from ._invocations import InvocationsHostServer
from ._responses import ResponsesHostServer
@@ -10,4 +20,13 @@ try:
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0"
__all__ = ["InvocationsHostServer", "ResponsesHostServer"]
__all__ = [
"FoundryHostedAgentHistoryProvider",
"InvocationsHostServer",
"ResponsesHostServer",
"bind_request_context",
"foundry_item_id",
"foundry_response_id",
"foundry_response_id_factory",
"get_current_request_context",
]
@@ -0,0 +1,991 @@
# Copyright (c) Microsoft. All rights reserved.
"""Foundry Hosted Agent history provider.
A standalone :class:`agent_framework.HistoryProvider` implementation that
sources conversation history from the Foundry Hosted Agent storage backend.
Transport is delegated to the SDK's
:class:`azure.ai.agentserver.responses.FoundryStorageProvider` (when running
inside a Foundry Hosted Agent container) or
:class:`azure.ai.agentserver.responses.InMemoryResponseProvider` (for local
development). Both implement the same read/write surface
(``get_history_item_ids`` / ``get_items`` / ``create_response``), so this
provider's persistence logic stays backend-agnostic.
Allowed dependencies (deliberately narrow):
* :mod:`agent_framework` (core, for ``HistoryProvider`` / ``Message``)
* :mod:`azure.ai.agentserver.responses` (for the storage backends,
``IsolationContext`` typing, and ``OutputItem`` deserialization)
* :mod:`azure.core.credentials_async` (typing of token credentials)
It MUST NOT depend on any ``agent_framework_hosting*`` package at module
import time. (The host's isolation contextvar is consulted lazily via an
``import`` inside :func:`_host_isolation` so the dependency stays soft.)
Environment variables read:
* ``FOUNDRY_HOSTING_ENVIRONMENT`` — non-empty marks "running inside Foundry"
and selects the SDK-backed storage transport. Detection is delegated to
:class:`azure.ai.agentserver.core.AgentConfig` so a future SDK rename
propagates without touching this module.
* ``FOUNDRY_PROJECT_ENDPOINT`` — base URL of the Foundry project; required
when running hosted unless an explicit ``endpoint=`` is supplied.
* ``FOUNDRY_AGENT_NAME`` / ``FOUNDRY_AGENT_VERSION`` — stamped onto the
``agent_reference`` field of every persisted response envelope.
* ``MODEL_DEPLOYMENT_NAME`` / ``AZURE_AI_MODEL_DEPLOYMENT_NAME`` — model
field stamped on the persisted envelope (must match a real deployment).
Note on ``FOUNDRY_AGENT_SESSION_ID``: this env var identifies the
*container instance*, not the conversation, so it is **not** consulted as
a fallback ``previous_response_id``. The host-bound
``previous_response_id`` (set by :class:`ResponsesChannel` from the
request envelope) is the authoritative anchor. The value is still
persisted into the ``agent_session_id`` envelope field for operator
correlation only.
Local fallback: when ``FOUNDRY_HOSTING_ENVIRONMENT`` is unset, the provider
transparently falls back to :class:`InMemoryResponseProvider` so the same
agent code runs in dev. Pass ``local_storage_root`` to use a persistent
file-based store instead of in-memory; histories are then laid out as
``{root}/{user_key or "~none"}/{chat_key or "~none"}/{session_id}.jsonl``
via :class:`agent_framework.FileHistoryProvider`.
"""
from __future__ import annotations
import logging
import os
import time
from base64 import urlsafe_b64encode
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar
from agent_framework import FileHistoryProvider, HistoryProvider, Message
from azure.ai.agentserver.core import AgentConfig
from azure.ai.agentserver.responses import (
FoundryStorageProvider,
FoundryStorageSettings,
InMemoryResponseProvider,
IsolationContext,
)
from azure.ai.agentserver.responses._id_generator import IdGenerator
from azure.ai.agentserver.responses.models import OutputItem, ResponseObject
from azure.ai.agentserver.responses.store._foundry_errors import ( # pyright: ignore[reportPrivateUsage]
FoundryBadRequestError,
FoundryResourceNotFoundError,
FoundryStorageError,
)
from ._shared import (
_messages_to_output_items, # pyright: ignore[reportPrivateUsage]
_output_items_to_messages, # pyright: ignore[reportPrivateUsage]
)
if TYPE_CHECKING:
from collections.abc import Iterator, Sequence
from azure.core.credentials_async import AsyncTokenCredential
logger = logging.getLogger(__name__)
# Environment variable name — re-declared (not imported) so this module
# stays decoupled from the private ``azure.ai.agentserver.core._config``
# constants while still matching exactly. Hosted-vs-local detection is
# delegated to :class:`AgentConfig` so a future SDK rename propagates.
_ENV_FOUNDRY_PROJECT_ENDPOINT = "FOUNDRY_PROJECT_ENDPOINT"
# Per-request isolation context. The owning Channel is expected to set this
# from the inbound request (e.g. user / tenant headers) for the duration of
# an ``agent.run(...)`` call. When unset, requests are made without
# isolation headers (matches how ``ResponseContext`` behaves with no
# ``IsolationContext``).
_isolation_var: ContextVar[IsolationContext | None] = ContextVar(
"agent_framework_foundry_hosting_isolation",
default=None,
)
def set_current_isolation(isolation: IsolationContext | None) -> Any:
"""Set the per-request isolation context for downstream history calls.
Channels that drive an agent backed by :class:`FoundryHostedAgentHistoryProvider`
should call this before invoking ``agent.run(...)`` and reset the token
afterwards.
Args:
isolation: The isolation context to associate with the current
``contextvars`` context, or ``None`` to clear it.
Returns:
A token suitable for :func:`reset_current_isolation` that restores
the previous value.
"""
return _isolation_var.set(isolation)
def reset_current_isolation(token: Any) -> None:
"""Restore a previously-saved isolation context.
Args:
token: A token returned by :func:`set_current_isolation`.
"""
_isolation_var.reset(token)
def get_current_isolation() -> IsolationContext | None:
"""Return the isolation context bound to the current async context, if any.
Returns:
The :class:`IsolationContext` for the current request, or ``None``
when no channel has set one.
"""
return _isolation_var.get()
@dataclass(frozen=True)
class _RequestContext:
"""Per-request anchors the host binds before invoking the agent.
``response_id`` is the id this provider's :meth:`save_messages` call
will write under, so the channel and the storage backend agree on
one stable handle per turn (the channel surfaces the same id on the
response envelope, the next turn arrives with this value as
``previous_response_id`` and the chain walks).
``previous_response_id`` is the prior turn's anchor (``None`` on
first turn). Used to seed ``history_item_ids`` on the new write so
the storage chain stays connected, and to load history without
needing to know the channel's session minting convention.
Per-request Foundry isolation keys (the
``x-agent-{user,chat}-isolation-key`` headers) are *not* carried
here; the host's own ASGI middleware lifts them off every inbound
HTTP request into a contextvar
(:func:`agent_framework_hosting.get_current_isolation_keys`) which
this provider consults at storage-call time. Keeping the headers
out of the per-request bind means channels never have to import
Foundry-specific types and the host owns the (intentional) coupling
to those two well-known headers.
"""
response_id: str
previous_response_id: str | None
_request_var: ContextVar[_RequestContext | None] = ContextVar(
"agent_framework_foundry_hosting_request",
default=None,
)
@contextmanager
def bind_request_context(
*,
response_id: str,
previous_response_id: str | None = None,
**_unused: Any,
) -> Iterator[None]:
"""Bind the per-request response-chain anchors for this provider.
Intended for the host (or any caller orchestrating an
``agent.run(...)``) to call immediately before invocation, so the
provider's :meth:`save_messages` writes under a known, stable
``response_id`` (the same one the channel surfaces to the client)
and walks ``previous_response_id`` for history continuity. Unknown
keyword arguments are accepted and ignored so the host can extend
the ``ChannelRequest.attributes`` contract without breaking existing
providers. Foundry isolation keys flow through a separate
host-installed contextvar; see the class docstring on
:class:`_RequestContext`.
The binding is scoped to the current ``contextvars.Context``, so
concurrent requests in the same process do not interfere.
"""
token = _request_var.set(
_RequestContext(
response_id=response_id,
previous_response_id=previous_response_id,
)
)
try:
yield
finally:
_request_var.reset(token)
def get_current_request_context() -> _RequestContext | None:
"""Return the per-request response chain anchors, if bound."""
return _request_var.get()
def _host_isolation() -> IsolationContext | None:
"""Lift the host-bound isolation contextvar into our local type.
The host installs an ASGI middleware that reads
``x-agent-{user,chat}-isolation-key`` off every inbound HTTP request
and stores them in a generic ``IsolationKeys`` slot on a contextvar
we import from :mod:`agent_framework_hosting`. We translate it into
our :class:`IsolationContext` shape on demand so the provider stays
in charge of the storage-side type while the host stays free of any
Foundry-specific dependencies.
"""
# Soft dep: ``agent_framework_hosting`` may not be installed (this
# provider is also usable standalone). The whole block is wrapped in
# ``# pyright: ignore`` so the optional import does not block type
# checking when the package isn't on sys.path; when it is, pyright
# picks up the real types automatically.
try:
from agent_framework_hosting import ( # pyright: ignore[reportMissingImports]
get_current_isolation_keys, # pyright: ignore[reportUnknownVariableType]
)
except ImportError: # pragma: no cover - hosting is a soft dep
return None
keys = get_current_isolation_keys() # pyright: ignore[reportUnknownVariableType]
if keys is None or keys.is_empty: # pyright: ignore[reportUnknownMemberType]
return None
return IsolationContext(
user_key=keys.user_key, # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType]
chat_key=keys.chat_key, # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType]
)
# Type alias for the storage backend surface this provider depends on.
# Both ``FoundryStorageProvider`` and ``InMemoryResponseProvider`` from
# ``azure.ai.agentserver.responses`` expose the same
# ``get_history_item_ids`` / ``get_items`` / ``create_response`` methods.
_StorageBackend = "FoundryStorageProvider | InMemoryResponseProvider"
# Sentinel directory name used in place of a missing ``user_key`` /
# ``chat_key`` when laying out file-based local history. The tilde
# prefix is reserved (``_is_safe_isolation_segment`` rejects keys that
# start with one) so a real isolation key can never collide with the
# sentinel after sanitisation.
_ISOLATION_NONE_MARKER = "~none"
_ISOLATION_ENCODED_PREFIX = "~iso-"
# Windows reserved file/directory stems. Mirrors
# ``FileHistoryProvider._WINDOWS_RESERVED_FILE_STEMS`` so the directory
# layer enforces the same portability constraints the file layer does.
_WINDOWS_RESERVED_STEMS = frozenset({
"CON",
"PRN",
"AUX",
"NUL",
*(f"COM{i}" for i in range(1, 10)),
*(f"LPT{i}" for i in range(1, 10)),
})
def _is_safe_isolation_segment(value: str) -> bool:
"""Return whether ``value`` is safe to use directly as a directory name.
Rules mirror :meth:`FileHistoryProvider._is_literal_session_file_stem_safe`,
with the additional rule that a leading tilde is reserved for our
sentinel/encoded prefixes so real keys can never collide with them.
"""
if (
not value
or value.startswith((".", "~"))
or value.endswith((" ", "."))
or value.upper() in _WINDOWS_RESERVED_STEMS
):
return False
if any(ord(character) < 32 for character in value):
return False
return all(character.isalnum() or character in "._-" for character in value)
def _encode_isolation_segment(value: str | None) -> str:
"""Encode an isolation key into a filesystem-safe directory name.
* ``None`` / empty → ``"~none"`` sentinel.
* Already-safe values pass through unchanged.
* Anything else is base64-url-encoded and prefixed with ``"~iso-"``
so it is unambiguous and never collides with a real (safe) key.
"""
if value is None or value == "":
return _ISOLATION_NONE_MARKER
if _is_safe_isolation_segment(value):
return value
encoded = urlsafe_b64encode(value.encode("utf-8")).decode("ascii").rstrip("=")
return f"{_ISOLATION_ENCODED_PREFIX}{encoded}"
class FoundryHostedAgentHistoryProvider(HistoryProvider):
"""``HistoryProvider`` backed by Foundry Hosted Agent storage.
Wraps :class:`azure.ai.agentserver.responses.FoundryStorageProvider`
when running inside a Foundry Hosted Agent container, or
:class:`InMemoryResponseProvider` for local development. The
selection is driven by the ``FOUNDRY_HOSTING_ENVIRONMENT``
environment variable.
For local runs that need to *persist* history across process
restarts, pass ``local_storage_root``: the provider then writes
each conversation to
``{root}/{user_key or "~none"}/{chat_key or "~none"}/{session_id}.jsonl``
via :class:`agent_framework.FileHistoryProvider`. The Foundry
response-chain semantics (``previous_response_id`` walking,
``caresp_*`` id stamping, ``ResponseObject`` envelopes) are
bypassed in file mode — the on-disk format is plain JSONL of
:class:`Message` payloads, identical to ``FileHistoryProvider``
standalone usage. ``local_storage_root`` is ignored when running
hosted (Foundry storage always wins).
``session_id`` semantics: in hosted / in-memory mode the value
passed to :meth:`get_messages` and :meth:`save_messages` is treated
as the Responses ``previous_response_id`` (or ``conversation_id``)
whose chain to load. When omitted (and no host-bound chain anchor
is set), :meth:`get_messages` returns an empty list (a fresh
conversation). In file mode ``session_id`` is used as the literal
filename stem (``FileHistoryProvider`` sanitises unsafe values).
"""
DEFAULT_SOURCE_ID: ClassVar[str] = "foundry_hosted_agent"
def __init__(
self,
*,
credential: AsyncTokenCredential | None = None,
endpoint: str | None = None,
history_limit: int = 100,
source_id: str = DEFAULT_SOURCE_ID,
load_messages: bool = True,
store_inputs: bool = True,
store_context_messages: bool = False,
store_context_from: set[str] | None = None,
store_outputs: bool = True,
local_storage_root: str | Path | None = None,
) -> None:
"""Initialize the provider.
Args:
credential: Async token credential used to authenticate against
the Foundry storage API. Required when running hosted
(``FOUNDRY_HOSTING_ENVIRONMENT`` is set). Ignored in
local-mode (the in-memory / file backends need no auth).
endpoint: Foundry project endpoint URL. Defaults to the value
of the ``FOUNDRY_PROJECT_ENDPOINT`` environment variable.
Required when running hosted.
history_limit: Maximum number of history items to fetch per
``get_messages`` call. Mirrors the agent-server runtime's
``ResponseContext._history_limit``. Default ``100``.
Ignored in file mode (``FileHistoryProvider`` returns the
full session file each call).
source_id: Unique identifier for this provider instance, as
required by ``HistoryProvider``.
load_messages: Whether to load messages before invocation.
Default ``True``.
store_inputs: Whether to mirror input messages into Foundry
storage. Default ``True`` — the Foundry Hosted Agents
runtime does not persist Responses turns automatically, so
without this the chain would never be visible to subsequent
requests. Set ``False`` only if you know an external writer
is populating storage on your behalf.
store_context_messages: Whether to mirror context-provider
messages. Default ``False``.
store_context_from: If set, only mirror context messages from
these source IDs.
store_outputs: Whether to mirror response messages into Foundry
storage. Default ``True`` for the same reason as
``store_inputs``.
local_storage_root: When set, *and* the provider is running
outside a Foundry Hosted Agent container, persist history
to JSONL files under
``{root}/{user_key or "~none"}/{chat_key or "~none"}/{session_id}.jsonl``
instead of using the in-memory backend. Ignored when
hosted (with a one-time INFO log). Defaults to ``None``
(in-memory local fallback).
"""
super().__init__(
source_id=source_id,
load_messages=load_messages,
store_inputs=store_inputs,
store_context_messages=store_context_messages,
store_context_from=store_context_from,
store_outputs=store_outputs,
)
self._history_limit = history_limit
self._credential = credential
self._endpoint = endpoint or os.environ.get(_ENV_FOUNDRY_PROJECT_ENDPOINT) or None
self._backend: FoundryStorageProvider | InMemoryResponseProvider | None = None
self._local_storage_root: Path | None = (
Path(local_storage_root).resolve() if local_storage_root is not None else None
)
# Cache one ``FileHistoryProvider`` per (user_key, chat_key)
# tuple. Bounded by the number of distinct isolation scopes the
# process sees; cleared on ``aclose``.
self._file_providers: dict[tuple[str, str], FileHistoryProvider] = {}
self._hosted_local_root_warned = False
if self._local_storage_root is not None and self.is_hosted_environment():
self._warn_hosted_local_root_ignored()
# Observability: number of ``save_messages`` calls dropped by
# :class:`FoundryStorageError` from ``backend.create_response``.
# Operators / health probes can read this attribute directly to
# detect silent persistence loss; never decremented.
self.failed_writes: int = 0
@staticmethod
def is_hosted_environment() -> bool:
"""Return ``True`` when running inside a Foundry Hosted Agent container.
Delegates to :meth:`azure.ai.agentserver.core.AgentConfig.from_env`
so the detection rule stays in lockstep with the Foundry SDK; if
the platform ever renames the underlying signal (today
``FOUNDRY_HOSTING_ENVIRONMENT``) the SDK update is picked up
automatically without a code change here.
"""
return AgentConfig.from_env().is_hosted
def _resolve_backend(self) -> FoundryStorageProvider | InMemoryResponseProvider:
"""Return the storage backend, constructing it lazily on first use.
* If ``FOUNDRY_HOSTING_ENVIRONMENT`` is set, build a
:class:`FoundryStorageProvider` (requires ``credential`` and a
resolved ``endpoint``).
* Otherwise, fall back to a process-local
:class:`InMemoryResponseProvider` so dev/local runs work without
additional configuration.
"""
if self._backend is not None:
return self._backend
if self.is_hosted_environment():
if self._credential is None:
raise RuntimeError(
"FoundryHostedAgentHistoryProvider requires an async credential when running "
"inside a Foundry Hosted Agent container. Pass credential=... ."
)
if not self._endpoint:
raise RuntimeError(
"FoundryHostedAgentHistoryProvider needs a Foundry project endpoint. Pass "
"endpoint=... or set the FOUNDRY_PROJECT_ENDPOINT environment variable."
)
self._backend = FoundryStorageProvider(
credential=self._credential,
settings=FoundryStorageSettings.from_endpoint(self._endpoint),
)
logger.debug(
"FoundryHostedAgentHistoryProvider using FoundryStorageProvider against %s",
self._endpoint,
)
return self._backend
logger.info(
"FOUNDRY_HOSTING_ENVIRONMENT is unset — FoundryHostedAgentHistoryProvider falling "
"back to InMemoryResponseProvider for local development.",
)
self._backend = InMemoryResponseProvider()
return self._backend
async def aclose(self) -> None:
"""Release storage resources held by this provider.
Safe to call multiple times. Closes the lazily-constructed
backend if one was created and drops any cached file-history
providers. ``InMemoryResponseProvider`` and
``FileHistoryProvider`` have no ``aclose`` and are closed
implicitly on garbage collection.
"""
self._file_providers.clear()
if self._backend is None:
return
aclose = getattr(self._backend, "aclose", None)
if aclose is not None:
await aclose()
self._backend = None
def _warn_hosted_local_root_ignored(self) -> None:
"""Log (once) that ``local_storage_root`` is being ignored under hosted mode."""
if self._hosted_local_root_warned:
return
self._hosted_local_root_warned = True
logger.info(
"FoundryHostedAgentHistoryProvider ignored local_storage_root=%s because "
"FOUNDRY_HOSTING_ENVIRONMENT is set; Foundry storage takes precedence "
"when hosted.",
self._local_storage_root,
)
def _resolve_local_file_provider(
self,
isolation: IsolationContext | None,
) -> FileHistoryProvider | None:
"""Return a ``FileHistoryProvider`` for the current isolation, or ``None``.
Returns ``None`` when ``local_storage_root`` is unset *or* the
provider is running in hosted mode (in which case Foundry
storage handles persistence). Otherwise builds — and caches —
one provider per (user_key, chat_key) tuple, rooted at the
sanitised ``{root}/{user_segment}/{chat_segment}`` directory.
Raises:
ValueError: If the resolved isolation directory escapes
``local_storage_root`` (defence in depth — the
sanitisation should already prevent this).
"""
if self._local_storage_root is None:
return None
if self.is_hosted_environment():
self._warn_hosted_local_root_ignored()
return None
user_key = isolation.user_key if isolation is not None else None
chat_key = isolation.chat_key if isolation is not None else None
cache_key = (user_key or "", chat_key or "")
cached = self._file_providers.get(cache_key)
if cached is not None:
return cached
user_segment = _encode_isolation_segment(user_key)
chat_segment = _encode_isolation_segment(chat_key)
target_dir = (self._local_storage_root / user_segment / chat_segment).resolve()
if not target_dir.is_relative_to(self._local_storage_root):
raise ValueError(
"Isolation segments resolved outside of local_storage_root: "
f"user_key={user_key!r} chat_key={chat_key!r}"
)
provider = FileHistoryProvider(
target_dir,
source_id=f"{self.source_id}__file__{user_segment}__{chat_segment}",
load_messages=self.load_messages,
store_inputs=self.store_inputs,
store_context_messages=self.store_context_messages,
store_context_from=self.store_context_from,
store_outputs=self.store_outputs,
)
self._file_providers[cache_key] = provider
logger.debug(
"FoundryHostedAgentHistoryProvider created file backend for isolation (user=%s, chat=%s) at %s",
user_key,
chat_key,
target_dir,
)
return provider
async def get_messages(
self,
session_id: str | None,
*,
state: dict[str, Any] | None = None,
**kwargs: Any,
) -> list[Message]:
"""Load conversation history for the given Foundry response chain.
Args:
session_id: The Responses ``previous_response_id`` /
``conversation_id`` to anchor history on. When ``None`` /
empty, an empty history is returned (fresh conversation).
state: Unused — kept for ``HistoryProvider`` compatibility.
**kwargs: Extensibility hook; ``isolation`` may be supplied
explicitly to override the contextvar.
Returns:
The conversation history materialised as a list of
:class:`agent_framework.Message`, oldest-first.
Notes:
History anchoring follows the Foundry response-id chain. The
preferred anchor is the per-request ``previous_response_id``
bound by the host via :func:`bind_request_context` — that's
the prior turn's resp id, written by *this* provider's
previous :meth:`save_messages` call, so the chain is
guaranteed walkable. When unbound (e.g. local dev calling
the provider directly), we fall back to the ``session_id``
argument as long as it's ``resp_*``-shaped; opaque tokens
(such as chat-isolation-key values) are skipped because the
storage backend rejects them with HTTP 400 "Malformed
identifier".
When ``local_storage_root`` is configured (and the provider
is running outside a Foundry Hosted Agent container), this
method instead delegates to a per-isolation
:class:`FileHistoryProvider` and ``session_id`` is used as
the literal file stem.
"""
isolation = kwargs.get("isolation") or _host_isolation() or get_current_isolation()
file_provider = self._resolve_local_file_provider(isolation)
if file_provider is not None:
return await file_provider.get_messages(session_id, state=state, **kwargs)
bound = get_current_request_context()
# Prefer the host-bound previous_response_id over the session_id
# the framework feeds in: the bound value is the id we ourselves
# wrote on the previous turn, so we know it's storage-valid.
anchor = bound.previous_response_id if bound is not None else None
if anchor is None and session_id and session_id.startswith(("caresp_", "resp_")):
anchor = session_id
if anchor is None:
# No walkable anchor → fresh conversation, nothing to load.
# Note: we intentionally do NOT fall back to
# ``FOUNDRY_AGENT_SESSION_ID`` — per the Foundry SDK that env
# var identifies the *container instance*, not the
# conversation, so it doesn't yield a walkable response-id
# chain. The host-bound ``previous_response_id`` (set by
# ``ResponsesChannel`` from the request envelope) is the
# authoritative anchor.
return []
backend = self._resolve_backend()
try:
item_ids = await backend.get_history_item_ids(
anchor,
None,
self._history_limit,
isolation=isolation,
)
except (FoundryBadRequestError, FoundryResourceNotFoundError) as err:
# 400 / 404 here means the anchor isn't storage-valid — treat
# it as an empty history rather than failing the whole request.
logger.debug(
"get_messages: anchor %r rejected by storage (%s); returning empty history",
anchor,
type(err).__name__,
)
return []
if not item_ids:
return []
items = await backend.get_items(item_ids, isolation=isolation)
# ``get_items`` may return ``None`` placeholders for missing IDs.
resolved = [item for item in items if item is not None]
return await _output_items_to_messages(resolved)
async def save_messages(
self,
session_id: str | None,
messages: Sequence[Message],
*,
state: dict[str, Any] | None = None,
**kwargs: Any,
) -> None:
"""Persist messages for ``session_id`` into Foundry storage.
Unlike the standalone ``azure.ai.agentserver`` runtime — which
owns response orchestration end-to-end and writes turns
authoritatively — the Agent Framework hosting stack treats
``HistoryProvider`` as the *only* persistence path. Without this
method actively writing, a deployed hosted agent would silently
drop every turn.
Strategy:
* Use the host-bound ``response_id`` as the envelope id (mints
a fresh ``caresp_*`` id when unbound, e.g. local dev).
* Anchor the new write to the previous turn via
``previous_response_id``, walking the prior turn's history
item ids forward so the full transcript stays visible.
* Split items by role: ``"message"`` (user/system inputs) into
``input_items``, everything else (assistant outputs, tool
calls, reasoning, ...) into ``response.output``.
Args:
session_id: The Responses ``previous_response_id`` /
``conversation_id`` the messages belong to.
messages: The messages selected for persistence by the base
``HistoryProvider`` after-run hook.
state: Unused — kept for ``HistoryProvider`` compatibility.
**kwargs: Extensibility hook; ``isolation`` may be supplied
explicitly to override the contextvar.
Notes:
When ``local_storage_root`` is configured (and the provider
is running outside a Foundry Hosted Agent container), this
method instead delegates to a per-isolation
:class:`FileHistoryProvider` and ``session_id`` is used as
the literal file stem. The Foundry response-chain stamping
described above is bypassed entirely in that mode.
"""
if not messages:
return
isolation = kwargs.get("isolation") or _host_isolation() or get_current_isolation()
file_provider = self._resolve_local_file_provider(isolation)
if file_provider is not None:
await file_provider.save_messages(session_id, messages, state=state, **kwargs)
return
bound = get_current_request_context()
# Prefer the host-bound response_id so the channel envelope and
# the storage write agree on a single id per turn — which is
# what makes the next turn's ``previous_response_id`` walkable.
# Without a binding (e.g. local dev calling ``save_messages``
# directly), fall back to a fresh Foundry-format response id.
# Free-form ``resp_<uuid>`` ids carry no embedded partition key
# and the storage backend rejects writes with a server error;
# ``IdGenerator.new_response_id()`` mints a ``caresp_*`` id with
# the partition-key segment the backend expects. The chain
# walks only when ``session_id`` is itself a ``caresp_*``-shaped
# value (i.e. a previous response id), matching the prefix the
# ``ResponsesChannel`` factory uses.
if bound is not None:
response_id = bound.response_id
previous_response_id = bound.previous_response_id
else:
if not session_id:
return
response_id = IdGenerator.new_response_id()
previous_response_id = session_id if session_id.startswith(("caresp_", "resp_")) else None
# Note: we intentionally do NOT consult ``FOUNDRY_AGENT_SESSION_ID``
# as a fallback ``previous_response_id`` here. Per the Foundry SDK
# that env var identifies the *container instance*, not the
# conversation, so chaining off it produces an unwalkable history.
# The host-bound ``previous_response_id`` (set by
# ``ResponsesChannel`` from the request envelope) is the only
# authoritative anchor; if it's missing the new turn is the start
# of a fresh chain.
logger.debug(
"save_messages: response_id=%r previous_response_id=%r isolation=%s",
response_id,
previous_response_id,
"<set>" if isolation else "<None>",
)
backend = self._resolve_backend()
# The agentserver runtime puts INBOUND items (user/system messages
# the request sent in) in the envelope's ``input_items`` axis and
# OUTBOUND items (assistant outputs, tool calls, reasoning) in
# ``response.output``. See
# ``_resolve_input_items_for_persistence`` (orchestrator.py:61) +
# ``_extract_response_snapshot_from_events`` in
# ``azure.ai.agentserver.responses``: ``input_items`` comes from
# ``ctx.input_items`` (request inputs only); ``response.output``
# is populated from the lifecycle event stream.
#
# Putting everything in ``input_items`` with ``response.output: []``
# is a schema violation that the storage backend rejects with an
# opaque HTTP 500. Split by role to mirror the runtime.
all_items = _messages_to_output_items(list(messages), id_prefix=response_id)
# Re-stamp every item id via ``IdGenerator`` so each carries a
# Foundry-format ``{type-prefix}_<partitionKey><entropy>``
# identifier, with the response_id as the partition-key hint
# (co-locates each item with the response record). Free-form
# ``{response_id}_itm_N`` ids are rejected by the storage
# backend with an opaque HTTP 500 because the partition-key
# extractor cannot parse them. ``IdGenerator.new_item_id``
# dispatches by *Item* (input) type and returns ``None`` for
# our *OutputItem* (storage) instances, so we dispatch by the
# ``type`` discriminator string instead.
ITEM_ID_FACTORY: dict[str, Any] = {
"message": IdGenerator.new_message_item_id,
"output_message": IdGenerator.new_output_message_item_id,
"function_call": IdGenerator.new_function_call_item_id,
"function_call_output": IdGenerator.new_function_call_output_item_id,
"reasoning": IdGenerator.new_reasoning_item_id,
"file_search_call": IdGenerator.new_file_search_call_item_id,
"web_search_call": IdGenerator.new_web_search_call_item_id,
"image_generation_call": IdGenerator.new_image_gen_call_item_id,
"code_interpreter_call": IdGenerator.new_code_interpreter_call_item_id,
"computer_call": IdGenerator.new_computer_call_item_id,
"computer_call_output": IdGenerator.new_computer_call_output_item_id,
"local_shell_call": IdGenerator.new_local_shell_call_item_id,
"local_shell_call_output": IdGenerator.new_local_shell_call_output_item_id,
"mcp_call": IdGenerator.new_mcp_call_item_id,
"mcp_list_tools": IdGenerator.new_mcp_list_tools_item_id,
"mcp_approval_request": IdGenerator.new_mcp_approval_request_item_id,
"mcp_approval_response": IdGenerator.new_mcp_approval_response_item_id,
"custom_tool_call": IdGenerator.new_custom_tool_call_item_id,
"custom_tool_call_output": IdGenerator.new_custom_tool_call_output_item_id,
}
for item in all_items:
factory = ITEM_ID_FACTORY.get(getattr(item, "type", "") or "")
if factory is None:
continue
new_id = factory(response_id)
# Plain attribute assignment — the SDK ``OutputItem`` models
# are ``MutableMapping``s with ``__setattr__`` wired to dict
# set, so this is expected to succeed for every type listed
# above. The previous ``contextlib.suppress`` masked SDK
# contract changes (next save would silently retain the
# synthetic prefix-based id and the storage backend would
# reject the entire ``create_response`` with HTTP 500).
# Letting it raise surfaces those breakages to the test
# suite instead.
item.id = new_id # type: ignore[attr-defined]
input_items: list[Any] = []
output_items: list[Any] = []
for item in all_items:
item_type = getattr(item, "type", None)
if item_type == "message":
input_items.append(item)
else:
# ``output_message``, tool calls, reasoning, etc. all
# belong to the response output stream.
output_items.append(item)
# Walk the previous response's history chain so the new write
# carries the full transcript forward. Without this, each turn
# would only see the messages saved on that very turn.
history_item_ids: list[str] | None = None
if previous_response_id is not None:
try:
history_item_ids = await backend.get_history_item_ids(
previous_response_id,
None,
self._history_limit,
isolation=isolation,
)
except (FoundryBadRequestError, FoundryResourceNotFoundError) as err:
# Don't let history fetch failures torpedo the write —
# we still want to persist the new turn even if the
# chain seed is unreachable for some reason.
logger.warning(
"save_messages: failed to walk previous_response_id=%r (%s); writing new turn without history seed",
previous_response_id,
type(err).__name__,
)
# Mirror what the agentserver runtime serialises onto the wire
# (see ``_extract_response_snapshot_from_events`` +
# ``strip_nulls`` in
# ``azure.ai.agentserver.responses.streaming._helpers``):
#
# * ``agent_reference`` (Required on the response envelope) —
# built from ``FOUNDRY_AGENT_NAME`` / ``FOUNDRY_AGENT_VERSION``,
# which the hosted platform sets per-deploy (sentinel fallback
# for local dev so the envelope stays well-formed).
# * ``agent_session_id`` (S-038) — forcibly stamped by the
# runtime; sourced from ``FOUNDRY_AGENT_SESSION_ID``.
# * ``conversation`` is intentionally omitted: the (user, chat)
# isolation headers are the Foundry storage partition key,
# and the chat-isolation-key value is opaque (the API
# returns "Malformed identifier"/HTTP 400 if used as a
# body-level ``conversation_id``).
# * Per-item ``response_id`` / ``agent_reference`` are NOT
# stamped here — those B20/B21 defaults only apply to items
# inside ``response.output_item.added/done`` *events* (see
# ``_coerce_handler_event``); items inside ``input_items``
# and ``response.output`` go through ``to_output_item`` which
# never sets these fields, and the storage validator returns
# HTTP 400 ``invalid_payload`` when extras leak in.
agent_name = os.environ.get("FOUNDRY_AGENT_NAME") or "agent-framework-host"
agent_version = os.environ.get("FOUNDRY_AGENT_VERSION") or None
agent_reference: dict[str, Any] = {"type": "agent_reference", "name": agent_name}
if agent_version:
agent_reference["version"] = agent_version
agent_session_id = os.environ.get("FOUNDRY_AGENT_SESSION_ID") or None
# ``model`` must be a real deployed model name — the storage
# validator rejects arbitrary strings. Pull it from the
# platform-provided ``MODEL_DEPLOYMENT_NAME`` (set in agent.yaml)
# and fall back to ``AZURE_AI_MODEL_DEPLOYMENT_NAME`` for local
# dev. When neither is set we omit the field entirely (it is
# ``Optional[str]`` per the ResponseObject schema).
model_deployment = (
os.environ.get("MODEL_DEPLOYMENT_NAME") or os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME") or None
)
# Build the wire payload to match exactly what the agentserver
# runtime emits via ``_extract_response_snapshot_from_events``
# for a synthetic ``status=completed`` snapshot:
#
# {id, object, output, created_at, [model], agent_reference,
# status, completed_at, [agent_session_id]}
#
# ``previous_response_id`` is appended when chaining; the runtime
# threads it through the same code path.
now = int(time.time())
response_body: dict[str, Any] = {
"id": response_id,
# SDK mirror: ``streaming/_helpers.py:244`` always stamps
# ``response_id`` alongside ``id`` on the snapshot before it
# reaches ``serialize_create_request``.
"response_id": response_id,
"object": "response",
# S-040 auto-stamp: the orchestrator (``_orchestrator.py:1706``)
# echoes ``background`` from the request to every response
# envelope; storage rejects payloads that omit it.
"background": False,
# ``ResponseObject`` schema (``_models.py:13995``) declares
# ``parallel_tool_calls: bool`` as REQUIRED. The SDK's synthetic
# fallback path (``_build_events``) never sets it because it's
# only invoked for failure recovery; real handler events carry
# it through. Storage rejects payloads that omit it.
"parallel_tool_calls": False,
# Same story for ``instructions`` (``_models.py:13989``) —
# required ``str | list[Item]`` field.
"instructions": "",
"output": [item.as_dict() for item in output_items],
"created_at": now,
"agent_reference": agent_reference,
"status": "completed",
"completed_at": now,
}
if model_deployment is not None:
response_body["model"] = model_deployment
if agent_session_id is not None:
response_body["agent_session_id"] = agent_session_id
if previous_response_id is not None:
response_body["previous_response_id"] = previous_response_id
response = ResponseObject(response_body)
try:
await backend.create_response(
response,
input_items=input_items,
history_item_ids=history_item_ids,
isolation=isolation,
)
except FoundryStorageError as exc:
# Storage-validation failures (4xx ``invalid_payload`` /
# ``not_found``, opaque 5xx) are best-effort losses: the
# caller's run already produced output and we don't want to
# crash the whole turn over a chain-write the user can't
# recover from. They are still observable: every drop bumps
# ``failed_writes`` (operators can poll it / surface in
# health probes) and the full traceback + ``response_body``
# is logged.
#
# Network / TLS / DNS errors, expired-credential 401/403s,
# and bugs in the wire-payload builder above (e.g. a
# required-field regression) deliberately propagate so they
# surface to the caller and trigger retry / alerting paths
# instead of being silently dropped here.
self.failed_writes += 1
err_body = getattr(exc, "response_body", None)
logger.exception(
"FoundryHostedAgentHistoryProvider.save_messages: storage rejected "
"%d message(s) (response_id=%s, previous_response_id=%s, error_body=%s, "
"failed_writes=%d).",
len(messages),
response_id,
previous_response_id,
err_body,
self.failed_writes,
)
return
logger.debug(
"FoundryHostedAgentHistoryProvider.save_messages: persisted %d message(s) "
"(response_id=%s, previous_response_id=%s).",
len(messages),
response_id,
previous_response_id,
)
# Re-export ``OutputItem`` for callers that want to construct test items
# without reaching into the SDK's ``models`` namespace directly.
__all__ = [
"FoundryHostedAgentHistoryProvider",
"OutputItem",
"bind_request_context",
"get_current_isolation",
"get_current_request_context",
"reset_current_isolation",
"set_current_isolation",
]
@@ -0,0 +1,72 @@
# Copyright (c) Microsoft. All rights reserved.
"""Foundry-storage-compatible identifier helpers.
The Foundry hosted-agent storage backend partitions records by extracting
an embedded partition-key segment from every record/item id. The id
format is ``{prefix}_{18charPartitionKey}{32charEntropy}`` (or a 48-char
legacy body). Free-form ids such as ``resp_<uuid hex>`` carry no valid
partition key and the storage API rejects writes with an opaque
``HTTP 500 server_error``.
These helpers wrap :class:`azure.ai.agentserver.responses._id_generator.IdGenerator`
so callers (e.g. the ``ResponsesChannel.response_id_factory`` argument
or :class:`FoundryHostedAgentHistoryProvider.save_messages`) can mint
ids that the storage backend accepts without leaking the SDK import
path into user code.
"""
from __future__ import annotations
from typing import Any
from azure.ai.agentserver.responses._id_generator import IdGenerator
__all__ = [
"foundry_item_id",
"foundry_response_id",
"foundry_response_id_factory",
]
def foundry_response_id(previous_response_id: str | None = None) -> str:
"""Mint a Foundry-storage-compatible response id (``caresp_*``).
Args:
previous_response_id: When supplied (and shaped like a Foundry
id with an embedded partition key), the new id co-locates
with the chain by reusing that partition key. The storage
backend rejects chained writes whose new record sits in a
different partition than the prior one.
Returns:
A new id of the form ``caresp_<18charPartitionKey><32charEntropy>``.
"""
return IdGenerator.new_response_id(previous_response_id or "")
def foundry_response_id_factory() -> "Any":
"""Return a callable suitable for ``ResponsesChannel(response_id_factory=...)``.
The returned callable accepts an optional ``previous_response_id``
hint which the channel passes for chained turns so the new id
inherits the prior turn's partition key (Foundry storage requirement).
"""
return foundry_response_id
def foundry_item_id(item: "Any", response_id: str | None = None) -> str | None:
"""Mint a Foundry-storage-compatible item id for *item*.
Dispatches via :meth:`IdGenerator.new_item_id` so the id picks up
the right type prefix (``msg`` / ``om`` / ``fc`` / ``rs`` / ...).
When ``response_id`` is supplied it acts as a partition-key hint so
every item written under one response co-locates with the response
record (Foundry storage requirement).
Returns:
A new id of the form ``{type-prefix}_<partitionKey><entropy>``,
or ``None`` when *item* is an unrecognised / reference-only type
(mirrors the SDK helper's contract).
"""
return IdGenerator.new_item_id(item, response_id)
@@ -3,17 +3,15 @@
from __future__ import annotations
import asyncio
import base64
import json
import logging
import os
import tempfile
import threading
from collections.abc import AsyncIterable, AsyncIterator, Generator, Mapping, Sequence
from contextlib import suppress
from pathlib import Path
from collections.abc import AsyncIterable, AsyncIterator, Generator
from contextlib import AbstractAsyncContextManager, AsyncExitStack, suppress
from typing import Protocol, cast
from pathlib import Path
from typing import cast
from agent_framework import (
ChatOptions,
@@ -21,7 +19,6 @@ from agent_framework import (
ContextProvider,
FileCheckpointStorage,
HistoryProvider,
Message,
RawAgent,
SupportsAgentRun,
WorkflowAgent,
@@ -32,77 +29,10 @@ from azure.ai.agentserver.responses import (
ResponseEventStream,
ResponseProviderProtocol,
ResponsesServerOptions,
models,
)
from azure.ai.agentserver.responses._id_generator import IdGenerator
from azure.ai.agentserver.responses.hosting import ResponsesAgentServerHost
from azure.ai.agentserver.responses.models import (
ApplyPatchToolCallItemParam,
ApplyPatchToolCallOutputItemParam,
ComputerCallOutputItemParam,
ComputerScreenshotContent,
CreateResponse,
FunctionCallOutputItemParam,
FunctionShellAction,
FunctionShellCallItemParam,
FunctionShellCallOutputContent,
FunctionShellCallOutputExitOutcome,
FunctionShellCallOutputItemParam,
Item,
ItemCodeInterpreterToolCall,
ItemComputerToolCall,
ItemCustomToolCall,
ItemCustomToolCallOutput,
ItemFileSearchToolCall,
ItemFunctionToolCall,
ItemImageGenToolCall,
ItemLocalShellToolCall,
ItemLocalShellToolCallOutput,
ItemMcpApprovalRequest,
ItemMcpToolCall,
ItemMessage,
ItemOutputMessage,
ItemReasoningItem,
ItemWebSearchToolCall,
LocalEnvironmentResource,
MCPApprovalResponse,
MessageContent,
MessageContentInputFileContent,
MessageContentInputImageContent,
MessageContentInputTextContent,
MessageContentOutputTextContent,
MessageContentReasoningTextContent,
MessageContentRefusalContent,
OAuthConsentRequestOutputItem,
OutputItem,
OutputItemApplyPatchToolCall,
OutputItemApplyPatchToolCallOutput,
OutputItemCodeInterpreterToolCall,
OutputItemComputerToolCall,
OutputItemComputerToolCallOutputResource,
OutputItemCustomToolCall,
OutputItemCustomToolCallOutput,
OutputItemFileSearchToolCall,
OutputItemFunctionShellCall,
OutputItemFunctionShellCallOutput,
OutputItemFunctionToolCall,
OutputItemImageGenToolCall,
OutputItemLocalShellToolCall,
OutputItemLocalShellToolCallOutput,
OutputItemMcpApprovalRequest,
OutputItemMcpApprovalResponseResource,
OutputItemMcpToolCall,
OutputItemMessage,
OutputItemOutputMessage,
OutputItemReasoningItem,
OutputItemWebSearchToolCall,
OutputMessageContent,
OutputMessageContentOutputTextContent,
OutputMessageContentRefusalContent,
ResponseStreamEvent,
StructuredOutputsOutputItem,
SummaryTextContent,
TextContent,
)
from azure.ai.agentserver.responses.streaming._builders import (
OutputItemFunctionCallBuilder,
OutputItemMcpCallBuilder,
@@ -114,22 +44,45 @@ from azure.ai.agentserver.responses.streaming._builders import (
from mcp import McpError
from typing_extensions import Any
from ._shared import (
ApprovalStorage,
_arguments_to_str, # pyright: ignore[reportPrivateUsage]
_convert_message_content, # pyright: ignore[reportPrivateUsage]
_convert_output_message_content, # pyright: ignore[reportPrivateUsage]
_item_to_message, # pyright: ignore[reportPrivateUsage]
_items_to_messages, # pyright: ignore[reportPrivateUsage]
_output_item_to_message, # pyright: ignore[reportPrivateUsage]
_output_items_to_messages, # pyright: ignore[reportPrivateUsage]
)
# Re-export the conversion helpers under their historical names so existing
# tests (which import them from this module) keep working — the canonical
# definitions now live in :mod:`._shared`.
__all__ = (
"ApprovalStorage",
"_arguments_to_str",
"_convert_message_content",
"_convert_output_message_content",
"_item_to_message",
"_items_to_messages",
"_output_item_to_message",
"_output_items_to_messages",
)
# Local aliases for the agent-server SDK types this module touches at the
# Python type-annotation layer. Using ``models.X`` everywhere would work but
# would noisily clutter type-only positions where the alias adds no value.
CreateResponse = models.CreateResponse
ResponseStreamEvent = models.ResponseStreamEvent
FunctionShellAction = models.FunctionShellAction
FunctionShellCallOutputContent = models.FunctionShellCallOutputContent
FunctionShellCallOutputExitOutcome = models.FunctionShellCallOutputExitOutcome
LocalEnvironmentResource = models.LocalEnvironmentResource
OAuthConsentRequestOutputItem = models.OAuthConsentRequestOutputItem
logger = logging.getLogger(__name__)
# region Approval Storage
class ApprovalStorage(Protocol):
"""Storage for saving function approval requests."""
async def save_approval_request(self, approval_request_id: str, request: Content) -> None:
"""Save a function approval request under the given ID."""
...
async def load_approval_request(self, approval_request_id: str) -> Content:
"""Load a function approval request by its ID."""
...
class InMemoryFunctionApprovalStorage:
"""An in-memory storage for function approval requests."""
@@ -515,7 +468,7 @@ class ResponsesHostServer(ResponsesAgentServerHost):
by the hosting infrastructure or files will be preserved upon deactivation.
"""
input_items = await context.get_input_items()
input_messages = await _items_to_messages(input_items)
input_messages = await _items_to_messages(input_items, approval_storage=self._approval_storage)
is_streaming_request = request.stream is not None and request.stream is True
_, are_options_set = _to_chat_options(request)
@@ -563,9 +516,9 @@ class ResponsesHostServer(ResponsesAgentServerHost):
# conversation_id when set). When conversation_id is set, this
# matches restore_storage; when only previous_response_id was
# supplied, restore_storage points at the *prior* response's
# directory and write_storage points at the *current* response's.
# directory and checkpoint_storage points at the *current* response's.
write_context_id = context.conversation_id or context.response_id
write_storage = _checkpoint_storage_for_context(self._checkpoint_storage_path, write_context_id)
checkpoint_storage = _checkpoint_storage_for_context(self._checkpoint_storage_path, write_context_id)
# Multi-turn pattern: when we have a prior checkpoint, restore it
# first (drive the workflow back to idle with prior state intact),
@@ -584,6 +537,8 @@ class ResponsesHostServer(ResponsesAgentServerHost):
# items (carried as FunctionResult/FunctionApprovalResponse content)
# that fulfill them via :meth:`WorkflowAgent._process_pending_requests`.
if latest_checkpoint_id is not None:
if restore_storage is None: # pragma: no cover - defensive
raise RuntimeError("Checkpoint restore storage is not configured.")
if is_streaming_request:
async for _ in self._agent.run(
stream=True,
@@ -605,19 +560,19 @@ class ResponsesHostServer(ResponsesAgentServerHost):
yield response_event_stream.emit_in_progress()
if not is_streaming_request:
# Run the agent in non-streaming mode with the new user input.
response = await self._agent.run(
input_messages,
stream=False,
checkpoint_storage=write_storage,
)
# Run the agent in non-streaming mode
response = await self._agent.run(input_messages, stream=False, checkpoint_storage=checkpoint_storage)
for message in response.messages:
for content in message.contents:
async for item in _to_outputs(response_event_stream, content):
async for item in _to_outputs(
response_event_stream,
content,
approval_storage=self._approval_storage,
):
yield item
await self._delete_not_latest_checkpoints(write_storage, self._agent.workflow.name)
await self._delete_not_latest_checkpoints(checkpoint_storage, self._agent.workflow.name)
yield response_event_stream.emit_completed()
return
@@ -625,17 +580,17 @@ class ResponsesHostServer(ResponsesAgentServerHost):
# lazily created on matching content, closed when a different type arrives.
tracker = _OutputItemTracker(response_event_stream)
# Run the workflow agent in streaming mode with the new user input.
async for update in self._agent.run(
input_messages,
stream=True,
checkpoint_storage=write_storage,
):
# Run the workflow agent in streaming mode
async for update in self._agent.run(input_messages, stream=True, checkpoint_storage=checkpoint_storage):
for content in update.contents:
for event in tracker.handle(content):
yield event
if tracker.needs_async:
async for item in _to_outputs(response_event_stream, content):
async for item in _to_outputs(
response_event_stream,
content,
approval_storage=self._approval_storage,
):
yield item
tracker.needs_async = False
@@ -643,7 +598,7 @@ class ResponsesHostServer(ResponsesAgentServerHost):
for event in tracker.close():
yield event
await self._delete_not_latest_checkpoints(write_storage, self._agent.workflow.name)
await self._delete_not_latest_checkpoints(checkpoint_storage, self._agent.workflow.name)
yield response_event_stream.emit_completed()
@staticmethod
@@ -846,681 +801,6 @@ def _to_chat_options(request: CreateResponse) -> tuple[ChatOptions, bool]:
# endregion
# region Input Message Conversion
async def _items_to_messages(
input_items: Sequence[Item], *, approval_storage: ApprovalStorage | None = None
) -> list[Message]:
"""Converts a sequence of input items to a list of Messages, one per item.
Args:
input_items: The input items to convert.
approval_storage: An optional ApprovalStorage instance used to look up
approval requests when converting MCP approval response items.
Returns:
A list of Messages, one per supported input item.
"""
messages: list[Message] = []
for item in input_items:
messages.append(await _item_to_message(item, approval_storage=approval_storage))
return messages
async def _item_to_message(item: Item, *, approval_storage: ApprovalStorage | None = None) -> Message:
"""Converts an Item to a Message.
Args:
item: The Item to convert.
approval_storage: An optional ApprovalStorage instance used to look up
approval requests when converting MCP approval response items.
Returns:
The converted Message.
Raises:
ValueError: If the Item type is not supported.
"""
if item.type == "message":
msg = cast(ItemMessage, item)
if isinstance(msg.content, str):
return Message(role=msg.role, contents=[Content.from_text(msg.content)])
return Message(role=msg.role, contents=[_convert_message_content(part) for part in msg.content])
if item.type == "output_message":
output_msg = cast(ItemOutputMessage, item)
return Message(
role=output_msg.role, contents=[_convert_output_message_content(part) for part in output_msg.content]
)
if item.type == "function_call":
fc = cast(ItemFunctionToolCall, item)
return Message(
role="assistant",
contents=[Content.from_function_call(fc.call_id, fc.name, arguments=fc.arguments)],
)
if item.type == "function_call_output":
fco = cast(FunctionCallOutputItemParam, item)
output = fco.output if isinstance(fco.output, str) else str(fco.output)
return Message(
role="tool",
contents=[Content.from_function_result(fco.call_id, result=output)],
)
if item.type == "reasoning":
reasoning = cast(ItemReasoningItem, item)
reason_contents: list[Content] = []
if reasoning.summary:
for summary in reasoning.summary:
reason_contents.append(Content.from_text(summary.text))
return Message(role="assistant", contents=reason_contents)
if item.type == "mcp_call":
mcp = cast(ItemMcpToolCall, item)
return Message(
role="assistant",
contents=[
Content.from_mcp_server_tool_call(
mcp.id,
mcp.name,
server_name=mcp.server_label,
arguments=mcp.arguments,
)
],
)
if item.type == "mcp_approval_request":
mcp_req = cast(ItemMcpApprovalRequest, item)
if approval_storage is not None:
function_approval_request_content = await approval_storage.load_approval_request(mcp_req.id)
else:
raise ValueError("ApprovalStorage is required to load approval request.")
return Message(
role="assistant",
contents=[function_approval_request_content],
)
if item.type == "mcp_approval_response":
mcp_resp = cast(MCPApprovalResponse, item)
if approval_storage is not None:
function_approval_request_content = await approval_storage.load_approval_request(
mcp_resp.approval_request_id
)
else:
raise ValueError("ApprovalStorage is required to load approval request.")
return Message(
role="user",
contents=[function_approval_request_content.to_function_approval_response(mcp_resp.approve)],
)
if item.type == "code_interpreter_call":
ci = cast(ItemCodeInterpreterToolCall, item)
return Message(
role="assistant",
contents=[Content.from_code_interpreter_tool_call(call_id=ci.id)],
)
if item.type == "image_generation_call":
ig = cast(ItemImageGenToolCall, item)
return Message(
role="assistant",
contents=[Content.from_image_generation_tool_call(image_id=ig.id)],
)
if item.type == "shell_call":
sc = cast(FunctionShellCallItemParam, item)
return Message(
role="assistant",
contents=[
Content.from_shell_tool_call(
call_id=sc.call_id,
commands=sc.action.commands,
status=str(sc.status),
)
],
)
if item.type == "shell_call_output":
sco = cast(FunctionShellCallOutputItemParam, item)
outputs = [
Content.from_shell_command_output(
stdout=out.stdout or "",
stderr=out.stderr or "",
exit_code=getattr(out.outcome, "exit_code", None) if hasattr(out, "outcome") else None,
)
for out in (sco.output or [])
]
return Message(
role="tool",
contents=[
Content.from_shell_tool_result(
call_id=sco.call_id,
outputs=outputs,
max_output_length=sco.max_output_length,
)
],
)
if item.type == "local_shell_call":
lsc = cast(ItemLocalShellToolCall, item)
commands = lsc.action.command if hasattr(lsc.action, "command") and lsc.action.command else []
return Message(
role="assistant",
contents=[
Content.from_shell_tool_call(
call_id=lsc.call_id,
commands=commands,
status=str(lsc.status),
)
],
)
if item.type == "local_shell_call_output":
lsco = cast(ItemLocalShellToolCallOutput, item)
return Message(
role="tool",
contents=[
Content.from_shell_tool_result(
call_id=lsco.id,
outputs=[Content.from_shell_command_output(stdout=lsco.output)],
)
],
)
if item.type == "file_search_call":
fs = cast(ItemFileSearchToolCall, item)
return Message(
role="assistant",
contents=[
Content.from_function_call(
fs.id,
"file_search",
arguments=json.dumps({"queries": fs.queries}),
)
],
)
if item.type == "web_search_call":
ws = cast(ItemWebSearchToolCall, item)
return Message(
role="assistant",
contents=[Content.from_function_call(ws.id, "web_search")],
)
if item.type == "computer_call":
cc = cast(ItemComputerToolCall, item)
return Message(
role="assistant",
contents=[
Content.from_function_call(
cc.call_id,
"computer_use",
arguments=str(cc.action),
)
],
)
if item.type == "computer_call_output":
cco = cast(ComputerCallOutputItemParam, item)
return Message(
role="tool",
contents=[Content.from_function_result(cco.call_id, result=str(cco.output))],
)
if item.type == "custom_tool_call":
ct = cast(ItemCustomToolCall, item)
return Message(
role="assistant",
contents=[Content.from_function_call(ct.call_id, ct.name, arguments=ct.input)],
)
if item.type == "custom_tool_call_output":
cto = cast(ItemCustomToolCallOutput, item)
output = cto.output if isinstance(cto.output, str) else str(cto.output)
# Hosted-MCP results land here because the host writes them via
# `aoutput_item_custom_tool_call_output` (see `_to_outputs` for
# `mcp_server_tool_result`). The persisted `call_id` keeps its
# `mcp_*` prefix; on read, route those back to a hosted-MCP result
# Content so the chat-client serialize layer can coalesce them
# onto a single `mcp_call` input item with `output` populated.
# Issue #5546.
if cto.call_id and cto.call_id.startswith("mcp_"):
return Message(
role="tool",
contents=[Content.from_mcp_server_tool_result(call_id=cto.call_id, output=output)],
)
return Message(
role="tool",
contents=[Content.from_function_result(cto.call_id, result=output)],
)
if item.type == "apply_patch_call":
ap = cast(ApplyPatchToolCallItemParam, item)
return Message(
role="assistant",
contents=[
Content.from_function_call(
ap.call_id,
"apply_patch",
arguments=str(ap.operation),
)
],
)
if item.type == "apply_patch_call_output":
apo = cast(ApplyPatchToolCallOutputItemParam, item)
return Message(
role="tool",
contents=[Content.from_function_result(apo.call_id, result=apo.output or "")],
)
raise ValueError(f"Unsupported Item type: {item.type}")
async def _output_items_to_messages(
history: Sequence[OutputItem],
*,
approval_storage: ApprovalStorage | None = None,
) -> list[Message]:
"""Converts a sequence of OutputItem objects to a list of Message objects.
Args:
history (Sequence[OutputItem]): The sequence of OutputItem objects to convert.
approval_storage (ApprovalStorage | None, optional): The approval storage to use for
resolving MCP approval requests. Defaults to None.
Returns:
list[Message]: The list of Message objects.
"""
messages: list[Message] = []
for item in history:
messages.append(await _output_item_to_message(item, approval_storage=approval_storage))
return messages
async def _output_item_to_message(item: OutputItem, *, approval_storage: ApprovalStorage | None = None) -> Message:
"""Converts an OutputItem to a Message.
Args:
item (OutputItem): The OutputItem to convert.
approval_storage (ApprovalStorage | None, optional): The approval storage to use for
resolving MCP approval requests. Defaults to None.
Returns:
Message: The converted Message.
Raises:
ValueError: If the OutputItem type is not supported.
"""
if item.type == "output_message":
output_msg = cast(OutputItemOutputMessage, item)
return Message(
role=output_msg.role, contents=[_convert_output_message_content(part) for part in output_msg.content]
)
if item.type == "message":
msg = cast(OutputItemMessage, item)
return Message(role=msg.role, contents=[_convert_message_content(part) for part in msg.content])
if item.type == "function_call":
fc = cast(OutputItemFunctionToolCall, item)
return Message(
role="assistant",
contents=[Content.from_function_call(fc.call_id, fc.name, arguments=fc.arguments)],
)
if item.type == "function_call_output":
fco = cast(FunctionCallOutputItemParam, item)
output = fco.output if isinstance(fco.output, str) else str(fco.output)
return Message(
role="tool",
contents=[Content.from_function_result(fco.call_id, result=output)],
)
if item.type == "reasoning":
reasoning = cast(OutputItemReasoningItem, item)
contents: list[Content] = []
if reasoning.summary:
for summary in reasoning.summary:
contents.append(Content.from_text(summary.text))
return Message(role="assistant", contents=contents)
if item.type == "mcp_call":
mcp = cast(OutputItemMcpToolCall, item)
return Message(
role="assistant",
contents=[
Content.from_mcp_server_tool_call(
mcp.id,
mcp.name,
server_name=mcp.server_label,
arguments=mcp.arguments,
)
],
)
if item.type == "mcp_approval_request":
mcp_req = cast(OutputItemMcpApprovalRequest, item)
if approval_storage is not None:
function_approval_request_content = await approval_storage.load_approval_request(mcp_req.id)
else:
raise ValueError("ApprovalStorage is required to load approval request.")
return Message(
role="assistant",
contents=[function_approval_request_content],
)
if item.type == "mcp_approval_response":
mcp_resp = cast(OutputItemMcpApprovalResponseResource, item)
if approval_storage is not None:
function_approval_request_content = await approval_storage.load_approval_request(
mcp_resp.approval_request_id
)
else:
raise ValueError("ApprovalStorage is required to load approval request.")
return Message(
role="user",
contents=[function_approval_request_content.to_function_approval_response(mcp_resp.approve)],
)
if item.type == "code_interpreter_call":
ci = cast(OutputItemCodeInterpreterToolCall, item)
return Message(
role="assistant",
contents=[Content.from_code_interpreter_tool_call(call_id=ci.id)],
)
if item.type == "image_generation_call":
ig = cast(OutputItemImageGenToolCall, item)
return Message(
role="assistant",
contents=[Content.from_image_generation_tool_call(image_id=ig.id)],
)
if item.type == "shell_call":
sc = cast(OutputItemFunctionShellCall, item)
return Message(
role="assistant",
contents=[
Content.from_shell_tool_call(
call_id=sc.call_id,
commands=sc.action.commands,
status=str(sc.status),
)
],
)
if item.type == "shell_call_output":
sco = cast(OutputItemFunctionShellCallOutput, item)
outputs = [
Content.from_shell_command_output(
stdout=out.stdout or "",
stderr=out.stderr or "",
exit_code=getattr(out.outcome, "exit_code", None) if hasattr(out, "outcome") else None,
)
for out in (sco.output or [])
]
return Message(
role="tool",
contents=[
Content.from_shell_tool_result(
call_id=sco.call_id,
outputs=outputs,
max_output_length=sco.max_output_length,
)
],
)
if item.type == "local_shell_call":
lsc = cast(OutputItemLocalShellToolCall, item)
commands = lsc.action.command if hasattr(lsc.action, "command") and lsc.action.command else []
return Message(
role="assistant",
contents=[
Content.from_shell_tool_call(
call_id=lsc.call_id,
commands=commands,
status=str(lsc.status),
)
],
)
if item.type == "local_shell_call_output":
lsco = cast(OutputItemLocalShellToolCallOutput, item)
return Message(
role="tool",
contents=[
Content.from_shell_tool_result(
call_id=lsco.id,
outputs=[Content.from_shell_command_output(stdout=lsco.output)],
)
],
)
if item.type == "file_search_call":
fs = cast(OutputItemFileSearchToolCall, item)
return Message(
role="assistant",
contents=[
Content.from_function_call(
fs.id,
"file_search",
arguments=json.dumps({"queries": fs.queries}),
)
],
)
if item.type == "web_search_call":
ws = cast(OutputItemWebSearchToolCall, item)
return Message(
role="assistant",
contents=[Content.from_function_call(ws.id, "web_search")],
)
if item.type == "computer_call":
cc = cast(OutputItemComputerToolCall, item)
return Message(
role="assistant",
contents=[
Content.from_function_call(
cc.call_id,
"computer_use",
arguments=str(cc.action),
)
],
)
if item.type == "computer_call_output":
cco = cast(OutputItemComputerToolCallOutputResource, item)
return Message(
role="tool",
contents=[Content.from_function_result(cco.call_id, result=str(cco.output))],
)
if item.type == "custom_tool_call":
ct = cast(OutputItemCustomToolCall, item)
return Message(
role="assistant",
contents=[Content.from_function_call(ct.call_id, ct.name, arguments=ct.input)],
)
if item.type == "custom_tool_call_output":
cto = cast(OutputItemCustomToolCallOutput, item)
output = cto.output if isinstance(cto.output, str) else str(cto.output)
# Hosted-MCP results land here because the host writes them via
# `aoutput_item_custom_tool_call_output`. Route `mcp_*` call_ids
# back to a hosted-MCP result Content so the chat-client serialize
# layer can coalesce onto the matching `mcp_call` input item.
# Issue #5546.
if cto.call_id and cto.call_id.startswith("mcp_"):
return Message(
role="tool",
contents=[Content.from_mcp_server_tool_result(call_id=cto.call_id, output=output)],
)
return Message(
role="tool",
contents=[Content.from_function_result(cto.call_id, result=output)],
)
if item.type == "apply_patch_call":
ap = cast(OutputItemApplyPatchToolCall, item)
return Message(
role="assistant",
contents=[
Content.from_function_call(
ap.call_id,
"apply_patch",
arguments=str(ap.operation),
)
],
)
if item.type == "apply_patch_call_output":
apo = cast(OutputItemApplyPatchToolCallOutput, item)
return Message(
role="tool",
contents=[Content.from_function_result(apo.call_id, result=apo.output or "")],
)
if item.type == "oauth_consent_request":
oauth = cast(OAuthConsentRequestOutputItem, item)
return Message(
role="assistant",
contents=[Content.from_oauth_consent_request(oauth.consent_link)],
)
if item.type == "structured_outputs":
so = cast(StructuredOutputsOutputItem, item)
text = json.dumps(so.output) if not isinstance(so.output, str) else so.output
return Message(role="assistant", contents=[Content.from_text(text)])
raise ValueError(f"Unsupported OutputItem type: {item.type}")
def _convert_output_message_content(content: OutputMessageContent) -> Content:
"""Converts an OutputMessageContent to a Content object.
Args:
content (OutputMessageContent): The OutputMessageContent to convert.
Returns:
Content: The converted Content object.
Raises:
ValueError: If the OutputMessageContent type is not supported.
"""
if content.type == "output_text":
text_content = cast(OutputMessageContentOutputTextContent, content)
return Content.from_text(text_content.text)
if content.type == "refusal":
refusal_content = cast(OutputMessageContentRefusalContent, content)
return Content.from_text(refusal_content.refusal)
raise ValueError(f"Unsupported OutputMessageContent type: {content.type}")
def _convert_file_data(data_uri: str, filename: str | None = None) -> Content:
"""Convert a file_data data URI to a Content object.
For text/* MIME types, decodes the base64 content and returns it as text.
For other types, returns a URI-based Content with the filename preserved.
"""
# Parse data URI: data:<media_type>;base64,<data>
if data_uri.startswith("data:") and ";base64," in data_uri:
header, encoded = data_uri.split(";base64,", 1)
media_type = header[len("data:") :]
if media_type.startswith("text/"):
try:
decoded_text = base64.b64decode(encoded).decode("utf-8")
except (ValueError, UnicodeDecodeError):
logger.warning(
"Failed to decode text/* file_data as UTF-8, falling through to URI passthrough.",
exc_info=True,
)
else:
prefix = f"[File: {filename}]\n" if filename else ""
return Content.from_text(f"{prefix}{decoded_text}")
additional_properties = {"filename": filename} if filename else None
return Content.from_uri(data_uri, additional_properties=additional_properties)
def _convert_message_content(content: MessageContent) -> Content:
"""Converts a MessageContent to a Content object.
Args:
content (MessageContent): The MessageContent to convert.
Returns:
Content: The converted Content object.
Raises:
ValueError: If the MessageContent type is not supported.
"""
if content.type == "input_text":
input_text = cast(MessageContentInputTextContent, content)
return Content.from_text(input_text.text)
if content.type == "output_text":
output_text = cast(MessageContentOutputTextContent, content)
return Content.from_text(output_text.text)
if content.type == "text":
text = cast(TextContent, content)
return Content.from_text(text.text)
if content.type == "summary_text":
summary = cast(SummaryTextContent, content)
return Content.from_text(summary.text)
if content.type == "refusal":
refusal = cast(MessageContentRefusalContent, content)
return Content.from_text(refusal.refusal)
if content.type == "reasoning_text":
reasoning = cast(MessageContentReasoningTextContent, content)
return Content.from_text_reasoning(text=reasoning.text)
if content.type == "input_image":
image = cast(MessageContentInputImageContent, content)
if image.image_url:
if image.image_url.startswith("data:"):
return Content.from_uri(image.image_url)
return Content.from_uri(image.image_url, media_type="image/*")
if image.file_id:
return Content.from_hosted_file(image.file_id)
if content.type == "input_file":
file = cast(MessageContentInputFileContent, content)
if file.file_url:
return Content.from_uri(file.file_url)
if file.file_id:
return Content.from_hosted_file(file.file_id, name=file.filename)
if file.file_data:
return _convert_file_data(file.file_data, file.filename)
if content.type == "computer_screenshot":
screenshot = cast(ComputerScreenshotContent, content)
return Content.from_uri(screenshot.image_url)
raise ValueError(f"Unsupported MessageContent type: {content.type}")
# endregion
# region Output Item Conversion
def _arguments_to_str(arguments: str | Mapping[str, Any] | None) -> str:
"""Convert arguments to a JSON string.
Args:
arguments: The arguments to convert, can be a string, mapping, or None.
Returns:
The arguments as a JSON string.
"""
if arguments is None:
return ""
if isinstance(arguments, str):
return arguments
return json.dumps(arguments)
async def _to_outputs(
stream: ResponseEventStream,
content: Content,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -2892,6 +2892,8 @@ class TestCheckpointContextPathValidation:
f"before={before} after={after}"
)
assert list(root.iterdir()) == [], f"Checkpoint directory created inside root for {context_field}={bad_id!r}"
# region Agent lifecycle (lazy entry & OAuth consent surfacing)
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
+36
View File
@@ -0,0 +1,36 @@
# agent-framework-hosting-a2a
Agent-to-Agent (A2A) protocol channel for `agent-framework-hosting`.
Exposes the hosted target (an `Agent` or a `Workflow`) as an A2A peer agent: it
publishes an agent card and JSON-RPC routes and drives every request through the
host pipeline, so host sessions, request metadata, and run/response hooks all
apply.
```python
from agent_framework.openai import OpenAIChatClient
from agent_framework_hosting import AgentFrameworkHost
from agent_framework_hosting_a2a import A2AChannel
agent = OpenAIChatClient().as_agent(name="Assistant")
host = AgentFrameworkHost(
target=agent,
channels=[A2AChannel(url="https://my-host.example.com/")],
)
host.serve(port=8000)
```
By default the channel mounts at the app root so the well-known agent card is
reachable at `/.well-known/agent-card.json`, with the JSON-RPC endpoint at `/`.
The A2A `context_id` maps onto the host session (caller-supplied session family).
A default agent card is derived from the target's name and description; pass a
fully-specified `agent_card` to override it. To advertise additional protocol
bindings in the generated card, pass `supported_interfaces`.
> **Note:** Task state is held in an in-memory A2A task store for this version; it
> is independent of the host's session storage and is not persisted across
> restarts.
The base host plumbing lives in
[`agent-framework-hosting`](https://pypi.org/project/agent-framework-hosting/).
@@ -0,0 +1,24 @@
# Copyright (c) Microsoft. All rights reserved.
"""A2A (Agent-to-Agent) channel for :mod:`agent_framework_hosting`.
Exposes the hosted target (an ``Agent`` or a ``Workflow``) as an A2A peer agent
— publishing an agent card and JSON-RPC routes — while routing every request
through the host pipeline so sessions, request metadata, and hooks apply.
"""
import importlib.metadata
from ._channel import A2AChannel
from ._executor import HostAgentExecutor
try:
__version__ = importlib.metadata.version(__name__)
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0"
__all__ = [
"A2AChannel",
"HostAgentExecutor",
"__version__",
]
@@ -0,0 +1,141 @@
# Copyright (c) Microsoft. All rights reserved.
"""A2A (Agent-to-Agent) channel for :mod:`agent_framework_hosting`.
Exposes the hosted target as an A2A peer agent: it publishes an agent card and
JSON-RPC routes, and drives every request through the host pipeline via
:class:`HostAgentExecutor`.
"""
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes
from a2a.server.tasks import InMemoryTaskStore
from a2a.types import AgentCapabilities, AgentCard, AgentInterface, AgentSkill
from agent_framework_hosting import (
ChannelContext,
ChannelContribution,
ChannelResponseHook,
ChannelRunHook,
)
from ._executor import HostAgentExecutor
class A2AChannel:
"""Channel that exposes the hosted target over the A2A protocol.
The A2A ``context_id`` maps onto the host session (caller-supplied session
family) and each request is routed through :class:`ChannelContext`, so host
session resolution and hooks apply.
Note:
Task state is held in an in-memory A2A task store for this version; it
is independent of the host's session storage and is not persisted.
"""
name: str = "a2a"
def __init__(
self,
*,
name: str | None = None,
path: str = "",
url: str = "/",
agent_name: str | None = None,
agent_description: str | None = None,
agent_version: str = "1.0.0",
agent_card: AgentCard | None = None,
skills: Sequence[AgentSkill] | None = None,
supported_interfaces: Sequence[AgentInterface] | None = None,
streaming: bool = True,
rpc_url: str = "/",
card_url: str = "/.well-known/agent-card.json",
run_hook: ChannelRunHook | None = None,
response_hook: ChannelResponseHook | None = None,
) -> None:
"""Configure the A2A channel.
Keyword Args:
name: Override the channel name (defaults to ``"a2a"``).
path: Sub-path to mount the channel under; empty string (default)
mounts the agent-card and JSON-RPC routes at the app root so
the well-known card path is reachable.
url: Public URL advertised in the agent card's interface (the base
URL clients use to reach the JSON-RPC endpoint).
agent_name: Name advertised in the default agent card. Defaults to
the hosted target's name.
agent_description: Description advertised in the default agent card.
Defaults to the hosted target's description.
agent_version: Version advertised in the default agent card.
agent_card: A fully-specified agent card; when provided it takes
precedence over the ``agent_*``/``url``/``skills`` fields.
skills: Skills advertised in the default agent card.
supported_interfaces: Interfaces advertised in the default agent card.
Defaults to one JSON-RPC interface using ``url``.
streaming: Consume the target via streaming and publish incremental
A2A task artifacts (default ``True``).
rpc_url: Path for the JSON-RPC endpoint (relative to ``path``).
card_url: Path for the agent-card endpoint (relative to ``path``).
run_hook: Optional run hook applied to each request.
response_hook: Optional response hook applied to originating replies.
"""
if name is not None:
self.name = name
self.path = path
self._url = url
self._agent_name = agent_name
self._agent_description = agent_description
self._agent_version = agent_version
self._agent_card = agent_card
self._skills = list(skills) if skills is not None else []
self._supported_interfaces = list(supported_interfaces) if supported_interfaces is not None else None
self._streaming = streaming
self._rpc_url = rpc_url
self._card_url = card_url
self._run_hook = run_hook
self._response_hook = response_hook
def _build_agent_card(self, context: ChannelContext) -> AgentCard:
"""Derive a default agent card from the hosted target, if not supplied."""
if self._agent_card is not None:
return self._agent_card
target: Any = context.target
name = self._agent_name or getattr(target, "name", None) or self.name
description = self._agent_description or getattr(target, "description", None) or f"{name} (A2A)"
return AgentCard(
name=name,
description=description,
version=self._agent_version,
default_input_modes=["text"],
default_output_modes=["text"],
capabilities=AgentCapabilities(streaming=self._streaming),
supported_interfaces=self._supported_interfaces
or [AgentInterface(url=self._url, protocol_binding="JSONRPC")],
skills=self._skills,
)
def contribute(self, context: ChannelContext) -> ChannelContribution:
"""Build the A2A request handler and contribute its routes."""
agent_card = self._build_agent_card(context)
executor = HostAgentExecutor(
context,
channel_name=self.name,
streaming=self._streaming,
run_hook=self._run_hook,
response_hook=self._response_hook,
)
handler = DefaultRequestHandler(
agent_executor=executor,
task_store=InMemoryTaskStore(),
agent_card=agent_card,
)
routes = [
*create_agent_card_routes(agent_card, card_url=self._card_url),
*create_jsonrpc_routes(handler, self._rpc_url),
]
return ChannelContribution(routes=routes)
@@ -0,0 +1,195 @@
# Copyright (c) Microsoft. All rights reserved.
"""Host-routed A2A :class:`AgentExecutor`.
Unlike ``agent_framework_a2a.A2AExecutor`` (which calls ``agent.run`` directly
and manages its own session), :class:`HostAgentExecutor` routes every incoming
A2A request through the host pipeline via :class:`ChannelContext` — so host
session resolution, request metadata, and run/response hooks all apply. The A2A
``context_id`` maps onto :class:`ChannelSession` (caller-supplied session
family).
"""
from __future__ import annotations
import base64
import re
from asyncio import CancelledError
from typing import Any, cast
from a2a.server.agent_execution import AgentExecutor, RequestContext
from a2a.server.events import EventQueue
from a2a.server.tasks import TaskUpdater
from a2a.types import Part, Task, TaskState
from agent_framework import Content
from agent_framework_hosting import (
ChannelContext,
ChannelIdentity,
ChannelRequest,
ChannelResponseHook,
ChannelRunHook,
ChannelSession,
logger,
)
try:
from a2a.helpers import new_task_from_user_message
except ImportError: # pragma: no cover - older a2a-sdk layout
from a2a.utils import new_task_from_user_message # type: ignore[no-redef, attr-defined, import-not-found]
_DATA_URI_PATTERN = re.compile(r"^data:(?P<media_type>[^;]+);base64,(?P<data>[A-Za-z0-9+/=]+)$")
def _contents_to_parts(contents: list[Content]) -> list[Part]:
"""Convert Agent Framework contents into A2A parts (text, uri, inline data)."""
parts: list[Part] = []
for content in contents:
if content.type == "text" and content.text:
parts.append(Part(text=content.text))
elif content.type == "uri" and content.uri:
parts.append(Part(url=content.uri, media_type=content.media_type or ""))
elif content.type == "data" and content.uri:
match = _DATA_URI_PATTERN.match(content.uri)
if match is None:
logger.warning("A2AChannel could not parse data URI; omitted.")
continue
parts.append(Part(raw=base64.b64decode(match.group("data")), media_type=content.media_type or ""))
else:
logger.warning("A2AChannel does not support content type: %s. Omitted.", content.type)
return parts
class HostAgentExecutor(AgentExecutor):
"""A2A executor that drives the hosted target through :class:`ChannelContext`."""
def __init__(
self,
context: ChannelContext,
*,
channel_name: str,
streaming: bool = True,
run_hook: ChannelRunHook | None = None,
response_hook: ChannelResponseHook | None = None,
) -> None:
"""Bind the executor to the host context.
Args:
context: The host-supplied :class:`ChannelContext`.
Keyword Args:
channel_name: The owning channel's name (stamped on requests).
streaming: When ``True`` (default) the target is consumed via
:meth:`ChannelContext.run_stream` and incremental updates are
published as A2A task artifacts; otherwise the full reply is
published as a single working-state message.
run_hook: Optional :data:`ChannelRunHook` applied to the request.
response_hook: Optional :data:`ChannelResponseHook` applied to the
originating final response.
"""
super().__init__()
self._ctx = context
self._channel_name = channel_name
self._streaming = streaming
self._run_hook = run_hook
self._response_hook = response_hook
async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
"""Publish a cancellation event for the in-flight task."""
if context.context_id is None:
raise ValueError("Context ID must be provided in the RequestContext")
updater = TaskUpdater(event_queue, context.task_id or "", context.context_id)
await updater.cancel()
async def execute(self, context: RequestContext, event_queue: EventQueue) -> None:
"""Route an A2A request through the host and publish task events."""
if context.context_id is None:
raise ValueError("Context ID must be provided in the RequestContext")
if context.message is None:
raise ValueError("Message must be provided in the RequestContext")
query = context.get_user_input()
task: Task | None = context.current_task
if not task:
task = cast(Task, new_task_from_user_message(context.message)) # type: ignore[redundant-cast]
await event_queue.enqueue_event(task)
task_id: str = task.id
updater = TaskUpdater(event_queue, task_id, context.context_id)
await updater.submit()
try:
await updater.start_work()
request = self._build_request(query, context, task_id)
if request.stream:
await self._run_stream(request, updater, protocol_request=context.message)
else:
await self._run(request, updater, protocol_request=context.message)
await updater.complete()
except CancelledError:
await updater.update_status(state=TaskState.TASK_STATE_CANCELED)
except Exception as exc:
logger.exception("A2AChannel encountered an error during execution.")
await updater.update_status(
state=TaskState.TASK_STATE_FAILED,
message=updater.new_agent_message([Part(text=str(exc))]),
)
def _build_request(self, query: Any, context: RequestContext, task_id: str) -> ChannelRequest:
"""Build the channel-neutral request from the A2A request context."""
context_id = cast(str, context.context_id)
return ChannelRequest(
channel=self._channel_name,
operation="message.create",
input=query if isinstance(query, str) else str(query),
session=ChannelSession(isolation_key=context_id),
stream=self._streaming,
identity=ChannelIdentity(channel=self._channel_name, native_id=context_id),
attributes={"task_id": task_id},
)
async def _run(self, request: ChannelRequest, updater: TaskUpdater, *, protocol_request: Any) -> None:
"""Non-streaming: run the target and publish the reply as task messages."""
result = await self._ctx.run(
request,
run_hook=self._run_hook,
protocol_request=protocol_request,
response_hook=self._response_hook,
channel_name=self._channel_name,
)
response: Any = result.result
messages: list[Any] = list(getattr(response, "messages", None) or [])
for message in messages:
if getattr(message, "role", None) == "user":
continue
contents: list[Content] = list(getattr(message, "contents", None) or [])
parts = _contents_to_parts(contents)
if parts:
await updater.update_status(
state=TaskState.TASK_STATE_WORKING,
message=updater.new_agent_message(parts=parts),
)
async def _run_stream(self, request: ChannelRequest, updater: TaskUpdater, *, protocol_request: Any) -> None:
"""Streaming: publish incremental updates as task artifacts."""
streamed_ids: set[str] = set()
stream = await self._ctx.run_stream(
request,
run_hook=self._run_hook,
protocol_request=protocol_request,
response_hook=self._response_hook,
channel_name=self._channel_name,
)
async for update in stream:
contents: list[Content] = list(getattr(update, "contents", None) or [])
parts = _contents_to_parts(contents)
if not parts:
continue
message_id: str | None = getattr(update, "message_id", None)
await updater.add_artifact(
parts=parts,
artifact_id=message_id,
append=True if message_id is not None and message_id in streamed_ids else None,
)
if message_id is not None:
streamed_ids.add(message_id)
await stream.get_final_response()
+102
View File
@@ -0,0 +1,102 @@
[project]
name = "agent-framework-hosting-a2a"
description = "Agent-to-Agent (A2A) protocol channel for agent-framework-hosting."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0a260424"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
urls.issues = "https://github.com/microsoft/agent-framework/issues"
classifiers = [
"License :: OSI Approved :: MIT License",
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.2.0,<2",
"agent-framework-hosting>=1.0.0a260424,<2",
"a2a-sdk>=1.0.0,<2",
"starlette>=0.37",
]
[tool.uv]
prerelease = "if-necessary-or-explicit"
environments = [
"sys_platform == 'darwin'",
"sys_platform == 'linux'",
"sys_platform == 'win32'"
]
[tool.uv-dynamic-versioning]
fallback-version = "0.0.0"
[tool.pytest.ini_options]
testpaths = 'tests'
addopts = "-ra -q -r fEX"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = []
timeout = 120
markers = [
"integration: marks tests as integration tests that require external services",
]
[tool.ruff]
extend = "../../pyproject.toml"
[tool.coverage.run]
omit = [
"**/__init__.py"
]
[tool.pyright]
extends = "../../pyproject.toml"
include = ["agent_framework_hosting_a2a"]
exclude = ['tests']
[tool.mypy]
plugins = ['pydantic.mypy']
strict = true
python_version = "3.10"
ignore_missing_imports = true
disallow_untyped_defs = true
no_implicit_optional = true
check_untyped_defs = true
warn_return_any = true
show_error_codes = true
warn_unused_ignores = false
disallow_incomplete_defs = true
disallow_untyped_decorators = true
[tool.bandit]
targets = ["agent_framework_hosting_a2a"]
exclude_dirs = ["tests"]
[tool.poe]
executor.type = "uv"
include = "../../shared_tasks.toml"
[tool.poe.tasks.mypy]
help = "Run MyPy for this package."
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_hosting_a2a"
[tool.poe.tasks.test]
help = "Run the default unit test suite for this package."
cmd = 'pytest -m "not integration" --cov=agent_framework_hosting_a2a --cov-report=term-missing:skip-covered tests'
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
build-backend = "flit_core.buildapi"
[dependency-groups]
dev = []
@@ -0,0 +1,309 @@
# Copyright (c) Microsoft. All rights reserved.
"""Unit tests for :class:`A2AChannel` and :class:`HostAgentExecutor`."""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator, Awaitable
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from typing import Any
import pytest
import uvicorn
from a2a.server.events import EventQueue
from a2a.types import AgentCard, AgentInterface, Message, Part, Role, Task, TaskState
from agent_framework import AgentResponse, Content
from agent_framework import Message as AFMessage
from agent_framework_a2a import A2AAgent
from agent_framework_hosting import AgentFrameworkHost, ChannelContribution, ChannelRequest, HostedRunResult
from starlette.types import ASGIApp
from agent_framework_hosting_a2a import A2AChannel, HostAgentExecutor
# --------------------------------------------------------------------------- #
# Fakes #
# --------------------------------------------------------------------------- #
@dataclass
class _FakeResp:
text: str
messages: list[Message] = field(default_factory=list)
@dataclass
class _FakeUpdate:
text: str
contents: list[Content] = field(default_factory=list)
message_id: str | None = None
class _FakeStream:
def __init__(self, chunks: list[str]) -> None:
self._chunks = chunks
self._final = _FakeResp(text="".join(chunks))
def __aiter__(self) -> AsyncIterator[_FakeUpdate]:
async def _gen() -> AsyncIterator[_FakeUpdate]:
for i, c in enumerate(self._chunks):
yield _FakeUpdate(text=c, contents=[Content.from_text(text=c)], message_id=f"m{i}")
return _gen()
async def get_final_response(self) -> _FakeResp:
return self._final
@dataclass
class _FakeTarget:
name: str = "Assistant"
description: str = "A helpful assistant."
class _FakeContext:
def __init__(
self,
*,
reply: str = "hello",
chunks: list[str] | None = None,
) -> None:
self.target = _FakeTarget()
self._reply = reply
self._chunks = chunks or [reply]
self.requests: list[ChannelRequest] = []
async def run(
self,
request: ChannelRequest,
*,
run_hook: Any | None = None,
protocol_request: Any | None = None,
response_hook: Any | None = None,
channel_name: str | None = None,
) -> HostedRunResult[Any]:
if run_hook is not None:
maybe_request = run_hook(request, target=self.target, protocol_request=protocol_request)
if isinstance(maybe_request, Awaitable):
request = await maybe_request
else:
request = maybe_request
self.requests.append(request)
msg = Message(role=Role.ROLE_AGENT, parts=[Part(text=self._reply)])
result = HostedRunResult(_FakeResp(text=self._reply, messages=[msg]))
if response_hook is not None:
maybe_result = response_hook(result, request=request, channel_name=channel_name or request.channel)
if isinstance(maybe_result, Awaitable):
return await maybe_result
return maybe_result
return result
async def run_stream(
self,
request: ChannelRequest,
*,
run_hook: Any | None = None,
protocol_request: Any | None = None,
stream_update_hook: Any | None = None,
response_hook: Any | None = None,
channel_name: str | None = None,
) -> _FakeStream:
if run_hook is not None:
maybe_request = run_hook(request, target=self.target, protocol_request=protocol_request)
if isinstance(maybe_request, Awaitable):
request = await maybe_request
else:
request = maybe_request
self.requests.append(request)
return _FakeStream(self._chunks)
class _RecordingEventQueue(EventQueue):
def __init__(self) -> None:
super().__init__()
self.events: list[Any] = []
async def enqueue_event(self, event: Any) -> None:
self.events.append(event)
await super().enqueue_event(event)
class _FakeRequestContext:
def __init__(self, *, context_id: str, text: str, current_task: Task | None = None) -> None:
self.context_id = context_id
self.task_id: str | None = None
self.message = Message(
message_id="msg-1",
context_id=context_id,
role=Role.ROLE_USER,
parts=[Part(text=text)],
)
self.current_task = current_task
self._text = text
def get_user_input(self) -> str:
return self._text
class _HostedAgent:
name = "HostedAssistant"
description = "A hosted test assistant."
async def run(self, messages: Any = None, *, stream: bool = False, **_kwargs: Any) -> AgentResponse[Any]:
text = messages.text if isinstance(messages, AFMessage) else str(messages)
return AgentResponse(messages=[AFMessage(role="assistant", contents=[Content.from_text(text=f"host: {text}")])])
@asynccontextmanager
async def _serve_app(app: ASGIApp, *, port: int) -> AsyncIterator[str]:
config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning", lifespan="on")
server = uvicorn.Server(config)
task = asyncio.create_task(server.serve())
try:
for _ in range(100):
if server.started:
break
await asyncio.sleep(0.01)
else:
raise RuntimeError("Test A2A server did not start")
yield f"http://127.0.0.1:{port}"
finally:
server.should_exit = True
await task
def _status_states(events: list[Any]) -> list[int]:
states: list[int] = []
for event in events:
status = getattr(event, "status", None)
if status is not None and getattr(status, "state", None):
states.append(status.state)
return states
# --------------------------------------------------------------------------- #
# A2AChannel tests #
# --------------------------------------------------------------------------- #
def test_default_name_and_root_path() -> None:
channel = A2AChannel()
assert channel.name == "a2a"
assert channel.path == ""
def test_build_agent_card_defaults_from_target() -> None:
channel = A2AChannel(url="https://example.com/")
card = channel._build_agent_card(_FakeContext()) # type: ignore[arg-type]
assert card.name == "Assistant"
assert card.description == "A helpful assistant."
assert card.capabilities.streaming is True
assert card.supported_interfaces[0].url == "https://example.com/"
def test_build_agent_card_accepts_supported_interfaces() -> None:
interfaces = [
AgentInterface(url="https://example.com/jsonrpc", protocol_binding="JSONRPC"),
AgentInterface(url="https://example.com/grpc", protocol_binding="GRPC"),
]
channel = A2AChannel(supported_interfaces=interfaces)
card = channel._build_agent_card(_FakeContext()) # type: ignore[arg-type]
assert card.supported_interfaces == interfaces
def test_build_agent_card_override_wins() -> None:
custom = AgentCard(name="Custom", description="custom card", version="9.9.9")
channel = A2AChannel(agent_card=custom)
card = channel._build_agent_card(_FakeContext()) # type: ignore[arg-type]
assert card.name == "Custom"
assert card.version == "9.9.9"
def test_contribute_returns_card_and_jsonrpc_routes() -> None:
channel = A2AChannel(url="https://example.com/")
contribution = channel.contribute(_FakeContext()) # type: ignore[arg-type]
assert isinstance(contribution, ChannelContribution)
paths = {getattr(r, "path", None) for r in contribution.routes}
assert "/.well-known/agent-card.json" in paths
assert any(p == "/" for p in paths)
# --------------------------------------------------------------------------- #
# HostAgentExecutor tests #
# --------------------------------------------------------------------------- #
async def test_execute_routes_through_host_and_completes() -> None:
ctx = _FakeContext(reply="hi back")
executor = HostAgentExecutor(ctx, channel_name="a2a", streaming=False) # type: ignore[arg-type]
queue = _RecordingEventQueue()
request_context = _FakeRequestContext(context_id="conv-1", text="hello")
await executor.execute(request_context, queue) # type: ignore[arg-type]
# Routed through the host with the context id mapped onto the session.
assert len(ctx.requests) == 1
request = ctx.requests[0]
assert request.channel == "a2a"
assert request.input == "hello"
assert request.session is not None
assert request.session.isolation_key == "conv-1"
assert request.identity is not None
assert request.identity.native_id == "conv-1"
# Task progressed to a completed state.
assert TaskState.TASK_STATE_COMPLETED in _status_states(queue.events)
async def test_execute_streaming_emits_artifacts() -> None:
ctx = _FakeContext(chunks=["foo", "bar"])
executor = HostAgentExecutor(ctx, channel_name="a2a", streaming=True) # type: ignore[arg-type]
queue = _RecordingEventQueue()
request_context = _FakeRequestContext(context_id="conv-2", text="hello")
await executor.execute(request_context, queue) # type: ignore[arg-type]
artifact_events = [e for e in queue.events if getattr(e, "artifact", None)]
assert artifact_events, "expected at least one artifact update event"
assert ctx.requests[0].stream is True
assert TaskState.TASK_STATE_COMPLETED in _status_states(queue.events)
async def test_execute_requires_context_id() -> None:
ctx = _FakeContext()
executor = HostAgentExecutor(ctx, channel_name="a2a") # type: ignore[arg-type]
queue = _RecordingEventQueue()
request_context = _FakeRequestContext(context_id="x", text="hello")
request_context.context_id = None # type: ignore[assignment]
with pytest.raises(ValueError, match="Context ID"):
await executor.execute(request_context, queue) # type: ignore[arg-type]
async def test_a2a_agent_can_call_hosted_channel(unused_tcp_port: int) -> None:
host = AgentFrameworkHost(target=_HostedAgent(), channels=[A2AChannel(streaming=False)])
async with (
_serve_app(host.app, port=unused_tcp_port) as base_url,
A2AAgent(
url=base_url,
timeout=5.0,
) as agent,
):
response = await agent.run("hello")
assert response.messages[0].text == "host: hello"
def test_contents_to_parts_conversion() -> None:
from agent_framework_hosting_a2a._executor import _contents_to_parts
contents = [
Content.from_text(text="hello"),
Content.from_uri(uri="https://x/y.png", media_type="image/png"),
Content.from_data(data=b"AAAA", media_type="image/png"),
]
parts = _contents_to_parts(contents)
assert parts[0].text == "hello"
assert parts[1].url == "https://x/y.png"
assert parts[2].raw == b"AAAA"
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
@@ -0,0 +1,43 @@
# agent-framework-hosting-activity-protocol
Bot Framework **Activity Protocol** channel for
[agent-framework-hosting](../hosting). Connects to **Azure Bot Service** so
the same agent can be reached from Microsoft Teams, Slack, Webex,
Telegram-via-bot-channel, and any other channel Azure Bot Service
supports — without having to learn each channel's native protocol.
> Looking for a deeper Microsoft Teams integration with adaptive cards,
> message extensions, dialogs, SSO, etc? See the companion
> [`agent-framework-hosting-teams`](../hosting-teams) package, which is
> built on `microsoft-teams-apps` and exposes Teams-specific affordances
> on top of (still) Azure Bot Service.
Handles inbound `message` activities, outbound replies, mid-stream
`updateActivity` edits, typing indicators, and both client-secret and
certificate credential modes for the outbound Bot Framework token.
## Usage
```python
from agent_framework_hosting import AgentFrameworkHost
from agent_framework_hosting_activity_protocol import ActivityProtocolChannel
host = AgentFrameworkHost(
target=my_agent,
channels=[
ActivityProtocolChannel(
app_id="<entra app id>",
client_secret="<entra client secret>",
tenant_id="botframework.com", # or your tenant id
)
],
)
host.serve()
```
For tenants that disallow client secrets, supply `certificate_path=` (and
optionally `certificate_password=`) instead. See the docstring at the top of
`_channel.py` for the openssl one-liner that generates a usable PEM.
In dev mode (no credentials), the channel skips outbound auth so the Bot
Framework Emulator can hit the endpoint without setup.
@@ -0,0 +1,7 @@
# Copyright (c) Microsoft. All rights reserved.
"""Bot Framework Activity Protocol channel for :mod:`agent_framework_hosting`."""
from ._channel import ActivityProtocolChannel, activity_protocol_isolation_key
__all__ = ["ActivityProtocolChannel", "activity_protocol_isolation_key"]
@@ -0,0 +1,931 @@
# Copyright (c) Microsoft. All rights reserved.
r"""Built-in channel: Bot Framework Activity Protocol (Azure Bot Service).
Activity Protocol is the Bot Framework messaging shape used by Azure Bot
Service to fan one bot endpoint out across many surfaces (Microsoft
Teams, Slack, Webex, Telegram, …). An incoming ``Activity`` is POSTed to
your bot's ``/messages`` endpoint, and you reply by POSTing one or more
``Activity`` objects back to the conversation URL the inbound activity
carried in ``serviceUrl``. Auth is an OAuth2 client-credentials token
from Entra (the legacy multi-tenant ``botframework.com`` authority for
public Bot Framework channels, or your own tenant for single-tenant
bots).
This is the channel-neutral Activity-Protocol channel — it surfaces what
every Bot-Service-connected channel has in common (text in, text out).
For deeper Microsoft Teams affordances (adaptive cards, message
extensions, dialogs, SSO, …) on the same Bot Service transport, see the
companion ``agent-framework-hosting-teams`` package.
This channel handles:
- inbound ``message`` activities — text and attachments resolved to URIs,
- outbound replies via ``POST /v3/conversations/{id}/activities``,
- streaming via ``PUT /v3/conversations/{id}/activities/{id}`` mid-stream
edits on channels that support ``updateActivity`` (Teams personal chats
and groups); every other channel — Web Chat, Direct Line, the Emulator —
rejects the PUT with ``405``, so those buffer the stream and POST a
single final message instead,
- typing indicators while the agent works,
- per-conversation isolation key ``activity:<conversation_id>`` so a Responses
caller can resume a Teams conversation by passing the conversation id,
- two credential modes for the outbound token — **client secret** or
**certificate** (for tenants that disallow secrets) — both via
``azure.identity.aio``,
- dev-mode auth bypass when no credentials are passed so the Bot Framework
Emulator can hit the endpoint with no credentials.
Out of scope for the prototype: full JWT validation of inbound requests,
adaptive cards, file uploads, OAuth sign-in flows, and the Teams streaming
preview API (``StreamItem``).
Generating a certificate
------------------------
For tenants that disallow client secrets, register a certificate on your
Bot Framework / Entra app instead. Self-signed PEM (private key + cert in
one file) is what ``azure.identity.CertificateCredential`` expects::
# 1. Generate a 2048-bit RSA key + self-signed cert (10y), single PEM.
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \\
-subj "/CN=my-teams-bot" \\
-keyout teams-bot.key -out teams-bot.crt
cat teams-bot.key teams-bot.crt > teams-bot.pem
# 2. Upload teams-bot.crt to your Entra app under
# "Certificates & secrets" → "Certificates" → "Upload certificate".
# 3. Point the channel at the combined PEM:
ActivityProtocolChannel(
app_id="<app id>",
tenant_id="<tenant id>", # or "botframework.com" for legacy bots
certificate_path="teams-bot.pem",
)
To encrypt the private key, drop ``-nodes`` from the openssl command and
pass ``certificate_password=<bytes>`` to the channel.
"""
from __future__ import annotations
import asyncio
import time
from collections.abc import Awaitable, Callable, Mapping, Sequence
from typing import Any
from urllib.parse import urlparse
import httpx
from agent_framework import (
AgentResponse,
AgentResponseUpdate,
Content,
Message,
ResponseStream,
)
from agent_framework.exceptions import ContentError
from agent_framework_hosting import (
ChannelCommand,
ChannelCommandContext,
ChannelContext,
ChannelContribution,
ChannelIdentity,
ChannelRequest,
ChannelResponseHook,
ChannelRunHook,
ChannelSession,
ChannelStreamUpdateHook,
logger,
)
from azure.core.credentials_async import AsyncTokenCredential
from azure.identity.aio import CertificateCredential, ClientSecretCredential
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from starlette.routing import Route
# Bot Framework v4 multi-tenant authority used by the public Bot Framework
# channels (including Microsoft Teams). Single-tenant bots should override
# ``tenant_id`` with their own tenant.
_BOTFRAMEWORK_TENANT = "botframework.com"
_BOTFRAMEWORK_SCOPE = "https://api.botframework.com/.default"
# Default allow-list of host suffixes the channel will POST a bearer token
# to. Bot Service surfaces ``serviceUrl`` per-conversation as one of these
# canonical hosts; a malicious inbound activity claiming a serviceUrl
# outside this set could otherwise exfiltrate a real Bot Framework access
# token. Operators with a private deployment (sovereign cloud, Direct Line
# only, etc.) override this via ``service_url_allowed_hosts``.
_DEFAULT_SERVICE_URL_HOSTS = (
"botframework.com",
"smba.trafficmanager.net",
)
# Bot Framework channels that support editing an Activity in place via
# ``PUT /v3/conversations/{id}/activities/{id}`` (the ``updateActivity``
# REST operation). Progressive-edit streaming (POST a placeholder, then
# repeatedly PUT it) only works on these. Every other channel — Web Chat,
# Direct Line, the Emulator, etc. — returns ``405 Method Not Allowed`` on
# the PUT, so those channels buffer the stream and POST a single final
# message instead. Teams is the canonical (and effectively only) public
# channel that supports the edit operation.
_EDIT_CAPABLE_CHANNELS = frozenset({"msteams"})
InboundAuthValidator = Callable[[Request], Awaitable[bool]]
def activity_protocol_isolation_key(conversation_id: Any) -> str:
"""Build the namespaced isolation key the Teams channel writes under.
Exposed at module scope so other channels' run hooks can opt into the
same per-conversation session (e.g. a Responses caller resuming a Teams
conversation by passing the conversation id).
"""
return f"activity:{conversation_id}"
class _OutboundError(RuntimeError):
"""Marker for transient outbound failures that should produce 502/retry."""
def _parse_activity(activity: Mapping[str, Any]) -> Message:
"""Translate one Bot Framework ``message`` Activity into an Agent Framework Message.
Pulls the activity's ``text`` plus any image/file attachments that expose a
resolvable ``contentUrl`` into ``Content`` parts. Bot Framework's inline
``content`` field (e.g. the ``text/html`` rendering Teams attaches alongside
``text``, or an Adaptive Card payload) is *not* a URI, so it is ignored here
to avoid mis-parsing it as a URL. If the activity has no usable parts an
empty text part is emitted so the caller never sees a content-less message.
"""
parts: list[Content] = []
if (text := activity.get("text")) and isinstance(text, str):
parts.append(Content.from_text(text=text))
for attachment in activity.get("attachments") or []:
if not isinstance(attachment, Mapping):
continue
url = attachment.get("contentUrl")
content_type = attachment.get("contentType")
if not (isinstance(url, str) and isinstance(content_type, str) and "/" in content_type):
continue
# contentUrl is occasionally a relative reference or otherwise lacks a
# scheme; skip those so one odd attachment can't fail the whole turn.
if not urlparse(url).scheme:
logger.debug("Skipping attachment with non-absolute contentUrl: %r", url)
continue
try:
parts.append(Content.from_uri(uri=url, media_type=content_type))
except ContentError:
logger.debug("Skipping attachment with unparseable contentUrl: %r", url)
continue
if not parts:
parts.append(Content.from_text(text=""))
return Message("user", parts)
def _command_text(activity: Mapping[str, Any]) -> str:
"""Return the activity text with the bot's own @mention stripped.
Channels that require an @mention to address the bot (Teams team and
group-chat scopes) prefix the message ``text`` with a mention whose literal
rendering is carried in the matching ``entities[].text`` (e.g.
``"<at>Personal Assistant</at> /todos"``). Personal 1:1 chats carry no
mention. We remove only the bot's own mention substring(s) — never other
users' mentions — so a leading ``/command`` can be detected in every scope.
"""
text = activity.get("text")
if not isinstance(text, str):
return ""
bot_id = (activity.get("recipient") or {}).get("id")
for entity in activity.get("entities") or []:
if not isinstance(entity, Mapping) or entity.get("type") != "mention":
continue
mentioned = entity.get("mentioned")
mentioned_id = mentioned.get("id") if isinstance(mentioned, Mapping) else None
# Only strip the bot's own mention; leave mentions of other users intact.
# When the recipient id is unknown we cannot disambiguate, so fall back
# to stripping every mention to keep command detection working.
if bot_id is not None and mentioned_id != bot_id:
continue
mention_text = entity.get("text")
if isinstance(mention_text, str) and mention_text:
text = text.replace(mention_text, "")
return text.strip()
class ActivityProtocolChannel:
"""Microsoft Teams channel via Bot Framework v4 webhook.
Streaming
---------
When ``stream=True`` (default), the channel sends an initial placeholder
activity, then edits it in place as the agent emits ``AgentResponseUpdate``
chunks (``PUT /v3/conversations/{id}/activities/{id}``). When ``stream=False``
it just sends the final reply. A ``stream_update_hook`` can rewrite or
drop individual updates before they hit the wire.
"""
name = "activity"
def __init__(
self,
*,
path: str = "/activity/messages",
app_id: str | None = None,
app_password: str | None = None,
certificate_path: str | None = None,
certificate_password: bytes | None = None,
tenant_id: str = _BOTFRAMEWORK_TENANT,
token_scope: str = _BOTFRAMEWORK_SCOPE,
credential: AsyncTokenCredential | None = None,
commands: Sequence[ChannelCommand] = (),
run_hook: ChannelRunHook | None = None,
response_hook: ChannelResponseHook | None = None,
send_typing_action: bool = True,
stream: bool = True,
stream_update_hook: ChannelStreamUpdateHook | None = None,
stream_edit_min_interval: float = 0.7,
inbound_auth_validator: InboundAuthValidator | None = None,
service_url_allowed_hosts: tuple[str, ...] = _DEFAULT_SERVICE_URL_HOSTS,
) -> None:
"""Configure the Teams channel.
Keyword Args:
path: Messages endpoint path on the host. Use ``""`` to expose the
webhook at the app root.
app_id: Bot Framework / Entra application (client) id. Required
whenever any credential is supplied.
app_password: Application secret for OAuth2 client credentials.
Mutually exclusive with ``certificate_path``.
certificate_path: Path to a PEM file containing **both** the
private key and the X.509 certificate. Use this for tenants
that disallow client secrets. See the module docstring for an
``openssl`` recipe.
certificate_password: Password for the PEM private key, if any.
tenant_id: Entra tenant. Defaults to ``"botframework.com"`` for
public Bot Framework channels; pass your tenant id for
single-tenant bots.
token_scope: OAuth2 scope to request. Defaults to the Bot
Framework resource.
credential: Bring your own ``AsyncTokenCredential`` (e.g. a
``DefaultAzureCredential`` configured elsewhere). Overrides
``app_password`` / ``certificate_path``.
commands: Discoverable ``/command`` handlers. An inbound message
whose text (after stripping the bot's own @mention) begins with
``/`` and matches a command ``name`` (case-insensitive) is
dispatched to that handler instead of the agent, mirroring the
Telegram channel. The matching ``run_hook`` is applied to the
command request first, so command handlers observe the same
resolved ``session.isolation_key`` as ordinary messages.
Unknown ``/foo`` text falls through to the agent. Handlers reply
via ``ChannelCommandContext.reply``; surface them to users with
a Teams manifest ``commandLists`` entry.
run_hook: Optional rewrite of ``ChannelRequest`` before invocation;
the host owns invocation of this hook.
response_hook: Optional rewrite of the
:class:`HostedRunResult` before the originating Activity
reply is serialized; the host owns invocation of this hook.
send_typing_action: Whether to send ``typing`` activities while
the agent runs.
stream: Whether to stream by default.
stream_update_hook: Optional rewrite of each
``AgentResponseUpdate`` before it hits the wire.
stream_edit_min_interval: Seconds between successive in-place
edits. Teams is more rate-sensitive than Telegram, so default
is higher.
inbound_auth_validator: Optional async callable invoked for each
inbound webhook request **before** the activity is parsed.
Return ``True`` to allow, ``False`` to reject with HTTP 401.
The webhook endpoint accepts unauthenticated requests by
default — Bot Framework normally validates inbound calls via
the JWT in the ``Authorization`` header (see Microsoft's
bot framework auth docs). The prototype intentionally does
NOT ship a built-in JWT validator (key rotation, OpenID
config caching, etc. are out of scope); plug your own
validator here, or terminate auth in front of the channel
(e.g. APIM, Application Gateway). When no credentials AND
no validator are configured the channel logs a loud
warning at startup so the dev-mode bypass cannot
accidentally ship.
service_url_allowed_hosts: Host (or host suffix) allow-list the
channel will POST a bearer token to. Defaults to the public
Bot Framework host suffixes (``botframework.com`` and
``smba.trafficmanager.net``). An inbound activity claiming a
``serviceUrl`` outside this set is rejected — without this
gate a malicious caller could redirect outbound replies (and
the attached bearer token) to an attacker-controlled host.
Pass an extended tuple for sovereign clouds or private
deployments; pass ``()`` to disable the check entirely
(only safe with strong inbound auth).
"""
if app_password and certificate_path:
raise ValueError("ActivityProtocolChannel: pass either app_password or certificate_path, not both.")
self.path = path
self._app_id = app_id
self._token_scope = token_scope
self._tenant_id = tenant_id
self._commands = list(commands)
self._hook = run_hook
self.response_hook = response_hook
self._send_typing_action = send_typing_action
self._stream_default = stream
self._stream_update_hook = stream_update_hook
self._stream_edit_min_interval = stream_edit_min_interval
self._inbound_auth_validator = inbound_auth_validator
self._service_url_allowed_hosts = tuple(h.lower().lstrip(".") for h in service_url_allowed_hosts)
self._ctx: ChannelContext | None = None
self._http: httpx.AsyncClient | None = None
# Build the credential up front so misconfiguration fails at construction.
self._credential: AsyncTokenCredential | None
if credential is not None:
self._credential = credential
elif app_id and certificate_path:
self._credential = CertificateCredential(
tenant_id=tenant_id,
client_id=app_id,
certificate_path=certificate_path,
password=certificate_password,
)
elif app_id and app_password:
self._credential = ClientSecretCredential(
tenant_id=tenant_id,
client_id=app_id,
client_secret=app_password,
)
else:
self._credential = None # dev mode
def contribute(self, context: ChannelContext) -> ChannelContribution:
"""Capture the host context and register the messages webhook."""
self._ctx = context
return ChannelContribution(
routes=[Route("/", self._handle, methods=["POST"])],
commands=self._commands,
on_startup=[self._on_startup],
on_shutdown=[self._on_shutdown],
)
# -- lifecycle --------------------------------------------------------- #
async def _on_startup(self) -> None:
"""Open the outbound HTTP client and emit a startup banner.
When no Bot Framework credential is configured we log a loud warning —
outbound replies will not authenticate, which is only acceptable
against the local Bot Framework Emulator.
When no inbound auth validator is configured we also log a loud
warning so the dev-mode bypass cannot accidentally ship to
production: Bot Framework normally validates inbound requests via
a JWT in ``Authorization``; without that gate any caller that can
reach the webhook can drive the bot.
"""
if self._http is None:
self._http = httpx.AsyncClient(timeout=30.0)
if self._credential is None:
logger.warning(
"ActivityProtocolChannel running without credentials — outbound replies "
"will not authenticate. Use only with the Bot Framework "
"Emulator for local development."
)
else:
cred_kind = type(self._credential).__name__
logger.info(
"ActivityProtocolChannel listening on %s (auth=%s, tenant=%s)",
self.path,
cred_kind,
self._tenant_id,
)
if self._inbound_auth_validator is None:
logger.warning(
"ActivityProtocolChannel %s has no inbound_auth_validator — "
"the webhook will accept ANY caller. Plug an inbound_auth_validator "
"or terminate auth in front of the channel before exposing this "
"endpoint to a public network.",
self.path,
)
async def _on_shutdown(self) -> None:
"""Close the HTTP client and best-effort close the credential.
Credential ``close`` failures are logged but never raised — shutdown
must never be allowed to mask the original cause of an app exit.
"""
if self._http is not None:
await self._http.aclose()
if self._credential is not None:
close = getattr(self._credential, "close", None)
if close is not None:
try:
await close()
except Exception: # pragma: no cover - best-effort
logger.exception("ActivityProtocolChannel credential close failed")
# -- token management -------------------------------------------------- #
async def _get_token(self) -> str | None:
"""Acquire (and cache) an outbound bearer token.
``azure.identity`` credentials cache and refresh internally, so we
just delegate.
"""
if self._credential is None:
return None
access_token = await self._credential.get_token(self._token_scope)
return access_token.token
def _auth_headers(self, token: str | None) -> dict[str, str]:
"""Return Bot Framework auth headers, or an empty dict in dev mode."""
return {"Authorization": f"Bearer {token}"} if token else {}
# -- request handling -------------------------------------------------- #
def _is_service_url_allowed(self, service_url: str | None) -> bool:
"""Return ``True`` if ``service_url`` host matches the allow-list."""
if not self._service_url_allowed_hosts:
return True
if not service_url:
return False
try:
host = (urlparse(service_url).hostname or "").lower()
except Exception:
return False
if not host:
return False
return any(host == allowed or host.endswith(f".{allowed}") for allowed in self._service_url_allowed_hosts)
async def _handle(self, request: Request) -> Response:
"""Bot Framework webhook entry point.
Only ``message`` activities are processed; ``conversationUpdate``,
``invoke``, ``typing`` and other activity types are silently
acknowledged. Auth-rejected requests return 401, malformed JSON
returns 400, and serviceUrl outside the allow-list returns 400.
For *transient* outbound failures (network error / non-2xx from
Bot Service / token acquisition failure) we surface 502 so Bot
Service retries the inbound activity. Non-transient failures
(parsing errors, validation errors, deterministic agent crashes)
return 200 so Bot Service does not retry the same broken
activity in a loop.
"""
if self._inbound_auth_validator is not None:
try:
allowed = await self._inbound_auth_validator(request)
except Exception:
logger.exception("ActivityProtocolChannel inbound_auth_validator raised; rejecting request")
return JSONResponse({"error": "unauthorized"}, status_code=401)
if not allowed:
return JSONResponse({"error": "unauthorized"}, status_code=401)
try:
activity = await request.json()
except Exception:
return JSONResponse({"error": "invalid json"}, status_code=400)
# We accept only message activities for now. ``conversationUpdate``,
# ``invoke``, ``typing`` and friends are silently ack'd.
if activity.get("type") != "message":
return JSONResponse({}, status_code=202)
service_url = activity.get("serviceUrl")
if not self._is_service_url_allowed(service_url if isinstance(service_url, str) else None):
logger.warning(
"ActivityProtocolChannel rejecting activity with serviceUrl=%r (not in allow-list)",
service_url,
)
return JSONResponse({"error": "serviceUrl not allowed"}, status_code=400)
try:
await self._process_activity(activity)
except (httpx.HTTPError, _OutboundError):
# Transient outbound failure (network error, non-2xx from Bot
# Service, token acquisition error). Surface 502 so Bot
# Service retries the inbound activity rather than dropping it.
logger.exception("ActivityProtocolChannel outbound transient failure — signalling Bot Service to retry")
return JSONResponse({"error": "upstream failure"}, status_code=502)
except Exception:
# Deterministic / agent-side failure: 200 so Bot Service does
# not retry the same broken activity in a loop. Operator picks
# the failure up via logs / telemetry.
logger.exception("ActivityProtocolChannel activity processing failed")
# Bot Framework expects 200 OK to dequeue the activity.
return JSONResponse({}, status_code=200)
async def _process_activity(self, activity: Mapping[str, Any]) -> None:
"""Build a :class:`ChannelRequest` from a message Activity and dispatch.
The Teams isolation key is per-conversation so all members of a
group chat share session state. Activity metadata (``reply_to_id``,
``recipient``) is preserved so reply-as-reaction style flows can
reconstruct the original message context.
"""
if self._ctx is None: # pragma: no cover - guarded by lifecycle
raise RuntimeError("activity channel not started")
conversation = activity.get("conversation") or {}
conversation_id = conversation.get("id")
service_url = activity.get("serviceUrl")
if not isinstance(conversation_id, str) or not isinstance(service_url, str):
logger.warning("Teams activity missing conversation.id or serviceUrl — dropping")
return
# Native command dispatch — a leading ``/command`` (after stripping the
# bot's own @mention) bypasses the agent, mirroring the Telegram channel.
# Unknown commands fall through to the agent as a normal message.
if self._commands:
command_text = _command_text(activity)
if command_text.startswith("/"):
tokens = command_text[1:].split()
if tokens:
command_name = tokens[0].split("@", 1)[0].lower()
handler = next((c for c in self._commands if c.name.lower() == command_name), None)
if handler is not None:
await self._invoke_command(activity, conversation_id, service_url, handler, command_text)
return
parsed = _parse_activity(activity)
# Store a Bot Framework conversation reference on the identity so
# channel hooks and command handlers can inspect it. Cross-channel
# proactive delivery is a follow-up enhancement outside the v1 host
# contract.
identity = ChannelIdentity(
channel=self.name,
native_id=conversation_id,
attributes={
"service_url": service_url,
"conversation": dict(conversation),
# Inbound recipient is the bot → outbound ``from``; inbound
# ``from`` is the user → outbound ``recipient``.
"bot": dict(activity.get("recipient") or {}),
"user": dict(activity.get("from") or {}),
"channel_id": activity.get("channelId"),
"locale": activity.get("locale"),
},
)
channel_request = ChannelRequest(
channel=self.name,
operation="message.create",
input=[parsed],
session=ChannelSession(isolation_key=activity_protocol_isolation_key(conversation_id)),
identity=identity,
attributes={
"conversation_id": conversation_id,
"service_url": service_url,
"from_id": (activity.get("from") or {}).get("id"),
"channel_id": activity.get("channelId"),
},
metadata={"reply_to_id": activity.get("id"), "recipient": activity.get("recipient")},
stream=self._stream_default,
)
await self._dispatch(activity, channel_request)
async def _invoke_command(
self,
activity: Mapping[str, Any],
conversation_id: str,
service_url: str,
handler: ChannelCommand,
command_text: str,
) -> None:
"""Run a matched ``/command`` handler and reply into the conversation.
The command request mirrors the message-path request (same isolation
key, identity and attributes) and is run through the channel ``run_hook``
first, so handlers observe the same resolved ``session.isolation_key`` as
ordinary messages. Handler/reply failures are logged but never raised:
commands are best-effort, and surfacing a 502 would make Bot Service
retry the inbound activity and re-run a non-idempotent command.
"""
if self._ctx is None: # pragma: no cover - guarded by lifecycle
raise RuntimeError("activity channel not started")
identity = ChannelIdentity(
channel=self.name,
native_id=conversation_id,
attributes={
"service_url": service_url,
"conversation": dict(activity.get("conversation") or {}),
"bot": dict(activity.get("recipient") or {}),
"user": dict(activity.get("from") or {}),
"channel_id": activity.get("channelId"),
"locale": activity.get("locale"),
},
)
request = ChannelRequest(
channel=self.name,
operation="command.invoke",
input=command_text,
session=ChannelSession(isolation_key=activity_protocol_isolation_key(conversation_id)),
identity=identity,
attributes={
"conversation_id": conversation_id,
"service_url": service_url,
"from_id": (activity.get("from") or {}).get("id"),
"channel_id": activity.get("channelId"),
"aad_object_id": (activity.get("from") or {}).get("aadObjectId"),
},
metadata={"reply_to_id": activity.get("id"), "recipient": activity.get("recipient")},
)
async def _reply(body: str) -> None:
await self._send_message(activity, body)
ctx = ChannelCommandContext(request=request, reply=_reply)
try:
await handler.handle(ctx)
except Exception:
logger.exception("ActivityProtocolChannel command %r failed", command_text)
# -- outbound helpers -------------------------------------------------- #
async def _dispatch(self, inbound: Mapping[str, Any], request: ChannelRequest) -> None:
"""Run the target and ship the result back into the originating Teams conversation.
Optionally fires a typing indicator before non-streaming runs;
streaming runs route through ``_stream_to_conversation`` which
progressively edits a single placeholder activity.
"""
if self._ctx is None: # pragma: no cover - guarded by lifecycle
raise RuntimeError("activity channel not started")
if self._send_typing_action:
await self._send_typing(inbound)
if not request.stream:
result = await self._ctx.run(
request,
run_hook=self._hook,
protocol_request=inbound,
response_hook=self.response_hook,
channel_name=self.name,
)
text = getattr(result.result, "text", None) or "(no response)"
await self._send_message(inbound, text)
return
stream = await self._ctx.run_stream(
request,
run_hook=self._hook,
protocol_request=inbound,
stream_update_hook=self._stream_update_hook,
response_hook=self.response_hook,
channel_name=self.name,
)
await self._stream_to_conversation(inbound, request, stream)
async def _stream_to_conversation(
self,
inbound: Mapping[str, Any],
request: ChannelRequest,
stream: ResponseStream[AgentResponseUpdate, AgentResponse],
) -> None:
"""Stream the reply back into the originating conversation.
Channels that support the ``updateActivity`` REST operation (see
``_EDIT_CAPABLE_CHANNELS`` — effectively only Teams) get the
progressive-edit experience: a ``…`` placeholder is POSTed, then
repeatedly PUT-edited as text accumulates. Every other channel —
Web Chat, Direct Line, the Emulator, etc. — returns ``405 Method
Not Allowed`` on the PUT, so those buffer the whole stream and POST
a single final message (``_buffer_and_send``); attempting the
edit path there would leave the user staring at a stray ``…``.
"""
if str(inbound.get("channelId") or "").lower() not in _EDIT_CAPABLE_CHANNELS:
await self._buffer_and_send(inbound, request, stream)
return
accumulated = ""
last_sent = ""
last_edit_at = 0.0
activity_id: str | None = None
placeholder_ok = False
edit_unsupported = False
worker_done = asyncio.Event()
wake = asyncio.Event()
async def send_initial_placeholder() -> None:
nonlocal activity_id, last_edit_at, placeholder_ok
try:
activity_id = await self._send_message(inbound, "…")
last_edit_at = time.monotonic()
placeholder_ok = activity_id is not None
except Exception:
logger.exception(
"Activity placeholder send failed — falling back to single final POST",
)
placeholder_ok = False
async def edit_worker() -> None:
nonlocal last_sent, last_edit_at, edit_unsupported
# When the placeholder failed we have no activity_id to PUT
# into; the loop's only useful work is exiting cleanly. Skip
# straight to that — the final flush below will POST the
# accumulated text in one shot.
if not placeholder_ok:
return
while not (worker_done.is_set() and accumulated == last_sent):
await wake.wait()
wake.clear()
if accumulated == last_sent:
continue
elapsed = time.monotonic() - last_edit_at
if elapsed < self._stream_edit_min_interval:
try:
await asyncio.wait_for(wake.wait(), timeout=self._stream_edit_min_interval - elapsed)
wake.clear()
except asyncio.TimeoutError:
pass
snapshot = accumulated
if snapshot == last_sent:
continue
try:
await self._update_activity(inbound, activity_id or "", snapshot)
except httpx.HTTPStatusError as exc:
# Some channels advertised as edit-capable may still
# reject the PUT (405). Stop editing and let the final
# flush POST the accumulated text as a new message;
# don't advance ``last_sent`` so that flush still fires.
if exc.response.status_code == 405:
edit_unsupported = True
logger.warning(
"Activity edit not supported by channel %r — sending a single final message instead",
inbound.get("channelId"),
)
return
logger.exception("Activity interim edit failed")
continue
except Exception: # pragma: no cover
logger.exception("Activity interim edit failed")
continue
last_sent = snapshot
last_edit_at = time.monotonic()
await send_initial_placeholder()
edit_task = asyncio.create_task(edit_worker(), name="activity-edit-worker")
try:
async for update in stream:
chunk = getattr(update, "text", None)
if chunk:
accumulated += chunk
wake.set()
except Exception:
logger.exception("Activity streaming consumption failed")
finally:
worker_done.set()
wake.set()
try:
await edit_task
except Exception: # pragma: no cover
logger.exception("Activity edit worker crashed")
try:
final = await stream.get_final_response()
except Exception: # pragma: no cover
logger.exception("Stream finalize failed")
final = None
final_text = getattr(final, "text", None) or accumulated
# Final flush — make sure the user sees everything that arrived after
# the worker's last edit. If the placeholder failed, or the channel
# turned out not to support edits (405), POST a fresh activity here
# with whatever accumulated rather than PUT-editing the placeholder.
if not placeholder_ok or edit_unsupported:
text = final_text or "(no response)"
try:
await self._send_message(inbound, text)
except Exception: # pragma: no cover
logger.exception("Activity fallback final send failed")
elif activity_id is not None and final_text and final_text != last_sent:
try:
await self._update_activity(inbound, activity_id, final_text)
except Exception: # pragma: no cover
logger.exception("Activity final edit failed")
elif not final_text and activity_id is not None:
# No text streamed — replace the placeholder with a stub so the
# user isn't left staring at "…".
try:
await self._update_activity(inbound, activity_id, "(no response)")
except Exception: # pragma: no cover
logger.exception("Activity placeholder replace failed")
async def _buffer_and_send(
self,
inbound: Mapping[str, Any],
request: ChannelRequest,
stream: ResponseStream[AgentResponseUpdate, AgentResponse],
) -> None:
"""Consume the whole stream and POST a single final message.
Used for Bot Framework channels that do not support editing an
activity in place (everything except Teams — see
``_EDIT_CAPABLE_CHANNELS``). Those channels return ``405`` to
``PUT /v3/conversations/{id}/activities/{id}``, so the progressive
in-place edit cannot be used; we buffer the stream and ``POST`` a
single message at the end. Mirrors the non-streaming path's
response-hook semantics so behaviour is consistent regardless of
whether the target streamed.
"""
accumulated = ""
try:
async for update in stream:
chunk = getattr(update, "text", None)
if chunk:
accumulated += chunk
except Exception:
logger.exception("Activity streaming consumption failed")
try:
final = await stream.get_final_response()
except Exception: # pragma: no cover
logger.exception("Stream finalize failed")
final = None
text = getattr(final, "text", None) or accumulated or "(no response)"
try:
await self._send_message(inbound, text)
except Exception: # pragma: no cover
logger.exception("Activity buffered final send failed")
# -- Bot Framework REST helpers --------------------------------------- #
def _activity_payload(self, inbound: Mapping[str, Any], text: str) -> dict[str, Any]:
"""Build the outbound Activity envelope (text-only message)."""
recipient = inbound.get("from") or {}
from_user = inbound.get("recipient") or {}
return {
"type": "message",
"from": from_user,
"recipient": recipient,
"conversation": inbound.get("conversation") or {},
"replyToId": inbound.get("id"),
"channelId": inbound.get("channelId"),
"serviceUrl": inbound.get("serviceUrl"),
"text": text,
"textFormat": "markdown",
}
async def _send_message(self, inbound: Mapping[str, Any], text: str) -> str | None:
"""POST a new Activity. Returns the assigned activity id."""
if self._http is None: # pragma: no cover - guarded by lifecycle
raise RuntimeError("activity channel not started")
service_url = str(inbound.get("serviceUrl") or "").rstrip("/")
conversation_id = (inbound.get("conversation") or {}).get("id")
if not service_url or not isinstance(conversation_id, str):
return None
url = f"{service_url}/v3/conversations/{conversation_id}/activities"
token = await self._get_token()
response = await self._http.post(
url, json=self._activity_payload(inbound, text), headers=self._auth_headers(token)
)
response.raise_for_status()
payload = response.json() if response.content else {}
return payload.get("id") if isinstance(payload, dict) else None
async def _update_activity(self, inbound: Mapping[str, Any], activity_id: str, text: str) -> None:
"""PUT-edit an existing Activity (Teams updateActivity)."""
if self._http is None: # pragma: no cover - guarded by lifecycle
raise RuntimeError("activity channel not started")
service_url = str(inbound.get("serviceUrl") or "").rstrip("/")
conversation_id = (inbound.get("conversation") or {}).get("id")
if not service_url or not isinstance(conversation_id, str):
return
url = f"{service_url}/v3/conversations/{conversation_id}/activities/{activity_id}"
token = await self._get_token()
response = await self._http.put(
url, json=self._activity_payload(inbound, text), headers=self._auth_headers(token)
)
response.raise_for_status()
async def _send_typing(self, inbound: Mapping[str, Any]) -> None:
"""Send a Teams typing indicator; failures are logged and swallowed.
The typing activity is purely a UX nicety — if it fails (token
expired, transient network issue, channel that doesn't support
typing) we never surface that to the user or block the actual
agent run.
"""
if self._http is None: # pragma: no cover - guarded by lifecycle
raise RuntimeError("activity channel not started")
service_url = str(inbound.get("serviceUrl") or "").rstrip("/")
conversation_id = (inbound.get("conversation") or {}).get("id")
if not service_url or not isinstance(conversation_id, str):
return
url = f"{service_url}/v3/conversations/{conversation_id}/activities"
token = await self._get_token()
try:
await self._http.post(
url,
json={
"type": "typing",
"from": inbound.get("recipient") or {},
"recipient": inbound.get("from") or {},
"conversation": inbound.get("conversation") or {},
"serviceUrl": inbound.get("serviceUrl"),
},
headers=self._auth_headers(token),
)
except Exception: # pragma: no cover - non-critical UX
logger.exception("Teams typing send failed")
__all__ = ["ActivityProtocolChannel", "activity_protocol_isolation_key"]
@@ -0,0 +1,107 @@
[project]
name = "agent-framework-hosting-activity-protocol"
description = "Bot Framework Activity Protocol channel for agent-framework-hosting (Teams, Slack, etc. via Azure Bot Service)."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0a260424"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
urls.issues = "https://github.com/microsoft/agent-framework/issues"
classifiers = [
"License :: OSI Approved :: MIT License",
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.2.0,<2",
"agent-framework-hosting==1.0.0a260424",
"httpx>=0.27,<1",
"azure-identity>=1.20,<2",
]
[tool.uv]
prerelease = "if-necessary-or-explicit"
environments = [
"sys_platform == 'darwin'",
"sys_platform == 'linux'",
"sys_platform == 'win32'"
]
[tool.uv-dynamic-versioning]
fallback-version = "0.0.0"
[tool.pytest.ini_options]
testpaths = 'tests'
addopts = "-ra -q -r fEX"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = []
timeout = 120
markers = [
"integration: marks tests as integration tests that require external services",
]
[tool.ruff]
extend = "../../pyproject.toml"
[tool.coverage.run]
omit = [
"**/__init__.py"
]
[tool.pyright]
extends = "../../pyproject.toml"
include = ["agent_framework_hosting_activity_protocol"]
exclude = ['tests']
# Bot Framework activities arrive as loosely-typed JSON-ish maps. Strict
# ``Unknown`` reporting on every ``.get(...)`` adds noise without catching
# real bugs — narrowing happens via runtime isinstance checks instead.
reportUnknownArgumentType = "none"
reportUnknownMemberType = "none"
reportUnknownVariableType = "none"
reportUnknownLambdaType = "none"
reportOptionalMemberAccess = "none"
[tool.mypy]
plugins = ['pydantic.mypy']
strict = true
python_version = "3.10"
ignore_missing_imports = true
disallow_untyped_defs = true
no_implicit_optional = true
check_untyped_defs = true
warn_return_any = true
show_error_codes = true
warn_unused_ignores = false
disallow_incomplete_defs = true
disallow_untyped_decorators = true
[tool.bandit]
targets = ["agent_framework_hosting_activity_protocol"]
exclude_dirs = ["tests"]
[tool.poe]
executor.type = "uv"
include = "../../shared_tasks.toml"
[tool.poe.tasks.mypy]
help = "Run MyPy for this package."
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_hosting_activity_protocol"
[tool.poe.tasks.test]
help = "Run the default unit test suite for this package."
cmd = 'pytest -m "not integration" --cov=agent_framework_hosting_activity_protocol --cov-report=term-missing:skip-covered tests'
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
build-backend = "flit_core.buildapi"
@@ -0,0 +1,775 @@
# Copyright (c) Microsoft. All rights reserved.
"""Unit tests for :mod:`agent_framework_hosting_activity_protocol`.
The Bot Framework outbound calls and azure-identity credentials are mocked
out so the suite never touches the network. Live token acquisition,
streaming edits and certificate paths are out of scope here.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from agent_framework_hosting import (
AgentFrameworkHost,
ChannelCommand,
ChannelCommandContext,
ChannelRequest,
HostedRunResult,
)
from starlette.testclient import TestClient
from agent_framework_hosting_activity_protocol import ActivityProtocolChannel, activity_protocol_isolation_key
from agent_framework_hosting_activity_protocol._channel import _command_text, _parse_activity
def test_activity_protocol_isolation_key_format() -> None:
assert activity_protocol_isolation_key("19:meeting_xyz@thread.v2") == "activity:19:meeting_xyz@thread.v2"
assert activity_protocol_isolation_key(123) == "activity:123"
class TestParseActivity:
def test_text_only(self) -> None:
msg = _parse_activity({"type": "message", "text": "hello"})
assert msg.role == "user"
assert msg.text == "hello"
def test_with_attachment(self) -> None:
msg = _parse_activity({
"type": "message",
"text": "see this",
"attachments": [
{"contentType": "image/png", "contentUrl": "https://example.com/x.png"},
],
})
assert msg.text == "see this"
assert any((getattr(c, "uri", None) or "").endswith("/x.png") for c in msg.contents)
def test_skips_invalid_attachments(self) -> None:
msg = _parse_activity({
"type": "message",
"text": "hi",
"attachments": [
"not-a-mapping",
{"contentType": "image/png"}, # no url
{"contentUrl": "https://example.com/y", "contentType": "no-slash"},
],
})
assert msg.text == "hi"
# No URI content survived.
assert not any(getattr(c, "uri", None) for c in msg.contents)
def test_skips_teams_text_html_inline_content(self) -> None:
# Teams attaches a text/html rendering whose inline ``content`` is raw
# HTML (not a URL). It must not be parsed as a URI.
msg = _parse_activity({
"type": "message",
"text": "hello there",
"attachments": [
{"contentType": "text/html", "content": "<p>hello there</p>"},
],
})
assert msg.text == "hello there"
assert not any(getattr(c, "uri", None) for c in msg.contents)
def test_skips_attachment_contenturl_without_scheme(self) -> None:
msg = _parse_activity({
"type": "message",
"text": "hi",
"attachments": [
{"contentType": "image/png", "contentUrl": "/relative/path.png"},
],
})
assert msg.text == "hi"
assert not any(getattr(c, "uri", None) for c in msg.contents)
class TestCommandText:
def test_plain_text_unchanged(self) -> None:
assert _command_text({"text": "/help"}) == "/help"
def test_non_string_text_returns_empty(self) -> None:
assert _command_text({"text": None}) == ""
assert _command_text({}) == ""
def test_strips_bot_mention(self) -> None:
activity = {
"text": "<at>Personal Assistant</at> /todos",
"recipient": {"id": "bot-1"},
"entities": [
{"type": "mention", "text": "<at>Personal Assistant</at>", "mentioned": {"id": "bot-1"}},
],
}
assert _command_text(activity) == "/todos"
def test_strips_bot_mention_without_space(self) -> None:
activity = {
"text": "<at>Bot</at>/help",
"recipient": {"id": "bot-1"},
"entities": [{"type": "mention", "text": "<at>Bot</at>", "mentioned": {"id": "bot-1"}}],
}
assert _command_text(activity) == "/help"
def test_keeps_other_user_mention(self) -> None:
activity = {
"text": "/whoami <at>Someone</at>",
"recipient": {"id": "bot-1"},
"entities": [{"type": "mention", "text": "<at>Someone</at>", "mentioned": {"id": "user-9"}}],
}
# Another user's mention must not be stripped.
assert _command_text(activity) == "/whoami <at>Someone</at>"
def test_malformed_entities_are_ignored(self) -> None:
activity = {
"text": "/help",
"recipient": {"id": "bot-1"},
"entities": ["not-a-mapping", {"type": "clientInfo"}, {"type": "mention"}],
}
assert _command_text(activity) == "/help"
@dataclass
class _FakeAgentResponse:
text: str
@dataclass
class _FakeUpdate:
text: str
class _FakeStream:
def __init__(self, chunks: list[str]) -> None:
self._chunks = chunks
def __aiter__(self) -> Any:
async def gen() -> Any:
for chunk in self._chunks:
yield _FakeUpdate(chunk)
return gen()
async def get_final_response(self) -> _FakeAgentResponse:
return _FakeAgentResponse(text="".join(self._chunks))
class _FakeAgent:
def __init__(self, reply: str = "ok") -> None:
self._reply = reply
self.runs: list[Any] = []
def create_session(self, *, session_id: str | None = None) -> Any:
return {"session_id": session_id}
def run(self, messages: Any = None, *, stream: bool = False, **kwargs: Any) -> Any:
self.runs.append({"messages": messages, "stream": stream, "kwargs": kwargs})
if stream:
return _FakeStream([self._reply])
async def _coro() -> _FakeAgentResponse:
return _FakeAgentResponse(text=self._reply)
return _coro()
def _make_teams(
stream: bool = False, *, path: str = "/activity/messages"
) -> tuple[ActivityProtocolChannel, _FakeAgent]:
agent = _FakeAgent("hi there")
ch = ActivityProtocolChannel(path=path, stream=stream, send_typing_action=False)
fake_http = MagicMock()
response_mock = MagicMock()
response_mock.raise_for_status = MagicMock()
response_mock.json = MagicMock(return_value={"id": "act-1"})
fake_http.post = AsyncMock(return_value=response_mock)
fake_http.put = AsyncMock(return_value=response_mock)
fake_http.aclose = AsyncMock()
ch._http = fake_http
return ch, agent
_VALID_ACTIVITY: dict[str, Any] = {
"type": "message",
"id": "in-1",
"text": "hello bot",
"conversation": {"id": "19:meeting_xyz@thread.v2"},
"from": {"id": "user-1"},
"recipient": {"id": "bot-1"},
"channelId": "msteams",
"serviceUrl": "https://smba.trafficmanager.net/amer/",
}
# Minimal request envelope for direct ``_stream_to_conversation`` calls.
_VALID_REQUEST = ChannelRequest(channel="activity", operation="message.create", input=[])
class TestTeamsWebhook:
def test_message_activity_dispatches_to_agent(self) -> None:
ch, agent = _make_teams()
host = AgentFrameworkHost(target=agent, channels=[ch])
with TestClient(host.app) as client:
r = client.post("/activity/messages", json=_VALID_ACTIVITY)
assert r.status_code == 200
assert agent.runs, "expected the agent to be invoked"
# And the channel posted a reply back to the conversation URL.
assert ch._http is not None
ch._http.post.assert_called() # type: ignore[attr-defined]
url, _ = ch._http.post.call_args[0], ch._http.post.call_args[1] # type: ignore[attr-defined] # noqa: F841
assert "/v3/conversations/" in ch._http.post.call_args[0][0] # type: ignore[attr-defined]
body = ch._http.post.call_args[1]["json"] # type: ignore[attr-defined]
assert body["text"] == "hi there"
def test_empty_path_mounts_at_app_root(self) -> None:
ch, agent = _make_teams(path="")
host = AgentFrameworkHost(target=agent, channels=[ch])
with TestClient(host.app) as client:
r = client.post("/", json=_VALID_ACTIVITY)
assert r.status_code == 200
assert agent.runs, "expected the agent to be invoked"
def test_response_hook_can_rewrite_originating_reply(self) -> None:
seen_kwargs: list[dict[str, Any]] = []
def hook(result: HostedRunResult, **kwargs: Any) -> HostedRunResult:
seen_kwargs.append(dict(kwargs))
return HostedRunResult(_FakeAgentResponse(text=result.result.text.upper()), session=result.session)
ch, agent = _make_teams()
ch.response_hook = hook
host = AgentFrameworkHost(target=agent, channels=[ch])
with TestClient(host.app) as client:
r = client.post("/activity/messages", json=_VALID_ACTIVITY)
assert r.status_code == 200
assert ch._http is not None
body = ch._http.post.call_args[1]["json"] # type: ignore[attr-defined]
assert body["text"] == "HI THERE"
assert seen_kwargs
assert seen_kwargs[0]["channel_name"] == "activity"
def test_non_message_activities_are_acked(self) -> None:
ch, agent = _make_teams()
host = AgentFrameworkHost(target=agent, channels=[ch])
with TestClient(host.app) as client:
r = client.post(
"/activity/messages",
json={"type": "conversationUpdate", "conversation": {"id": "x"}},
)
assert r.status_code == 202
assert not agent.runs
def test_invalid_json_returns_400(self) -> None:
ch, agent = _make_teams()
host = AgentFrameworkHost(target=agent, channels=[ch])
with TestClient(host.app) as client:
r = client.post(
"/activity/messages",
content=b"not-json",
headers={"content-type": "application/json"},
)
assert r.status_code == 400
assert not agent.runs
def test_message_missing_serviceurl_is_dropped(self) -> None:
ch, agent = _make_teams()
host = AgentFrameworkHost(target=agent, channels=[ch])
bad = dict(_VALID_ACTIVITY)
bad.pop("serviceUrl")
with TestClient(host.app) as client:
r = client.post("/activity/messages", json=bad)
# No serviceUrl → fails the allow-list check (None doesn't match
# any allowed host suffix), surfaced as 400 so a misconfigured
# caller knows the activity was structurally invalid.
assert r.status_code == 400
assert not agent.runs
class TestCommands:
def _make_with_commands(self, commands: list[ChannelCommand]) -> tuple[ActivityProtocolChannel, _FakeAgent]:
agent = _FakeAgent("hi there")
ch = ActivityProtocolChannel(send_typing_action=False, commands=commands)
fake_http = MagicMock()
response_mock = MagicMock()
response_mock.raise_for_status = MagicMock()
response_mock.json = MagicMock(return_value={"id": "act-1"})
fake_http.post = AsyncMock(return_value=response_mock)
fake_http.put = AsyncMock(return_value=response_mock)
fake_http.aclose = AsyncMock()
ch._http = fake_http
return ch, agent
def test_slash_command_bypasses_agent_and_replies(self) -> None:
seen: list[ChannelCommandContext] = []
async def handle(ctx: ChannelCommandContext) -> None:
seen.append(ctx)
await ctx.reply("listed")
ch, agent = self._make_with_commands([ChannelCommand("todos", "List", handle)])
host = AgentFrameworkHost(target=agent, channels=[ch])
activity = dict(_VALID_ACTIVITY, text="/todos")
with TestClient(host.app) as client:
r = client.post("/activity/messages", json=activity)
assert r.status_code == 200
assert not agent.runs, "command must bypass the agent"
assert seen and seen[0].request.operation == "command.invoke"
assert seen[0].request.input == "/todos"
assert seen[0].request.session is not None
assert seen[0].request.session.isolation_key == activity_protocol_isolation_key("19:meeting_xyz@thread.v2")
assert ch._http is not None
assert ch._http.post.call_args[1]["json"]["text"] == "listed" # type: ignore[attr-defined]
def test_command_match_is_case_insensitive(self) -> None:
ran = False
async def handle(ctx: ChannelCommandContext) -> None:
nonlocal ran
ran = True
ch, agent = self._make_with_commands([ChannelCommand("New", "reset", handle)])
host = AgentFrameworkHost(target=agent, channels=[ch])
with TestClient(host.app) as client:
r = client.post("/activity/messages", json=dict(_VALID_ACTIVITY, text="/new"))
assert r.status_code == 200
assert ran
assert not agent.runs
def test_unknown_command_falls_through_to_agent(self) -> None:
async def handle(ctx: ChannelCommandContext) -> None: # pragma: no cover - never called
raise AssertionError("should not run")
ch, agent = self._make_with_commands([ChannelCommand("todos", "List", handle)])
host = AgentFrameworkHost(target=agent, channels=[ch])
with TestClient(host.app) as client:
r = client.post("/activity/messages", json=dict(_VALID_ACTIVITY, text="/unknown"))
assert r.status_code == 200
assert agent.runs, "unknown /command must reach the agent"
def test_command_failure_does_not_retry(self) -> None:
async def handle(ctx: ChannelCommandContext) -> None:
raise RuntimeError("boom")
ch, agent = self._make_with_commands([ChannelCommand("todos", "List", handle)])
host = AgentFrameworkHost(target=agent, channels=[ch])
with TestClient(host.app) as client:
r = client.post("/activity/messages", json=dict(_VALID_ACTIVITY, text="/todos"))
# Best-effort: a failing command is swallowed and acked with 200 so Bot
# Service does not retry (and re-run a non-idempotent command).
assert r.status_code == 200
assert not agent.runs
def test_command_request_uses_activity_session(self) -> None:
captured: list[str] = []
async def handle(ctx: ChannelCommandContext) -> None:
assert ctx.request.session is not None
captured.append(ctx.request.session.isolation_key)
agent = _FakeAgent("hi")
ch = ActivityProtocolChannel(send_typing_action=False, commands=[ChannelCommand("todos", "x", handle)])
fake_http = MagicMock()
response_mock = MagicMock()
response_mock.raise_for_status = MagicMock()
response_mock.json = MagicMock(return_value={"id": "act-1"})
fake_http.post = AsyncMock(return_value=response_mock)
fake_http.aclose = AsyncMock()
ch._http = fake_http
host = AgentFrameworkHost(target=agent, channels=[ch])
with TestClient(host.app) as client:
r = client.post("/activity/messages", json=dict(_VALID_ACTIVITY, text="/todos"))
assert r.status_code == 200
assert captured == [activity_protocol_isolation_key("19:meeting_xyz@thread.v2")]
class TestOutbound:
async def test_send_message_posts_to_conversation_url(self) -> None:
ch, _agent = _make_teams()
await ch._send_message(_VALID_ACTIVITY, "hi")
assert ch._http is not None
ch._http.post.assert_called() # type: ignore[attr-defined]
url = ch._http.post.call_args[0][0] # type: ignore[attr-defined]
assert "/v3/conversations/" in url
body = ch._http.post.call_args[1]["json"] # type: ignore[attr-defined]
assert body["text"] == "hi"
class TestIdentityRecording:
"""``_process_activity`` must stamp the inbound conversation reference
onto ``ChannelRequest.identity`` so hooks and commands can inspect it."""
async def test_inbound_sets_request_identity(self) -> None:
ch, agent = _make_teams()
captured: dict[str, Any] = {}
async def hook(req: ChannelRequest, **_: Any) -> ChannelRequest:
captured["request"] = req
return req
ch._hook = hook # type: ignore[assignment]
host = AgentFrameworkHost(target=agent, channels=[ch])
with TestClient(host.app) as client:
r = client.post("/activity/messages", json=_VALID_ACTIVITY)
assert r.status_code == 200
request = captured["request"]
assert request.identity is not None
assert request.identity.channel == "activity"
assert request.identity.native_id == "19:meeting_xyz@thread.v2"
attrs = request.identity.attributes
assert attrs["service_url"] == "https://smba.trafficmanager.net/amer/"
assert attrs["bot"] == {"id": "bot-1"}
assert attrs["user"] == {"id": "user-1"}
class TestConfig:
def test_rejects_both_secret_and_certificate(self) -> None:
with pytest.raises(ValueError, match="not both"):
ActivityProtocolChannel(
app_id="x",
app_password="s",
certificate_path="/tmp/does-not-exist.pem",
)
def test_dev_mode_no_credential(self) -> None:
ch = ActivityProtocolChannel()
assert ch._credential is None
class TestServiceUrlAllowList:
"""``serviceUrl`` is supplied by the inbound activity and the channel
POSTs a real bearer token to it — anything outside the Bot Framework
host suffixes must be rejected so a malicious caller can't redirect
outbound replies to an attacker-controlled host."""
def test_default_allows_smba_trafficmanager(self) -> None:
ch = ActivityProtocolChannel()
assert ch._is_service_url_allowed("https://smba.trafficmanager.net/amer/")
assert ch._is_service_url_allowed("https://emea.smba.trafficmanager.net/")
assert ch._is_service_url_allowed("https://api.botframework.com/")
def test_default_rejects_arbitrary_host(self) -> None:
ch = ActivityProtocolChannel()
assert not ch._is_service_url_allowed("https://attacker.example.com/")
assert not ch._is_service_url_allowed("https://botframework.com.attacker.com/")
assert not ch._is_service_url_allowed("")
assert not ch._is_service_url_allowed(None)
def test_custom_allowlist(self) -> None:
ch = ActivityProtocolChannel(service_url_allowed_hosts=("internal.contoso.com",))
assert ch._is_service_url_allowed("https://internal.contoso.com/v3/")
assert ch._is_service_url_allowed("https://eu.internal.contoso.com/")
assert not ch._is_service_url_allowed("https://smba.trafficmanager.net/")
def test_empty_allowlist_disables_check(self) -> None:
ch = ActivityProtocolChannel(service_url_allowed_hosts=())
assert ch._is_service_url_allowed("https://anywhere.example.org/")
def test_webhook_rejects_disallowed_serviceurl(self) -> None:
ch, agent = _make_teams()
host = AgentFrameworkHost(target=agent, channels=[ch])
bad = dict(_VALID_ACTIVITY)
bad["serviceUrl"] = "https://attacker.example.com/v3/"
with TestClient(host.app) as client:
r = client.post("/activity/messages", json=bad)
assert r.status_code == 400
assert not agent.runs
# No outbound POST attempted with a bearer token.
assert ch._http is not None
ch._http.post.assert_not_called() # type: ignore[attr-defined]
class TestInboundAuthValidator:
def test_allow_passes_through(self) -> None:
async def allow(_req: Any) -> bool:
return True
ch, agent = _make_teams()
ch._inbound_auth_validator = allow
host = AgentFrameworkHost(target=agent, channels=[ch])
with TestClient(host.app) as client:
r = client.post("/activity/messages", json=_VALID_ACTIVITY)
assert r.status_code == 200
assert agent.runs
def test_reject_returns_401(self) -> None:
async def deny(_req: Any) -> bool:
return False
ch, agent = _make_teams()
ch._inbound_auth_validator = deny
host = AgentFrameworkHost(target=agent, channels=[ch])
with TestClient(host.app) as client:
r = client.post("/activity/messages", json=_VALID_ACTIVITY)
assert r.status_code == 401
assert not agent.runs
def test_validator_raises_returns_401(self) -> None:
async def boom(_req: Any) -> bool:
raise RuntimeError("validator broke")
ch, agent = _make_teams()
ch._inbound_auth_validator = boom
host = AgentFrameworkHost(target=agent, channels=[ch])
with TestClient(host.app) as client:
r = client.post("/activity/messages", json=_VALID_ACTIVITY)
assert r.status_code == 401
assert not agent.runs
class TestOutboundAuthHeader:
async def test_no_credential_sends_no_authorization_header(self) -> None:
ch, _agent = _make_teams()
# Default _make_teams has no credential — dev mode.
await ch._send_message(_VALID_ACTIVITY, "hi")
assert ch._http is not None
headers = ch._http.post.call_args[1]["headers"] # type: ignore[attr-defined]
assert "Authorization" not in headers
async def test_with_credential_sends_bearer_token(self) -> None:
ch, _agent = _make_teams()
# Inject a fake credential with a fixed token.
token_obj = MagicMock()
token_obj.token = "tok-abc123"
cred = MagicMock()
cred.get_token = AsyncMock(return_value=token_obj)
ch._credential = cred # type: ignore[assignment]
await ch._send_message(_VALID_ACTIVITY, "hi")
assert ch._http is not None
headers = ch._http.post.call_args[1]["headers"] # type: ignore[attr-defined]
assert headers.get("Authorization") == "Bearer tok-abc123"
class TestRetrySignal:
"""Distinguish transient outbound failures (network / 5xx) — which
must surface 502 so Bot Service retries — from deterministic agent
failures (which must return 200 to avoid retry loops)."""
def test_outbound_http_error_returns_502(self) -> None:
import httpx as _httpx
ch, agent = _make_teams()
# Make _send_message raise a transient httpx error.
assert ch._http is not None
ch._http.post = AsyncMock(side_effect=_httpx.ConnectError("nope")) # type: ignore[attr-defined]
host = AgentFrameworkHost(target=agent, channels=[ch])
with TestClient(host.app) as client:
r = client.post("/activity/messages", json=_VALID_ACTIVITY)
assert r.status_code == 502
def test_deterministic_agent_failure_returns_200(self) -> None:
ch, agent = _make_teams()
def boom(messages: Any = None, *, stream: bool = False, **kwargs: Any) -> Any:
async def _coro() -> Any:
raise ValueError("agent crashed")
return _coro()
agent.run = boom # type: ignore[assignment]
host = AgentFrameworkHost(target=agent, channels=[ch])
with TestClient(host.app) as client:
r = client.post("/activity/messages", json=_VALID_ACTIVITY)
# Deterministic failure → 200 (Bot Service does not retry the same
# broken activity in a loop).
assert r.status_code == 200
class TestStreaming:
async def test_stream_sends_placeholder_and_edits(self) -> None:
ch, _agent = _make_teams(stream=True)
# Build a fake stream that emits two text chunks then finalizes.
@dataclass
class _Up:
text: str
class _Stream:
def __init__(self) -> None:
self._chunks = ["hel", "lo"]
def __aiter__(self) -> Any:
async def gen() -> Any:
for c in self._chunks:
yield _Up(c)
return gen()
async def get_final_response(self) -> Any:
return _FakeAgentResponse(text="hello")
# Use a tight throttle so the test doesn't sit on `wait_for`.
ch._stream_edit_min_interval = 0.0
await ch._stream_to_conversation(_VALID_ACTIVITY, _VALID_REQUEST, _Stream()) # type: ignore[arg-type]
assert ch._http is not None
# Placeholder POST + at least one final PUT.
ch._http.post.assert_called() # type: ignore[attr-defined]
ch._http.put.assert_called() # type: ignore[attr-defined]
# Final edit body carries the full accumulated text.
last_put_body = ch._http.put.call_args[1]["json"] # type: ignore[attr-defined]
assert last_put_body["text"] == "hello"
async def test_stream_placeholder_failure_falls_back_to_single_post(self) -> None:
# The bug: when send_initial_placeholder fails, activity_id stays
# None, the edit_worker can never reach its exit condition
# (`accumulated == last_sent` while no PUT possible) and the
# whole conversation deadlocks. After the fix we fall back to
# buffering the stream and POSTing a single final activity.
ch, _agent = _make_teams(stream=True)
# Make the FIRST POST (placeholder) raise; subsequent POST (final
# fallback) succeeds.
import httpx as _httpx
ok_response = MagicMock()
ok_response.raise_for_status = MagicMock()
ok_response.json = MagicMock(return_value={"id": "act-final"})
ok_response.content = b"{}"
post_mock = AsyncMock(side_effect=[_httpx.HTTPError("boom"), ok_response])
assert ch._http is not None
ch._http.post = post_mock # type: ignore[attr-defined]
@dataclass
class _Up:
text: str
class _Stream:
def __aiter__(self) -> Any:
async def gen() -> Any:
yield _Up("partial-1")
yield _Up("-partial-2")
return gen()
async def get_final_response(self) -> Any:
return _FakeAgentResponse(text="partial-1-partial-2")
ch._stream_edit_min_interval = 0.0
# Should NOT hang. Use asyncio.wait_for with a small timeout to
# guard the test against future regressions of the deadlock.
import asyncio as _asyncio
await _asyncio.wait_for(
ch._stream_to_conversation(_VALID_ACTIVITY, _VALID_REQUEST, _Stream()), # type: ignore[arg-type]
timeout=2.0,
)
# Two POSTs total: placeholder (failed) + fallback final.
assert post_mock.await_count == 2
# Fallback POST contains the full accumulated text.
fallback_body = post_mock.call_args[1]["json"]
assert fallback_body["text"] == "partial-1-partial-2"
async def test_stream_with_no_text_replaces_placeholder(self) -> None:
ch, _agent = _make_teams(stream=True)
class _EmptyStream:
def __aiter__(self) -> Any:
async def gen() -> Any:
if False:
yield None # type: ignore[unreachable]
return gen()
async def get_final_response(self) -> Any:
return _FakeAgentResponse(text="")
ch._stream_edit_min_interval = 0.0
await ch._stream_to_conversation(_VALID_ACTIVITY, _VALID_REQUEST, _EmptyStream()) # type: ignore[arg-type]
# The placeholder PUT-replaces with "(no response)" so the user
# isn't left staring at "…".
assert ch._http is not None
last_put_body = ch._http.put.call_args[1]["json"] # type: ignore[attr-defined]
assert last_put_body["text"] == "(no response)"
async def test_non_edit_channel_buffers_and_posts_single_message(self) -> None:
# Web Chat (and every non-Teams channel) does not support
# PUT /activities/{id}; the channel must buffer the stream and POST
# a single final message rather than the placeholder+edit dance.
ch, _agent = _make_teams(stream=True)
webchat_activity = {**_VALID_ACTIVITY, "channelId": "webchat"}
@dataclass
class _Up:
text: str
class _Stream:
def __aiter__(self) -> Any:
async def gen() -> Any:
yield _Up("hel")
yield _Up("lo")
return gen()
async def get_final_response(self) -> Any:
return _FakeAgentResponse(text="hello")
ch._stream_edit_min_interval = 0.0
await ch._stream_to_conversation(webchat_activity, _VALID_REQUEST, _Stream()) # type: ignore[arg-type]
assert ch._http is not None
# No PUT (no editing); exactly one POST with the full text.
ch._http.put.assert_not_called() # type: ignore[attr-defined]
assert ch._http.post.await_count == 1 # type: ignore[attr-defined]
body = ch._http.post.call_args[1]["json"] # type: ignore[attr-defined]
assert body["text"] == "hello"
async def test_non_edit_channel_empty_stream_posts_no_response(self) -> None:
ch, _agent = _make_teams(stream=True)
webchat_activity = {**_VALID_ACTIVITY, "channelId": "directline"}
class _EmptyStream:
def __aiter__(self) -> Any:
async def gen() -> Any:
if False:
yield None # type: ignore[unreachable]
return gen()
async def get_final_response(self) -> Any:
return _FakeAgentResponse(text="")
ch._stream_edit_min_interval = 0.0
await ch._stream_to_conversation(webchat_activity, _VALID_REQUEST, _EmptyStream()) # type: ignore[arg-type]
assert ch._http is not None
ch._http.put.assert_not_called() # type: ignore[attr-defined]
body = ch._http.post.call_args[1]["json"] # type: ignore[attr-defined]
assert body["text"] == "(no response)"
async def test_edit_405_falls_back_to_single_post(self) -> None:
# Defensive: a channel advertised as edit-capable that nonetheless
# rejects the PUT with 405 must stop editing and POST the final
# text as a fresh message instead of silently leaving "…".
import httpx as _httpx
ch, _agent = _make_teams(stream=True)
assert ch._http is not None
request_405 = _httpx.Request("PUT", "https://smba.trafficmanager.net/amer/v3/x")
response_405 = _httpx.Response(405, request=request_405)
ch._http.put = AsyncMock( # type: ignore[attr-defined]
side_effect=_httpx.HTTPStatusError("405", request=request_405, response=response_405)
)
@dataclass
class _Up:
text: str
class _Stream:
def __aiter__(self) -> Any:
async def gen() -> Any:
yield _Up("hel")
yield _Up("lo")
return gen()
async def get_final_response(self) -> Any:
return _FakeAgentResponse(text="hello")
ch._stream_edit_min_interval = 0.0
await ch._stream_to_conversation(_VALID_ACTIVITY, _VALID_REQUEST, _Stream()) # type: ignore[arg-type]
# Placeholder POST + fallback final POST = 2 POSTs; the final one
# carries the full text.
assert ch._http.post.await_count == 2 # type: ignore[attr-defined]
final_body = ch._http.post.call_args[1]["json"] # type: ignore[attr-defined]
assert final_body["text"] == "hello"
+22
View File
@@ -0,0 +1,22 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
+78
View File
@@ -0,0 +1,78 @@
# agent-framework-hosting-discord
Discord HTTP Interactions channel for [agent-framework-hosting](../hosting).
The channel exposes a signed Starlette route for Discord slash commands, maps a
configurable slash command to the hosted agent, maps `ChannelCommand` instances
to native Discord commands, and supports push to Discord channel ids.
## Usage
```python
from agent_framework_hosting import AgentFrameworkHost
from agent_framework_hosting_discord import DiscordChannel
host = AgentFrameworkHost(
target=my_agent,
channels=[
DiscordChannel(
application_id="<discord application id>",
public_key="<discord public key>",
bot_token="<discord bot token>",
guild_id="<guild id for fast dev command registration>",
)
],
)
host.serve()
```
Configure the Discord Developer Portal interaction endpoint as:
```text
https://<your-host>/discord/interactions
```
The channel verifies Discord's `X-Signature-Ed25519` header against the raw
request body before parsing JSON. `skip_signature_verification=True` exists only
for local tests and should not be used on a public endpoint.
## Slash commands
By default, `/ask prompt:<text>` invokes the hosted agent. Additional
`ChannelCommand` instances are registered as Discord slash commands with an
optional `input` string option:
```python
from agent_framework_hosting import ChannelCommand
async def reset(ctx):
await ctx.reply("Reset acknowledged")
DiscordChannel(
application_id="...",
public_key="...",
bot_token="...",
commands=[ChannelCommand("reset", "Reset the conversation", reset)],
)
```
When `guild_id` is set, commands are registered only for that guild and usually
appear quickly. Global command registration can take much longer to propagate.
If `register_commands=True` but `bot_token` is omitted, the channel logs a
warning and assumes commands were registered outside the host.
## Identity, sessions, and push
The default isolation key is `discord:<guild-or-dm>:<channel_id>:<user_id>`,
which keeps each user private inside a Discord channel or thread. Pass
`isolation_key_factory=` to use a different scope.
`ChannelIdentity.native_id` is the Discord user id. Push requires
`identity.attributes["channel_id"]`; the first slice intentionally does not
create DM channels as a fallback.
## Streaming
Set `streaming=True` to consume the host stream and edit the original Discord
interaction response as text accumulates. Edits are debounced with
`edit_interval` to avoid excessive Discord REST calls.
@@ -0,0 +1,19 @@
# Copyright (c) Microsoft. All rights reserved.
"""Discord channel for ``agent-framework-hosting``."""
import importlib.metadata
from ._channel import DiscordChannel, DiscordIsolationKeyFactory, discord_isolation_key
try:
__version__ = importlib.metadata.version(__name__)
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0"
__all__ = [
"DiscordChannel",
"DiscordIsolationKeyFactory",
"__version__",
"discord_isolation_key",
]
@@ -0,0 +1,610 @@
# Copyright (c) Microsoft. All rights reserved.
"""Discord HTTP Interactions channel."""
from __future__ import annotations
import asyncio
import json
import logging
import re
import time
from collections.abc import Callable, Coroutine, Mapping, Sequence
from typing import Any, cast
import httpx
from agent_framework import AgentResponse, AgentResponseUpdate, ResponseStream
from agent_framework_hosting import (
ChannelCommand,
ChannelCommandContext,
ChannelContext,
ChannelContribution,
ChannelIdentity,
ChannelRequest,
ChannelResponseHook,
ChannelRunHook,
ChannelSession,
ChannelStreamUpdateHook,
HostedRunResult,
)
from nacl.exceptions import BadSignatureError
from nacl.signing import VerifyKey
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from starlette.routing import Route
logger = logging.getLogger("agent_framework.hosting.discord")
DiscordInteraction = Mapping[str, Any]
DiscordIsolationKeyFactory = Callable[[DiscordInteraction], str]
_DISCORD_API_BASE = "https://discord.com/api/v10"
_DISCORD_MAX_BODY_BYTES = 1024 * 1024
_DISCORD_MAX_CONTENT_LEN = 2000
_INTERACTION_PING = 1
_INTERACTION_APPLICATION_COMMAND = 2
_RESPONSE_PONG = 1
_RESPONSE_DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE = 5
_OPTION_STRING = 3
_APPLICATION_COMMAND_CHAT_INPUT = 1
_COMMAND_NAME_RE = re.compile(r"^[a-z0-9_-]{1,32}$")
def discord_isolation_key(guild_id: str | None, channel_id: str, user_id: str) -> str:
"""Build the default Discord isolation key.
Args:
guild_id: Discord guild id, or ``None`` for a DM interaction.
channel_id: Discord channel or thread id.
user_id: Discord user id.
Returns:
A stable host isolation key scoped to guild/channel/user.
"""
scope = guild_id or "dm"
return f"discord:{scope}:{channel_id}:{user_id}"
def _default_isolation_key(interaction: DiscordInteraction) -> str:
user = _user_from_interaction(interaction)
user_id = _require_string(user.get("id"), "interaction user id")
channel_id = _require_string(interaction.get("channel_id"), "interaction channel_id")
guild_id = _string_or_none(interaction.get("guild_id"))
return discord_isolation_key(guild_id, channel_id, user_id)
class DiscordChannel:
"""Discord channel backed by signed HTTP Interactions."""
name = "discord"
def __init__(
self,
*,
application_id: str,
public_key: str,
bot_token: str | None = None,
guild_id: str | None = None,
path: str = "/discord/interactions",
agent_command: str = "ask",
agent_command_description: str = "Ask the agent",
agent_command_option: str = "prompt",
register_commands: bool = True,
commands: Sequence[ChannelCommand] | None = None,
run_hook: ChannelRunHook | None = None,
response_hook: ChannelResponseHook | None = None,
stream_update_hook: ChannelStreamUpdateHook | None = None,
streaming: bool = False,
isolation_key_factory: DiscordIsolationKeyFactory | None = None,
skip_signature_verification: bool = False,
edit_interval: float = 1.0,
max_body_bytes: int = _DISCORD_MAX_BODY_BYTES,
api_base_url: str = _DISCORD_API_BASE,
) -> None:
"""Configure the Discord channel.
Keyword Args:
application_id: Discord application id.
public_key: Discord application public key as lowercase or
uppercase hex. Used to verify interaction signatures.
bot_token: Bot token used to register slash commands and push
messages to Discord channel ids. Interaction webhook replies
do not require this token.
guild_id: Optional guild id for guild-scoped slash command
registration. Recommended for development because global
command registration can take a long time to propagate.
path: Interaction endpoint path on the host. Use ``""`` to expose
the interaction route at the app root.
agent_command: Slash command name that invokes the hosted agent.
agent_command_description: Description for the agent slash command.
agent_command_option: String option name that carries the prompt.
register_commands: Whether startup should register slash commands
through Discord REST when ``bot_token`` is configured.
commands: Additional host ``ChannelCommand`` instances to expose
as Discord slash commands.
run_hook: Optional hook that can rewrite the channel request before
it reaches the host.
response_hook: Optional hook that can rewrite the hosted result
before the originating Discord response is serialized.
stream_update_hook: Optional per-update hook applied
while streaming.
streaming: Whether the agent command should call ``run_stream``
and edit the original interaction response as deltas arrive.
isolation_key_factory: Optional callable that receives the raw
Discord interaction and returns a host isolation key.
skip_signature_verification: Disable Ed25519 verification. Use
only for local tests; never expose publicly with this enabled.
edit_interval: Minimum seconds between streaming edits to the
original Discord interaction response.
max_body_bytes: Maximum raw interaction request body size.
api_base_url: Discord API base URL. Primarily useful for tests.
Raises:
ValueError: If public key hex or command names are invalid, or if
command names collide.
"""
self.application_id = application_id
self.public_key = public_key
self.bot_token = bot_token
self.guild_id = guild_id
self.path = path
self.agent_command = agent_command
self.agent_command_description = agent_command_description
self.agent_command_option = agent_command_option
self.register_commands = register_commands
self._commands = tuple(commands or ())
self._command_by_name = {command.name: command for command in self._commands}
self._run_hook = run_hook
self.response_hook = response_hook
self._stream_update_hook = stream_update_hook
self._streaming = streaming
self._isolation_key_factory = isolation_key_factory or _default_isolation_key
self._skip_signature_verification = skip_signature_verification
self._edit_interval = edit_interval
self._max_body_bytes = max_body_bytes
self._api_base_url = api_base_url.rstrip("/")
self._ctx: ChannelContext | None = None
self._http: httpx.AsyncClient | None = None
self._tasks: set[asyncio.Task[None]] = set()
self._validate_configuration()
try:
self._verify_key = VerifyKey(bytes.fromhex(public_key))
except ValueError as exc:
raise ValueError("DiscordChannel public_key must be a valid Ed25519 public key hex string") from exc
def contribute(self, context: ChannelContext) -> ChannelContribution:
"""Register the Discord interaction route and lifecycle hooks."""
self._ctx = context
return ChannelContribution(
routes=[Route("/", self._handle, methods=["POST"])],
commands=self._commands,
on_startup=[self._on_startup],
on_shutdown=[self._on_shutdown],
)
async def _on_startup(self) -> None:
"""Open the Discord REST client and optionally register slash commands."""
self._ensure_http()
if self._skip_signature_verification:
logger.warning(
"DiscordChannel running with skip_signature_verification=True. "
"Use only for local tests; public Discord endpoints must verify signatures."
)
if not self.register_commands:
return
if self.bot_token is None:
logger.warning(
"DiscordChannel register_commands=True but bot_token is not configured; "
"slash commands must be registered outside the host."
)
return
if self.guild_id is None:
logger.warning(
"DiscordChannel registering global slash commands; Discord can take a long time "
"to propagate global command changes. Set guild_id for faster development updates."
)
try:
await self._register_commands()
except (RuntimeError, httpx.HTTPError):
logger.exception("DiscordChannel slash command registration failed; continuing startup")
async def _on_shutdown(self) -> None:
"""Drain in-flight interaction tasks and close the Discord REST client."""
if self._tasks:
await asyncio.gather(*self._tasks, return_exceptions=True)
if self._http is not None:
await self._http.aclose()
self._http = None
async def _handle(self, request: Request) -> Response:
"""Handle one Discord interaction webhook request."""
raw_body = await request.body()
if len(raw_body) > self._max_body_bytes:
return JSONResponse({"error": "request body too large"}, status_code=413)
if not self._skip_signature_verification and not self._verify_signature(request, raw_body):
return JSONResponse({"error": "invalid signature"}, status_code=401)
try:
body = json.loads(raw_body.decode("utf-8"))
except json.JSONDecodeError:
return JSONResponse({"error": "invalid JSON"}, status_code=400)
if not isinstance(body, Mapping):
return JSONResponse({"error": "interaction body must be a JSON object"}, status_code=400)
interaction = cast("DiscordInteraction", body)
interaction_type = interaction.get("type")
if interaction_type == _INTERACTION_PING:
return JSONResponse({"type": _RESPONSE_PONG})
if interaction_type != _INTERACTION_APPLICATION_COMMAND:
return JSONResponse({"error": f"unsupported interaction type: {interaction_type!r}"}, status_code=400)
self._schedule(self._dispatch_application_command(interaction))
return JSONResponse({"type": _RESPONSE_DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE})
async def _dispatch_application_command(self, interaction: DiscordInteraction) -> None:
token = _require_string(interaction.get("token"), "interaction token")
try:
name = _application_command_name(interaction)
if name == self.agent_command:
await self._run_agent_command(interaction, token)
return
command = self._command_by_name.get(name)
if command is None:
await self._edit_original(token, f"Unknown Discord command: {name}")
return
await self._run_channel_command(command, interaction, token)
except Exception:
logger.exception("DiscordChannel interaction handling failed")
await self._try_edit_original(token, "Sorry, something went wrong while handling that Discord command.")
raise
async def _run_agent_command(self, interaction: DiscordInteraction, token: str) -> None:
if self._ctx is None:
raise RuntimeError("DiscordChannel was not contributed to a host.")
prompt = _string_option(interaction, self.agent_command_option)
if prompt is None:
await self._edit_original(token, f"Missing required `{self.agent_command_option}` option.")
return
request = self._build_request(
interaction,
operation="message.create",
input_value=prompt,
stream=self._streaming,
)
if request.stream:
await self._run_streaming(request, token, protocol_request=interaction)
return
result = await self._ctx.run(
request=request,
run_hook=self._run_hook,
protocol_request=interaction,
response_hook=self.response_hook,
channel_name=self.name,
)
await self._edit_original_with_result(token, result)
async def _run_channel_command(
self,
command: ChannelCommand,
interaction: DiscordInteraction,
token: str,
) -> None:
command_input = _string_option(interaction, "input")
request = self._build_request(
interaction,
operation="command.invoke",
input_value=f"/{command.name}" if command_input is None else f"/{command.name} {command_input}",
stream=False,
)
reply = _DiscordInteractionReply(self, token)
await command.handle(ChannelCommandContext(request=request, reply=reply))
if not reply.sent:
await self._edit_original(token, "Done.")
async def _run_streaming(
self, request: ChannelRequest, token: str, *, protocol_request: DiscordInteraction | None = None
) -> None:
if self._ctx is None:
raise RuntimeError("DiscordChannel was not contributed to a host.")
stream: ResponseStream[AgentResponseUpdate, AgentResponse] = await self._ctx.run_stream(
request,
run_hook=self._run_hook,
protocol_request=protocol_request,
stream_update_hook=self._stream_update_hook,
response_hook=self.response_hook,
channel_name=self.name,
)
accumulated: list[str] = []
last_edit = 0.0
async for update in stream:
chunk = _update_text(update)
if not chunk:
continue
accumulated.append(chunk)
now = time.monotonic()
if self._edit_interval <= 0 or now - last_edit >= self._edit_interval:
await self._edit_original(token, _stream_preview_content("".join(accumulated)))
last_edit = now
final_response = await stream.get_final_response()
await self._edit_original_with_result(token, HostedRunResult(final_response))
def _build_request(
self,
interaction: DiscordInteraction,
*,
operation: str,
input_value: Any,
stream: bool,
) -> ChannelRequest:
identity = self._identity_from_interaction(interaction)
command_name = _application_command_name(interaction)
metadata = {
"interaction_id": _string_or_none(interaction.get("id")),
"application_id": self.application_id,
"guild_id": _string_or_none(interaction.get("guild_id")),
"channel_id": _string_or_none(interaction.get("channel_id")),
"user_id": identity.native_id,
"command": command_name,
}
clean_metadata = {key: value for key, value in metadata.items() if value is not None}
return ChannelRequest(
channel=self.name,
operation=operation,
input=input_value,
session=ChannelSession(isolation_key=self._isolation_key_factory(interaction)),
metadata=clean_metadata,
attributes=clean_metadata,
stream=stream,
identity=identity,
)
def _identity_from_interaction(self, interaction: DiscordInteraction) -> ChannelIdentity:
user = _user_from_interaction(interaction)
user_id = _require_string(user.get("id"), "interaction user id")
attributes = {
"username": _string_or_none(user.get("username")),
"global_name": _string_or_none(user.get("global_name")),
"guild_id": _string_or_none(interaction.get("guild_id")),
"channel_id": _string_or_none(interaction.get("channel_id")),
"application_id": self.application_id,
}
return ChannelIdentity(
channel=self.name,
native_id=user_id,
attributes={key: value for key, value in attributes.items() if value is not None},
)
def _verify_signature(self, request: Request, raw_body: bytes) -> bool:
signature = request.headers.get("x-signature-ed25519")
timestamp = request.headers.get("x-signature-timestamp")
if not signature or not timestamp:
return False
try:
self._verify_key.verify(timestamp.encode("utf-8") + raw_body, bytes.fromhex(signature))
except (BadSignatureError, ValueError):
return False
return True
def _schedule(self, coro: Coroutine[Any, Any, None]) -> None:
task = asyncio.create_task(coro)
self._tasks.add(task)
task.add_done_callback(self._on_task_done)
def _on_task_done(self, task: asyncio.Task[None]) -> None:
self._tasks.discard(task)
try:
task.result()
except asyncio.CancelledError:
return
except Exception:
logger.exception("DiscordChannel background task failed")
def _ensure_http(self) -> httpx.AsyncClient:
if self._http is None:
self._http = httpx.AsyncClient(base_url=self._api_base_url, timeout=30.0)
return self._http
async def _register_commands(self) -> None:
http = self._ensure_http()
path = f"/applications/{self.application_id}/commands"
if self.guild_id is not None:
path = f"/applications/{self.application_id}/guilds/{self.guild_id}/commands"
response = await http.put(path, headers=self._bot_headers(), json=self._command_payloads())
_raise_for_discord_error(response, "register slash commands")
async def _edit_original_with_result(self, token: str, payload: HostedRunResult[Any]) -> None:
chunks = _split_content(_payload_text(payload))
await self._edit_original(token, chunks[0])
for chunk in chunks[1:]:
await self._send_followup(token, chunk)
async def _edit_original(self, token: str, content: str) -> None:
http = self._ensure_http()
response = await http.patch(
f"/webhooks/{self.application_id}/{token}/messages/@original",
json={"content": _normalize_content(content)},
)
_raise_for_discord_error(response, "edit interaction response")
async def _try_edit_original(self, token: str, content: str) -> None:
try:
await self._edit_original(token, content)
except (RuntimeError, httpx.HTTPError):
logger.exception("DiscordChannel failed to edit interaction error response")
async def _send_followup(self, token: str, content: str) -> None:
http = self._ensure_http()
response = await http.post(
f"/webhooks/{self.application_id}/{token}",
json={"content": _normalize_content(content)},
)
_raise_for_discord_error(response, "send interaction follow-up")
def _bot_headers(self) -> dict[str, str]:
if self.bot_token is None:
raise RuntimeError("Discord bot token is required for this operation")
return {"Authorization": f"Bot {self.bot_token}"}
def _command_payloads(self) -> list[dict[str, Any]]:
payloads = [
{
"type": _APPLICATION_COMMAND_CHAT_INPUT,
"name": self.agent_command,
"description": self.agent_command_description,
"options": [
{
"type": _OPTION_STRING,
"name": self.agent_command_option,
"description": "Prompt for the agent.",
"required": True,
}
],
}
]
for command in self._commands:
payloads.append({
"type": _APPLICATION_COMMAND_CHAT_INPUT,
"name": command.name,
"description": command.description,
"options": [
{
"type": _OPTION_STRING,
"name": "input",
"description": "Optional command input.",
"required": False,
}
],
})
return payloads
def _validate_configuration(self) -> None:
names = [self.agent_command, *(command.name for command in self._commands)]
for name in names:
if not _COMMAND_NAME_RE.fullmatch(name):
raise ValueError(
"Discord command names must be lowercase ASCII letters, numbers, hyphen, "
f"or underscore, and 1-32 characters long: {name!r}"
)
if not _COMMAND_NAME_RE.fullmatch(self.agent_command_option):
raise ValueError(
"Discord agent_command_option must be lowercase ASCII letters, numbers, hyphen, "
f"or underscore, and 1-32 characters long: {self.agent_command_option!r}"
)
if len(set(names)) != len(names):
raise ValueError("Discord command names must be unique; agent_command cannot collide with commands")
if self._edit_interval < 0:
raise ValueError("edit_interval must be >= 0")
if self._max_body_bytes <= 0:
raise ValueError("max_body_bytes must be > 0")
class _DiscordInteractionReply:
"""Reply helper that edits the deferred response first, then sends follow-ups."""
def __init__(self, channel: DiscordChannel, token: str) -> None:
self._channel = channel
self._token = token
self.sent = False
async def __call__(self, body: str) -> None:
chunks = _split_content(body)
if not self.sent:
await self._channel._edit_original(self._token, chunks[0]) # pyright: ignore[reportPrivateUsage]
self.sent = True
for chunk in chunks[1:]:
await self._channel._send_followup(self._token, chunk) # pyright: ignore[reportPrivateUsage]
return
for chunk in chunks:
await self._channel._send_followup(self._token, chunk) # pyright: ignore[reportPrivateUsage]
def _user_from_interaction(interaction: DiscordInteraction) -> Mapping[str, Any]:
member = interaction.get("member")
if isinstance(member, Mapping):
member_user = member.get("user")
if isinstance(member_user, Mapping):
return member_user
user = interaction.get("user")
if isinstance(user, Mapping):
return user
raise ValueError("Discord interaction is missing user information")
def _application_command_name(interaction: DiscordInteraction) -> str:
data = interaction.get("data")
if not isinstance(data, Mapping):
raise ValueError("Discord application command interaction is missing data")
return _require_string(data.get("name"), "application command name")
def _string_option(interaction: DiscordInteraction, name: str) -> str | None:
data = interaction.get("data")
if not isinstance(data, Mapping):
return None
options = data.get("options")
if not isinstance(options, Sequence) or isinstance(options, (str, bytes)):
return None
for option in options:
if not isinstance(option, Mapping):
continue
if option.get("name") != name:
continue
value = option.get("value")
if value is None:
return None
return str(value)
return None
def _payload_text(payload: HostedRunResult[Any]) -> str:
text = getattr(payload.result, "text", None)
if isinstance(text, str) and text:
return text
messages = getattr(payload.result, "messages", None)
if isinstance(messages, Sequence):
for message in reversed(messages):
message_text = getattr(message, "text", None)
if isinstance(message_text, str) and message_text:
return message_text
return "(no response)"
def _update_text(update: AgentResponseUpdate) -> str:
parts: list[str] = []
for content in update.contents:
text = getattr(content, "text", None)
if isinstance(text, str) and text:
parts.append(text)
return "".join(parts)
def _split_content(content: str) -> list[str]:
normalized = _normalize_content(content)
return [normalized[i : i + _DISCORD_MAX_CONTENT_LEN] for i in range(0, len(normalized), _DISCORD_MAX_CONTENT_LEN)]
def _stream_preview_content(content: str) -> str:
return _split_content(content)[0]
def _normalize_content(content: str) -> str:
return content if content else "(no response)"
def _string_or_none(value: Any) -> str | None:
return value if isinstance(value, str) and value else None
def _require_string(value: Any, field_name: str) -> str:
if isinstance(value, str) and value:
return value
raise ValueError(f"Discord {field_name} must be a non-empty string")
def _raise_for_discord_error(response: httpx.Response, action: str) -> None:
try:
response.raise_for_status()
except httpx.HTTPStatusError as exc:
body = response.text[:500]
raise RuntimeError(f"Discord {action} failed with HTTP {response.status_code}: {body}") from exc
@@ -0,0 +1,107 @@
[project]
name = "agent-framework-hosting-discord"
description = "Discord channel for agent-framework-hosting."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0a260526"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
urls.issues = "https://github.com/microsoft/agent-framework/issues"
classifiers = [
"License :: OSI Approved :: MIT License",
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.2.0,<2",
"agent-framework-hosting>=1.0.0a260424,<2",
"httpx>=0.27,<1",
"PyNaCl>=1.2.0,<2",
]
[tool.uv]
prerelease = "if-necessary-or-explicit"
environments = [
"sys_platform == 'darwin'",
"sys_platform == 'linux'",
"sys_platform == 'win32'"
]
[tool.uv-dynamic-versioning]
fallback-version = "0.0.0"
[tool.pytest.ini_options]
testpaths = 'tests'
addopts = "-ra -q -r fEX"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = []
timeout = 120
markers = [
"integration: marks tests as integration tests that require external services",
]
[tool.ruff]
extend = "../../pyproject.toml"
[tool.coverage.run]
omit = [
"**/__init__.py"
]
[tool.pyright]
extends = "../../pyproject.toml"
include = ["agent_framework_hosting_discord"]
exclude = ['tests']
# Discord interactions arrive as loosely-typed JSON maps. Runtime guards narrow
# payloads where needed; strict Unknown reporting on every `.get()` is noisy.
reportUnknownArgumentType = "none"
reportUnknownMemberType = "none"
reportUnknownVariableType = "none"
reportUnknownLambdaType = "none"
reportOptionalMemberAccess = "none"
[tool.mypy]
plugins = ['pydantic.mypy']
strict = true
python_version = "3.10"
ignore_missing_imports = true
disallow_untyped_defs = true
no_implicit_optional = true
check_untyped_defs = true
warn_return_any = true
show_error_codes = true
warn_unused_ignores = false
disallow_incomplete_defs = true
disallow_untyped_decorators = true
[tool.bandit]
targets = ["agent_framework_hosting_discord"]
exclude_dirs = ["tests"]
[tool.poe]
executor.type = "uv"
include = "../../shared_tasks.toml"
[tool.poe.tasks.mypy]
help = "Run MyPy for this package."
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_hosting_discord"
[tool.poe.tasks.test]
help = "Run the default unit test suite for this package."
cmd = 'pytest -m "not integration" --cov=agent_framework_hosting_discord --cov-report=term-missing:skip-covered tests'
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
build-backend = "flit_core.buildapi"
@@ -0,0 +1,643 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import json
from collections.abc import AsyncIterator, Awaitable
from typing import Any
import httpx
import pytest
from agent_framework import AgentResponse, AgentResponseUpdate, Content, Message
from agent_framework_hosting import (
ChannelCommand,
ChannelCommandContext,
ChannelRequest,
HostedRunResult,
)
from nacl.signing import SigningKey
from starlette.applications import Starlette
from starlette.testclient import TestClient
from agent_framework_hosting_discord import DiscordChannel, discord_isolation_key
def _run_result(text: str) -> HostedRunResult[AgentResponse]:
return HostedRunResult(AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text(text=text)])]))
def _interaction(command: str = "ask", *, prompt: str = "hello", token: str = "token") -> dict[str, Any]:
return {
"id": "interaction-1",
"type": 2,
"application_id": "app-1",
"token": token,
"guild_id": "guild-1",
"channel_id": "channel-1",
"member": {
"user": {
"id": "user-1",
"username": "ada",
"global_name": "Ada",
}
},
"data": {
"name": command,
"options": [{"name": "prompt", "type": 3, "value": prompt}],
},
}
def _headers(signing_key: SigningKey, body: bytes) -> dict[str, str]:
timestamp = "1234567890"
signature = signing_key.sign(timestamp.encode("utf-8") + body).signature.hex()
return {
"x-signature-ed25519": signature,
"x-signature-timestamp": timestamp,
"content-type": "application/json",
}
class _FakeContext:
def __init__(self, *, text: str = "agent reply") -> None:
self.target = object()
self.text = text
self.requests: list[ChannelRequest] = []
self.fake_stream: _FakeStream | None = None
async def run(
self,
request: ChannelRequest,
*,
run_hook: Any | None = None,
protocol_request: Any | None = None,
response_hook: Any | None = None,
channel_name: str | None = None,
) -> HostedRunResult[AgentResponse]:
if run_hook is not None:
maybe_request = run_hook(request, target=self.target, protocol_request=protocol_request)
if isinstance(maybe_request, Awaitable):
request = await maybe_request
else:
request = maybe_request
self.requests.append(request)
result = _run_result(self.text)
if response_hook is not None:
maybe_result = response_hook(result, request=request, channel_name=channel_name or request.channel)
if isinstance(maybe_result, Awaitable):
return await maybe_result
return maybe_result
return result
async def run_stream(
self,
request: ChannelRequest,
*,
run_hook: Any | None = None,
protocol_request: Any | None = None,
stream_update_hook: Any | None = None,
response_hook: Any | None = None,
channel_name: str | None = None,
) -> _FakeStream:
if run_hook is not None:
maybe_request = run_hook(request, target=self.target, protocol_request=protocol_request)
if isinstance(maybe_request, Awaitable):
request = await maybe_request
else:
request = maybe_request
self.requests.append(request)
if self.fake_stream is None:
self.fake_stream = _FakeStream(["a", "b"])
if stream_update_hook is not None:
self.fake_stream.transform = stream_update_hook
if response_hook is not None:
self.fake_stream.response_hook = response_hook
self.fake_stream.request = request
self.fake_stream.channel_name = channel_name or request.channel
return self.fake_stream
class _FakeStream:
def __init__(self, chunks: list[str]) -> None:
self._chunks = chunks
self.transform: Any | None = None
self.response_hook: Any | None = None
self.request: ChannelRequest | None = None
self.channel_name: str | None = None
def __aiter__(self) -> AsyncIterator[AgentResponseUpdate]:
return self._iter()
async def _iter(self) -> AsyncIterator[AgentResponseUpdate]:
for chunk in self._chunks:
update = AgentResponseUpdate(contents=[Content.from_text(text=chunk)], role="assistant")
if self.transform is not None:
transformed = self.transform(update)
if isinstance(transformed, Awaitable):
transformed = await transformed
if transformed is None:
continue
update = transformed
yield update
async def get_final_response(self) -> AgentResponse:
result = _run_result("".join(self._chunks))
if self.response_hook is None:
return result.result
shaped = self.response_hook(result, request=self.request, channel_name=self.channel_name)
if isinstance(shaped, Awaitable):
shaped = await shaped
return shaped.result
class _DiscordRecorder:
def __init__(self) -> None:
self.requests: list[httpx.Request] = []
self.json_payloads: list[Any] = []
def transport(self) -> httpx.MockTransport:
def handler(request: httpx.Request) -> httpx.Response:
self.requests.append(request)
if request.content:
self.json_payloads.append(json.loads(request.content.decode("utf-8")))
return httpx.Response(200, json={"ok": True})
return httpx.MockTransport(handler)
def test_discord_isolation_key_scopes_to_guild_channel_user() -> None:
assert discord_isolation_key("guild", "channel", "user") == "discord:guild:channel:user"
assert discord_isolation_key(None, "dm-channel", "user") == "discord:dm:dm-channel:user"
def test_ping_requires_valid_signature_and_returns_pong() -> None:
signing_key = SigningKey.generate()
channel = DiscordChannel(
application_id="app-1",
public_key=signing_key.verify_key.encode().hex(),
register_commands=False,
)
app = Starlette(routes=list(channel.contribute(_FakeContext()).routes)) # type: ignore[arg-type]
body = json.dumps({"type": 1}).encode("utf-8")
with TestClient(app) as client:
ok = client.post("/", content=body, headers=_headers(signing_key, body))
bad = client.post(
"/",
content=body,
headers={
**_headers(signing_key, body),
"x-signature-ed25519": "00" * 64,
},
)
assert ok.status_code == 200
assert ok.json() == {"type": 1}
assert bad.status_code == 401
def test_request_validation_errors() -> None:
channel = DiscordChannel(
application_id="app-1",
public_key=SigningKey.generate().verify_key.encode().hex(),
register_commands=False,
skip_signature_verification=True,
max_body_bytes=2,
)
app = Starlette(routes=list(channel.contribute(_FakeContext()).routes)) # type: ignore[arg-type]
unsupported_channel = DiscordChannel(
application_id="app-1",
public_key=SigningKey.generate().verify_key.encode().hex(),
register_commands=False,
skip_signature_verification=True,
)
unsupported_app = Starlette(routes=list(unsupported_channel.contribute(_FakeContext()).routes)) # type: ignore[arg-type]
with TestClient(app) as client:
too_large = client.post("/", content=b"{}x")
invalid_json = client.post("/", content=b"{")
with TestClient(unsupported_app) as client:
non_object = client.post("/", json=[])
unsupported = client.post("/", json={"type": 99})
assert too_large.status_code == 413
assert invalid_json.status_code == 400
assert non_object.status_code == 400
assert unsupported.status_code == 400
def test_constructor_validates_discord_configuration() -> None:
public_key = SigningKey.generate().verify_key.encode().hex()
with pytest.raises(ValueError, match="public_key"):
DiscordChannel(application_id="app-1", public_key="not-hex")
with pytest.raises(ValueError, match="command names"):
DiscordChannel(application_id="app-1", public_key=public_key, agent_command="Ask")
with pytest.raises(ValueError, match="unique"):
DiscordChannel(
application_id="app-1",
public_key=public_key,
commands=[ChannelCommand(name="ask", description="Ask again", handle=lambda _ctx: _noop())],
)
with pytest.raises(ValueError, match="edit_interval"):
DiscordChannel(application_id="app-1", public_key=public_key, edit_interval=-1)
with pytest.raises(ValueError, match="max_body_bytes"):
DiscordChannel(application_id="app-1", public_key=public_key, max_body_bytes=0)
async def test_agent_command_runs_host_and_edits_original_response() -> None:
recorder = _DiscordRecorder()
context = _FakeContext(text="agent says hi")
channel = DiscordChannel(
application_id="app-1",
public_key=SigningKey.generate().verify_key.encode().hex(),
register_commands=False,
skip_signature_verification=True,
api_base_url="https://discord.test",
)
channel.contribute(context) # type: ignore[arg-type]
channel._http = httpx.AsyncClient(base_url="https://discord.test", transport=recorder.transport())
await channel._run_agent_command(_interaction(prompt="what now?"), "token")
assert context.requests[0].operation == "message.create"
assert context.requests[0].input == "what now?"
assert context.requests[0].session is not None
assert context.requests[0].session.isolation_key == "discord:guild-1:channel-1:user-1"
assert context.requests[0].identity is not None
assert context.requests[0].identity.native_id == "user-1"
assert context.requests[0].identity.attributes["channel_id"] == "channel-1"
assert recorder.requests[0].method == "PATCH"
assert recorder.requests[0].url.path == "/webhooks/app-1/token/messages/@original"
assert recorder.json_payloads[0] == {"content": "agent says hi"}
async def test_run_hook_can_rewrite_agent_request() -> None:
recorder = _DiscordRecorder()
context = _FakeContext(text="agent says hi")
async def hook(request: ChannelRequest, **_: Any) -> ChannelRequest:
return ChannelRequest(
channel=request.channel,
operation=request.operation,
input="rewritten",
session=request.session,
metadata=request.metadata,
attributes=request.attributes,
stream=request.stream,
identity=request.identity,
)
channel = DiscordChannel(
application_id="app-1",
public_key=SigningKey.generate().verify_key.encode().hex(),
register_commands=False,
run_hook=hook,
api_base_url="https://discord.test",
)
channel.contribute(context) # type: ignore[arg-type]
channel._http = httpx.AsyncClient(base_url="https://discord.test", transport=recorder.transport())
await channel._run_agent_command(_interaction(prompt="original"), "token")
assert context.requests[0].input == "rewritten"
async def test_response_hook_rewrites_originating_reply() -> None:
recorder = _DiscordRecorder()
context = _FakeContext(text="original")
async def hook(result: HostedRunResult[Any], **kwargs: Any) -> HostedRunResult[Any]:
assert result.result.text == "original"
assert kwargs["channel_name"] == "discord"
return _run_result("rewritten")
channel = DiscordChannel(
application_id="app-1",
public_key=SigningKey.generate().verify_key.encode().hex(),
register_commands=False,
response_hook=hook,
api_base_url="https://discord.test",
)
channel.contribute(context) # type: ignore[arg-type]
channel._http = httpx.AsyncClient(base_url="https://discord.test", transport=recorder.transport())
await channel._run_agent_command(_interaction(), "token")
assert recorder.json_payloads[-1] == {"content": "rewritten"}
async def test_missing_prompt_edits_original_without_calling_host() -> None:
recorder = _DiscordRecorder()
context = _FakeContext(text="should not run")
channel = DiscordChannel(
application_id="app-1",
public_key=SigningKey.generate().verify_key.encode().hex(),
register_commands=False,
api_base_url="https://discord.test",
)
channel.contribute(context) # type: ignore[arg-type]
channel._http = httpx.AsyncClient(base_url="https://discord.test", transport=recorder.transport())
interaction = _interaction()
interaction["data"]["options"] = []
await channel._run_agent_command(interaction, "token")
assert context.requests == []
assert recorder.json_payloads[-1] == {"content": "Missing required `prompt` option."}
async def test_dispatch_application_command_routes_agent_command() -> None:
recorder = _DiscordRecorder()
context = _FakeContext(text="dispatched")
channel = DiscordChannel(
application_id="app-1",
public_key=SigningKey.generate().verify_key.encode().hex(),
register_commands=False,
api_base_url="https://discord.test",
)
channel.contribute(context) # type: ignore[arg-type]
channel._http = httpx.AsyncClient(base_url="https://discord.test", transport=recorder.transport())
await channel._dispatch_application_command(_interaction(command="ask"))
assert context.requests[0].operation == "message.create"
assert recorder.json_payloads[-1] == {"content": "dispatched"}
async def test_channel_command_handler_receives_context_and_replies() -> None:
recorder = _DiscordRecorder()
captured: list[ChannelCommandContext] = []
async def handler(ctx: ChannelCommandContext) -> None:
captured.append(ctx)
await ctx.reply("reset done")
command = ChannelCommand(name="reset", description="Reset", handle=handler)
context = _FakeContext()
channel = DiscordChannel(
application_id="app-1",
public_key=SigningKey.generate().verify_key.encode().hex(),
register_commands=False,
commands=[command],
api_base_url="https://discord.test",
)
channel.contribute(context) # type: ignore[arg-type]
channel._http = httpx.AsyncClient(base_url="https://discord.test", transport=recorder.transport())
interaction = _interaction(command="reset")
interaction["data"]["options"] = [{"name": "input", "type": 3, "value": "please"}]
await channel._run_channel_command(command, interaction, "token")
assert captured
assert captured[0].request.operation == "command.invoke"
assert captured[0].request.input == "/reset please"
assert recorder.json_payloads == [{"content": "reset done"}]
async def test_channel_command_reply_sends_followups_after_first_edit() -> None:
recorder = _DiscordRecorder()
async def handler(ctx: ChannelCommandContext) -> None:
await ctx.reply("first")
await ctx.reply("second")
command = ChannelCommand(name="reset", description="Reset", handle=handler)
context = _FakeContext()
channel = DiscordChannel(
application_id="app-1",
public_key=SigningKey.generate().verify_key.encode().hex(),
register_commands=False,
commands=[command],
api_base_url="https://discord.test",
)
channel.contribute(context) # type: ignore[arg-type]
channel._http = httpx.AsyncClient(base_url="https://discord.test", transport=recorder.transport())
await channel._run_channel_command(command, _interaction(command="reset"), "token")
assert [request.method for request in recorder.requests] == ["PATCH", "POST"]
assert recorder.json_payloads == [{"content": "first"}, {"content": "second"}]
async def test_channel_command_reply_chunks_long_content() -> None:
recorder = _DiscordRecorder()
async def handler(ctx: ChannelCommandContext) -> None:
await ctx.reply("a" * 2001)
command = ChannelCommand(name="reset", description="Reset", handle=handler)
context = _FakeContext()
channel = DiscordChannel(
application_id="app-1",
public_key=SigningKey.generate().verify_key.encode().hex(),
register_commands=False,
commands=[command],
api_base_url="https://discord.test",
)
channel.contribute(context) # type: ignore[arg-type]
channel._http = httpx.AsyncClient(base_url="https://discord.test", transport=recorder.transport())
await channel._run_channel_command(command, _interaction(command="reset"), "token")
assert [request.method for request in recorder.requests] == ["PATCH", "POST"]
assert [len(payload["content"]) for payload in recorder.json_payloads] == [2000, 1]
async def test_channel_command_edits_done_when_handler_does_not_reply() -> None:
recorder = _DiscordRecorder()
async def handler(_ctx: ChannelCommandContext) -> None:
return None
command = ChannelCommand(name="reset", description="Reset", handle=handler)
context = _FakeContext()
channel = DiscordChannel(
application_id="app-1",
public_key=SigningKey.generate().verify_key.encode().hex(),
register_commands=False,
commands=[command],
api_base_url="https://discord.test",
)
channel.contribute(context) # type: ignore[arg-type]
channel._http = httpx.AsyncClient(base_url="https://discord.test", transport=recorder.transport())
await channel._run_channel_command(command, _interaction(command="reset"), "token")
assert recorder.json_payloads == [{"content": "Done."}]
async def test_unknown_command_edits_error_response() -> None:
recorder = _DiscordRecorder()
channel = DiscordChannel(
application_id="app-1",
public_key=SigningKey.generate().verify_key.encode().hex(),
register_commands=False,
api_base_url="https://discord.test",
)
channel._http = httpx.AsyncClient(base_url="https://discord.test", transport=recorder.transport())
await channel._dispatch_application_command(_interaction(command="missing"))
assert recorder.json_payloads == [{"content": "Unknown Discord command: missing"}]
async def test_startup_bulk_registers_guild_commands() -> None:
recorder = _DiscordRecorder()
command = ChannelCommand(name="reset", description="Reset", handle=lambda _ctx: _noop())
channel = DiscordChannel(
application_id="app-1",
public_key=SigningKey.generate().verify_key.encode().hex(),
bot_token="bot-token",
guild_id="guild-1",
commands=[command],
api_base_url="https://discord.test",
)
channel._http = httpx.AsyncClient(base_url="https://discord.test", transport=recorder.transport())
await channel._on_startup()
assert recorder.requests[0].method == "PUT"
assert recorder.requests[0].url.path == "/applications/app-1/guilds/guild-1/commands"
assert recorder.requests[0].headers["authorization"] == "Bot bot-token"
assert [payload["name"] for payload in recorder.json_payloads[0]] == ["ask", "reset"]
async def test_global_startup_registration_warns_about_propagation(caplog: pytest.LogCaptureFixture) -> None:
recorder = _DiscordRecorder()
channel = DiscordChannel(
application_id="app-1",
public_key=SigningKey.generate().verify_key.encode().hex(),
bot_token="bot-token",
api_base_url="https://discord.test",
)
channel._http = httpx.AsyncClient(base_url="https://discord.test", transport=recorder.transport())
await channel._on_startup()
assert recorder.requests[0].url.path == "/applications/app-1/commands"
assert "global slash commands" in caplog.text
async def test_startup_warns_when_registration_has_no_bot_token(caplog: pytest.LogCaptureFixture) -> None:
channel = DiscordChannel(
application_id="app-1",
public_key=SigningKey.generate().verify_key.encode().hex(),
)
await channel._on_startup()
await channel._on_shutdown()
assert "slash commands must be registered outside the host" in caplog.text
async def test_originating_reply_sends_followup_chunks() -> None:
recorder = _DiscordRecorder()
context = _FakeContext(text="a" * 2001)
channel = DiscordChannel(
application_id="app-1",
public_key=SigningKey.generate().verify_key.encode().hex(),
register_commands=False,
api_base_url="https://discord.test",
)
channel.contribute(context) # type: ignore[arg-type]
channel._http = httpx.AsyncClient(base_url="https://discord.test", transport=recorder.transport())
await channel._run_agent_command(_interaction(), "token")
assert [request.method for request in recorder.requests] == ["PATCH", "POST"]
assert [len(payload["content"]) for payload in recorder.json_payloads] == [2000, 1]
async def test_streaming_edits_original_and_delivers_final_response() -> None:
recorder = _DiscordRecorder()
context = _FakeContext()
channel = DiscordChannel(
application_id="app-1",
public_key=SigningKey.generate().verify_key.encode().hex(),
register_commands=False,
streaming=True,
edit_interval=0,
api_base_url="https://discord.test",
)
channel.contribute(context) # type: ignore[arg-type]
channel._http = httpx.AsyncClient(base_url="https://discord.test", transport=recorder.transport())
await channel._run_agent_command(_interaction(), "token")
assert [payload["content"] for payload in recorder.json_payloads] == ["a", "ab", "ab"]
async def test_streaming_preview_is_limited_and_final_reply_is_chunked() -> None:
recorder = _DiscordRecorder()
context = _FakeContext()
context.fake_stream = _FakeStream(["a" * 2001])
channel = DiscordChannel(
application_id="app-1",
public_key=SigningKey.generate().verify_key.encode().hex(),
register_commands=False,
streaming=True,
edit_interval=0,
api_base_url="https://discord.test",
)
channel.contribute(context) # type: ignore[arg-type]
channel._http = httpx.AsyncClient(base_url="https://discord.test", transport=recorder.transport())
await channel._run_agent_command(_interaction(), "token")
assert [request.method for request in recorder.requests] == ["PATCH", "PATCH", "POST"]
assert [len(payload["content"]) for payload in recorder.json_payloads] == [2000, 2000, 1]
async def test_stream_update_hook_can_drop_updates() -> None:
recorder = _DiscordRecorder()
context = _FakeContext()
async def hook(update: AgentResponseUpdate) -> AgentResponseUpdate | None:
if update.text == "a":
return None
return update
channel = DiscordChannel(
application_id="app-1",
public_key=SigningKey.generate().verify_key.encode().hex(),
register_commands=False,
streaming=True,
stream_update_hook=hook,
edit_interval=0,
api_base_url="https://discord.test",
)
channel.contribute(context) # type: ignore[arg-type]
channel._http = httpx.AsyncClient(base_url="https://discord.test", transport=recorder.transport())
await channel._run_agent_command(_interaction(), "token")
assert [payload["content"] for payload in recorder.json_payloads] == ["b", "ab"]
async def test_stream_update_hook_can_synchronously_rewrite_updates() -> None:
recorder = _DiscordRecorder()
context = _FakeContext()
def hook(_update: AgentResponseUpdate) -> AgentResponseUpdate:
return AgentResponseUpdate(contents=[Content.from_text(text="x")], role="assistant")
channel = DiscordChannel(
application_id="app-1",
public_key=SigningKey.generate().verify_key.encode().hex(),
register_commands=False,
streaming=True,
stream_update_hook=hook,
edit_interval=0,
api_base_url="https://discord.test",
)
channel.contribute(context) # type: ignore[arg-type]
channel._http = httpx.AsyncClient(base_url="https://discord.test", transport=recorder.transport())
await channel._run_agent_command(_interaction(), "token")
assert [payload["content"] for payload in recorder.json_payloads] == ["x", "xx", "ab"]
async def _noop() -> None:
return None
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
@@ -0,0 +1,30 @@
# agent-framework-hosting-invocations
Minimal `POST /invocations` channel for [agent-framework-hosting](../hosting). Useful
for smoke-testing, durable-task drivers, and bespoke clients that don't speak
the OpenAI Responses protocol.
## Wire shape
```
POST /invocations
{
"message": "hello",
"session_id": "user-42",
"stream": false
}
```
Non-streaming response: `{"response": "...", "session_id": "..."}`.
Streaming response: `text/event-stream` of `data:` lines, terminated by
`data: [DONE]`.
## Usage
```python
from agent_framework_hosting import AgentFrameworkHost
from agent_framework_hosting_invocations import InvocationsChannel
host = AgentFrameworkHost(target=my_agent, channels=[InvocationsChannel()])
host.serve()
```
@@ -0,0 +1,7 @@
# Copyright (c) Microsoft. All rights reserved.
"""Minimal ``POST /invocations`` channel for :mod:`agent_framework_hosting`."""
from ._channel import InvocationsChannel
__all__ = ["InvocationsChannel"]
@@ -0,0 +1,193 @@
# Copyright (c) Microsoft. All rights reserved.
"""Minimal ``POST /invocations`` channel.
Inspired by ``agent-framework-foundry-hosting``'s ``InvocationsHostServer``.
A framework-agnostic surface for callers that just want to send a message and
get an answer back — no OpenAI-style envelope, no Responses item lattice.
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from typing import Any, cast
from agent_framework_hosting import (
ChannelContext,
ChannelContribution,
ChannelRequest,
ChannelResponseHook,
ChannelRunHook,
ChannelSession,
ChannelStreamUpdateHook,
logger,
)
from starlette.requests import Request
from starlette.responses import JSONResponse, Response, StreamingResponse
from starlette.routing import Route
class InvocationsChannel:
"""Minimal ``POST /invocations`` surface.
A run hook can rewrite the channel request (e.g. inject a session, add
options) before the host invokes the agent. A stream-transform hook can
rewrite or drop ``AgentResponseUpdate`` chunks before they hit the wire.
"""
name = "invocations"
def __init__(
self,
*,
path: str = "/invocations",
run_hook: ChannelRunHook | None = None,
response_hook: ChannelResponseHook | None = None,
stream_update_hook: ChannelStreamUpdateHook | None = None,
) -> None:
"""Configure the invocations endpoint.
``path`` is the endpoint path the host uses when registering this
channel. Use ``""`` to expose the handler at the app root.
``run_hook`` may rewrite the :class:`ChannelRequest` before the host
invokes the target — typically to attach session metadata or
translate the wire payload into ``Message`` instances.
``response_hook`` may rewrite the :class:`HostedRunResult` before
the channel serializes it to JSON for the originating caller.
``stream_update_hook`` lets callers map or drop individual
``AgentResponseUpdate`` chunks while streaming.
"""
self.path = path
self._hook = run_hook
self.response_hook = response_hook
self._stream_update_hook = stream_update_hook
self._ctx: ChannelContext | None = None
def contribute(self, context: ChannelContext) -> ChannelContribution:
"""Capture the host-supplied context and register the endpoint route."""
self._ctx = context
return ChannelContribution(routes=[Route("/", self._handle, methods=["POST"])])
async def _handle(self, request: Request) -> Response:
"""Handle a single Invocations call.
Validates the JSON body shape, builds a :class:`ChannelRequest`
(optionally with a ``ChannelSession`` keyed by ``session_id``),
runs the configured ``run_hook``, and either streams SSE chunks
when ``stream`` is true or returns a single JSON ``{response,
session_id}`` envelope.
"""
if self._ctx is None: # pragma: no cover - guarded by Channel lifecycle
return JSONResponse({"error": "channel not initialized"}, status_code=500)
try:
body: Any = await request.json()
except Exception:
return JSONResponse({"error": "invalid json"}, status_code=400)
if not isinstance(body, dict):
return JSONResponse({"error": "request body must be an object"}, status_code=422)
body_map: dict[str, Any] = cast("dict[str, Any]", body)
message = body_map.get("message")
if not isinstance(message, str) or not message:
return JSONResponse({"error": "missing or empty 'message'"}, status_code=422)
session_id = body_map.get("session_id")
if session_id is not None and not isinstance(session_id, str):
return JSONResponse({"error": "'session_id' must be a string"}, status_code=422)
session = ChannelSession(isolation_key=f"invocations:{session_id}") if session_id else None
attributes: dict[str, Any] = {}
if session_id:
attributes["session_id"] = session_id
channel_request = ChannelRequest(
channel=self.name,
operation="invoke",
input=message,
session=session,
stream=bool(body_map.get("stream")),
attributes=attributes,
)
if channel_request.stream:
return StreamingResponse(
self._stream(channel_request, body_map),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
result = await self._ctx.run(
channel_request,
run_hook=self._hook,
protocol_request=body_map,
response_hook=self.response_hook,
channel_name=self.name,
)
return JSONResponse({"response": result.result.text, "session_id": session_id})
async def _stream(self, request: ChannelRequest, protocol_request: dict[str, Any]) -> AsyncIterator[str]:
r"""Yield bare ``data:`` SSE lines for each text chunk + a final ``[DONE]``.
SSE protocol notes:
* The HTTP status is committed when ASGI sends headers, before the
generator runs. Emitting a stream-opening 200 + ``text/event-stream``
and signalling errors via ``event: error`` SSE frames is the
conventional contract — ``EventSource`` and OpenAI-style SSE
consumers treat ``event: error`` as a terminal error condition.
Hard run-acquisition failures (e.g. target rejected) therefore
surface as the first frame, not as an HTTP error code.
* The SSE spec treats ``\r``, ``\n``, and ``\r\n`` as line
terminators. Per-chunk text is split on all three so embedded
carriage returns don't corrupt ``data:`` framing on the wire.
"""
if self._ctx is None: # pragma: no cover - guarded by Channel lifecycle
yield "event: error\ndata: channel not initialized\n\n"
return
try:
stream = await self._ctx.run_stream(
request,
run_hook=self._hook,
protocol_request=protocol_request,
stream_update_hook=self._stream_update_hook,
)
async for update in stream:
chunk = getattr(update, "text", None)
if chunk:
# Each text chunk is its own SSE event so curl-friendly
# consumers can read it directly. Newlines inside the
# chunk are escaped per SSE spec by emitting one
# ``data:`` line per source line. ``splitlines()`` is
# used over ``split('\n')`` so embedded ``\r`` /
# ``\r\n`` don't bleed into the framing.
for line in str(chunk).splitlines() or [""]:
yield f"data: {line}\n"
yield "\n"
try:
# Finalize so context-provider / history hooks on the agent
# still run even though we are emitting our own SSE.
# If finalization fails, the agent's persistence side
# effects (history-provider write, context-provider hooks)
# are unreliable — surface that to the client as an
# ``event: error`` frame so it isn't a silent drop.
await stream.get_final_response()
except Exception as finalize_exc:
logger.exception("Invocations stream finalize failed")
yield "event: error\n"
for line in f"finalize failed: {finalize_exc!s}".splitlines() or [""]:
yield f"data: {line}\n"
yield "\n"
return
except Exception as exc:
logger.exception("Invocations stream consumption failed")
yield "event: error\n"
for line in str(exc).splitlines() or [""]:
yield f"data: {line}\n"
yield "\n"
return
yield "data: [DONE]\n\n"
__all__ = ["InvocationsChannel"]
@@ -0,0 +1,97 @@
[project]
name = "agent-framework-hosting-invocations"
description = "Minimal POST /invocations channel for agent-framework-hosting."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0a260424"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
urls.issues = "https://github.com/microsoft/agent-framework/issues"
classifiers = [
"License :: OSI Approved :: MIT License",
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.2.0,<2",
"agent-framework-hosting==1.0.0a260424",
]
[tool.uv]
prerelease = "if-necessary-or-explicit"
environments = [
"sys_platform == 'darwin'",
"sys_platform == 'linux'",
"sys_platform == 'win32'"
]
[tool.uv-dynamic-versioning]
fallback-version = "0.0.0"
[tool.pytest.ini_options]
testpaths = 'tests'
addopts = "-ra -q -r fEX"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = []
timeout = 120
markers = [
"integration: marks tests as integration tests that require external services",
]
[tool.ruff]
extend = "../../pyproject.toml"
[tool.coverage.run]
omit = [
"**/__init__.py"
]
[tool.pyright]
extends = "../../pyproject.toml"
include = ["agent_framework_hosting_invocations"]
exclude = ['tests']
[tool.mypy]
plugins = ['pydantic.mypy']
strict = true
python_version = "3.10"
ignore_missing_imports = true
disallow_untyped_defs = true
no_implicit_optional = true
check_untyped_defs = true
warn_return_any = true
show_error_codes = true
warn_unused_ignores = false
disallow_incomplete_defs = true
disallow_untyped_decorators = true
[tool.bandit]
targets = ["agent_framework_hosting_invocations"]
exclude_dirs = ["tests"]
[tool.poe]
executor.type = "uv"
include = "../../shared_tasks.toml"
[tool.poe.tasks.mypy]
help = "Run MyPy for this package."
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_hosting_invocations"
[tool.poe.tasks.test]
help = "Run the default unit test suite for this package."
cmd = 'pytest -m "not integration" --cov=agent_framework_hosting_invocations --cov-report=term-missing:skip-covered tests'
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
build-backend = "flit_core.buildapi"
@@ -0,0 +1,259 @@
# Copyright (c) Microsoft. All rights reserved.
"""End-to-end tests for :class:`InvocationsChannel`."""
from __future__ import annotations
from collections.abc import AsyncIterator
from dataclasses import dataclass, replace
from typing import Any
from agent_framework_hosting import AgentFrameworkHost, ChannelRequest, HostedRunResult
from starlette.testclient import TestClient
from agent_framework_hosting_invocations import InvocationsChannel
@dataclass
class _FakeAgentResponse:
text: str
@dataclass
class _FakeUpdate:
text: str
class _FakeStream:
def __init__(self, chunks: list[str]) -> None:
self._chunks = chunks
self._final = _FakeAgentResponse(text="".join(chunks))
def __aiter__(self) -> AsyncIterator[_FakeUpdate]:
async def _gen() -> AsyncIterator[_FakeUpdate]:
for c in self._chunks:
yield _FakeUpdate(c)
return _gen()
async def get_final_response(self) -> _FakeAgentResponse:
return self._final
class _FakeAgent:
def __init__(self, reply: str = "hi", chunks: list[str] | None = None) -> None:
self._reply = reply
self._chunks = chunks or [reply]
self.calls: list[dict[str, Any]] = []
def create_session(self, *, session_id: str | None = None) -> Any:
return {"session_id": session_id}
def run(self, messages: Any = None, *, stream: bool = False, **kwargs: Any) -> Any:
self.calls.append({"messages": messages, "stream": stream, "kwargs": kwargs})
if stream:
return _FakeStream(self._chunks)
async def _coro() -> _FakeAgentResponse:
return _FakeAgentResponse(text=self._reply)
return _coro()
def _make_client(agent: _FakeAgent | None = None, *, path: str = "/invocations") -> tuple[TestClient, _FakeAgent]:
agent = agent or _FakeAgent()
host = AgentFrameworkHost(target=agent, channels=[InvocationsChannel(path=path)])
return TestClient(host.app), agent
class TestInvocations:
def test_post_invoke_returns_response(self) -> None:
client, _agent = _make_client(_FakeAgent(reply="pong"))
with client:
r = client.post("/invocations", json={"message": "ping"})
assert r.status_code == 200
assert r.json() == {"response": "pong", "session_id": None}
def test_empty_path_mounts_at_app_root(self) -> None:
client, _agent = _make_client(_FakeAgent(reply="pong"), path="")
with client:
r = client.post("/", json={"message": "ping"})
assert r.status_code == 200
assert r.json() == {"response": "pong", "session_id": None}
def test_session_id_propagates_to_target(self) -> None:
client, agent = _make_client()
with client:
r = client.post("/invocations", json={"message": "x", "session_id": "s1"})
assert r.status_code == 200
assert r.json()["session_id"] == "s1"
sess = agent.calls[0]["kwargs"].get("session")
# Host converts ChannelSession.isolation_key -> AgentSession via
# target.create_session(session_id=...). Our fake stashes that here.
assert sess is not None
assert sess["session_id"] == "invocations:s1"
def test_invalid_json_returns_400(self) -> None:
client, _ = _make_client()
with client:
r = client.post(
"/invocations",
content=b"{not json",
headers={"content-type": "application/json"},
)
assert r.status_code == 400
def test_empty_message_returns_422(self) -> None:
client, _ = _make_client()
with client:
r = client.post("/invocations", json={"message": ""})
assert r.status_code == 422
def test_non_string_session_id_returns_422(self) -> None:
client, _ = _make_client()
with client:
r = client.post("/invocations", json={"message": "x", "session_id": 1})
assert r.status_code == 422
def test_non_object_body_returns_422(self) -> None:
client, _ = _make_client()
with client:
r = client.post("/invocations", json=[])
assert r.status_code == 422
def test_streaming_emits_data_lines_and_done(self) -> None:
agent = _FakeAgent(chunks=["hel", "lo"])
host = AgentFrameworkHost(target=agent, channels=[InvocationsChannel()])
with TestClient(host.app) as client:
r = client.post("/invocations", json={"message": "x", "stream": True})
assert r.status_code == 200
body = r.text
assert "data: hel" in body
assert "data: lo" in body
assert body.rstrip().endswith("data: [DONE]")
def test_run_hook_can_rewrite_request(self) -> None:
captured: list[ChannelRequest] = []
async def hook(req: ChannelRequest, **_: Any) -> ChannelRequest:
captured.append(req)
return replace(req, input="rewritten")
agent = _FakeAgent(reply="ok")
host = AgentFrameworkHost(target=agent, channels=[InvocationsChannel(run_hook=hook)])
with TestClient(host.app) as client:
r = client.post("/invocations", json={"message": "x", "stream": True})
assert r.status_code == 200
assert r.headers["content-type"].startswith("text/event-stream")
assert captured and captured[0].channel == "invocations"
assert agent.calls[0]["messages"].text == "rewritten"
def test_response_hook_can_rewrite_originating_reply(self) -> None:
seen_kwargs: list[dict[str, Any]] = []
def hook(result: HostedRunResult, **kwargs: Any) -> HostedRunResult:
seen_kwargs.append(dict(kwargs))
return HostedRunResult(_FakeAgentResponse(text=f"hooked:{result.result.text}"), session=result.session)
agent = _FakeAgent(reply="pong")
host = AgentFrameworkHost(target=agent, channels=[InvocationsChannel(response_hook=hook)])
with TestClient(host.app) as client:
r = client.post("/invocations", json={"message": "ping"})
assert r.status_code == 200
assert r.json() == {"response": "hooked:pong", "session_id": None}
assert seen_kwargs
assert seen_kwargs[0]["channel_name"] == "invocations"
def test_stream_update_hook_can_rewrite_chunks(self) -> None:
agent = _FakeAgent(chunks=["foo", "bar"])
def transform(update: Any) -> Any:
return _FakeUpdate(text=update.text.upper())
host = AgentFrameworkHost(
target=agent,
channels=[InvocationsChannel(stream_update_hook=transform)],
)
with TestClient(host.app) as client:
r = client.post("/invocations", json={"message": "x", "stream": True})
assert r.status_code == 200
body = r.text
assert "data: FOO" in body
assert "data: BAR" in body
assert "data: foo" not in body
def test_stream_update_hook_can_drop_chunks(self) -> None:
agent = _FakeAgent(chunks=["keep", "drop", "keep2"])
def transform(update: Any) -> Any:
return None if update.text == "drop" else update
host = AgentFrameworkHost(
target=agent,
channels=[InvocationsChannel(stream_update_hook=transform)],
)
with TestClient(host.app) as client:
r = client.post("/invocations", json={"message": "x", "stream": True})
assert r.status_code == 200
body = r.text
assert "data: keep" in body
assert "data: keep2" in body
assert "data: drop" not in body
def test_stream_update_hook_supports_async(self) -> None:
agent = _FakeAgent(chunks=["aa"])
async def transform(update: Any) -> Any:
return _FakeUpdate(text=update.text + "!")
host = AgentFrameworkHost(
target=agent,
channels=[InvocationsChannel(stream_update_hook=transform)],
)
with TestClient(host.app) as client:
r = client.post("/invocations", json={"message": "x", "stream": True})
assert r.status_code == 200
assert "data: aa!" in r.text
def test_streaming_chunk_with_crlf_splits_into_separate_data_lines(self) -> None:
# Per SSE spec, ``\r``, ``\n`` and ``\r\n`` are all line terminators;
# a chunk like ``"line1\r\nline2"`` must produce two ``data:`` lines,
# not one ``data:`` line containing an embedded ``\r``.
agent = _FakeAgent(chunks=["line1\r\nline2"])
host = AgentFrameworkHost(target=agent, channels=[InvocationsChannel()])
with TestClient(host.app) as client:
r = client.post("/invocations", json={"message": "x", "stream": True})
assert r.status_code == 200
body = r.text
assert "data: line1\n" in body
assert "data: line2\n" in body
assert "\r" not in body.split("data: [DONE]")[0]
def test_streaming_finalize_error_emits_error_frame_no_done(self) -> None:
# ``get_final_response()`` is what triggers history-provider
# persistence on the agent side; if it fails we must surface that
# to the client as ``event: error`` rather than emitting ``[DONE]``
# as if the run completed cleanly.
class _FailingFinalStream(_FakeStream):
async def get_final_response(self) -> _FakeAgentResponse:
raise RuntimeError("history backend exploded")
class _AgentWithFailingFinal(_FakeAgent):
def run(self, messages: Any = None, *, stream: bool = False, **kwargs: Any) -> Any:
self.calls.append({"messages": messages, "stream": stream, "kwargs": kwargs})
if stream:
return _FailingFinalStream(["partial"])
return super().run(messages, stream=stream, **kwargs)
agent = _AgentWithFailingFinal()
host = AgentFrameworkHost(target=agent, channels=[InvocationsChannel()])
with TestClient(host.app) as client:
r = client.post("/invocations", json={"message": "x", "stream": True})
assert r.status_code == 200
body = r.text
assert "data: partial" in body
assert "event: error" in body
assert "history backend exploded" in body
assert "[DONE]" not in body
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
+29
View File
@@ -0,0 +1,29 @@
# agent-framework-hosting-mcp
Model Context Protocol (MCP) tool channel for `agent-framework-hosting`.
Exposes the hosted target (an `Agent` or a `Workflow`) as a single MCP tool over
the Streamable-HTTP transport, so MCP clients — other agents, IDE tooling — can
invoke it. Every call is routed through the host pipeline, so host sessions,
request metadata, and run/response hooks all apply.
```python
from agent_framework.openai import OpenAIChatClient
from agent_framework_hosting import AgentFrameworkHost
from agent_framework_hosting_mcp import MCPChannel
agent = OpenAIChatClient().as_agent(name="Assistant")
host = AgentFrameworkHost(target=agent, channels=[MCPChannel()])
host.serve(port=8000)
```
The Streamable-HTTP endpoint is mounted at `path` (default `/mcp`). The advertised
tool accepts `{"input": str, "session_id": str?}` and returns the target's reply
as MCP content blocks, including structured output when the agent returns one.
Pass `session_id` to continue a prior conversation (it maps onto the host
session). When `streaming=True` (default) incremental text is forwarded as MCP
progress notifications while the full reply is returned as the tool result.
The base host plumbing lives in
[`agent-framework-hosting`](https://pypi.org/project/agent-framework-hosting/).
@@ -0,0 +1,23 @@
# Copyright (c) Microsoft. All rights reserved.
"""Model Context Protocol (MCP) tool channel for :mod:`agent_framework_hosting`.
Exposes the hosted target (an ``Agent`` or a ``Workflow``) as a single MCP
tool over the Streamable-HTTP transport so MCP clients — other agents, IDE
tooling — can invoke it. Routes through the host pipeline, so sessions,
request metadata, and hooks apply.
"""
import importlib.metadata
from ._channel import MCPChannel
try:
__version__ = importlib.metadata.version(__name__)
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0"
__all__ = [
"MCPChannel",
"__version__",
]
@@ -0,0 +1,437 @@
# Copyright (c) Microsoft. All rights reserved.
"""``MCPChannel`` — exposes the hosted target as a Model Context Protocol tool.
Mounts a Streamable-HTTP MCP endpoint that advertises a single tool. An MCP
client (another agent, an IDE, tooling) calls the tool with
``{"input": "...", "session_id": "..."}`` and receives the target's reply as
the tool result.
Like the other ``agent-framework-hosting`` channels this routes through the
host pipeline (``ChannelContext.run`` / ``run_stream``) so session resolution,
request metadata, and run/response hooks all apply. The MCP ``tool/call``
conversation key maps onto :class:`ChannelSession` (caller-supplied-session
family); the same single-tool shape works for an ``Agent`` or a ``Workflow``
target (use a ``run_hook`` to reshape the free-form input into a workflow's
typed inputs).
"""
from __future__ import annotations
import base64
import json
import re
from collections.abc import Mapping, Sequence
from contextlib import AbstractAsyncContextManager
from dataclasses import asdict, is_dataclass
from typing import Any, cast
import mcp.types as types
from agent_framework import Content, Message
from agent_framework_hosting import (
ChannelContext,
ChannelContribution,
ChannelIdentity,
ChannelRequest,
ChannelResponseHook,
ChannelRunHook,
ChannelSession,
HostedRunResult,
logger,
)
from mcp.server.lowlevel import Server
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from pydantic import AnyUrl
from starlette.routing import Mount
from starlette.types import Receive, Scope, Send
_DEFAULT_TOOL_NAME = "run_agent"
_DEFAULT_TOOL_DESCRIPTION = (
"Invoke the hosted agent (or workflow) with a free-form text request and "
"return its reply. Pass an optional ``session_id`` to continue a prior "
"conversation."
)
_DATA_URI_PATTERN = re.compile(r"^data:(?P<media_type>[^;]+);base64,(?P<data>[A-Za-z0-9+/=]+)$")
def _mcp_uri(uri: str) -> AnyUrl:
"""Build an MCP URI model from a string URI."""
return AnyUrl(uri)
def _json_safe(value: Any) -> Any:
"""Return a JSON-serializable representation for MCP structured content."""
try:
return json.loads(json.dumps(value, default=str))
except (TypeError, ValueError):
return str(value)
def _structured_content(value: Any) -> dict[str, Any] | None:
"""Normalize an Agent Framework structured output value for MCP."""
if value is None:
return None
model_dump = getattr(value, "model_dump", None)
if callable(model_dump):
value = model_dump(mode="json")
elif is_dataclass(value) and not isinstance(value, type):
value = asdict(value)
if isinstance(value, Mapping):
mapping_value = cast("Mapping[Any, Any]", value) # type: ignore[redundant-cast]
safe_value = _json_safe(dict(mapping_value))
if isinstance(safe_value, dict):
safe_mapping = cast("Mapping[Any, Any]", safe_value)
return {str(key): item for key, item in safe_mapping.items()}
return {"value": safe_value}
safe_value = _json_safe(value)
return {"value": safe_value}
def _data_content_to_mcp(content: Content) -> list[types.ContentBlock]:
"""Convert Agent Framework data content into the closest MCP content block."""
if not content.uri:
return []
match = _DATA_URI_PATTERN.match(content.uri)
if match is None:
logger.warning("MCPChannel could not parse data URI; omitted.")
return []
media_type = content.media_type or match.group("media_type")
data = match.group("data")
if media_type.startswith("image/"):
return [types.ImageContent(type="image", data=data, mimeType=media_type)]
if media_type.startswith("audio/"):
return [types.AudioContent(type="audio", data=data, mimeType=media_type)]
return [
types.EmbeddedResource(
type="resource",
resource=types.BlobResourceContents(uri=_mcp_uri(content.uri), mimeType=media_type, blob=data),
)
]
def _content_to_mcp(content: Content) -> list[types.ContentBlock]:
"""Convert one Agent Framework content item into MCP content blocks."""
match content.type:
case "text":
return [types.TextContent(type="text", text=content.text or "")]
case "text_reasoning":
return [types.TextContent(type="text", text=content.text)] if content.text else []
case "data":
return _data_content_to_mcp(content)
case "uri":
if not content.uri:
return []
block: types.ContentBlock = types.ResourceLink(
type="resource_link",
name=content.uri,
uri=_mcp_uri(content.uri),
mimeType=content.media_type,
)
return [block]
case "function_result":
if content.items:
blocks: list[types.ContentBlock] = []
for item in content.items:
blocks.extend(_content_to_mcp(item))
return blocks
return [types.TextContent(type="text", text=str(content.result or ""))]
case "error":
return [types.TextContent(type="text", text=content.message or content.error_details or "")]
case _:
logger.warning("MCPChannel does not support content type: %s. Omitted.", content.type)
return []
def _value_to_mcp(value: Any) -> list[types.ContentBlock]:
"""Convert a workflow output or fallback value into MCP content blocks."""
if isinstance(value, Content):
return _content_to_mcp(value)
if isinstance(value, Message):
blocks: list[types.ContentBlock] = []
for content in value.contents:
blocks.extend(_content_to_mcp(content))
return blocks
if isinstance(value, str):
return [types.TextContent(type="text", text=value)]
if isinstance(value, bytes):
data = base64.b64encode(value).decode("utf-8")
return [
types.EmbeddedResource(
type="resource",
resource=types.BlobResourceContents(
uri=_mcp_uri("data:application/octet-stream;base64," + data),
mimeType="application/octet-stream",
blob=data,
),
)
]
return [types.TextContent(type="text", text=json.dumps(_json_safe(value), default=str))]
class MCPChannel:
"""Exposes the hosted target as a single MCP tool over Streamable HTTP.
Mounts the MCP Streamable-HTTP transport at ``path`` (default ``/mcp``).
The advertised tool accepts ``{"input": str, "session_id": str?}`` and
returns the target's reply as MCP content blocks. Agent structured outputs
are returned as MCP ``structuredContent``.
"""
name = "mcp"
def __init__(
self,
*,
path: str = "/mcp",
tool_name: str = _DEFAULT_TOOL_NAME,
tool_description: str = _DEFAULT_TOOL_DESCRIPTION,
server_name: str | None = None,
server_version: str | None = None,
streaming: bool = True,
json_response: bool = False,
stateless: bool = False,
run_hook: ChannelRunHook | None = None,
response_hook: ChannelResponseHook | None = None,
) -> None:
"""Create an MCP tool channel.
Keyword Args:
path: Mount path for the Streamable-HTTP transport. Default ``/mcp``.
tool_name: Name of the advertised tool. Default ``run_agent``.
tool_description: Human-readable description advertised to clients.
server_name: MCP server name reported in the initialize handshake.
Defaults to the hosted target's ``name`` attribute when available.
server_version: Optional MCP server version string.
streaming: When ``True`` (default) the channel consumes the target
via :meth:`ChannelContext.run_stream` and forwards incremental
text to the client as MCP progress notifications (when the
client supplied a ``progressToken``). The full reply is always
returned as the tool result regardless of this flag.
json_response: Forwarded to :class:`StreamableHTTPSessionManager`.
When ``True`` the transport returns a single JSON response
instead of an SSE stream for each request.
stateless: Forwarded to :class:`StreamableHTTPSessionManager`. When
``True`` the transport does not retain per-session state between
requests.
run_hook: Optional :data:`ChannelRunHook` invoked with the parsed
:class:`ChannelRequest` before the target runs.
response_hook: Optional :data:`ChannelResponseHook` invoked before
the channel serializes an originating reply into tool content.
"""
self.path = path
self.response_hook = response_hook
self._tool_name = tool_name
self._tool_description = tool_description
self._server_name = server_name
self._server_version = server_version
self._streaming = streaming
self._json_response = json_response
self._stateless = stateless
self._hook = run_hook
self._ctx: ChannelContext | None = None
self._server: Server[Any, Any] | None = None
self._session_manager: StreamableHTTPSessionManager | None = None
self._run_cm: AbstractAsyncContextManager[None] | None = None
def contribute(self, context: ChannelContext) -> ChannelContribution:
"""Capture the host context and mount the Streamable-HTTP transport."""
self._ctx = context
self._server = self._build_server()
self._session_manager = StreamableHTTPSessionManager(
app=self._server,
json_response=self._json_response,
stateless=self._stateless,
)
# StreamableHTTPSessionManager owns MCP initialize/session/progress semantics;
# mounting it keeps the channel on the real MCP HTTP transport.
return ChannelContribution(
routes=[Mount("/", app=self._handle_asgi)],
on_startup=[self._on_startup],
on_shutdown=[self._on_shutdown],
)
async def _handle_asgi(self, scope: Scope, receive: Receive, send: Send) -> None:
"""ASGI entrypoint delegating to the MCP Streamable-HTTP session manager."""
if self._session_manager is None: # pragma: no cover - guarded by lifecycle
raise RuntimeError("MCPChannel transport not initialized")
await self._session_manager.handle_request(scope, receive, send)
async def _on_startup(self) -> None:
"""Enter the session-manager task-group lifecycle on host startup."""
if self._session_manager is None: # pragma: no cover - guarded by lifecycle
return
self._run_cm = self._session_manager.run()
await self._run_cm.__aenter__()
async def _on_shutdown(self) -> None:
"""Exit the session-manager task-group lifecycle on host shutdown."""
if self._run_cm is not None:
await self._run_cm.__aexit__(None, None, None)
self._run_cm = None
def _build_server(self) -> Server[Any, Any]:
"""Build the low-level MCP server with the single host-routed tool."""
target_name = getattr(self._ctx.target, "name", None) if self._ctx is not None else None
server_name = self._server_name or (target_name if isinstance(target_name, str) and target_name else None)
server: Server[Any, Any] = Server(name=server_name or "agent-framework-hosting", version=self._server_version)
tool = types.Tool(
name=self._tool_name,
description=self._tool_description,
inputSchema={
"type": "object",
"properties": {
"input": {
"type": "string",
"description": "The request to send to the hosted agent or workflow.",
},
"session_id": {
"type": "string",
"description": "Optional conversation id to continue a prior session.",
},
},
"required": ["input"],
},
)
@server.list_tools() # type: ignore[no-untyped-call, untyped-decorator, misc]
async def _list_tools() -> list[types.Tool]: # noqa: RUF029 # pyright: ignore[reportUnusedFunction]
return [tool]
@server.call_tool() # type: ignore[no-untyped-call, untyped-decorator, misc]
async def _call_tool(name: str, arguments: Mapping[str, Any]) -> types.CallToolResult: # pyright: ignore[reportUnusedFunction]
return await self._invoke_tool(arguments)
return server
async def _invoke_tool(self, arguments: Mapping[str, Any]) -> types.CallToolResult:
"""Route a single ``tool/call`` through the host pipeline."""
if self._ctx is None: # pragma: no cover - guarded by Channel lifecycle
raise RuntimeError("MCPChannel not initialized")
text_input = arguments.get("input")
if not isinstance(text_input, str) or not text_input:
return types.CallToolResult(
content=[types.TextContent(type="text", text="Error: 'input' must be a non-empty string.")],
isError=True,
)
session_id = arguments.get("session_id")
session = ChannelSession(isolation_key=session_id) if isinstance(session_id, str) and session_id else None
identity = (
ChannelIdentity(channel=self.name, native_id=session_id)
if isinstance(session_id, str) and session_id
else None
)
channel_request = ChannelRequest(
channel=self.name,
operation="message.create",
input=text_input,
session=session,
stream=self._streaming,
identity=identity,
attributes={"tool_name": self._tool_name},
)
if channel_request.stream:
result = await self._run_streaming(channel_request, protocol_request=dict(arguments))
else:
result = await self._ctx.run(
channel_request,
run_hook=self._hook,
protocol_request=dict(arguments),
response_hook=self.response_hook,
channel_name=self.name,
)
return self._result_to_content(result)
async def _run_streaming(
self, request: ChannelRequest, *, protocol_request: Mapping[str, Any]
) -> HostedRunResult[Any]:
"""Consume the target as a stream, forwarding progress, returning the full reply."""
if self._ctx is None: # pragma: no cover - guarded by Channel lifecycle
raise RuntimeError("MCPChannel not initialized")
progress_token, request_id = self._progress_context()
progress = 0.0
stream = await self._ctx.run_stream(
request,
run_hook=self._hook,
protocol_request=protocol_request,
response_hook=self.response_hook,
channel_name=self.name,
)
async for update in stream:
chunk = getattr(update, "text", None)
if not chunk:
continue
if progress_token is not None:
progress += 1.0
try:
await self._send_progress(progress_token, progress, chunk, request_id)
except Exception: # pragma: no cover - progress is best-effort
logger.exception("MCPChannel progress notification failed")
return HostedRunResult(await stream.get_final_response())
def _progress_context(self) -> tuple[str | int | None, str | None]:
"""Best-effort lookup of the active request's progress token + id."""
if self._server is None: # pragma: no cover - guarded by lifecycle
return None, None
try:
ctx = self._server.request_context
except Exception: # pragma: no cover - no active request context
return None, None
token = ctx.meta.progressToken if ctx.meta is not None else None
request_id = str(ctx.request_id)
return token, request_id
async def _send_progress(
self,
progress_token: str | int,
progress: float,
message: str,
request_id: str | None,
) -> None:
"""Send a single MCP progress notification for streamed text."""
if self._server is None: # pragma: no cover - guarded by lifecycle
return
await self._server.request_context.session.send_progress_notification(
progress_token=progress_token,
progress=progress,
message=message,
related_request_id=request_id,
)
def _result_to_content(self, result: HostedRunResult[Any]) -> types.CallToolResult:
"""Convert a host result into an MCP tool result."""
response = result.result
content: list[types.ContentBlock] = []
messages = cast("Sequence[Any] | None", getattr(response, "messages", None))
if messages:
for message in messages:
for item in cast("Sequence[Any]", getattr(message, "contents", None) or ()):
if isinstance(item, Content):
content.extend(_content_to_mcp(item))
else:
content.append(types.TextContent(type="text", text=str(item)))
get_outputs = getattr(response, "get_outputs", None)
if callable(get_outputs):
for output in cast("Sequence[Any]", get_outputs()):
content.extend(_value_to_mcp(output))
structured = _structured_content(getattr(response, "value", None))
if not content:
text = getattr(response, "text", None)
if isinstance(text, str) and text:
content.append(types.TextContent(type="text", text=text))
elif structured is not None:
content.append(types.TextContent(type="text", text=json.dumps(structured, indent=2)))
else:
content.append(types.TextContent(type="text", text=""))
return types.CallToolResult(content=content, structuredContent=structured, isError=False)
+102
View File
@@ -0,0 +1,102 @@
[project]
name = "agent-framework-hosting-mcp"
description = "Model Context Protocol (MCP) tool channel for agent-framework-hosting."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0a260424"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
urls.issues = "https://github.com/microsoft/agent-framework/issues"
classifiers = [
"License :: OSI Approved :: MIT License",
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.2.0,<2",
"agent-framework-hosting>=1.0.0a260424,<2",
"mcp>=1.12,<2",
"starlette>=0.37",
]
[tool.uv]
prerelease = "if-necessary-or-explicit"
environments = [
"sys_platform == 'darwin'",
"sys_platform == 'linux'",
"sys_platform == 'win32'"
]
[tool.uv-dynamic-versioning]
fallback-version = "0.0.0"
[tool.pytest.ini_options]
testpaths = 'tests'
addopts = "-ra -q -r fEX"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = []
timeout = 120
markers = [
"integration: marks tests as integration tests that require external services",
]
[tool.ruff]
extend = "../../pyproject.toml"
[tool.coverage.run]
omit = [
"**/__init__.py"
]
[tool.pyright]
extends = "../../pyproject.toml"
include = ["agent_framework_hosting_mcp"]
exclude = ['tests']
[tool.mypy]
plugins = ['pydantic.mypy']
strict = true
python_version = "3.10"
ignore_missing_imports = true
disallow_untyped_defs = true
no_implicit_optional = true
check_untyped_defs = true
warn_return_any = true
show_error_codes = true
warn_unused_ignores = false
disallow_incomplete_defs = true
disallow_untyped_decorators = true
[tool.bandit]
targets = ["agent_framework_hosting_mcp"]
exclude_dirs = ["tests"]
[tool.poe]
executor.type = "uv"
include = "../../shared_tasks.toml"
[tool.poe.tasks.mypy]
help = "Run MyPy for this package."
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_hosting_mcp"
[tool.poe.tasks.test]
help = "Run the default unit test suite for this package."
cmd = 'pytest -m "not integration" --cov=agent_framework_hosting_mcp --cov-report=term-missing:skip-covered tests'
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
build-backend = "flit_core.buildapi"
[dependency-groups]
dev = []
@@ -0,0 +1,390 @@
# Copyright (c) Microsoft. All rights reserved.
"""Unit tests for :class:`MCPChannel`."""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator, Awaitable, Sequence
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from typing import Any
import mcp.types as types
import uvicorn
from agent_framework import AgentResponse, AgentResponseUpdate, Content, Message, ResponseStream
from agent_framework_hosting import AgentFrameworkHost, ChannelRequest, HostedRunResult
from mcp import ClientSession
from mcp.client.streamable_http import streamable_http_client
from mcp.shared.memory import create_connected_server_and_client_session
from starlette.types import ASGIApp
from agent_framework_hosting_mcp import MCPChannel
# --------------------------------------------------------------------------- #
# Fakes #
# --------------------------------------------------------------------------- #
@dataclass
class _FakeResp:
text: str
messages: list[Message] = field(default_factory=list)
value: Any | None = None
@dataclass
class _FakeUpdate:
text: str
contents: list[Content] = field(default_factory=list)
message_id: str | None = None
class _FakeStream:
def __init__(self, chunks: list[str], final: _FakeResp | None = None) -> None:
self._chunks = chunks
self._final = final or _FakeResp(text="".join(chunks))
def __aiter__(self) -> AsyncIterator[_FakeUpdate]:
async def _gen() -> AsyncIterator[_FakeUpdate]:
for c in self._chunks:
yield _FakeUpdate(text=c)
return _gen()
async def get_final_response(self) -> _FakeResp:
return self._final
@dataclass
class _FakeTarget:
name: str = "Assistant"
description: str = "A helpful assistant."
class _FakeContext:
"""Minimal stand-in for :class:`ChannelContext`."""
def __init__(
self,
*,
reply: str = "hello",
chunks: list[str] | None = None,
contents: list[Content] | None = None,
structured: Any | None = None,
) -> None:
self.target = _FakeTarget()
self._reply = reply
self._chunks = chunks or [reply]
self._contents = contents or [Content.from_text(text=reply)]
self._structured = structured
self.requests: list[ChannelRequest] = []
async def run(
self,
request: ChannelRequest,
*,
run_hook: Any | None = None,
protocol_request: Any | None = None,
response_hook: Any | None = None,
channel_name: str | None = None,
) -> HostedRunResult[Any]:
if run_hook is not None:
maybe_request = run_hook(request, target=self.target, protocol_request=protocol_request)
if isinstance(maybe_request, Awaitable):
request = await maybe_request
else:
request = maybe_request
self.requests.append(request)
message = Message(role="assistant", contents=self._contents)
result = HostedRunResult(_FakeResp(text=self._reply, messages=[message], value=self._structured))
if response_hook is not None:
maybe_result = response_hook(result, request=request, channel_name=channel_name or request.channel)
if isinstance(maybe_result, Awaitable):
return await maybe_result
return maybe_result
return result
async def run_stream(
self,
request: ChannelRequest,
*,
run_hook: Any | None = None,
protocol_request: Any | None = None,
stream_update_hook: Any | None = None,
response_hook: Any | None = None,
channel_name: str | None = None,
) -> _FakeStream:
if run_hook is not None:
maybe_request = run_hook(request, target=self.target, protocol_request=protocol_request)
if isinstance(maybe_request, Awaitable):
request = await maybe_request
else:
request = maybe_request
self.requests.append(request)
result = HostedRunResult(_FakeResp(text="".join(self._chunks), value=self._structured))
if response_hook is not None:
maybe_result = response_hook(result, request=request, channel_name=channel_name or request.channel)
if isinstance(maybe_result, Awaitable):
result = await maybe_result
else:
result = maybe_result
return _FakeStream(self._chunks, final=result.result)
def _make_channel(ctx: _FakeContext, **kwargs: Any) -> MCPChannel:
channel = MCPChannel(**kwargs)
channel.contribute(ctx) # type: ignore[arg-type]
return channel
class _HostedAgent:
name = "HostedAssistant"
description = "A hosted test assistant."
async def run(self, messages: Any = None, *, stream: bool = False, **_kwargs: Any) -> Any:
text = messages.text if isinstance(messages, Message) else str(messages)
if stream:
updates = [AgentResponseUpdate(contents=[Content.from_text(text=f"host: {text}")], role="assistant")]
async def _gen() -> AsyncIterator[AgentResponseUpdate]:
for update in updates:
yield update
async def _finalize(items: Sequence[AgentResponseUpdate]) -> AgentResponse: # noqa: RUF029
return AgentResponse.from_updates(items)
return ResponseStream[AgentResponseUpdate, AgentResponse](_gen(), finalizer=_finalize)
return AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text(text=f"host: {text}")])])
@asynccontextmanager
async def _serve_app(app: ASGIApp, *, port: int) -> AsyncIterator[str]:
config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning", lifespan="on")
server = uvicorn.Server(config)
task = asyncio.create_task(server.serve())
try:
for _ in range(100):
if server.started:
break
await asyncio.sleep(0.01)
else:
raise RuntimeError("Test MCP server did not start")
yield f"http://127.0.0.1:{port}"
finally:
server.should_exit = True
await task
# --------------------------------------------------------------------------- #
# Tests #
# --------------------------------------------------------------------------- #
async def test_list_tools_advertises_single_configured_tool() -> None:
ctx = _FakeContext()
channel = _make_channel(ctx, tool_name="ask", tool_description="Ask the assistant.")
async with create_connected_server_and_client_session(channel._server) as client: # type: ignore[arg-type]
result = await client.list_tools()
assert len(result.tools) == 1
tool = result.tools[0]
assert tool.name == "ask"
assert tool.description == "Ask the assistant."
assert tool.inputSchema["required"] == ["input"]
assert set(tool.inputSchema["properties"]) == {"input", "session_id"}
async def test_initialize_uses_target_name_by_default() -> None:
ctx = _FakeContext()
channel = _make_channel(ctx)
async with create_connected_server_and_client_session(channel._server) as client: # type: ignore[arg-type]
result = await client.initialize()
assert result.serverInfo.name == "Assistant"
async def test_call_tool_routes_through_host_and_returns_text() -> None:
ctx = _FakeContext(reply="hi back", chunks=["hi", " back"])
channel = _make_channel(ctx, streaming=False)
async with create_connected_server_and_client_session(channel._server) as client: # type: ignore[arg-type]
result = await client.call_tool("run_agent", {"input": "hello", "session_id": "conv-1"})
assert not result.isError
assert isinstance(result.content[0], types.TextContent)
assert result.content[0].text == "hi back"
# The channel built a channel-neutral request routed through the host.
assert len(ctx.requests) == 1
request = ctx.requests[0]
assert request.channel == "mcp"
assert request.operation == "message.create"
assert request.input == "hello"
assert request.session is not None
assert request.session.isolation_key == "conv-1"
assert request.identity is not None
assert request.identity.native_id == "conv-1"
async def test_call_tool_returns_rich_content_and_structured_output() -> None:
ctx = _FakeContext(
contents=[
Content.from_text(text="text"),
Content.from_data(data=b"image-bytes", media_type="image/png"),
Content.from_data(data=b"audio-bytes", media_type="audio/wav"),
Content.from_data(data=b"raw-bytes", media_type="application/octet-stream"),
Content.from_uri(uri="https://example.com/file.json", media_type="application/json"),
],
structured={"answer": 42},
)
channel = _make_channel(ctx, streaming=False)
async with create_connected_server_and_client_session(channel._server) as client: # type: ignore[arg-type]
result = await client.call_tool("run_agent", {"input": "hello"})
assert result.structuredContent == {"answer": 42}
assert [item.type for item in result.content] == ["text", "image", "audio", "resource", "resource_link"]
assert result.content[0].text == "text" # type: ignore[union-attr]
async def test_call_tool_streaming_aggregates_chunks() -> None:
ctx = _FakeContext(chunks=["foo", "bar", "baz"])
channel = _make_channel(ctx, streaming=True)
async with create_connected_server_and_client_session(channel._server) as client: # type: ignore[arg-type]
result = await client.call_tool("run_agent", {"input": "hello"})
assert result.content[0].text == "foobarbaz" # type: ignore[union-attr]
# No session_id supplied -> no session / identity.
assert ctx.requests[0].session is None
assert ctx.requests[0].identity is None
async def test_call_tool_rejects_empty_input() -> None:
ctx = _FakeContext()
channel = _make_channel(ctx)
async with create_connected_server_and_client_session(channel._server) as client: # type: ignore[arg-type]
result = await client.call_tool("run_agent", {"input": ""})
assert result.isError
assert "non-empty string" in result.content[0].text # type: ignore[union-attr]
assert ctx.requests == []
async def test_run_hook_can_reshape_request() -> None:
ctx = _FakeContext(reply="ok")
async def _hook(request: ChannelRequest, *, target: Any, protocol_request: Any) -> ChannelRequest:
import dataclasses
return dataclasses.replace(request, attributes={**dict(request.attributes), "hooked": True})
channel = _make_channel(ctx, streaming=False, run_hook=_hook)
async with create_connected_server_and_client_session(channel._server) as client: # type: ignore[arg-type]
await client.call_tool("run_agent", {"input": "hello"})
assert ctx.requests[0].attributes.get("hooked") is True
async def test_response_hook_can_shape_originating_reply() -> None:
ctx = _FakeContext(reply="original")
async def _hook(
result: HostedRunResult[Any],
*,
request: ChannelRequest,
channel_name: str,
) -> HostedRunResult[Any]:
assert channel_name == "mcp"
assert request.channel == "mcp"
return HostedRunResult(_FakeResp(text="hooked"))
channel = _make_channel(ctx, streaming=False, response_hook=_hook)
async with create_connected_server_and_client_session(channel._server) as client: # type: ignore[arg-type]
result = await client.call_tool("run_agent", {"input": "hello"})
assert result.content[0].text == "hooked" # type: ignore[union-attr]
async def test_streaming_response_hook_shapes_final_reply() -> None:
ctx = _FakeContext(chunks=["raw"])
async def _hook(
result: HostedRunResult[Any],
*,
request: ChannelRequest,
channel_name: str,
) -> HostedRunResult[Any]:
return HostedRunResult(_FakeResp(text=f"{channel_name}:{request.channel}:{result.result.text}"))
channel = _make_channel(ctx, streaming=True, response_hook=_hook)
async with create_connected_server_and_client_session(channel._server) as client: # type: ignore[arg-type]
result = await client.call_tool("run_agent", {"input": "hello"})
assert result.content[0].text == "mcp:mcp:raw" # type: ignore[union-attr]
def test_default_path_and_name() -> None:
channel = MCPChannel()
assert channel.name == "mcp"
assert channel.path == "/mcp"
def test_content_conversion_handles_non_text_shapes() -> None:
from agent_framework_hosting_mcp._channel import _content_to_mcp, _structured_content, _value_to_mcp
@dataclass
class StructuredValue:
answer: int
circular: list[Any] = []
circular.append(circular)
assert _structured_content(None) is None
assert _structured_content(StructuredValue(answer=42)) == {"answer": 42}
assert _structured_content(circular) == {"value": "[[...]]"}
assert _content_to_mcp(Content("data", uri="not-a-data-uri", media_type="application/octet-stream")) == []
assert _content_to_mcp(Content("text_reasoning", text="because"))[0].text == "because" # type: ignore[union-attr]
assert (
_content_to_mcp(Content.from_function_result("call-1", result=[Content.from_text("nested")]))[0].text
== "nested"
) # type: ignore[union-attr]
assert _content_to_mcp(Content.from_function_result("call-1", result={"x": 1}))[0].text == '{"x": 1}' # type: ignore[union-attr]
assert _content_to_mcp(Content.from_error(message="bad"))[0].text == "bad" # type: ignore[union-attr]
assert _content_to_mcp(Content.from_function_call("call-1", "tool")) == []
assert _value_to_mcp(Message(role="assistant", contents=[Content.from_text("message")]))[0].text == "message" # type: ignore[union-attr]
assert _value_to_mcp(b"bytes")[0].type == "resource"
assert _value_to_mcp({"x": 1})[0].text == '{"x": 1}' # type: ignore[union-attr]
def test_result_conversion_handles_workflow_and_fallback_shapes() -> None:
class WorkflowResult:
value = None
def get_outputs(self) -> list[Message]:
return [Message(role="assistant", contents=[Content.from_text("workflow")])]
@dataclass
class TextOnlyResult:
text: str
value: Any | None = None
channel = MCPChannel()
workflow_result = channel._result_to_content(HostedRunResult(WorkflowResult()))
assert workflow_result.content[0].text == "workflow" # type: ignore[union-attr]
text_result = channel._result_to_content(HostedRunResult(TextOnlyResult(text="fallback")))
assert text_result.content[0].text == "fallback" # type: ignore[union-attr]
structured_result = channel._result_to_content(HostedRunResult(TextOnlyResult(text="", value={"x": 1})))
assert structured_result.structuredContent == {"x": 1}
assert structured_result.content[0].text == '{\n "x": 1\n}' # type: ignore[union-attr]
empty_result = channel._result_to_content(HostedRunResult(TextOnlyResult(text="")))
assert empty_result.content[0].text == "" # type: ignore[union-attr]
async def test_http_mcp_client_can_call_hosted_channel(unused_tcp_port: int) -> None:
host = AgentFrameworkHost(target=_HostedAgent(), channels=[MCPChannel(streaming=False)])
async with (
_serve_app(host.app, port=unused_tcp_port) as base_url,
streamable_http_client(f"{base_url}/mcp/") as (read_stream, write_stream, _),
ClientSession(read_stream, write_stream) as session,
):
await session.initialize()
tools = await session.list_tools()
result = await session.call_tool("run_agent", {"input": "hello", "session_id": "conv-1"})
assert [tool.name for tool in tools.tools] == ["run_agent"]
assert result.content[0].text == "host: hello" # type: ignore[union-attr]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
@@ -0,0 +1,21 @@
# agent-framework-hosting-responses
OpenAI Responses-shaped channel for `agent-framework-hosting`.
Exposes a single `POST /responses` endpoint that accepts the OpenAI
Responses API request body and returns either a Responses-shaped JSON
body or a Server-Sent-Events stream when `stream=True`.
```python
from agent_framework.openai import OpenAIChatClient
from agent_framework_hosting import AgentFrameworkHost
from agent_framework_hosting_responses import ResponsesChannel
agent = OpenAIChatClient().as_agent(name="Assistant")
host = AgentFrameworkHost(target=agent, channels=[ResponsesChannel()])
host.serve(port=8000)
```
The base host plumbing lives in
[`agent-framework-hosting`](https://pypi.org/project/agent-framework-hosting/).
@@ -0,0 +1,25 @@
# Copyright (c) Microsoft. All rights reserved.
"""OpenAI Responses-shaped channel for ``agent-framework-hosting``."""
import importlib.metadata
from ._channel import ResponsesChannel
from ._parsing import (
messages_from_responses_input,
parse_responses_identity,
parse_responses_request,
)
try:
__version__ = importlib.metadata.version(__name__)
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0"
__all__ = [
"ResponsesChannel",
"__version__",
"messages_from_responses_input",
"parse_responses_identity",
"parse_responses_request",
]
@@ -0,0 +1,363 @@
# Copyright (c) Microsoft. All rights reserved.
"""``ResponsesChannel`` — OpenAI Responses-shaped HTTP surface.
Exposes a single ``POST /responses`` endpoint that accepts
``{"input": "...", "stream": false}`` (and the rest of the Responses API
request body) and returns either a Responses-shaped JSON body
(``stream=False``, default) or a Server-Sent-Events stream
(``stream=True``).
Payload construction reuses the ``openai.types.responses`` Pydantic
models so the OpenAI Python SDK ``stream=True`` consumer parses every
required field without surprises.
"""
from __future__ import annotations
import time
import uuid
from collections.abc import AsyncIterator, Callable, Mapping
from typing import Any
from agent_framework_hosting import (
ChannelContext,
ChannelContribution,
ChannelRequest,
ChannelResponseHook,
ChannelRunHook,
ChannelSession,
ChannelStreamUpdateHook,
get_current_isolation_keys,
logger,
)
from openai.types.responses import (
Response as OpenAIResponse,
)
from openai.types.responses import (
ResponseCompletedEvent,
ResponseCreatedEvent,
ResponseError,
ResponseFailedEvent,
ResponseOutputMessage,
ResponseOutputText,
ResponseTextDeltaEvent,
)
from starlette.requests import Request
from starlette.responses import JSONResponse, Response, StreamingResponse
from starlette.routing import Route
from ._parsing import (
parse_responses_identity,
parse_responses_request,
)
class ResponsesChannel:
"""Minimal OpenAI-Responses-shaped surface.
Mounts ``POST <path>/responses`` (default path ``/responses`` so the
full route is ``/responses/responses`` when the channel is prefixed,
or just ``/`` when ``path=""``).
"""
name = "responses"
def __init__(
self,
*,
path: str = "/responses",
run_hook: ChannelRunHook | None = None,
response_hook: ChannelResponseHook | None = None,
stream_update_hook: ChannelStreamUpdateHook | None = None,
response_id_factory: Callable[..., str] | None = None,
) -> None:
"""Create a Responses channel.
Keyword Args:
path: Endpoint path on the host. Default ``"/responses"`` matches
the upstream OpenAI surface; use ``""`` to expose this channel
at the app root.
run_hook: Optional :data:`ChannelRunHook` the host invokes with
the parsed :class:`ChannelRequest` before the agent target
runs. May return a replacement request.
response_hook: Optional :data:`ChannelResponseHook` the host invokes
before the channel serializes an originating
:class:`HostedRunResult` into a Responses envelope.
stream_update_hook: Optional per-update hook
applied while streaming Server-Sent Events. Return a
replacement update, or ``None`` to drop the update.
response_id_factory: Optional callable that mints the
per-request response id. Default produces
``resp_<uuid hex>`` which matches the OpenAI Responses
wire shape. Override when the host backing storage
requires a different id format (e.g. Foundry storage,
whose partition keys are encoded in the id and which
rejects free-form ``resp_*`` ids with a server error).
The same id is used for the channel envelope and for
the host-side anchoring (``ChannelRequest.attributes``)
so storage and replay agree.
Security note on partition co-location: when a caller
supplies ``previous_response_id`` we forward it to the
factory so id backends that embed partition keys can
co-locate the new record with the chain's existing
partition. The factory passes that hint through to the
storage layer; **partition ownership is enforced at
the storage layer**, not in the channel: the Foundry
storage provider, for example, validates the request
against the bound user/chat isolation keys and rejects
writes whose embedded partition does not match the
authenticated caller's isolation. Channel-level
forwarding is therefore a performance hint, not a
security boundary; the host's isolation middleware
must establish the caller's identity before this
route is entered.
"""
self.path = path
self._hook = run_hook
self.response_hook = response_hook
self._stream_update_hook = stream_update_hook
self._ctx: ChannelContext | None = None
self._response_id_factory: Callable[..., str] = (
response_id_factory if response_id_factory is not None else (lambda *_a, **_kw: f"resp_{uuid.uuid4().hex}")
)
def contribute(self, context: ChannelContext) -> ChannelContribution:
"""Capture the host-supplied context and register the endpoint route."""
self._ctx = context
return ChannelContribution(routes=[Route("/", self._handle, methods=["POST"])])
async def _handle(self, request: Request) -> Response:
"""Handle a single Responses API call.
Parses the OpenAI Responses-shaped body into ``Message`` /
``options`` / ``ChannelSession`` triples via :mod:`._parsing`,
applies the optional ``run_hook``, and either streams an SSE
response stream or returns a one-shot OpenAI ``Response`` envelope.
"""
if self._ctx is None: # pragma: no cover - guarded by Channel lifecycle
return JSONResponse({"error": "channel not initialized"}, status_code=500)
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "invalid json"}, status_code=400)
try:
messages, options, session = parse_responses_request(body)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=422)
# When no ``previous_response_id`` chain anchor is on the body,
# surface the isolation key the **host** lifted off the request
# (via ``_FoundryIsolationASGIMiddleware`` for the default
# Foundry-platform deployment, or whatever middleware the
# operator configured in front of the host) as the channel
# session id, so callers without an explicit anchor still get
# a stable per-conversation session id (used by non-Foundry
# history providers, routing/idempotency, etc.).
#
# Security note: we consume the host-bound contextvar set by the
# ASGI isolation middleware, NOT the raw header off the wire.
# That middleware is the operator's place to enforce auth and
# gate which callers get to set isolation. If you mount the host
# in front of a custom auth boundary, your middleware should
# validate the caller before stamping ``set_current_isolation_keys``;
# never trust raw wire headers to identify a session bucket.
# The chat-iso value is *not* a valid storage anchor: the
# Foundry history provider deliberately ignores it — multi-turn
# storage chaining goes through the ``previous_response_id`` /
# bound ``response_id`` pair on ``ChannelRequest.attributes``.
bound_keys = get_current_isolation_keys()
chat_iso = bound_keys.chat_key if bound_keys is not None else None
if session is None and chat_iso:
session = ChannelSession(isolation_key=chat_iso)
# Mint the response id once per request so the channel envelope
# (one-shot or streamed) and any host-side anchoring (e.g. the
# Foundry history provider's ``bind_request_context``) agree on
# the same handle. The next turn arrives with this value as
# ``previous_response_id`` and the storage chain walks. We pass
# both anchors via ``ChannelRequest.attributes`` so the host
# can pick them up without a channel-specific contract.
previous_response_id: str | None = None
prev_raw = body.get("previous_response_id")
if isinstance(prev_raw, str) and prev_raw:
previous_response_id = prev_raw
# Pass the previous id (if any) as a hint to the factory so id
# backends that embed partition keys (e.g. Foundry storage) can
# co-locate the new record with the chain's existing partition.
# No-arg factories continue to work via ``Callable[..., str]``.
response_id = self._response_id_factory(previous_response_id)
attributes: dict[str, Any] = {"response_id": response_id}
if previous_response_id is not None:
attributes["previous_response_id"] = previous_response_id
# Honor the OpenAI-Responses ``stream`` flag — non-streaming by
# default, SSE when the caller opts in. The channel chooses the
# transport before run hooks execute.
channel_request = ChannelRequest(
channel=self.name,
operation="message.create",
input=messages,
session=session,
options=options or None,
stream=bool(body.get("stream", False)),
identity=parse_responses_identity(body, self.name),
attributes=attributes,
)
if channel_request.stream:
return StreamingResponse(
self._stream_events(channel_request, body, response_id=response_id),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
result = await self._ctx.run(
channel_request,
run_hook=self._hook,
protocol_request=body,
response_hook=self.response_hook,
channel_name=self.name,
)
text = result.result.text
envelope = self._build_response(body, text, status="completed", response_id=response_id)
return JSONResponse(envelope.model_dump(mode="json", exclude_none=True))
def _build_response(
self,
body: Mapping[str, Any],
text: str,
*,
status: str,
response_id: str | None = None,
) -> OpenAIResponse:
"""Construct an OpenAI ``Response`` for a finished (non-streaming) run.
``status`` mirrors the top-level Response status set values
(``in_progress`` / ``completed`` / ``failed`` / ``incomplete`` /
``cancelled``). The nested ``ResponseOutputMessage.status`` field
only accepts ``in_progress`` / ``completed`` / ``incomplete``, so
terminal-but-non-success states collapse to ``incomplete`` there
— the failure detail still travels via the top-level ``status``
and (for streamed errors) the ``error`` field.
``response_id``: the per-request id minted in :meth:`_handle`.
Passed in so envelope and storage agree on a single handle per
turn (see :meth:`_handle` notes). Falls back to a fresh uuid
when callers (e.g. :meth:`_stream_events`'s skeleton path
before this argument was introduced) don't supply one.
"""
message_status = status if status in ("in_progress", "completed", "incomplete") else "incomplete"
return OpenAIResponse(
id=response_id or self._response_id_factory(None),
object="response",
created_at=time.time(),
status=status, # type: ignore[arg-type]
model=body.get("model", "agent"),
output=[
ResponseOutputMessage(
id=f"msg_{uuid.uuid4().hex}",
type="message",
role="assistant",
status=message_status, # type: ignore[arg-type]
content=[ResponseOutputText(type="output_text", text=text, annotations=[])],
)
],
parallel_tool_calls=False,
tool_choice="auto",
tools=[],
metadata={},
)
async def _stream_events(
self,
request: ChannelRequest,
body: Mapping[str, Any],
*,
response_id: str,
) -> AsyncIterator[str]:
"""Yield SSE events shaped like the OpenAI Responses streaming protocol.
Emits ``response.created`` → many ``response.output_text.delta``
→ ``response.completed`` (or ``response.failed`` on error).
"""
if self._ctx is None: # pragma: no cover - guarded by Channel lifecycle
return
msg_id = f"msg_{uuid.uuid4().hex}"
seq = 0
def next_seq() -> int:
nonlocal seq
seq += 1
return seq
def sse(event: Any) -> str:
return f"event: {event.type}\ndata: {event.model_dump_json(exclude_none=True)}\n\n"
skeleton = self._build_response(body, "", status="in_progress", response_id=response_id)
yield sse(ResponseCreatedEvent(type="response.created", response=skeleton, sequence_number=next_seq()))
accumulated = ""
try:
stream = await self._ctx.run_stream(
request,
run_hook=self._hook,
protocol_request=body,
stream_update_hook=self._stream_update_hook,
response_hook=self.response_hook,
channel_name=self.name,
)
async for update in stream:
chunk = getattr(update, "text", None)
if chunk:
accumulated += chunk
yield sse(
ResponseTextDeltaEvent(
type="response.output_text.delta",
item_id=msg_id,
output_index=0,
content_index=0,
delta=chunk,
logprobs=[],
sequence_number=next_seq(),
)
)
try:
# Finalize so context-provider / history hooks on the agent
# still run even though we are emitting our own SSE.
final_response = await stream.get_final_response()
except Exception: # pragma: no cover - finalize is best-effort
logger.exception("Responses stream finalize failed")
final_response = None
except Exception as exc:
logger.exception("Responses stream consumption failed")
failed = self._build_response(body, accumulated, status="failed", response_id=response_id)
failed.error = ResponseError(code="server_error", message=str(exc))
yield sse(
ResponseFailedEvent(
type="response.failed",
response=failed,
sequence_number=next_seq(),
)
)
return
completed_text = getattr(final_response, "text", None) or accumulated
completed = self._build_response(body, completed_text, status="completed", response_id=response_id)
# Reuse the same message id we emitted deltas under.
if completed.output and isinstance(completed.output[0], ResponseOutputMessage):
completed.output[0].id = msg_id
yield sse(
ResponseCompletedEvent(
type="response.completed",
response=completed,
sequence_number=next_seq(),
)
)
__all__ = ["ResponsesChannel"]
@@ -0,0 +1,169 @@
# Copyright (c) Microsoft. All rights reserved.
"""Parsing helpers for the OpenAI Responses-API request body.
The Responses API accepts ``input`` as either a string or a list of "input
items". An item is either a content part (``input_text`` / ``input_image``
/ ``input_file``) or a message envelope ``{type: "message", role,
content: [...]}``. We translate that into an Agent Framework ``Message``
list and split out the ChatOptions-shaped fields the API also carries.
"""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any, cast
from agent_framework import Content, Message
from agent_framework_hosting import ChannelIdentity, ChannelSession
# OpenAI Responses field name → Agent Framework ChatOptions field name.
_RESPONSES_OPTION_REMAP = {
"max_output_tokens": "max_tokens",
"parallel_tool_calls": "allow_multiple_tool_calls",
}
# Fields we forward to ChatOptions verbatim. ``instructions`` stays here
# because Agent Framework exposes it as a ChatOptions field; it must not be
# lifted into a synthetic system message.
_RESPONSES_OPTION_PASSTHROUGH = {
"instructions",
"temperature",
"top_p",
"metadata",
"user",
"safety_identifier",
"tool_choice",
"tools",
"store",
"response_format",
"stop",
"seed",
"frequency_penalty",
"presence_penalty",
"logit_bias",
}
# Fields the Responses transport owns; they must not be forwarded as options.
_RESPONSES_TRANSPORT_KEYS = {"input", "model", "stream", "previous_response_id"}
def parse_responses_identity(body: Mapping[str, Any], channel_name: str) -> ChannelIdentity | None:
"""Surface the caller as a :class:`ChannelIdentity` so the host can record it.
OpenAI Responses replaced ``user`` with ``safety_identifier`` — we use
that as the native id, falling back to the legacy ``user`` field.
"""
native = body.get("safety_identifier") or body.get("user")
if not isinstance(native, str) or not native:
return None
return ChannelIdentity(channel=channel_name, native_id=native)
def _content_from_input_item(item: Mapping[str, Any]) -> Content:
"""Convert a single OpenAI Responses ``input`` item into a :class:`Content` part.
Handles the ``input_text``/``output_text``/``text`` text variants,
``input_image`` URL references, and ``input_file`` references via either
a public URL or a hosted ``file_id``. Raises ``ValueError`` for any
unsupported item type so the surrounding parser can return a 422.
"""
item_type = item.get("type")
if item_type in ("input_text", "output_text", "text"):
return Content.from_text(text=str(item.get("text", "")))
if item_type == "input_image":
image_url: Any = item.get("image_url")
if isinstance(image_url, Mapping):
image_url = cast("Mapping[str, Any]", image_url).get("url")
if not isinstance(image_url, str):
raise ValueError("input_image requires `image_url`")
return Content.from_uri(uri=image_url, media_type="image/*")
if item_type == "input_file":
if (uri := item.get("file_url")) and isinstance(uri, str):
return Content.from_uri(uri=uri, media_type=item.get("mime_type"))
if file_id := item.get("file_id"):
return Content(type="hosted_file", file_id=str(file_id))
raise ValueError("input_file requires `file_url` or `file_id`")
raise ValueError(f"Unsupported Responses input content type: {item_type!r}")
def messages_from_responses_input(value: Any) -> list[Message]:
"""Translate ``input`` (string or list of items) into :class:`Message` objects."""
if isinstance(value, str):
return [Message("user", [Content.from_text(text=value)])]
if not isinstance(value, list) or not value:
raise ValueError("`input` must be a non-empty string or list")
messages: list[Message] = []
pending_user_parts: list[Content] = []
def flush() -> None:
"""Emit any buffered loose user content as a single user message."""
if pending_user_parts:
messages.append(Message("user", list(pending_user_parts)))
pending_user_parts.clear()
for item in cast("list[Any]", value): # type: ignore[redundant-cast]
if not isinstance(item, Mapping):
raise ValueError("each `input` item must be an object")
item_map = cast("Mapping[str, Any]", item)
if item_map.get("type") == "message":
flush()
role = str(item_map.get("role") or "user")
content: Any = item_map.get("content") or []
parts: list[Content]
if isinstance(content, str):
parts = [Content.from_text(text=content)]
elif isinstance(content, list):
parts = [
_content_from_input_item(cast("Mapping[str, Any]", c))
for c in cast("list[Any]", content) # type: ignore[redundant-cast]
if isinstance(c, Mapping)
]
else:
parts = []
messages.append(Message(role, parts))
else:
pending_user_parts.append(_content_from_input_item(item_map))
flush()
if not messages:
raise ValueError("`input` produced no messages")
return messages
def parse_responses_request(
body: Mapping[str, Any],
) -> tuple[list[Message], dict[str, Any], ChannelSession | None]:
"""Translate a Responses-API request body into Agent Framework constructs.
Returns a triple ``(messages, options, session)`` where:
- ``messages`` is the parsed conversation.
- ``options`` is a ``ChatOptions``-shaped dict with the model-tunable
fields the channel lifted off the body.
- ``session`` is a :class:`ChannelSession` keyed by
``previous_response_id`` when one was supplied, else ``None``.
"""
messages = messages_from_responses_input(body.get("input"))
options: dict[str, Any] = {}
for key, value in body.items():
if key in _RESPONSES_TRANSPORT_KEYS or value is None:
continue
if (mapped := _RESPONSES_OPTION_REMAP.get(key)) is not None:
options[mapped] = value
elif key in _RESPONSES_OPTION_PASSTHROUGH:
options[key] = value
# silently drop everything else (truncation, reasoning, include, ...)
session: ChannelSession | None = None
if (prev := body.get("previous_response_id")) and isinstance(prev, str):
session = ChannelSession(isolation_key=prev)
return messages, options, session
__all__ = [
"messages_from_responses_input",
"parse_responses_identity",
"parse_responses_request",
]
@@ -0,0 +1,98 @@
[project]
name = "agent-framework-hosting-responses"
description = "OpenAI Responses-shaped channel for agent-framework-hosting."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0a260424"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
urls.issues = "https://github.com/microsoft/agent-framework/issues"
classifiers = [
"License :: OSI Approved :: MIT License",
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.2.0,<2",
"agent-framework-hosting==1.0.0a260424",
"openai>=1.99.0,<3",
]
[tool.uv]
prerelease = "if-necessary-or-explicit"
environments = [
"sys_platform == 'darwin'",
"sys_platform == 'linux'",
"sys_platform == 'win32'"
]
[tool.uv-dynamic-versioning]
fallback-version = "0.0.0"
[tool.pytest.ini_options]
testpaths = 'tests'
addopts = "-ra -q -r fEX"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = []
timeout = 120
markers = [
"integration: marks tests as integration tests that require external services",
]
[tool.ruff]
extend = "../../pyproject.toml"
[tool.coverage.run]
omit = [
"**/__init__.py"
]
[tool.pyright]
extends = "../../pyproject.toml"
include = ["agent_framework_hosting_responses"]
exclude = ['tests']
[tool.mypy]
plugins = ['pydantic.mypy']
strict = true
python_version = "3.10"
ignore_missing_imports = true
disallow_untyped_defs = true
no_implicit_optional = true
check_untyped_defs = true
warn_return_any = true
show_error_codes = true
warn_unused_ignores = false
disallow_incomplete_defs = true
disallow_untyped_decorators = true
[tool.bandit]
targets = ["agent_framework_hosting_responses"]
exclude_dirs = ["tests"]
[tool.poe]
executor.type = "uv"
include = "../../shared_tasks.toml"
[tool.poe.tasks.mypy]
help = "Run MyPy for this package."
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_hosting_responses"
[tool.poe.tasks.test]
help = "Run the default unit test suite for this package."
cmd = 'pytest -m "not integration" --cov=agent_framework_hosting_responses --cov-report=term-missing:skip-covered tests'
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
build-backend = "flit_core.buildapi"
@@ -0,0 +1,272 @@
# Copyright (c) Microsoft. All rights reserved.
"""End-to-end tests for :class:`ResponsesChannel` via Starlette's ``TestClient``."""
from __future__ import annotations
from collections.abc import AsyncIterator
from dataclasses import dataclass
from typing import Any
from agent_framework_hosting import (
AgentFrameworkHost,
HostedRunResult,
)
from starlette.testclient import TestClient
from agent_framework_hosting_responses import ResponsesChannel
# --------------------------------------------------------------------------- #
# Fakes #
# --------------------------------------------------------------------------- #
@dataclass
class _FakeAgentResponse:
text: str
@dataclass
class _FakeUpdate:
text: str
class _FakeStream:
"""Minimal stand-in for AF's ``ResponseStream`` returned by ``run(stream=True)``."""
def __init__(self, chunks: list[str]) -> None:
self._chunks = chunks
self._final = _FakeAgentResponse(text="".join(chunks))
def __aiter__(self) -> AsyncIterator[_FakeUpdate]:
async def _gen() -> AsyncIterator[_FakeUpdate]:
for c in self._chunks:
yield _FakeUpdate(c)
return _gen()
async def get_final_response(self) -> _FakeAgentResponse:
return self._final
class _FakeAgent:
def __init__(self, reply: str = "hello", chunks: list[str] | None = None) -> None:
self._reply = reply
self._chunks = chunks or [reply]
self.calls: list[dict[str, Any]] = []
def create_session(self, *, session_id: str | None = None) -> Any:
return {"session_id": session_id}
def run(self, messages: Any = None, *, stream: bool = False, **kwargs: Any) -> Any:
self.calls.append({"messages": messages, "stream": stream, "kwargs": kwargs})
if stream:
return _FakeStream(self._chunks)
async def _coro() -> _FakeAgentResponse:
return _FakeAgentResponse(text=self._reply)
return _coro()
# --------------------------------------------------------------------------- #
# Tests #
# --------------------------------------------------------------------------- #
def _make_client(
agent: _FakeAgent | None = None,
*,
path: str = "/responses",
) -> tuple[TestClient, AgentFrameworkHost, _FakeAgent]:
agent = agent or _FakeAgent()
host = AgentFrameworkHost(target=agent, channels=[ResponsesChannel(path=path)])
return TestClient(host.app), host, agent
class TestResponsesChannelNonStreaming:
def test_post_responses_returns_completed_envelope(self) -> None:
client, _host, agent = _make_client(_FakeAgent(reply="hi back"))
with client:
r = client.post("/responses", json={"input": "hi"})
assert r.status_code == 200
body = r.json()
assert body["status"] == "completed"
assert body["object"] == "response"
assert body["id"].startswith("resp_")
assert body["output"][0]["content"][0]["text"] == "hi back"
assert len(agent.calls) == 1
def test_empty_path_mounts_at_app_root(self) -> None:
client, _host, _agent = _make_client(_FakeAgent(reply="hi back"), path="")
with client:
r = client.post("/", json={"input": "hi"})
assert r.status_code == 200
assert r.json()["output"][0]["content"][0]["text"] == "hi back"
def test_invalid_json_returns_400(self) -> None:
client, *_ = _make_client()
with client:
r = client.post("/responses", content=b"{not json", headers={"content-type": "application/json"})
assert r.status_code == 400
def test_invalid_input_returns_422(self) -> None:
client, *_ = _make_client()
with client:
r = client.post("/responses", json={"input": 42})
assert r.status_code == 422
def test_options_propagate_to_target_run(self) -> None:
client, _host, agent = _make_client()
with client:
r = client.post("/responses", json={"input": "x", "temperature": 0.5, "max_output_tokens": 64})
assert r.status_code == 200
opts = agent.calls[0]["kwargs"]["options"]
assert opts == {"temperature": 0.5, "max_tokens": 64}
def test_previous_response_id_creates_session(self) -> None:
client, _host, agent = _make_client()
with client:
client.post("/responses", json={"input": "x", "previous_response_id": "resp_42"})
# AgentFrameworkHost converts the channel session into an AgentSession.
sess = agent.calls[0]["kwargs"].get("session")
assert sess is not None
# _FakeAgent.create_session stashes the session_id on the dict it returns.
assert sess["session_id"] == "resp_42"
def test_chat_isolation_header_ignored_outside_foundry(self) -> None:
client, _host, agent = _make_client()
with client:
client.post(
"/responses",
json={"input": "x"},
headers={"x-agent-chat-isolation-key": "chat-abc"},
)
assert "session" not in agent.calls[0]["kwargs"]
def test_chat_isolation_header_creates_session_in_foundry(self, monkeypatch: Any) -> None:
"""Foundry-style ``x-agent-chat-isolation-key`` falls back to a session anchor.
First-turn requests have no ``previous_response_id`` (the client
doesn't have one yet), but Foundry Hosted Agents always inject
the isolation headers. The channel must derive a session from the
chat key so the host can build a stable per-conversation session
that history providers persist under.
"""
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1")
client, _host, agent = _make_client()
with client:
client.post(
"/responses",
json={"input": "x"},
headers={"x-agent-chat-isolation-key": "chat-abc"},
)
sess = agent.calls[0]["kwargs"].get("session")
assert sess is not None
assert sess["session_id"] == "chat-abc"
def test_prev_response_id_wins_over_chat_isolation_header(self, monkeypatch: Any) -> None:
"""When both anchors are present, ``previous_response_id`` wins.
``previous_response_id`` is the protocol-native chain anchor; the
header fallback is only meant to bootstrap when no protocol
anchor exists.
"""
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1")
client, _host, agent = _make_client()
with client:
client.post(
"/responses",
json={"input": "x", "previous_response_id": "resp_99"},
headers={"x-agent-chat-isolation-key": "chat-abc"},
)
sess = agent.calls[0]["kwargs"].get("session")
assert sess is not None
assert sess["session_id"] == "resp_99"
def test_response_hook_can_rewrite_originating_reply(self) -> None:
seen_kwargs: list[dict[str, Any]] = []
def hook(result: HostedRunResult, **kwargs: Any) -> HostedRunResult:
seen_kwargs.append(dict(kwargs))
return HostedRunResult(_FakeAgentResponse(text=result.result.text.upper()), session=result.session)
agent = _FakeAgent(reply="hooked")
host = AgentFrameworkHost(target=agent, channels=[ResponsesChannel(response_hook=hook)])
with TestClient(host.app) as client:
r = client.post("/responses", json={"input": "hi"})
assert r.status_code == 200
body = r.json()
assert body["output"][0]["content"][0]["text"] == "HOOKED"
assert seen_kwargs
assert seen_kwargs[0]["channel_name"] == "responses"
class TestResponsesChannelStreaming:
def test_sse_emits_created_delta_completed(self) -> None:
agent = _FakeAgent(reply="hello world", chunks=["hello", " ", "world"])
host = AgentFrameworkHost(target=agent, channels=[ResponsesChannel()])
with TestClient(host.app) as client:
r = client.post("/responses", json={"input": "hi", "stream": True})
assert r.status_code == 200
body = r.text
# SSE event lines look like "event: <type>\ndata: <json>\n\n".
events = [line[len("event: ") :] for line in body.splitlines() if line.startswith("event: ")]
assert events[0] == "response.created"
assert events[-1] == "response.completed"
assert events.count("response.output_text.delta") == 3
def test_sse_transform_hook_can_rewrite_chunks(self) -> None:
agent = _FakeAgent(reply="hello", chunks=["he", "llo"])
def transform(update: _FakeUpdate) -> _FakeUpdate:
return _FakeUpdate(text=update.text.upper())
host = AgentFrameworkHost(target=agent, channels=[ResponsesChannel(stream_update_hook=transform)])
with TestClient(host.app) as client:
r = client.post("/responses", json={"input": "hi", "stream": True})
assert r.status_code == 200
assert '"delta":"HE"' in r.text
assert '"delta":"LLO"' in r.text
# Stream update hooks are update-only; they do not rewrite get_final_response().
assert '"text":"hello"' in r.text
def test_sse_emits_failed_when_stream_raises(self) -> None:
# Regression: ResponseOutputMessage.status only accepts in_progress/
# completed/incomplete, so building an OpenAIResponse with status="failed"
# used to crash with a pydantic ValidationError. The channel must map the
# nested message status to "incomplete" while keeping the top-level
# Response.status="failed".
class _BoomStream:
def __aiter__(self) -> AsyncIterator[_FakeUpdate]:
async def _gen() -> AsyncIterator[_FakeUpdate]:
yield _FakeUpdate("partial")
raise RuntimeError("upstream blew up")
return _gen()
async def get_final_response(self) -> _FakeAgentResponse: # pragma: no cover
return _FakeAgentResponse(text="")
class _BoomAgent(_FakeAgent):
def run(self, messages: Any = None, *, stream: bool = False, **kwargs: Any) -> Any:
self.calls.append({"messages": messages, "stream": stream, "kwargs": kwargs})
if stream:
return _BoomStream()
raise AssertionError("non-streaming path not exercised here")
host = AgentFrameworkHost(target=_BoomAgent(), channels=[ResponsesChannel()])
with TestClient(host.app) as client:
r = client.post("/responses", json={"input": "hi", "stream": True})
assert r.status_code == 200
body = r.text
events = [line[len("event: ") :] for line in body.splitlines() if line.startswith("event: ")]
assert events[0] == "response.created"
assert events[-1] == "response.failed"
# The failed envelope must serialize cleanly — i.e. no ValidationError raised.
assert "upstream blew up" in body
@@ -0,0 +1,144 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for the OpenAI Responses request-body parser."""
from __future__ import annotations
import pytest
from agent_framework_hosting_responses import (
messages_from_responses_input,
parse_responses_identity,
parse_responses_request,
)
class TestMessagesFromResponsesInput:
def test_string_input_becomes_single_user_message(self) -> None:
msgs = messages_from_responses_input("hello")
assert len(msgs) == 1
assert msgs[0].role == "user"
assert msgs[0].text == "hello"
def test_input_text_items_collapse_into_one_user_message(self) -> None:
msgs = messages_from_responses_input([{"type": "input_text", "text": "a"}, {"type": "input_text", "text": "b"}])
assert len(msgs) == 1
assert msgs[0].role == "user"
assert msgs[0].text == "a b"
def test_message_envelope_with_string_content(self) -> None:
msgs = messages_from_responses_input([
{"type": "message", "role": "system", "content": "be brief"},
{"type": "message", "role": "user", "content": "hi"},
])
assert [m.role for m in msgs] == ["system", "user"]
assert msgs[0].text == "be brief"
def test_message_envelope_with_content_parts(self) -> None:
msgs = messages_from_responses_input([
{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "describe this"}],
}
])
assert msgs[0].text == "describe this"
def test_pending_text_flushes_before_message_envelope(self) -> None:
msgs = messages_from_responses_input([
{"type": "input_text", "text": "first"},
{"type": "message", "role": "user", "content": "second"},
])
assert len(msgs) == 2
assert msgs[0].text == "first"
assert msgs[1].text == "second"
def test_image_url_via_string(self) -> None:
msgs = messages_from_responses_input([{"type": "input_image", "image_url": "https://example.com/cat.png"}])
assert len(msgs) == 1
# Image content present.
assert any(getattr(c, "uri", None) == "https://example.com/cat.png" for c in msgs[0].contents)
def test_image_url_via_object(self) -> None:
msgs = messages_from_responses_input([
{"type": "input_image", "image_url": {"url": "https://example.com/cat.png"}}
])
assert any(getattr(c, "uri", None) == "https://example.com/cat.png" for c in msgs[0].contents)
def test_unknown_input_type_raises(self) -> None:
with pytest.raises(ValueError, match="Unsupported"):
messages_from_responses_input([{"type": "weird"}])
def test_empty_list_raises(self) -> None:
with pytest.raises(ValueError, match="non-empty"):
messages_from_responses_input([])
def test_non_string_non_list_raises(self) -> None:
with pytest.raises(ValueError):
messages_from_responses_input(42) # type: ignore[arg-type]
def test_image_url_missing_raises(self) -> None:
with pytest.raises(ValueError, match="image_url"):
messages_from_responses_input([{"type": "input_image"}])
class TestParseResponsesRequest:
def test_instructions_are_forwarded_as_chat_options(self) -> None:
msgs, opts, sess = parse_responses_request({"input": "hi", "instructions": "be brief"})
assert len(msgs) == 1
assert msgs[0].role == "user"
assert msgs[0].text == "hi"
assert opts["instructions"] == "be brief"
assert sess is None
def test_options_passthrough(self) -> None:
_, opts, _ = parse_responses_request({"input": "x", "temperature": 0.4, "top_p": 0.9, "tool_choice": "auto"})
assert opts["temperature"] == 0.4
assert opts["top_p"] == 0.9
assert opts["tool_choice"] == "auto"
def test_options_remap(self) -> None:
_, opts, _ = parse_responses_request({"input": "x", "max_output_tokens": 256, "parallel_tool_calls": False})
assert opts == {"max_tokens": 256, "allow_multiple_tool_calls": False}
def test_transport_keys_not_forwarded(self) -> None:
_, opts, _ = parse_responses_request({
"input": "x",
"model": "gpt-x",
"stream": True,
"previous_response_id": "r",
})
for key in ("input", "model", "stream", "previous_response_id"):
assert key not in opts
def test_unknown_keys_silently_dropped(self) -> None:
_, opts, _ = parse_responses_request({"input": "x", "truncation": "auto", "reasoning": {"effort": "low"}})
assert opts == {}
def test_none_values_dropped(self) -> None:
_, opts, _ = parse_responses_request({"input": "x", "temperature": None})
assert "temperature" not in opts
def test_previous_response_id_becomes_session(self) -> None:
_, _, sess = parse_responses_request({"input": "x", "previous_response_id": "resp_42"})
assert sess is not None
assert sess.isolation_key == "resp_42"
class TestParseResponsesIdentity:
def test_safety_identifier_preferred(self) -> None:
ident = parse_responses_identity({"safety_identifier": "abc", "user": "legacy"}, "responses")
assert ident is not None
assert ident.native_id == "abc"
assert ident.channel == "responses"
def test_fallback_to_user(self) -> None:
ident = parse_responses_identity({"user": "legacy"}, "responses")
assert ident is not None
assert ident.native_id == "legacy"
def test_returns_none_when_absent(self) -> None:
assert parse_responses_identity({}, "responses") is None
def test_returns_none_for_non_string(self) -> None:
assert parse_responses_identity({"safety_identifier": 42}, "responses") is None
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
@@ -0,0 +1,29 @@
# agent-framework-hosting-telegram
Telegram channel for [agent-framework-hosting](../hosting). Supports both
**polling** (default — no public URL required, perfect for local dev) and
**webhook** transports, multi-content messages (text + media), command
registration, and end-to-end SSE-style streaming via Telegram message edits.
## Usage
```python
from agent_framework_hosting import AgentFrameworkHost
from agent_framework_hosting_telegram import TelegramChannel
host = AgentFrameworkHost(
target=my_agent,
channels=[TelegramChannel(bot_token="...")],
)
host.serve()
```
For production, configure `webhook_url="https://…"` and the channel will
register the webhook on startup and receive updates over HTTPS.
## Identity & sessions
Each Telegram chat is mapped to an opaque isolation key
(`telegram:<chat_id>`) so other channels can opt into the same per-chat
session by reusing the same key. The helper `telegram_isolation_key(chat_id)`
is exported for that purpose.
@@ -0,0 +1,7 @@
# Copyright (c) Microsoft. All rights reserved.
"""Telegram channel for :mod:`agent_framework_hosting`."""
from ._channel import TelegramChannel, telegram_isolation_key
__all__ = ["TelegramChannel", "telegram_isolation_key"]
@@ -0,0 +1,814 @@
# Copyright (c) Microsoft. All rights reserved.
"""Built-in channel: Telegram (polling + webhook transports).
Inspired by PR #5393's Telegram sample. Two transports are supported:
- ``polling`` (default when no ``webhook_url`` is set): the channel runs a
background ``getUpdates`` long-poll loop. No public URL required —
perfect for local development. This is what ``python-telegram-bot``
uses by default.
- ``webhook``: when ``webhook_url`` is set, the channel registers it via
``setWebhook`` on startup and receives updates over HTTPS POSTs to the
mounted ``/webhook`` route. This is the production-recommended mode.
"""
from __future__ import annotations
import asyncio
import contextlib
import time
from collections.abc import Awaitable, Callable, Mapping, Sequence
from typing import Any, Literal
import httpx
from agent_framework import (
AgentResponse,
AgentResponseUpdate,
Content,
Message,
ResponseStream,
)
from agent_framework_hosting import (
ChannelCommand,
ChannelCommandContext,
ChannelContext,
ChannelContribution,
ChannelIdentity,
ChannelRequest,
ChannelResponseHook,
ChannelRunHook,
ChannelSession,
ChannelStreamUpdateHook,
logger,
)
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from starlette.routing import BaseRoute, Route
# Telegram update parsing ------------------------------------------------------
#
# A Telegram message can carry text, a caption, and one of several media kinds
# (photo, document, voice, audio, video). For media we resolve the file_id
# into a public bot-file URL via ``getFile`` and emit a ``Content.from_uri``;
# the agent then receives a multi-content Message with text + media side by
# side, the same as it would over the Responses API.
_TELEGRAM_MEDIA_DEFAULT_MIMETYPE = {
"photo": "image/jpeg",
"document": "application/octet-stream",
"voice": "audio/ogg",
"audio": "audio/mpeg",
"video": "video/mp4",
}
# Telegram's hard limit on a single message body. Past this, sendMessage /
# editMessageText return 400. We truncate interim and final edits at this
# boundary; if the agent emits more, callers can split into a follow-up
# sendMessage in their run hook.
_TELEGRAM_MAX_TEXT_LEN = 4096
def telegram_isolation_key(chat_id: Any) -> str:
"""Build the namespaced isolation key the Telegram channel writes under.
Exposed at module scope so other channels' ``run_hook`` callbacks can opt
into the same per-chat session (e.g. a Responses caller resuming a
Telegram conversation by passing the chat id).
"""
return f"telegram:{chat_id}"
def _telegram_media_file_id(message: Mapping[str, Any]) -> tuple[str, str] | None:
"""Return ``(file_id, fallback_media_type)`` for any media on the message."""
photo = message.get("photo")
if isinstance(photo, list) and photo:
# Telegram delivers photos as an array of progressively-larger sizes.
largest = photo[-1]
if isinstance(largest, Mapping) and (fid := largest.get("file_id")):
return str(fid), _TELEGRAM_MEDIA_DEFAULT_MIMETYPE["photo"]
for kind in ("document", "voice", "audio", "video"):
media = message.get(kind)
if media and isinstance(media, Mapping) and (fid := media.get("file_id")):
return str(fid), str(media.get("mime_type") or _TELEGRAM_MEDIA_DEFAULT_MIMETYPE[kind])
return None
async def _parse_telegram_message(
message: Mapping[str, Any],
resolve_file_url: Callable[[str], Awaitable[str | None]],
) -> Message:
"""Translate one Telegram ``message`` object into an Agent Framework Message."""
parts: list[Content] = []
if (text := message.get("text") or message.get("caption")) and isinstance(text, str):
parts.append(Content.from_text(text=text))
if (media := _telegram_media_file_id(message)) is not None:
file_id, media_type = media
if (uri := await resolve_file_url(file_id)) is not None:
parts.append(Content.from_uri(uri=uri, media_type=media_type))
if not parts:
# Edge case: no recognizable content — emit an empty placeholder so the
# agent contract still receives a Message and can react gracefully.
parts.append(Content.from_text(text=""))
return Message("user", parts)
class TelegramChannel:
"""Telegram channel with both polling and webhook transports.
Update kinds handled (both transports):
- ``message`` / ``edited_message`` — text, captions, and media
(photo/document/voice/audio/video).
- ``callback_query`` — inline-button presses; the ``data`` payload is
treated as the user's next utterance and the click is acknowledged.
Streaming
---------
The channel defaults to ``stream=True`` on every ``ChannelRequest``: it
drives ``ChannelContext.run_stream`` and progressively edits a single
Telegram message as ``AgentResponseUpdate`` chunks arrive (Telegram has
no native streaming primitive). Pass ``stream=False`` on the constructor
to opt out for all messages, or override per-request inside the
the constructor. A ``stream_update_hook`` can rewrite or drop individual
updates before they hit the wire — useful for redaction, formatting, or
merging tool-call deltas.
"""
name = "telegram"
def __init__(
self,
*,
bot_token: str,
path: str = "/telegram/webhook",
commands: Sequence[ChannelCommand] = (),
register_native_commands: bool = True,
run_hook: ChannelRunHook | None = None,
response_hook: ChannelResponseHook | None = None,
api_base: str = "https://api.telegram.org",
webhook_url: str | None = None,
secret_token: str | None = None,
delete_webhook_on_shutdown: bool = False,
parse_mode: str | None = None,
send_typing_action: bool = True,
transport: Literal["auto", "polling", "webhook"] = "auto",
polling_timeout: int = 30,
stream: bool = True,
stream_update_hook: ChannelStreamUpdateHook | None = None,
stream_edit_min_interval: float = 0.4,
) -> None:
self.path = path
self._token = bot_token
self._commands = list(commands)
self._register = register_native_commands
self._hook = run_hook
self.response_hook = response_hook
self._stream_default = stream
self._stream_update_hook = stream_update_hook
self._stream_edit_min_interval = stream_edit_min_interval
self._api = f"{api_base}/bot{bot_token}"
self._webhook_url = webhook_url
self._secret_token = secret_token
self._delete_webhook_on_shutdown = delete_webhook_on_shutdown
self._parse_mode = parse_mode
self._send_typing_action = send_typing_action
if transport == "auto":
transport = "webhook" if webhook_url else "polling"
if transport == "webhook" and not webhook_url:
raise ValueError("transport='webhook' requires webhook_url")
self._transport: Literal["polling", "webhook"] = transport
self._polling_timeout = polling_timeout
self._ctx: ChannelContext | None = None
self._http: httpx.AsyncClient | None = None
self._poll_task: asyncio.Task[None] | None = None
# Tracks all in-flight tasks (per-chat workers + webhook-spawned
# dispatcher tasks). Drained on shutdown.
self._update_tasks: set[asyncio.Task[None]] = set()
# Per-chat serial workers preserve in-chat ordering: each
# chat_id has its own asyncio.Queue + worker task. Updates for
# different chats run in parallel; updates for the same chat
# run strictly in arrival order.
self._chat_queues: dict[int, asyncio.Queue[Mapping[str, Any]]] = {}
self._chat_workers: dict[int, asyncio.Task[None]] = {}
def contribute(self, context: ChannelContext) -> ChannelContribution:
"""Register the webhook route (only in ``webhook`` transport) plus lifecycle hooks.
Polling-mode hosts intentionally expose no HTTP route — adding one
would just confuse readers who expect inbound HTTP traffic to do
something.
"""
self._ctx = context
routes: list[BaseRoute] = []
if self._transport == "webhook":
routes.append(Route("/", self._handle, methods=["POST"]))
return ChannelContribution(
routes=routes,
commands=self._commands,
on_startup=[self._on_startup],
on_shutdown=[self._on_shutdown],
)
# -- lifecycle --------------------------------------------------------- #
async def _on_startup(self) -> None:
"""Open the HTTP client, optionally register slash commands, and start the transport.
- Polling: clears any previously-set webhook (Telegram refuses
``getUpdates`` while one is registered) and launches the
long-poll task.
- Webhook: ``setWebhook`` to the configured URL, including the
optional secret token used to authenticate inbound calls.
"""
# ``getUpdates`` blocks for up to ``polling_timeout`` seconds, so the
# client timeout has to comfortably exceed it. Skip when a client has
# been pre-injected (e.g. by tests).
if self._http is None:
self._http = httpx.AsyncClient(timeout=self._polling_timeout + 15)
if self._register and self._commands:
cmd_payload: dict[str, Any] = {
"commands": [{"command": c.name, "description": c.description} for c in self._commands]
}
await self._http.post(f"{self._api}/setMyCommands", json=cmd_payload)
logger.info("Registered %d Telegram commands", len(self._commands))
if self._transport == "webhook":
payload: dict[str, Any] = {
"url": self._webhook_url,
"allowed_updates": ["message", "edited_message", "callback_query"],
}
if self._secret_token:
payload["secret_token"] = self._secret_token
response = await self._http.post(f"{self._api}/setWebhook", json=payload)
response.raise_for_status()
logger.info("Telegram webhook registered: %s", self._webhook_url)
else:
# Telegram refuses getUpdates while a webhook is set, so clear it.
await self._http.post(f"{self._api}/deleteWebhook", json={"drop_pending_updates": False})
self._poll_task = asyncio.create_task(self._poll_loop(), name="telegram-poll")
logger.info("Telegram polling started (long-poll timeout=%ss)", self._polling_timeout)
async def _on_shutdown(self) -> None:
"""Stop the polling task, drain in-flight workers, close HTTP.
Drain order:
1. Cancel the poll task so no new updates are admitted.
2. Cancel + await per-chat worker tasks so any currently-running
agent invocations can finish before we yank the HTTP client
out from under them.
3. Cancel + await any webhook-dispatched tasks tracked in
``_update_tasks`` (the webhook handler returns 200 immediately
and runs the agent in a background task, which the previous
shutdown ignored entirely).
4. Close the HTTP client.
The webhook registration is intentionally **left in place** on
shutdown. A Telegram webhook is a single global resource, so
deleting it here races rolling redeploys: the new revision calls
``setWebhook`` on startup, then the old revision's shutdown would
delete it, silently breaking inbound delivery until the next boot.
``setWebhook`` is overwriting/idempotent, so the next startup
re-asserts it anyway. Set ``delete_webhook_on_shutdown=True`` to opt
into best-effort teardown (e.g. for a one-off/ephemeral deployment);
failures are logged but never raised so app shutdown can complete.
"""
if self._poll_task is not None:
self._poll_task.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await self._poll_task
self._poll_task = None
# Cancel per-chat workers; their queues are no longer being fed.
for worker in list(self._chat_workers.values()):
worker.cancel()
for worker in list(self._chat_workers.values()):
with contextlib.suppress(asyncio.CancelledError, Exception):
await worker
self._chat_workers.clear()
self._chat_queues.clear()
# Webhook-spawned dispatcher tasks (the ack-before-run path) live
# in _update_tasks alongside any leftover poll-spawned tasks.
for task in list(self._update_tasks):
task.cancel()
for task in list(self._update_tasks):
with contextlib.suppress(asyncio.CancelledError, Exception):
await task
self._update_tasks.clear()
if self._http is not None:
if self._transport == "webhook" and self._delete_webhook_on_shutdown:
try:
await self._http.post(f"{self._api}/deleteWebhook")
except Exception: # pragma: no cover - best-effort cleanup
logger.exception("deleteWebhook failed")
await self._http.aclose()
# -- polling loop ------------------------------------------------------ #
async def _poll_loop(self) -> None:
"""Long-poll ``getUpdates`` until cancelled.
Each batch advances the ``offset`` by the highest seen
``update_id`` so processed updates aren't redelivered. Updates
are routed to per-chat serial workers via :meth:`_enqueue_update`
— this preserves in-chat ordering (Telegram only guarantees
ordering up to ``getUpdates``; the previous fan-out into one
task per update broke that guarantee for adjacent updates).
Different chats still process in parallel because each has its
own worker. Transient errors back off for 2 seconds before
retrying.
"""
if self._http is None: # pragma: no cover - guarded by lifecycle
raise RuntimeError("telegram channel not started")
offset: int | None = None
while True:
try:
params: dict[str, Any] = {
"timeout": self._polling_timeout,
"allowed_updates": '["message","edited_message","callback_query"]',
}
if offset is not None:
params["offset"] = offset
response = await self._http.get(f"{self._api}/getUpdates", params=params)
response.raise_for_status()
payload = response.json()
if not payload.get("ok"):
logger.warning("Telegram getUpdates returned error: %s", payload)
await asyncio.sleep(1.0)
continue
for update in payload.get("result", []) or []:
update_id = update.get("update_id")
if isinstance(update_id, int):
offset = update_id + 1
self._enqueue_update(update)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Telegram polling iteration failed; retrying in 2s")
await asyncio.sleep(2.0)
def _chat_id_for_update(self, update: Mapping[str, Any]) -> int | None:
"""Best-effort extraction of the chat id from any supported update shape."""
message = update.get("message") or update.get("edited_message")
if isinstance(message, Mapping):
chat = message.get("chat")
if isinstance(chat, Mapping):
cid = chat.get("id")
if isinstance(cid, int):
return cid
callback = update.get("callback_query")
if isinstance(callback, Mapping):
inner = callback.get("message")
if isinstance(inner, Mapping):
chat = inner.get("chat")
if isinstance(chat, Mapping):
cid = chat.get("id")
if isinstance(cid, int):
return cid
return None
def _enqueue_update(self, update: Mapping[str, Any]) -> None:
"""Route an update to its per-chat serial worker.
Updates with no resolvable chat_id (malformed payloads, unknown
update types) fall back to a one-shot dispatcher task so they
can't deadlock the main loop.
"""
chat_id = self._chat_id_for_update(update)
if chat_id is None:
# No chat to serialise on — fire and forget, but still track
# so shutdown can drain.
task = asyncio.create_task(self._safe_process_update(update))
self._update_tasks.add(task)
task.add_done_callback(self._update_tasks.discard)
return
queue = self._chat_queues.get(chat_id)
if queue is None:
queue = asyncio.Queue()
self._chat_queues[chat_id] = queue
worker = asyncio.create_task(
self._chat_worker(chat_id, queue),
name=f"telegram-chat-worker-{chat_id}",
)
self._chat_workers[chat_id] = worker
# Ensure shutdown can drain this worker too.
self._update_tasks.add(worker)
worker.add_done_callback(self._update_tasks.discard)
queue.put_nowait(update)
async def _chat_worker(self, chat_id: int, queue: asyncio.Queue[Mapping[str, Any]]) -> None:
"""Drain a single chat's queue serially.
Per-chat ordering is preserved by processing one update at a
time. Exceptions in :meth:`_safe_process_update` are already
swallowed, so the worker keeps running. The worker is cancelled
on channel shutdown.
"""
try:
while True:
update = await queue.get()
try:
await self._safe_process_update(update)
finally:
queue.task_done()
except asyncio.CancelledError:
raise
async def _safe_process_update(self, update: Mapping[str, Any]) -> None:
"""Wrap :meth:`_process_update` so a failure on one update never escapes a task."""
try:
await self._process_update(update)
except Exception:
logger.exception("Telegram update processing failed: %s", update.get("update_id"))
# -- request handling -------------------------------------------------- #
async def _handle(self, request: Request) -> Response:
"""Webhook endpoint — verifies the secret token then queues the update.
Telegram includes the configured secret in the
``X-Telegram-Bot-Api-Secret-Token`` header on every webhook delivery;
we reject mismatches so leaked URLs alone aren't enough to inject
traffic.
**Acks before running the agent.** Telegram redelivers any update
the webhook doesn't 200 within ~60 seconds, so a streamed agent
reply that runs longer than that would otherwise trigger a
retry storm and duplicate replies. We enqueue onto the
per-chat serial worker (preserving ordering with polling-mode)
and immediately return 200; the actual processing happens in
the worker task tracked by ``_update_tasks`` and drained on
shutdown.
"""
if self._secret_token is not None:
received = request.headers.get("x-telegram-bot-api-secret-token")
if received != self._secret_token:
logger.warning("Telegram webhook secret token mismatch — rejecting update")
return JSONResponse({"ok": False, "error": "invalid secret"}, status_code=401)
try:
update = await request.json()
except Exception:
logger.warning("Telegram webhook received malformed JSON; returning 400")
return JSONResponse({"ok": False, "error": "invalid json"}, status_code=400)
if not isinstance(update, Mapping):
logger.warning("Telegram webhook received non-object payload; returning 400")
return JSONResponse({"ok": False, "error": "invalid payload"}, status_code=400)
# Ack immediately, route through per-chat worker so ordering with
# polling-mode is identical and shutdown drains all in-flight work.
self._enqueue_update(update)
return JSONResponse({"ok": True})
async def _process_update(self, update: Mapping[str, Any]) -> None:
"""Convert one Telegram update into a :class:`ChannelRequest` and dispatch.
Branches:
- ``callback_query`` — inline-button click; handled separately so we
can ack the click and treat the button payload as the next user
utterance.
- ``message`` / ``edited_message`` — the common text-and-attachment
case; runs slash commands when present, otherwise builds a
message and dispatches to the agent.
"""
if self._ctx is None: # pragma: no cover - guarded by lifecycle
raise RuntimeError("telegram channel not started")
# Inline-button presses: ack the click, treat the payload as input.
if (callback := update.get("callback_query")) is not None:
await self._handle_callback_query(callback)
return
# message and edited_message share the same shape.
message = update.get("message") or update.get("edited_message") or {}
chat_id = (message.get("chat") or {}).get("id")
text = message.get("text") or message.get("caption")
has_media = any(k in message for k in ("photo", "document", "voice", "audio", "video"))
if chat_id is None or (not isinstance(text, str) and not has_media):
return # Nothing actionable.
# Native command dispatch — bypasses the agent.
if isinstance(text, str) and text.startswith("/"):
command_name = text[1:].split()[0].split("@", 1)[0]
handler = next((c for c in self._commands if c.name == command_name), None)
if handler is not None:
channel_request = ChannelRequest(
channel=self.name,
operation="command.invoke",
input=text,
session=ChannelSession(isolation_key=telegram_isolation_key(chat_id)),
attributes={"chat_id": chat_id},
identity=ChannelIdentity(channel=self.name, native_id=str(chat_id)),
)
ctx = ChannelCommandContext(
request=channel_request,
reply=lambda body, cid=chat_id: self._send(cid, body),
)
await handler.handle(ctx)
return
# Plain message → agent run. Build a multi-content Message with the
# text/caption alongside any attached media (photo, document, ...).
parsed = await _parse_telegram_message(message, self._resolve_file_url)
channel_request = ChannelRequest(
channel=self.name,
operation="message.create",
input=[parsed],
session=ChannelSession(isolation_key=telegram_isolation_key(chat_id)),
attributes={"chat_id": chat_id},
stream=self._stream_default,
identity=ChannelIdentity(channel=self.name, native_id=str(chat_id)),
)
await self._dispatch(chat_id, channel_request, protocol_request=update)
async def _handle_callback_query(self, callback: Mapping[str, Any]) -> None:
"""Handle an inline-button click.
Always answers the callback query to clear the spinner on the user's
client, then treats the button's ``data`` payload as the user's
next utterance and dispatches it as if they had typed it.
Callbacks without a chat or string ``data`` are silently dropped.
"""
if self._ctx is None: # pragma: no cover - guarded by lifecycle
raise RuntimeError("telegram channel not started")
if self._http is None: # pragma: no cover - guarded by lifecycle
raise RuntimeError("telegram channel not started")
callback_id = callback.get("id")
data = callback.get("data")
message = callback.get("message") or {}
chat_id = (message.get("chat") or {}).get("id")
if callback_id is not None:
# Always answer to remove the loading spinner on the user's client.
try:
await self._http.post(f"{self._api}/answerCallbackQuery", json={"callback_query_id": callback_id})
except Exception: # pragma: no cover - defensive
logger.exception("answerCallbackQuery failed")
if chat_id is None or not isinstance(data, str):
return
channel_request = ChannelRequest(
channel=self.name,
operation="message.create",
input=data,
session=ChannelSession(isolation_key=telegram_isolation_key(chat_id)),
attributes={"chat_id": chat_id, "callback_query_id": callback_id},
stream=self._stream_default,
identity=ChannelIdentity(channel=self.name, native_id=str(chat_id)),
)
await self._dispatch(chat_id, channel_request, protocol_request=callback)
async def _resolve_file_url(self, file_id: str) -> str | None:
"""Resolve a Telegram file_id into an HTTPS URL via getFile."""
if self._http is None: # pragma: no cover - guarded by lifecycle
raise RuntimeError("telegram channel not started")
try:
response = await self._http.get(f"{self._api}/getFile", params={"file_id": file_id})
response.raise_for_status()
file_path = response.json().get("result", {}).get("file_path")
except Exception: # pragma: no cover - defensive: bad token, network, etc.
logger.exception("getFile failed for %s", file_id)
return None
return f"{self._api.replace('/bot', '/file/bot')}/{file_path}" if file_path else None
# -- outbound helpers -------------------------------------------------- #
async def _dispatch(self, chat_id: int, request: ChannelRequest, *, protocol_request: Any | None = None) -> None:
"""Run the request and forward results to ``chat_id``."""
if self._ctx is None: # pragma: no cover - guarded by lifecycle
raise RuntimeError("telegram channel not started")
if not request.stream:
if self._send_typing_action:
await self._send_chat_action(chat_id, "typing")
result = await self._ctx.run(
request,
run_hook=self._hook,
protocol_request=protocol_request,
response_hook=self.response_hook,
channel_name=self.name,
)
await self._reply_with_result(chat_id, result.result)
return
stream = await self._ctx.run_stream(
request,
run_hook=self._hook,
protocol_request=protocol_request,
stream_update_hook=self._stream_update_hook,
response_hook=self.response_hook,
channel_name=self.name,
)
await self._stream_to_chat(chat_id, request, stream)
async def _stream_to_chat(
self,
chat_id: int,
request: ChannelRequest,
stream: ResponseStream[AgentResponseUpdate, AgentResponse],
) -> None:
"""Iterate the agent's ResponseStream and progressively edit a Telegram message.
Smoothness recipe:
1. Send the placeholder message up front so the user sees instant
activity (a "…" bubble) instead of waiting for the first edit.
2. Token consumption never awaits the network — a background
``edit_worker`` watches an asyncio.Event, coalesces accumulated
text, rate-limits itself to ``stream_edit_min_interval`` (default
0.4s — well under Telegram's per-chat edit limits), and only sends
when the text actually changed.
3. Interim edits are sent as **plain text** even if a ``parse_mode``
is configured. Partial Markdown/HTML mid-stream is invalid and
Telegram rejects it with 400 ``can't parse entities``. The final
edit re-applies the configured ``parse_mode`` so the user ends up
with formatted output.
4. ``sendChatAction("typing")`` is re-issued every 4s while the
stream is live so the typing bubble doesn't disappear on long
responses (Telegram clears it after ~5s).
"""
if self._http is None: # pragma: no cover - guarded by lifecycle
raise RuntimeError("telegram channel not started")
# Pin to a local so mypy narrows inside the nested closures below.
http = self._http
accumulated = ""
last_sent = ""
last_edit_at = 0.0
message_id: int | None = None
worker_done = asyncio.Event()
wake = asyncio.Event()
async def send_initial_placeholder() -> None:
nonlocal message_id, last_edit_at
try:
response = await http.post(
f"{self._api}/sendMessage",
json={"chat_id": chat_id, "text": "…"},
)
response.raise_for_status()
message_id = response.json().get("result", {}).get("message_id")
last_edit_at = time.monotonic()
except Exception: # pragma: no cover - placeholder is best-effort
logger.exception("Telegram placeholder send failed")
async def edit_worker() -> None:
nonlocal last_sent, last_edit_at
while not (worker_done.is_set() and accumulated == last_sent):
await wake.wait()
wake.clear()
if message_id is None or accumulated == last_sent:
continue
elapsed = time.monotonic() - last_edit_at
if elapsed < self._stream_edit_min_interval:
try:
await asyncio.wait_for(wake.wait(), timeout=self._stream_edit_min_interval - elapsed)
wake.clear()
except asyncio.TimeoutError:
pass
snapshot = accumulated[:_TELEGRAM_MAX_TEXT_LEN]
if snapshot == last_sent:
continue
# Interim edits go out as plain text — partial Markdown/HTML
# is invalid mid-stream and Telegram returns 400.
try:
await http.post(
f"{self._api}/editMessageText",
json={"chat_id": chat_id, "message_id": message_id, "text": snapshot},
)
except Exception: # pragma: no cover - keep streaming on error
logger.exception("Telegram interim edit failed")
last_sent = snapshot
last_edit_at = time.monotonic()
async def typing_worker() -> None:
while not worker_done.is_set():
await self._send_chat_action(chat_id, "typing")
try:
await asyncio.wait_for(worker_done.wait(), timeout=4.0)
except asyncio.TimeoutError:
continue
await send_initial_placeholder()
edit_task = asyncio.create_task(edit_worker(), name="telegram-edit-worker")
typing_task = asyncio.create_task(typing_worker(), name="telegram-typing-worker")
try:
async for update in stream:
chunk = getattr(update, "text", None)
if chunk:
accumulated += chunk
wake.set()
except Exception:
logger.exception("Telegram streaming consumption failed")
finally:
worker_done.set()
wake.set()
try:
await edit_task
except Exception: # pragma: no cover
logger.exception("Telegram edit worker crashed")
typing_task.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await typing_task
# Always finalize so context providers / history hooks run.
try:
final = await stream.get_final_response()
except Exception: # pragma: no cover - finalize is best-effort
logger.exception("Stream finalize failed")
final = None
# Final edit applies parse_mode (if configured) to the full text.
final_text = (getattr(final, "text", None) or accumulated or last_sent)[:_TELEGRAM_MAX_TEXT_LEN]
if message_id is not None and final_text and final_text != last_sent:
payload: dict[str, Any] = {
"chat_id": chat_id,
"message_id": message_id,
"text": final_text,
}
if self._parse_mode:
payload["parse_mode"] = self._parse_mode
try:
response = await self._http.post(f"{self._api}/editMessageText", json=payload)
# If parse_mode rejected the final edit, retry as plain text
# so the user still sees the answer.
if response.status_code == 400 and self._parse_mode:
payload.pop("parse_mode", None)
await self._http.post(f"{self._api}/editMessageText", json=payload)
except Exception: # pragma: no cover
logger.exception("Telegram final edit failed")
# If nothing ever streamed (no text chunks at all), fall back to the
# full result so images / tool outputs still reach the user.
if not accumulated:
await self._reply_with_result(chat_id, final)
async def _reply_with_result(self, chat_id: int, result: Any) -> None:
"""Forward an AgentRunResponse back to Telegram.
Sends any image attachments on the last assistant message as photos,
then the text body via ``sendMessage``. Falls back to a ``"(no
response)"`` placeholder if neither text nor images are present so
the user is never left hanging.
"""
sent_photo = False
last_message = None
messages = getattr(result, "messages", None) or []
for msg in reversed(messages):
if getattr(msg, "role", None) == "assistant":
last_message = msg
break
if last_message is not None:
for content in getattr(last_message, "contents", []) or []:
uri = getattr(content, "uri", None)
media_type = getattr(content, "media_type", "") or ""
if uri and isinstance(media_type, str) and media_type.startswith("image/"):
await self._send_photo(chat_id, uri)
sent_photo = True
text = getattr(result, "text", None)
if text:
await self._send(chat_id, text)
elif not sent_photo:
await self._send(chat_id, "(no response)")
async def _send(self, chat_id: int, text: str, **extra: Any) -> None:
"""POST a ``sendMessage`` to Telegram, applying the configured ``parse_mode`` by default.
Extra kwargs are merged into the payload after ``parse_mode`` so
callers can override any field per-call (e.g. drop ``parse_mode``
for a known-unsafe interim text).
"""
if self._http is None: # pragma: no cover - guarded by lifecycle
raise RuntimeError("telegram channel not started")
payload: dict[str, Any] = {"chat_id": chat_id, "text": text}
if self._parse_mode and "parse_mode" not in extra:
payload["parse_mode"] = self._parse_mode
payload.update(extra)
await self._http.post(f"{self._api}/sendMessage", json=payload)
async def _send_photo(self, chat_id: int, photo_url: str, caption: str | None = None) -> None:
"""POST a ``sendPhoto`` to Telegram with an optional caption."""
if self._http is None: # pragma: no cover - guarded by lifecycle
raise RuntimeError("telegram channel not started")
payload: dict[str, Any] = {"chat_id": chat_id, "photo": photo_url}
if caption:
payload["caption"] = caption
await self._http.post(f"{self._api}/sendPhoto", json=payload)
async def _send_chat_action(self, chat_id: int, action: str) -> None:
"""Fire a ``sendChatAction`` (typing, upload_photo, …); errors are logged and swallowed.
Chat actions are pure UX hints — Telegram clears them after ~5s
— so failures should never propagate to the caller.
"""
if self._http is None: # pragma: no cover - guarded by lifecycle
raise RuntimeError("telegram channel not started")
try:
await self._http.post(f"{self._api}/sendChatAction", json={"chat_id": chat_id, "action": action})
except Exception: # pragma: no cover - non-critical UX
logger.exception("sendChatAction failed")
__all__ = ["TelegramChannel", "telegram_isolation_key"]
@@ -0,0 +1,107 @@
[project]
name = "agent-framework-hosting-telegram"
description = "Telegram channel for agent-framework-hosting."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0a260424"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
urls.issues = "https://github.com/microsoft/agent-framework/issues"
classifiers = [
"License :: OSI Approved :: MIT License",
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.2.0,<2",
"agent-framework-hosting==1.0.0a260424",
"httpx>=0.27,<1",
]
[tool.uv]
prerelease = "if-necessary-or-explicit"
environments = [
"sys_platform == 'darwin'",
"sys_platform == 'linux'",
"sys_platform == 'win32'"
]
[tool.uv-dynamic-versioning]
fallback-version = "0.0.0"
[tool.pytest.ini_options]
testpaths = 'tests'
addopts = "-ra -q -r fEX"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = []
timeout = 120
markers = [
"integration: marks tests as integration tests that require external services",
]
[tool.ruff]
extend = "../../pyproject.toml"
[tool.coverage.run]
omit = [
"**/__init__.py"
]
[tool.pyright]
extends = "../../pyproject.toml"
include = ["agent_framework_hosting_telegram"]
exclude = ['tests']
# Telegram's API delivers loosely-typed JSON-ish maps (chat, message, photo,
# media, callback_query). Strict ``Unknown`` reporting on every ``.get(...)``
# adds noise without catching real bugs — narrowing happens via runtime
# isinstance checks instead. Other type checks remain strict.
reportUnknownArgumentType = "none"
reportUnknownMemberType = "none"
reportUnknownVariableType = "none"
reportUnknownLambdaType = "none"
reportOptionalMemberAccess = "none"
[tool.mypy]
plugins = ['pydantic.mypy']
strict = true
python_version = "3.10"
ignore_missing_imports = true
disallow_untyped_defs = true
no_implicit_optional = true
check_untyped_defs = true
warn_return_any = true
show_error_codes = true
warn_unused_ignores = false
disallow_incomplete_defs = true
disallow_untyped_decorators = true
[tool.bandit]
targets = ["agent_framework_hosting_telegram"]
exclude_dirs = ["tests"]
[tool.poe]
executor.type = "uv"
include = "../../shared_tasks.toml"
[tool.poe.tasks.mypy]
help = "Run MyPy for this package."
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_hosting_telegram"
[tool.poe.tasks.test]
help = "Run the default unit test suite for this package."
cmd = 'pytest -m "not integration" --cov=agent_framework_hosting_telegram --cov-report=term-missing:skip-covered tests'
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
build-backend = "flit_core.buildapi"
@@ -0,0 +1,430 @@
# Copyright (c) Microsoft. All rights reserved.
"""Unit tests for :mod:`agent_framework_hosting_telegram`.
These tests exercise the internal parsing helpers and the webhook entry-point
without spinning up a real Telegram bot. The polling loop and HTTP-side
helpers are excluded from coverage because they require a live bot token.
"""
from __future__ import annotations
import asyncio
import contextlib
from collections.abc import Awaitable, Mapping
from dataclasses import dataclass
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from agent_framework_hosting import (
AgentFrameworkHost,
ChannelCommand,
ChannelCommandContext,
ChannelRequest,
HostedRunResult,
)
from starlette.testclient import TestClient
from agent_framework_hosting_telegram import TelegramChannel, telegram_isolation_key
from agent_framework_hosting_telegram._channel import (
_parse_telegram_message,
_telegram_media_file_id,
)
# --------------------------------------------------------------------------- #
# Pure helpers #
# --------------------------------------------------------------------------- #
def test_telegram_isolation_key_format() -> None:
assert telegram_isolation_key(42) == "telegram:42"
assert telegram_isolation_key("abc") == "telegram:abc"
class TestMediaFileId:
def test_no_media(self) -> None:
assert _telegram_media_file_id({"text": "hi"}) is None
def test_photo_picks_largest(self) -> None:
assert _telegram_media_file_id({"photo": [{"file_id": "small"}, {"file_id": "large"}]}) == (
"large",
"image/jpeg",
)
def test_photo_empty_list(self) -> None:
assert _telegram_media_file_id({"photo": []}) is None
def test_document_uses_mime_type(self) -> None:
result = _telegram_media_file_id({"document": {"file_id": "f1", "mime_type": "application/pdf"}})
assert result == ("f1", "application/pdf")
def test_voice_default_mime(self) -> None:
result = _telegram_media_file_id({"voice": {"file_id": "v1"}})
assert result == ("v1", "audio/ogg")
class TestParseTelegramMessage:
async def test_text_only(self) -> None:
async def resolve(_: str) -> str | None:
return None
msg = await _parse_telegram_message({"text": "hello"}, resolve)
assert msg.role == "user"
assert msg.text == "hello"
async def test_text_and_photo(self) -> None:
async def resolve(file_id: str) -> str | None:
return f"https://files.telegram.org/{file_id}"
msg = await _parse_telegram_message({"caption": "look", "photo": [{"file_id": "p1"}]}, resolve)
assert msg.text == "look"
# Image content present.
assert any((getattr(c, "uri", None) or "").endswith("/p1") for c in msg.contents)
async def test_unresolvable_media_falls_back_to_text(self) -> None:
async def resolve(_: str) -> str | None:
return None
msg = await _parse_telegram_message({"text": "x", "voice": {"file_id": "v1"}}, resolve)
# Resolver returned None — the contents should still include the
# text without crashing.
assert msg.text == "x"
# --------------------------------------------------------------------------- #
# Webhook entry point #
# --------------------------------------------------------------------------- #
@dataclass
class _FakeAgentResponse:
text: str
class _FakeAgent:
def __init__(self, reply: str = "ok") -> None:
self._reply = reply
self.runs: list[Any] = []
def create_session(self, *, session_id: str | None = None) -> Any:
return {"session_id": session_id}
def run(self, messages: Any = None, *, stream: bool = False, **kwargs: Any) -> Any:
self.runs.append({"messages": messages, "stream": stream, "kwargs": kwargs})
async def _coro() -> _FakeAgentResponse:
return _FakeAgentResponse(text=self._reply)
return _coro()
def _make_telegram(
stream_default: bool = False, *, path: str = "/telegram/webhook"
) -> tuple[TelegramChannel, _FakeAgent]:
agent = _FakeAgent("hi")
ch = TelegramChannel(
bot_token="123:abc",
path=path,
webhook_url="https://example.com/hook",
secret_token="s3cr3t",
stream=stream_default,
)
# Replace the internal HTTP client with an AsyncMock so the channel
# never tries to call the real Telegram API.
fake_http = MagicMock()
# post() returns a response object whose raise_for_status() is sync.
response_mock = MagicMock()
response_mock.json = MagicMock(return_value={"ok": True, "result": {}})
fake_http.post = AsyncMock(return_value=response_mock)
fake_http.get = AsyncMock(return_value=response_mock)
fake_http.aclose = AsyncMock()
ch._http = fake_http
return ch, agent
class TestTelegramWebhook:
def test_webhook_accepts_text_message_and_dispatches_to_agent(self) -> None:
ch, agent = _make_telegram()
host = AgentFrameworkHost(target=agent, channels=[ch])
# Skip lifespan so polling/setWebhook are not invoked.
with TestClient(host.app) as client:
r = client.post(
"/telegram/webhook",
json={"update_id": 1, "message": {"chat": {"id": 99}, "text": "hello"}},
headers={"x-telegram-bot-api-secret-token": "s3cr3t"},
)
assert r.status_code == 200
assert agent.runs, "expected the agent to be invoked"
def test_empty_path_mounts_at_app_root(self) -> None:
ch, agent = _make_telegram(path="")
host = AgentFrameworkHost(target=agent, channels=[ch])
with TestClient(host.app) as client:
r = client.post(
"/",
json={"update_id": 1, "message": {"chat": {"id": 99}, "text": "hello"}},
headers={"x-telegram-bot-api-secret-token": "s3cr3t"},
)
assert r.status_code == 200
assert agent.runs, "expected the agent to be invoked"
def test_webhook_rejects_bad_secret(self) -> None:
ch, agent = _make_telegram()
host = AgentFrameworkHost(target=agent, channels=[ch])
with TestClient(host.app) as client:
r = client.post(
"/telegram/webhook",
json={"update_id": 1, "message": {"chat": {"id": 99}, "text": "hi"}},
headers={"x-telegram-bot-api-secret-token": "WRONG"},
)
assert r.status_code == 401
assert not agent.runs
async def test_response_hook_can_rewrite_originating_reply(self) -> None:
seen_kwargs: list[dict[str, Any]] = []
def hook(result: HostedRunResult, **kwargs: Any) -> HostedRunResult:
seen_kwargs.append(dict(kwargs))
return HostedRunResult(_FakeAgentResponse(text=result.result.text.upper()), session=result.session)
ch, agent = _make_telegram()
ch.response_hook = hook
class _Ctx:
target: Any = agent
async def run(
self,
_request: ChannelRequest,
*,
run_hook: Any | None = None,
protocol_request: Any | None = None,
response_hook: Any | None = None,
channel_name: str | None = None,
) -> HostedRunResult:
result = HostedRunResult(_FakeAgentResponse(text="hi"))
if response_hook is None:
return result
shaped = response_hook(result, request=_request, channel_name=channel_name or _request.channel)
if isinstance(shaped, Awaitable):
return await shaped
return shaped
ch._ctx = _Ctx() # type: ignore[assignment] # pyright: ignore[reportPrivateUsage]
request = ChannelRequest(channel="telegram", operation="message.create", input="hi", stream=False)
await ch._dispatch(99, request) # pyright: ignore[reportPrivateUsage]
assert ch._http is not None
args, kwargs = ch._http.post.call_args # type: ignore[attr-defined]
assert args[0].endswith("/sendMessage")
assert kwargs["json"]["text"] == "HI"
assert seen_kwargs
assert seen_kwargs[0]["channel_name"] == "telegram"
class TestCommand:
async def test_command_handler_invoked(self) -> None:
captured: list[ChannelCommandContext] = []
async def handler(ctx: ChannelCommandContext) -> None:
captured.append(ctx)
await ctx.reply("pong")
ch = TelegramChannel(
bot_token="123:abc",
webhook_url="https://example.com/hook",
commands=[ChannelCommand(name="ping", description="ping", handle=handler)],
register_native_commands=False,
)
fake_http = MagicMock()
response_mock = MagicMock()
response_mock.json = MagicMock(return_value={"ok": True, "result": {}})
fake_http.post = AsyncMock(return_value=response_mock)
fake_http.get = AsyncMock(return_value=response_mock)
fake_http.aclose = AsyncMock()
ch._http = fake_http
host = AgentFrameworkHost(target=_FakeAgent(), channels=[ch])
with TestClient(host.app) as client:
r = client.post(
"/telegram/webhook",
json={"update_id": 2, "message": {"chat": {"id": 7}, "text": "/ping"}},
)
assert r.status_code == 200
assert captured and captured[0].request.operation == "command.invoke"
# --------------------------------------------------------------------------- #
# Per-chat serial ordering #
# --------------------------------------------------------------------------- #
class TestPerChatOrdering:
async def test_updates_for_same_chat_run_serially(self) -> None:
"""Two updates for the same chat must process in arrival order."""
ch, _ = _make_telegram()
order: list[int] = []
slow_event = asyncio.Event()
async def fake_process(update: Mapping[str, Any]) -> None:
uid = update.get("update_id")
assert isinstance(uid, int)
if uid == 1:
# Block the first update so the second is queued behind it.
await slow_event.wait()
order.append(uid)
ch._process_update = fake_process # type: ignore[method-assign]
ch._enqueue_update({"update_id": 1, "message": {"chat": {"id": 100}, "text": "first"}})
ch._enqueue_update({"update_id": 2, "message": {"chat": {"id": 100}, "text": "second"}})
# Let the worker start the first update.
await asyncio.sleep(0)
assert order == [] # blocked on slow_event
slow_event.set()
# Drain.
worker = ch._chat_workers[100]
# Wait for the queue to empty.
await ch._chat_queues[100].join()
# Cleanup
worker.cancel()
with contextlib.suppress(asyncio.CancelledError):
await worker
assert order == [1, 2]
async def test_updates_for_different_chats_run_in_parallel(self) -> None:
"""Different chats get separate workers and can interleave freely."""
ch, _ = _make_telegram()
started: list[int] = []
gate_a = asyncio.Event()
async def fake_process(update: Mapping[str, Any]) -> None:
uid = update.get("update_id")
assert isinstance(uid, int)
started.append(uid)
if uid == 1:
await gate_a.wait()
ch._process_update = fake_process # type: ignore[method-assign]
ch._enqueue_update({"update_id": 1, "message": {"chat": {"id": 1}, "text": "a"}})
ch._enqueue_update({"update_id": 2, "message": {"chat": {"id": 2}, "text": "b"}})
# Both should be admitted into their respective workers despite
# update 1 being blocked.
await asyncio.sleep(0)
# Update 2 finishes; update 1 still blocked.
assert 2 in started
gate_a.set()
for cid in (1, 2):
await ch._chat_queues[cid].join()
for w in ch._chat_workers.values():
w.cancel()
with contextlib.suppress(asyncio.CancelledError):
await w
# --------------------------------------------------------------------------- #
# Webhook ack-before-run + shutdown drains workers #
# --------------------------------------------------------------------------- #
class TestWebhookAckBeforeRun:
async def test_webhook_returns_200_before_agent_completes(self) -> None:
"""The webhook must ack before the agent runs, to dodge Telegram's 60s redelivery."""
ch, _ = _make_telegram()
from starlette.requests import Request
agent_started = asyncio.Event()
agent_release = asyncio.Event()
async def fake_process(update: Mapping[str, Any]) -> None:
agent_started.set()
await agent_release.wait()
ch._process_update = fake_process # type: ignore[method-assign]
async def receive() -> Any:
payload = b'{"update_id":1,"message":{"chat":{"id":42},"text":"hi"}}'
return {"type": "http.request", "body": payload, "more_body": False}
scope = {
"type": "http",
"method": "POST",
"path": "/telegram/webhook",
"headers": [(b"x-telegram-bot-api-secret-token", b"s3cr3t")],
"query_string": b"",
}
request = Request(scope, receive=receive)
# Drive the webhook handler. Even though the agent won't complete
# (gate_a still cleared) the webhook must still 200 promptly.
resp = await ch._handle(request)
assert resp.status_code == 200
# The agent task is in flight but not finished — proves ack came first.
await asyncio.wait_for(agent_started.wait(), timeout=1.0)
assert not agent_release.is_set()
# Cleanup: release the agent and drain.
agent_release.set()
await ch._chat_queues[42].join()
for w in list(ch._chat_workers.values()):
w.cancel()
with contextlib.suppress(asyncio.CancelledError):
await w
class TestShutdownDrainsWorkers:
async def test_shutdown_cancels_in_flight_chat_workers(self) -> None:
"""`_on_shutdown` must drain per-chat workers, not leak them."""
ch, _ = _make_telegram()
forever = asyncio.Event()
async def stuck(update: Mapping[str, Any]) -> None:
await forever.wait()
ch._process_update = stuck # type: ignore[method-assign]
ch._enqueue_update({"update_id": 9, "message": {"chat": {"id": 1}, "text": "a"}})
await asyncio.sleep(0)
assert ch._chat_workers and ch._update_tasks
await ch._on_shutdown()
assert not ch._chat_workers
assert not ch._update_tasks
def _deletewebhook_called(http_mock: MagicMock) -> bool:
return any(call.args and str(call.args[0]).endswith("/deleteWebhook") for call in http_mock.post.call_args_list)
class TestWebhookShutdownTeardown:
async def test_shutdown_keeps_webhook_by_default(self) -> None:
"""Default: shutdown must NOT delete the webhook (avoids redeploy races)."""
ch, _ = _make_telegram()
assert ch._transport == "webhook"
await ch._on_shutdown()
assert not _deletewebhook_called(ch._http) # type: ignore[arg-type]
ch._http.aclose.assert_awaited() # type: ignore[union-attr]
async def test_shutdown_deletes_webhook_when_opted_in(self) -> None:
"""Opt-in: ``delete_webhook_on_shutdown=True`` performs best-effort teardown."""
ch = TelegramChannel(
bot_token="123:abc",
webhook_url="https://example.com/hook",
secret_token="s3cr3t",
delete_webhook_on_shutdown=True,
stream=False,
)
fake_http = MagicMock()
response_mock = MagicMock()
response_mock.json = MagicMock(return_value={"ok": True, "result": {}})
fake_http.post = AsyncMock(return_value=response_mock)
fake_http.get = AsyncMock(return_value=response_mock)
fake_http.aclose = AsyncMock()
ch._http = fake_http
await ch._on_shutdown()
assert _deletewebhook_called(fake_http)
fake_http.aclose.assert_awaited()
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
+103
View File
@@ -0,0 +1,103 @@
# agent-framework-hosting
Multi-channel hosting for Microsoft Agent Framework agents.
`agent-framework-hosting` lets you serve a single agent or workflow target
through one or more **channels**. The host owns one Starlette ASGI app,
route/lifecycle composition, and per-`isolation_key` session resolution.
Each channel owns its protocol parsing and response rendering.
The base package contains only channel-neutral plumbing:
- `AgentFrameworkHost` — the Starlette host.
- `Channel` — the channel protocol.
- `ChannelRequest` / `ChannelSession` / `ChannelIdentity` — the request
envelope and optional channel metadata.
- `ChannelContext` / `ChannelContribution` / `ChannelCommand` — channel-side
hooks for invoking the target and contributing routes, commands, and
lifecycle callbacks.
- `ChannelRunHook` / `ChannelResponseHook` / `ChannelStreamUpdateHook` —
host-invoked customization seams.
`ChannelStreamUpdateHook` applies to streamed updates only. It is not a
substitute for final-response redaction.
Concrete channels live in their own packages so you only install what you use:
| Package | Transport |
|---|---|
| `agent-framework-hosting-responses` | OpenAI Responses API |
| `agent-framework-hosting-invocations` | Foundry-native invocation envelope |
| `agent-framework-hosting-telegram` | Telegram Bot API |
| `agent-framework-hosting-activity-protocol` | Bot Framework Activity Protocol |
| `agent-framework-hosting-discord` | Discord HTTP Interactions |
## Install
```bash
pip install agent-framework-hosting agent-framework-hosting-responses
# or with Hypercorn pre-installed for the demo `host.serve(...)` helper
pip install "agent-framework-hosting[serve]" agent-framework-hosting-responses
# add the [disk] extra to persist reset-session aliases
pip install "agent-framework-hosting[disk]"
```
## Quickstart
```python
from agent_framework.openai import OpenAIChatClient
from agent_framework_hosting import AgentFrameworkHost, Channel
agent = OpenAIChatClient().as_agent(name="Assistant")
# Add channels from sibling packages, e.g. `agent-framework-hosting-responses`
# exposes a `ResponsesChannel` that serves the OpenAI Responses API.
channels: list[Channel] = []
host = AgentFrameworkHost(target=agent, channels=channels)
host.serve(port=8000)
```
## Session state and workflow checkpoints
By default the host keeps live `AgentSession` objects and reset-session aliases
in memory. Channels opt into continuity by setting
`ChannelRequest.session = ChannelSession(isolation_key=...)`; requests with the
same isolation key reuse the same host-created session.
For long-running deployments that need `reset_session(...)` aliases to survive
restart, pass `state_dir`:
```python
host = AgentFrameworkHost(
target=agent,
channels=channels,
state_dir="./.host-state",
)
```
This creates `./.host-state/sessions/` and stores only lightweight alias
bookkeeping. Live `AgentSession` objects are still rehydrated lazily by the
configured history provider on the next turn.
For workflow targets, `checkpoint_location=...` is the clearest way to enable
checkpoint persistence. As a convenience, `state_dir="./.host-state"` also
derives `./.host-state/checkpoints/` for workflow targets. Use the mapping form
when you want only one component:
```python
from agent_framework_hosting import HostStatePaths
host = AgentFrameworkHost(
target=workflow,
channels=channels,
state_dir=HostStatePaths(
sessions="/var/lib/myapp/sessions",
checkpoints="/var/lib/myapp/checkpoints",
),
)
```
Cross-channel identity linking, multicast delivery, background runs,
continuation tokens, and durable delivery runners are follow-up enhancements,
not part of this v1 host contract.
@@ -0,0 +1,68 @@
# Copyright (c) Microsoft. All rights reserved.
"""Multi-channel hosting for Microsoft Agent Framework agents.
Serve a single agent target through one or more **channels** — pluggable
adapters that expose the target over different transports such as the
OpenAI Responses API, Microsoft Teams, Telegram, and others. The base
package contains only the channel-neutral plumbing; concrete channels
ship in their own packages (``agent-framework-hosting-responses``,
``agent-framework-hosting-telegram``, …) so users install only what
they need.
"""
import importlib.metadata
from ._host import AgentFrameworkHost, ChannelContext, logger
from ._isolation import (
ISOLATION_HEADER_CHAT,
ISOLATION_HEADER_USER,
IsolationKeys,
get_current_isolation_keys,
reset_current_isolation_keys,
set_current_isolation_keys,
)
from ._types import (
Channel,
ChannelCommand,
ChannelCommandContext,
ChannelContribution,
ChannelIdentity,
ChannelRequest,
ChannelResponseHook,
ChannelRunHook,
ChannelSession,
ChannelStreamUpdateHook,
HostedRunResult,
HostStatePaths,
)
try:
__version__ = importlib.metadata.version(__name__)
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0"
__all__ = [
"ISOLATION_HEADER_CHAT",
"ISOLATION_HEADER_USER",
"AgentFrameworkHost",
"Channel",
"ChannelCommand",
"ChannelCommandContext",
"ChannelContext",
"ChannelContribution",
"ChannelIdentity",
"ChannelRequest",
"ChannelResponseHook",
"ChannelRunHook",
"ChannelSession",
"ChannelStreamUpdateHook",
"HostStatePaths",
"HostedRunResult",
"IsolationKeys",
"__version__",
"get_current_isolation_keys",
"logger",
"reset_current_isolation_keys",
"set_current_isolation_keys",
]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,76 @@
# Copyright (c) Microsoft. All rights reserved.
"""Per-request isolation keys read from inbound HTTP headers.
The Foundry Hosted Agents runtime injects two well-known headers on every
request it forwards to the user's container:
* ``x-agent-user-isolation-key`` — opaque per-user partition key
* ``x-agent-chat-isolation-key`` — opaque per-conversation partition key
When the headers are present we are running inside (or being driven by) the
Foundry runtime; when they are absent we are running in plain local dev. The
host installs an ASGI middleware in :meth:`AgentFrameworkHost._build_app`
that reads both headers off every inbound HTTP request and pushes them into
the :data:`current_isolation_keys` contextvar for the duration of the
request, then resets it. Providers that need partition-aware storage (most
notably ``FoundryHostedAgentHistoryProvider``) read the contextvar via
:func:`get_current_isolation_keys` and apply the keys to their backend
calls — so app authors don't have to wire any middleware themselves and
channels stay free of Foundry-specific header knowledge.
The contextvar holds a plain :class:`IsolationKeys` mapping; conversion to
provider-specific types (e.g. Foundry's ``IsolationContext``) happens at
the consuming provider so this module has no provider dependencies.
"""
from __future__ import annotations
from contextvars import ContextVar, Token
__all__ = [
"ISOLATION_HEADER_CHAT",
"ISOLATION_HEADER_USER",
"IsolationKeys",
"current_isolation_keys",
"get_current_isolation_keys",
"reset_current_isolation_keys",
"set_current_isolation_keys",
]
ISOLATION_HEADER_USER = "x-agent-user-isolation-key"
ISOLATION_HEADER_CHAT = "x-agent-chat-isolation-key"
class IsolationKeys:
"""Per-request Foundry isolation keys lifted off the inbound headers."""
def __init__(self, user_key: str | None = None, chat_key: str | None = None) -> None:
self.user_key = user_key
self.chat_key = chat_key
@property
def is_empty(self) -> bool:
return self.user_key is None and self.chat_key is None
current_isolation_keys: ContextVar[IsolationKeys | None] = ContextVar(
"agent_framework_hosting_isolation_keys",
default=None,
)
def get_current_isolation_keys() -> IsolationKeys | None:
"""Return the isolation keys bound to the current request, if any."""
return current_isolation_keys.get()
def set_current_isolation_keys(keys: IsolationKeys | None) -> Token[IsolationKeys | None]:
"""Bind ``keys`` to the current async context and return a reset token."""
return current_isolation_keys.set(keys)
def reset_current_isolation_keys(token: Token[IsolationKeys | None]) -> None:
"""Restore the isolation contextvar to its prior value."""
current_isolation_keys.reset(token)
@@ -0,0 +1,128 @@
# Copyright (c) Microsoft. All rights reserved.
"""Shared persistence primitives for the hosting package.
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
import contextlib
import os
import sys
from collections.abc import Mapping
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from ._types import HostStatePaths
_KNOWN_COMPONENTS: tuple[str, ...] = ("sessions", "checkpoints")
def load_diskcache() -> Any:
"""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 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]`."
) from exc
return diskcache
def acquire_state_dir_lock(component_dir: Path) -> Any:
"""Acquire an exclusive single-owner lock on a component's state dir.
Raises:
RuntimeError: If another process already holds the lock.
"""
component_dir.mkdir(parents=True, exist_ok=True)
lock_path = component_dir / ".lock"
fh = open(lock_path, "a+", encoding="utf-8") # noqa: SIM115 - kept open for lifetime
try:
if sys.platform == "win32":
import msvcrt
try:
msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1)
except OSError as exc:
fh.close()
raise RuntimeError(
f"Another process already holds the hosting state lock at {lock_path}. "
"Point each host at its own state_dir."
) from exc
else:
import fcntl
try:
fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError as exc:
fh.close()
raise RuntimeError(
f"Another process already holds the hosting state lock at {lock_path}. "
"Point each host at its own state_dir."
) from exc
except RuntimeError:
raise
except Exception:
fh.close()
raise
return fh
def release_state_dir_lock(handle: Any) -> None:
"""Release a lock previously acquired by :func:`acquire_state_dir_lock`."""
if handle is None:
return
with contextlib.suppress(Exception):
handle.close()
def normalize_state_dir(
state_dir: str | os.PathLike[str] | HostStatePaths | Mapping[str, str | os.PathLike[str]] | None,
) -> dict[str, Path | None]:
"""Resolve the host-level ``state_dir`` parameter into a per-component map.
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
if isinstance(state_dir, (str, os.PathLike)):
root = Path(os.fspath(state_dir))
for name in _KNOWN_COMPONENTS:
result[name] = root / name
return result
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}."
)
for name in _KNOWN_COMPONENTS:
raw_value: Any = state_dir.get(name)
if raw_value is None:
result[name] = None
continue
if isinstance(raw_value, (str, os.PathLike)):
result[name] = Path(os.fspath(raw_value))
else:
raise TypeError(f"state_dir[{name!r}] must be a str or PathLike — got {type(raw_value).__name__}")
return result
raise TypeError(
f"state_dir must be a str, PathLike, HostStatePaths mapping, or None — got {type(state_dir).__name__}"
)
@@ -0,0 +1,146 @@
# Copyright (c) Microsoft. All rights reserved.
"""Disk-backed wrapper for the host's session-alias map.
``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
import logging
import os
from collections.abc import Mapping
from pathlib import Path
from typing import Any, TypeVar
from ._persistence import (
acquire_state_dir_lock,
load_diskcache,
release_state_dir_lock,
)
logger = logging.getLogger(__name__)
_V = TypeVar("_V")
_ALIASES_PREFIX = "aliases:"
class SessionsStateStore:
"""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))
diskcache = load_diskcache()
self._lock_handle: Any = acquire_state_dir_lock(self._sessions_dir)
try:
self._cache: Any = diskcache.Cache(str(self._sessions_dir))
except Exception:
release_state_dir_lock(self._lock_handle)
self._lock_handle = None
raise
@property
def cache(self) -> Any:
"""Return the underlying :mod:`diskcache` Cache."""
return self._cache
def close(self) -> None:
"""Close the cache and release the directory lock."""
if self._cache is not None:
try:
self._cache.close()
except Exception: # pragma: no cover - close errors aren't actionable
logger.exception("SessionsStateStore: 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
class _PersistedDict(dict[str, _V]):
"""Drop-in :class:`dict` whose mutations mirror to a diskcache prefix."""
def __init__(
self,
store: SessionsStateStore,
key_prefix: str,
initial: Mapping[str, _V] | None = None,
) -> 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
try:
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 key, value in initial.items():
self[key] = value
def __setitem__(self, key: str, value: _V) -> None:
super().__setitem__(key, value)
try:
self._store.cache.set(self._prefix + key, value)
except Exception: # pragma: no cover - cache write failures aren't actionable
logger.exception("SessionsStateStore: failed to persist %s%s", self._prefix, key)
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 - cache write failures aren't actionable
logger.exception("SessionsStateStore: failed to evict %s%s", self._prefix, key)
def pop(self, key: str, *args: Any) -> _V:
"""Mirror ``dict.pop`` to disk."""
value: _V = super().pop(key, *args)
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)
return value
def clear(self) -> None:
"""Mirror ``dict.clear`` to disk."""
keys = list(self.keys())
super().clear()
cache = self._store.cache
for key in keys:
try:
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, key)
def update( # type: ignore[override]
self,
other: Mapping[str, _V] | None = None,
/,
**kwargs: _V,
) -> None:
"""Mirror ``dict.update`` to disk one item at a time."""
if other is not None:
for key in other:
self[key] = other[key]
for key, value in kwargs.items():
self[key] = value
def build_session_aliases(store: SessionsStateStore) -> dict[str, str]:
"""Return the disk-backed session-alias map for ``store``."""
return _PersistedDict[str](store, _ALIASES_PREFIX)
@@ -0,0 +1,212 @@
# Copyright (c) Microsoft. All rights reserved.
# ``ChannelRequest`` is the only intentional dataclass here (callers use
# ``dataclasses.replace`` on it in run hooks). The other types are plain
# Python classes by preference, so the "could be a dataclass" lint is muted
# at the file level.
# ruff: noqa: B903
"""Channel-neutral request envelope and channel protocol types.
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 channel owns rendering the result back onto its originating protocol.
"""
from __future__ import annotations
import os
from collections.abc import Awaitable, Callable, Mapping, Sequence
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Generic, Protocol, TypedDict, TypeVar, runtime_checkable
from agent_framework import (
AgentResponseUpdate,
AgentRunInputs,
)
from starlette.routing import BaseRoute
if TYPE_CHECKING:
from ._host import ChannelContext
class ChannelSession:
"""Channel-supplied session hint.
The host turns this into an ``AgentSession`` keyed by ``isolation_key`` so
every distinct end user gets their own context-provider state (e.g. one
``FileHistoryProvider`` JSONL file per user).
"""
def __init__(self, isolation_key: str | None = None) -> None:
self.isolation_key = isolation_key
class ChannelIdentity:
"""Channel-native identity metadata observed on a request.
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__(
self,
channel: str,
native_id: str,
attributes: Mapping[str, Any] | None = None,
) -> None:
self.channel = channel
self.native_id = native_id
self.attributes: Mapping[str, Any] = attributes if attributes is not None else dict()
@dataclass
class ChannelRequest:
"""Uniform invocation envelope every channel produces from its native payload.
Kept as a dataclass so app authors can use ``dataclasses.replace(...)`` in
run hooks to produce a modified envelope without re-listing every field.
"""
channel: str
operation: str
input: AgentRunInputs
session: ChannelSession | None = None
options: Mapping[str, Any] | None = None
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
class ChannelCommand:
"""A discoverable command a channel exposes to its users (e.g. ``/reset``)."""
def __init__(
self,
name: str,
description: str,
handle: Callable[[ChannelCommandContext], Awaitable[None]],
) -> None:
self.name = name
self.description = description
self.handle = handle
class ChannelCommandContext:
"""Context passed to a :class:`ChannelCommand` handler."""
def __init__(
self,
request: ChannelRequest,
reply: Callable[[str], Awaitable[None]],
) -> None:
self.request = request
self.reply = reply
_EMPTY_ROUTES: tuple[BaseRoute, ...] = ()
_EMPTY_COMMANDS: tuple[ChannelCommand, ...] = ()
_EMPTY_LIFECYCLE: tuple[Callable[[], Awaitable[None]], ...] = ()
class ChannelContribution:
"""Routes, commands, and lifecycle hooks a channel contributes to the host."""
def __init__(
self,
routes: Sequence[BaseRoute] = _EMPTY_ROUTES,
commands: Sequence[ChannelCommand] = _EMPTY_COMMANDS,
on_startup: Sequence[Callable[[], Awaitable[None]]] = _EMPTY_LIFECYCLE,
on_shutdown: Sequence[Callable[[], Awaitable[None]]] = _EMPTY_LIFECYCLE,
) -> None:
self.routes = routes
self.commands = commands
self.on_startup = on_startup
self.on_shutdown = on_shutdown
class _Unset:
"""Sentinel for ``HostedRunResult.replace`` overrides.
Distinguishes "caller did not pass this kwarg" from "caller passed
``None`` explicitly" — needed because ``session`` is ``None`` in
many envelopes and we want the no-arg call to preserve it.
"""
_UNSET = _Unset()
TResult = TypeVar("TResult")
class HostedRunResult(Generic[TResult]):
"""Channel-neutral envelope around the target's full-fidelity result.
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__(
self,
result: TResult,
*,
session: Any | None = None,
) -> None:
self.result = result
self.session = session
def replace(
self,
*,
result: TResult | _Unset = _UNSET,
session: Any | _Unset | None = _UNSET,
) -> HostedRunResult[TResult]:
"""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 HostStatePaths(TypedDict, total=False):
"""Per-component disk paths for host-managed state.
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.
"""
sessions: str | os.PathLike[str]
"""Where the host persists session aliases created by ``reset_session``."""
checkpoints: str | os.PathLike[str]
"""Where the host persists workflow checkpoints for ``Workflow`` targets."""
ChannelStreamUpdateHook = Callable[
[AgentResponseUpdate],
"AgentResponseUpdate | Awaitable[AgentResponseUpdate | None] | None",
]
ChannelRunHook = Callable[..., "Awaitable[ChannelRequest] | ChannelRequest"]
ChannelResponseHook = Callable[..., "Awaitable[HostedRunResult[Any]] | HostedRunResult[Any]"]
@runtime_checkable
class Channel(Protocol):
"""A pluggable adapter that exposes one transport on the host."""
name: str
path: str
def contribute(self, context: ChannelContext) -> ChannelContribution: ...
+110
View File
@@ -0,0 +1,110 @@
[project]
name = "agent-framework-hosting"
description = "Multi-channel hosting for Microsoft Agent Framework agents."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0a260424"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
urls.issues = "https://github.com/microsoft/agent-framework/issues"
classifiers = [
"License :: OSI Approved :: MIT License",
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.2.0,<2",
"starlette>=0.37",
]
[project.optional-dependencies]
serve = [
"hypercorn>=0.17",
]
disk = [
"diskcache>=5.6",
]
[tool.uv]
prerelease = "if-necessary-or-explicit"
environments = [
"sys_platform == 'darwin'",
"sys_platform == 'linux'",
"sys_platform == 'win32'"
]
[tool.uv-dynamic-versioning]
fallback-version = "0.0.0"
[tool.pytest.ini_options]
testpaths = 'tests'
addopts = "-ra -q -r fEX"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = []
timeout = 120
markers = [
"integration: marks tests as integration tests that require external services",
]
[tool.ruff]
extend = "../../pyproject.toml"
[tool.coverage.run]
omit = [
"**/__init__.py"
]
[tool.pyright]
extends = "../../pyproject.toml"
include = ["agent_framework_hosting"]
exclude = ['tests']
[tool.mypy]
plugins = ['pydantic.mypy']
strict = true
python_version = "3.10"
ignore_missing_imports = true
disallow_untyped_defs = true
no_implicit_optional = true
check_untyped_defs = true
warn_return_any = true
show_error_codes = true
warn_unused_ignores = false
disallow_incomplete_defs = true
disallow_untyped_decorators = true
[tool.bandit]
targets = ["agent_framework_hosting"]
exclude_dirs = ["tests"]
[tool.poe]
executor.type = "uv"
include = "../../shared_tasks.toml"
[tool.poe.tasks.mypy]
help = "Run MyPy for this package."
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_hosting"
[tool.poe.tasks.test]
help = "Run the default unit test suite for this package."
cmd = 'pytest -m "not integration" --cov=agent_framework_hosting --cov-report=term-missing:skip-covered tests'
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
build-backend = "flit_core.buildapi"
[dependency-groups]
dev = [
"httpx>=0.28.1",
]
@@ -0,0 +1,43 @@
# Copyright (c) Microsoft. All rights reserved.
"""Workflow fixtures for hosting tests.
Defined in a module that does not use ``from __future__ import annotations``
because the workflow handler validation reflects on real annotation objects
rather than stringified forms.
"""
from agent_framework import Executor, Workflow, WorkflowBuilder, WorkflowContext, handler
class _UpperExecutor(Executor):
@handler
async def handle(self, text: str, ctx: WorkflowContext[str]) -> None:
await ctx.yield_output(text.upper())
class _EchoExecutor(Executor):
@handler
async def handle(self, text: str, ctx: WorkflowContext[str]) -> None:
await ctx.yield_output(text)
def build_upper_workflow() -> Workflow:
return WorkflowBuilder(start_executor=_UpperExecutor(id="upper")).build()
def build_echo_workflow() -> Workflow:
return WorkflowBuilder(start_executor=_EchoExecutor(id="echo")).build()
class _MultiChunkExecutor(Executor):
"""Yields three separate ``output`` events so streaming has something to chew on."""
@handler
async def handle(self, text: str, ctx: WorkflowContext[str]) -> None:
for chunk in (f"{text}-1", f"{text}-2", f"{text}-3"):
await ctx.yield_output(chunk)
def build_multi_chunk_workflow() -> Workflow:
return WorkflowBuilder(start_executor=_MultiChunkExecutor(id="multi")).build()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,227 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for narrowed ``state_dir`` support in :class:`AgentFrameworkHost`."""
from __future__ import annotations
from pathlib import Path
from typing import Any
import pytest
from agent_framework_hosting import AgentFrameworkHost, ChannelContext, ChannelContribution
pytest.importorskip("diskcache")
class _AgentStub:
"""Bare-minimum SupportsAgentRun stub for host construction."""
async def run(self, *_args: Any, **_kwargs: Any) -> None: # pragma: no cover - unused
return None
class _ChannelStub:
name = "stub"
path = "/stub"
def contribute(self, _context: ChannelContext) -> ChannelContribution:
return ChannelContribution()
def _close_host_disk(host: AgentFrameworkHost) -> None:
"""Release any session-alias store held by ``host``."""
if host._sessions_store is not None:
host._sessions_store.close()
def test_state_dir_none_keeps_plain_alias_dict(tmp_path: Path) -> None:
"""No store, no alias persistence, no files written."""
host = AgentFrameworkHost(target=_AgentStub(), channels=[_ChannelStub()])
assert host._sessions_store is None
assert isinstance(host._session_aliases, dict)
assert list(tmp_path.iterdir()) == []
def test_string_state_dir_creates_sessions_subfolder_only(tmp_path: Path) -> None:
"""Passing a single path expands to ``sessions/`` plus lazy checkpoint path."""
host = AgentFrameworkHost(
target=_AgentStub(),
channels=[_ChannelStub()],
state_dir=tmp_path,
)
try:
assert host._sessions_store is not None
assert (tmp_path / "sessions").is_dir()
assert not (tmp_path / "runner").exists()
assert not (tmp_path / "links").exists()
# Checkpoint path is derived but not created for agent targets.
assert not (tmp_path / "checkpoints").exists()
finally:
_close_host_disk(host)
def test_per_component_session_path(tmp_path: Path) -> None:
"""Dict form lets callers route session aliases to a specific root."""
sessions_dir = tmp_path / "state"
host = AgentFrameworkHost(
target=_AgentStub(),
channels=[_ChannelStub()],
state_dir={"sessions": sessions_dir},
)
try:
assert sessions_dir.is_dir()
assert host._sessions_store is not None
assert host._checkpoint_location is None
finally:
_close_host_disk(host)
@pytest.mark.parametrize("key", ["runner", "links", "active", "identities"])
def test_removed_state_dir_component_keys_raise(tmp_path: Path, key: str) -> None:
"""Obsolete follow-up components should fail loudly instead of becoming no-ops."""
with pytest.raises(ValueError, match="unknown"):
AgentFrameworkHost(
target=_AgentStub(),
channels=[_ChannelStub()],
state_dir={key: tmp_path / key}, # type: ignore[dict-item]
)
def test_session_aliases_survive_restart(tmp_path: Path) -> None:
"""Aliases written on host #1 must be visible to host #2."""
state_dir = tmp_path / "state"
host1 = AgentFrameworkHost(target=_AgentStub(), channels=[_ChannelStub()], state_dir=state_dir)
host1._session_aliases["user-1"] = "sess-abc"
host1._session_aliases["user-2"] = "sess-def"
_close_host_disk(host1)
host2 = AgentFrameworkHost(target=_AgentStub(), channels=[_ChannelStub()], state_dir=state_dir)
try:
assert host2._session_aliases["user-1"] == "sess-abc"
assert host2._session_aliases["user-2"] == "sess-def"
finally:
_close_host_disk(host2)
def _build_simple_workflow() -> Any:
"""Build a no-op workflow for checkpoint-wiring tests."""
from tests._workflow_fixtures import build_upper_workflow
return build_upper_workflow()
def test_single_path_state_dir_wires_workflow_checkpoints(tmp_path: Path) -> None:
"""``state_dir="/foo"`` + workflow target → ``/foo/checkpoints/`` is used."""
workflow = _build_simple_workflow()
host = AgentFrameworkHost(
target=workflow,
channels=[_ChannelStub()],
state_dir=tmp_path,
)
try:
assert host._checkpoint_location == tmp_path / "checkpoints"
finally:
_close_host_disk(host)
def test_mapping_state_dir_checkpoints_key_wires_workflow_checkpoints(tmp_path: Path) -> None:
"""``state_dir={"checkpoints": ...}`` + workflow target → that path is used."""
workflow = _build_simple_workflow()
ckpt_dir = tmp_path / "ck"
host = AgentFrameworkHost(
target=workflow,
channels=[_ChannelStub()],
state_dir={"checkpoints": ckpt_dir},
)
try:
assert host._checkpoint_location == ckpt_dir
assert host._sessions_store is None
finally:
_close_host_disk(host)
def test_mapping_state_dir_omits_checkpoints_for_workflow(tmp_path: Path) -> None:
"""Mapping form lets workflow callers opt out of checkpoint persistence."""
workflow = _build_simple_workflow()
host = AgentFrameworkHost(
target=workflow,
channels=[_ChannelStub()],
state_dir={"sessions": tmp_path / "s"},
)
try:
assert host._checkpoint_location is None
finally:
_close_host_disk(host)
def test_explicit_checkpoint_location_wins_over_state_dir(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
"""``checkpoint_location`` + ``state_dir`` → explicit param wins + warn."""
workflow = _build_simple_workflow()
explicit = tmp_path / "explicit-ck"
with caplog.at_level("WARNING", logger="agent_framework.hosting"):
host = AgentFrameworkHost(
target=workflow,
channels=[_ChannelStub()],
checkpoint_location=explicit,
state_dir=tmp_path,
)
try:
assert host._checkpoint_location == explicit
assert any(
"state_dir['checkpoints']" in rec.message and "checkpoint_location" in rec.message for rec in caplog.records
)
finally:
_close_host_disk(host)
def test_state_dir_checkpoints_for_agent_target_silent_for_single_path(tmp_path: Path) -> None:
"""Single-path state_dir + agent target → no checkpoint, no warning."""
host = AgentFrameworkHost(
target=_AgentStub(),
channels=[_ChannelStub()],
state_dir=tmp_path,
)
try:
assert host._checkpoint_location is None
assert not (tmp_path / "checkpoints").exists()
finally:
_close_host_disk(host)
def test_state_dir_checkpoints_for_agent_target_warns_when_explicit(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Mapping form with ``checkpoints`` + agent target → warn."""
with caplog.at_level("WARNING", logger="agent_framework.hosting"):
host = AgentFrameworkHost(
target=_AgentStub(),
channels=[_ChannelStub()],
state_dir={"checkpoints": tmp_path / "ck"},
)
try:
assert host._checkpoint_location is None
assert any(
"state_dir['checkpoints']" in rec.message and "not a Workflow" in rec.message for rec in caplog.records
)
finally:
_close_host_disk(host)
def test_state_dir_checkpoints_conflicts_with_workflow_own_storage(tmp_path: Path) -> None:
"""Derived checkpoint path triggers the same conflict guard as explicit."""
from agent_framework import InMemoryCheckpointStorage, WorkflowBuilder
from tests._workflow_fixtures import _UpperExecutor
workflow = WorkflowBuilder(
start_executor=_UpperExecutor(id="upper"),
checkpoint_storage=InMemoryCheckpointStorage(),
).build()
with pytest.raises(RuntimeError, match="already has checkpoint storage"):
AgentFrameworkHost(
target=workflow,
channels=[_ChannelStub()],
state_dir=tmp_path,
)
@@ -0,0 +1,303 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for the per-request isolation contextvar surface in
:mod:`agent_framework_hosting._isolation`.
The isolation keys are the ONLY seam Foundry-aware providers use to
find partition keys, and the host's ASGI middleware lifts them off the
two well-known headers on every inbound HTTP request. A regression
that drops the lookup, mistypes a header name, or fails to reset the
contextvar would silently misroute writes / leak per-request state
across requests, with zero unit-test signal — so cover the surface
fully here.
"""
from __future__ import annotations
import asyncio
import pytest
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.routing import BaseRoute, Route
from starlette.testclient import TestClient
from agent_framework_hosting import (
Channel,
ChannelContext,
ChannelContribution,
IsolationKeys,
get_current_isolation_keys,
reset_current_isolation_keys,
set_current_isolation_keys,
)
from agent_framework_hosting._isolation import ( # pyright: ignore[reportPrivateUsage]
ISOLATION_HEADER_CHAT,
ISOLATION_HEADER_USER,
current_isolation_keys,
)
class TestIsolationKeys:
def test_defaults_to_none_pair(self) -> None:
keys = IsolationKeys()
assert keys.user_key is None
assert keys.chat_key is None
assert keys.is_empty is True
def test_partial_with_only_user_is_not_empty(self) -> None:
keys = IsolationKeys(user_key="alice")
assert keys.user_key == "alice"
assert keys.chat_key is None
assert keys.is_empty is False
def test_partial_with_only_chat_is_not_empty(self) -> None:
keys = IsolationKeys(chat_key="general")
assert keys.is_empty is False
def test_full_pair_is_not_empty(self) -> None:
keys = IsolationKeys(user_key="alice", chat_key="general")
assert keys.is_empty is False
class TestContextVarHelpers:
def test_default_is_none(self) -> None:
# Each test gets a fresh contextvar value because pytest runs
# tests in fresh contexts. ``get`` returns the default.
assert get_current_isolation_keys() is None
def test_set_and_get_round_trip(self) -> None:
token = set_current_isolation_keys(IsolationKeys(user_key="alice", chat_key="general"))
try:
current = get_current_isolation_keys()
assert current is not None
assert current.user_key == "alice"
assert current.chat_key == "general"
finally:
reset_current_isolation_keys(token)
# Reset restores prior value (None in the default context).
assert get_current_isolation_keys() is None
def test_set_with_none_clears(self) -> None:
outer = set_current_isolation_keys(IsolationKeys(user_key="alice"))
try:
inner = set_current_isolation_keys(None)
try:
assert get_current_isolation_keys() is None
finally:
reset_current_isolation_keys(inner)
# Reset surfaces the outer value again.
current = get_current_isolation_keys()
assert current is not None
assert current.user_key == "alice"
finally:
reset_current_isolation_keys(outer)
def test_module_level_contextvar_is_the_same_instance(self) -> None:
"""Direct contextvar access (used by the ASGI middleware) and the
public `get_current_isolation_keys()` helper read from the SAME
underlying contextvar. A regression that introduced a second
contextvar would silently break the middleware → provider hop."""
token = current_isolation_keys.set(IsolationKeys(user_key="bob"))
try:
via_helper = get_current_isolation_keys()
assert via_helper is not None
assert via_helper.user_key == "bob"
finally:
current_isolation_keys.reset(token)
class TestHeaderConstants:
"""The two header names are part of the public contract — they
match the ones the Foundry Hosted Agents runtime stamps on every
inbound request. A typo here would silently misroute partition
writes."""
def test_user_header_value(self) -> None:
assert ISOLATION_HEADER_USER == "x-agent-user-isolation-key"
def test_chat_header_value(self) -> None:
assert ISOLATION_HEADER_CHAT == "x-agent-chat-isolation-key"
# --------------------------------------------------------------------------- #
# End-to-end: ASGI middleware lifts the headers into the contextvar.
# --------------------------------------------------------------------------- #
class _IsolationProbeChannel:
"""A minimal Channel that exposes a single GET route which captures
the contextvar value INSIDE the request and returns it as JSON.
Tests use this to exercise the full middleware → contextvar →
handler hop end-to-end.
"""
name = "probe"
path = ""
def __init__(self) -> None:
self.captured: list[IsolationKeys | None] = []
async def _handler(_request: Request) -> JSONResponse:
keys = get_current_isolation_keys()
self.captured.append(keys)
payload = (
{"user": keys.user_key, "chat": keys.chat_key}
if keys is not None
else {"user": None, "chat": None, "_present": False}
)
return JSONResponse(payload)
self._routes: list[BaseRoute] = [Route("/probe", _handler)]
def contribute(self, _context: ChannelContext) -> ChannelContribution:
return ChannelContribution(routes=self._routes)
def _make_host_with_probe() -> tuple[object, _IsolationProbeChannel]:
from agent_framework_hosting import AgentFrameworkHost
class _NoopAgent:
async def run(self, *_args: object, **_kwargs: object) -> object: # pragma: no cover - never called
raise RuntimeError("not invoked")
probe = _IsolationProbeChannel()
assert isinstance(probe, Channel)
host = AgentFrameworkHost(target=_NoopAgent(), channels=[probe]) # type: ignore[arg-type]
return host, probe
class TestIsolationMiddlewareEndToEnd:
def test_headers_ignored_outside_foundry_environment(self) -> None:
host, probe = _make_host_with_probe()
with TestClient(host.app) as client: # type: ignore[attr-defined]
r = client.get(
"/probe",
headers={
ISOLATION_HEADER_USER: "alice-uid",
ISOLATION_HEADER_CHAT: "general-cid",
},
)
assert r.status_code == 200
assert r.json() == {"user": None, "chat": None, "_present": False}
assert probe.captured == [None]
def test_both_headers_lifted_into_contextvar(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1")
host, probe = _make_host_with_probe()
with TestClient(host.app) as client: # type: ignore[attr-defined]
r = client.get(
"/probe",
headers={
ISOLATION_HEADER_USER: "alice-uid",
ISOLATION_HEADER_CHAT: "general-cid",
},
)
assert r.status_code == 200
assert r.json() == {"user": "alice-uid", "chat": "general-cid"}
assert len(probe.captured) == 1
captured = probe.captured[0]
assert captured is not None
assert captured.user_key == "alice-uid"
assert captured.chat_key == "general-cid"
def test_only_user_header_lifted(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""One-header-only branch: the middleware still binds (chat=None)."""
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1")
host, probe = _make_host_with_probe()
with TestClient(host.app) as client: # type: ignore[attr-defined]
r = client.get("/probe", headers={ISOLATION_HEADER_USER: "alice-uid"})
assert r.status_code == 200
assert r.json() == {"user": "alice-uid", "chat": None}
def test_only_chat_header_lifted(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1")
host, probe = _make_host_with_probe()
with TestClient(host.app) as client: # type: ignore[attr-defined]
r = client.get("/probe", headers={ISOLATION_HEADER_CHAT: "general-cid"})
assert r.status_code == 200
assert r.json() == {"user": None, "chat": "general-cid"}
def test_no_headers_keeps_contextvar_none(self) -> None:
"""Local-dev path: with neither header present the middleware is
a no-op and the contextvar stays at its default ``None`` —
providers see "no isolation" and route to the in-memory
fallback rather than picking up stale per-request state."""
host, probe = _make_host_with_probe()
with TestClient(host.app) as client: # type: ignore[attr-defined]
r = client.get("/probe")
assert r.status_code == 200
assert r.json() == {"user": None, "chat": None, "_present": False}
assert probe.captured == [None]
def test_empty_header_value_treated_as_absent(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""A header that's present but empty must not bind an empty key —
``IsolationContext`` rejects empty strings on the read side."""
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1")
host, probe = _make_host_with_probe()
with TestClient(host.app) as client: # type: ignore[attr-defined]
r = client.get(
"/probe",
headers={
ISOLATION_HEADER_USER: "",
ISOLATION_HEADER_CHAT: "general-cid",
},
)
assert r.status_code == 200
# Empty user header decodes to None; chat key stays bound.
assert r.json() == {"user": None, "chat": "general-cid"}
def test_contextvar_resets_after_request(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""The middleware must call ``reset_current_isolation_keys`` in
a ``finally`` so per-request state never leaks across requests
or back into the calling thread's context."""
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1")
host, probe = _make_host_with_probe()
with TestClient(host.app) as client: # type: ignore[attr-defined]
r1 = client.get("/probe", headers={ISOLATION_HEADER_USER: "alice-uid"})
assert r1.status_code == 200
# Reading the contextvar OUTSIDE the request scope must see
# the default — not the value the prior request bound.
assert get_current_isolation_keys() is None
# And a follow-up request without headers gets a clean
# ``None`` rather than inheriting alice-uid.
r2 = client.get("/probe")
assert r2.json() == {"user": None, "chat": None, "_present": False}
def test_concurrent_requests_get_isolated_contextvars(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Different requests run in different async contexts; binding
from request A must NOT leak into a concurrent request B."""
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1")
host, probe = _make_host_with_probe()
async def _drive() -> None:
# Run two requests in parallel asyncio tasks against the
# same TestClient and assert their captures don't bleed
# into each other.
async def _hit(user_key: str) -> dict[str, str | None]:
with TestClient(host.app) as client: # type: ignore[attr-defined]
r = client.get("/probe", headers={ISOLATION_HEADER_USER: user_key})
return r.json() # type: ignore[no-any-return]
r_alice, r_bob = await asyncio.gather(_hit("alice-uid"), _hit("bob-uid"))
assert r_alice == {"user": "alice-uid", "chat": None}
assert r_bob == {"user": "bob-uid", "chat": None}
asyncio.run(_drive())
class TestNonHttpScopesPassThrough:
"""The middleware intentionally only inspects ``http`` scopes;
lifespan / websocket scopes are forwarded untouched. A regression
that touched lifespan scopes here would crash boot."""
async def test_lifespan_scope_does_not_consult_headers(self) -> None:
# The TestClient context manager exercises the lifespan scope
# implicitly; if the middleware tried to decode headers on a
# non-http scope this would raise. Exercise it without binding
# any contextvar work.
host, _probe = _make_host_with_probe()
with TestClient(host.app): # type: ignore[attr-defined]
# Just enter / exit; no requests.
pass
@@ -0,0 +1,50 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for the channel-neutral envelope types in :mod:`agent_framework_hosting._types`."""
from __future__ import annotations
from agent_framework_hosting import (
ChannelIdentity,
ChannelRequest,
ChannelSession,
)
class TestChannelRequest:
def test_required_fields_only(self) -> None:
req = ChannelRequest(channel="responses", operation="message.create", input="hi")
assert req.channel == "responses"
assert req.operation == "message.create"
assert req.input == "hi"
assert req.session is None
assert req.options is None
assert req.session_mode == "auto"
assert req.metadata == {}
assert req.attributes == {}
assert req.stream is False
assert req.identity is None
def test_with_session_and_identity(self) -> None:
req = ChannelRequest(
channel="telegram",
operation="message.create",
input="hi",
session=ChannelSession(isolation_key="user:42"),
identity=ChannelIdentity(channel="telegram", native_id="42"),
)
assert req.session is not None
assert req.session.isolation_key == "user:42"
assert req.identity is not None
assert req.identity.channel == "telegram"
assert req.identity.native_id == "42"
class TestChannelIdentity:
def test_attributes_default_empty_mapping(self) -> None:
ident = ChannelIdentity(channel="teams", native_id="abc")
assert dict(ident.attributes) == {}
def test_attributes_passthrough(self) -> None:
ident = ChannelIdentity(channel="teams", native_id="abc", attributes={"role": "user"})
assert dict(ident.attributes) == {"role": "user"}
+13
View File
@@ -85,6 +85,13 @@ agent-framework-foundry-hosting = { workspace = true }
agent-framework-foundry-local = { workspace = true }
agent-framework-gemini = { workspace = true }
agent-framework-github-copilot = { workspace = true }
agent-framework-hosting = { workspace = true }
agent-framework-hosting-invocations = { workspace = true }
agent-framework-hosting-telegram = { workspace = true }
agent-framework-hosting-activity-protocol = { workspace = true }
agent-framework-hosting-discord = { workspace = true }
agent-framework-hosting-a2a = { workspace = true }
agent-framework-hosting-mcp = { workspace = true }
agent-framework-hyperlight = { workspace = true }
agent-framework-lab = { workspace = true }
agent-framework-mem0 = { workspace = true }
@@ -207,6 +214,12 @@ executionEnvironments = [
{ root = "packages/foundry/tests", reportPrivateUsage = "none" },
{ root = "packages/foundry_local/tests", reportPrivateUsage = "none" },
{ root = "packages/github_copilot/tests", reportPrivateUsage = "none" },
{ root = "packages/hosting/tests", reportPrivateUsage = "none" },
{ root = "packages/hosting-invocations/tests", reportPrivateUsage = "none" },
{ root = "packages/hosting-telegram/tests", reportPrivateUsage = "none" },
{ root = "packages/hosting-activity-protocol/tests", reportPrivateUsage = "none" },
{ root = "packages/hosting-a2a/tests", reportPrivateUsage = "none" },
{ root = "packages/hosting-mcp/tests", reportPrivateUsage = "none" },
{ root = "packages/lab/gaia/tests", reportPrivateUsage = "none" },
{ root = "packages/lab/lightning/tests", reportPrivateUsage = "none" },
{ root = "packages/lab/tau2/tests", reportPrivateUsage = "none" },
@@ -0,0 +1,51 @@
# Multi-channel hosting samples
End-to-end samples for serving an `agent-framework` agent (or workflow)
through one or more **channels** with `agent-framework-hosting`.
The general hosting plumbing lives in
[`agent-framework-hosting`](../../../packages/hosting); each channel is
its own package (`agent-framework-hosting-responses`,
`agent-framework-hosting-invocations`,
`agent-framework-hosting-telegram`, `agent-framework-hosting-activity-protocol`,
`agent-framework-hosting-discord`).
| Sample | What it shows | Packaging |
|---|---|---|
| [`local_responses/`](./local_responses) | The minimal shape: one agent + one `@tool` + `ResponsesChannel` + a single `run_hook` that strips caller-supplied options and forces a `reasoning` preset. | **Local only.** Start here to learn the run-hook seam. |
| [`local_responses_workflow/`](./local_responses_workflow) | A 4-step `Workflow` (typed `SloganBrief` intake → writer → legal → formatter) hosted behind **both** the Responses and Invocations channels via a shared `run_hook` that parses inbound text/JSON into the workflow's typed input. The host writes per-conversation checkpoints via `checkpoint_location=…`. Demonstrates workflow targets + structured input adaptation + multi-channel + resume-across-turns. Includes a `call_server.rest` file with REST examples for both endpoints. | **Local only.** |
| [`foundry_hosted_agent/`](./foundry_hosted_agent) | One Foundry agent, **Responses + Invocations only** — the minimal shape that is **runtime-compatible with the Foundry Hosted Agents platform**. | Ships with `Dockerfile` + `agent.yaml` + `agent.manifest.yaml` + `azure.yaml` so the same image runs locally **or** as a Foundry Hosted Agent (`azd up`). |
| [`foundry_telegram_invocations_weather/`](./foundry_telegram_invocations_weather) | Experimental Telegram weather bot that mounts `TelegramChannel` at `POST /invocations`, registers the Foundry Hosted Agents Invocations URL as the Telegram webhook, and uses `FoundryHostedAgentHistoryProvider` for storage. | Ships with `Dockerfile` + `agent.yaml` + `agent.manifest.yaml` + `azure.yaml`; used to validate whether a non-Responses channel can run under Foundry Invocations. |
| [`local_telegram/`](./local_telegram) | Adds Telegram, a `@tool`, `FileHistoryProvider`, run hooks (per-user / per-chat session keying), and extra Telegram commands. Runs under Hypercorn with multiple workers. | **Local only.** No Dockerfile / Foundry packaging. |
Each sample is fully self-contained — its own `pyproject.toml`, `uv.lock`,
server `app.py`, calling script(s), and `storage/` directory. Every
sample uses `[tool.uv.sources]` to wire its `agent-framework-hosting*`
dependencies to the
[`feature/python-hosting`](https://github.com/microsoft/agent-framework/tree/feature/python-hosting)
branch of the upstream repo via git refs, so they install cleanly outside
the monorepo while the hosting packages are still pre-PyPI. Once those
packages publish, drop the `[tool.uv.sources]` block and let the
declared deps resolve from PyPI.
## Relationship to `../foundry-hosted-agents/`
The sibling [`../foundry-hosted-agents/`](../foundry-hosted-agents) directory
contains samples for the **`agent-framework-hosted`** stack — agents
that run **inside** the Foundry Hosted Agents platform using its
built-in protocol surface (Responses, Invocations, conversation store,
isolation, identity), with **no `agent-framework-hosting` package
involved**.
| Aspect | `af-hosting/` (this directory) | `foundry-hosted-agents/` |
|---|---|---|
| Server stack | `agent-framework-hosting` + per-channel packages (`-responses`, `-invocations`, `-telegram`, `-activity-protocol`, `-discord`) | `agent-framework-hosted` only — the Foundry Hosted Agents runtime owns the HTTP surface |
| Channels other than Responses / Invocations | Yes — Telegram, Activity Protocol (Teams), Discord | No — the platform exposes Responses + Invocations only |
| Run target | Local Hypercorn (`local_responses/`, `local_telegram/`); Hosted Agents *or* local (`foundry_hosted_agent/`) | Hosted Agents *or* local container; targets the Hosted Agents platform contract |
| When to pick this | You need extra channels (Telegram/Teams via Activity Protocol/…), custom hosting middleware, or want to run outside the Foundry runtime | You only need Responses/Invocations and want zero hosting boilerplate, leveraging the Foundry-managed surface |
`foundry_hosted_agent/` is the bridge sample: it uses the
`agent-framework-hosting` stack but is packaged so the Foundry Hosted
Agents platform can run it as one of its own.
See [`ARCHITECTURE.md`](./ARCHITECTURE.md) for the cross-sample story.
@@ -0,0 +1,419 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
*.env
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
[Aa][Rr][Mm]64[Ee][Cc]/
bld/
[Oo]bj/
[Oo]ut/
[Ll]og/
[Ll]ogs/
# Build results on 'Bin' directories
**/[Bb]in/*
# Uncomment if you have tasks that rely on *.refresh files to move binaries
# (https://github.com/github/gitignore/pull/3736)
#!**/[Bb]in/*.refresh
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
*.trx
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Approval Tests result files
*.received.*
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.idb
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
# but not Directory.Build.rsp, as it configures directory-level build defaults
!Directory.Build.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
**/.paket/paket.exe
paket-files/
# FAKE - F# Make
**/.fake/
# CodeRush personal settings
**/.cr/personal
# Python Tools for Visual Studio (PTVS)
**/__pycache__/
*.pyc
# Cake - Uncomment if you are using it
#tools/**
#!tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
MSBuild_Logs/
# AWS SAM Build and Temporary Artifacts folder
.aws-sam
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
**/.mfractor/
# Local History for Visual Studio
**/.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
**/.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
.azure
@@ -0,0 +1,25 @@
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
# Build context for this Dockerfile is THIS folder (see ``azure.yaml`` ->
# ``services.<svc>.project: .``). The workspace packages this sample
# depends on are fetched from GitHub by ``uv sync`` (see the ``[tool.uv.sources]``
# git refs in ``pyproject.toml``). The build needs network access to GitHub
# during ``uv sync`` — no local vendoring step is required.
#
# ``Dockerfile.dockerignore`` (adjacent file, BuildKit) trims the upload to
# just the files COPYed below.
WORKDIR /app
COPY pyproject.toml ./
COPY app.py ./
# ``--no-dev`` skips the dev group (which only contains ``openai`` for
# ``call_server.py``). Locks fresh against the GitHub-hosted hosting
# packages declared in ``[tool.uv.sources]``.
RUN uv sync --no-dev
ENV PORT=8000
EXPOSE 8000
CMD ["uv", "run", "python", "app.py"]
@@ -0,0 +1,28 @@
# BuildKit per-Dockerfile ignore (sibling file: <Dockerfile>.dockerignore).
# Build context for this image is THIS folder. Trim everything except the
# files the Dockerfile actually COPYs.
# Local virtualenv & python caches.
.venv/
**/.venv/
**/__pycache__/
**/*.pyc
**/*.pyo
**/.pytest_cache/
**/.mypy_cache/
**/.ruff_cache/
# azd / git / IDE.
.azure/
.git/
.gitignore
.vscode/
.idea/
# Sample-specific files not needed at runtime.
README.md
call_server.py
agent.yaml
agent.manifest.yaml
azure.yaml
infra/
@@ -0,0 +1,134 @@
# foundry_hosted_agent — Responses + Invocations (Foundry Hosted Agents compatible)
Smallest end-to-end hosting sample. One Foundry-backed agent, two
channels, no human-chat surface — and that minimal shape is the whole
point: a host configured with at least the **Responses** and
**Invocations** channels under their default endpoints is
**runtime-compatible with the Foundry Hosted Agents platform**. The
same container image runs locally, behind any ASGI server, or as a
Hosted Agent — no protocol shim, no extra adapter.
| Route | Channel | Used by |
| ------------------------------ | -------------------- | ------------------------------------------- |
| `POST /responses` | `ResponsesChannel` | OpenAI Responses clients (`call_server.py`) |
| `POST /invocations` | `InvocationsChannel` | Host-native JSON envelope (Hosted Agents) |
## Conversation history
The agent is wired with `FoundryHostedAgentHistoryProvider` (from
`agent-framework-foundry-hosting`). When a Responses request supplies
`previous_response_id`, the channel uses it as the session id and the
provider fetches the prior turn chain directly from
`{FOUNDRY_PROJECT_ENDPOINT}/storage/...` using the same managed-identity
credential as the chat client. Locally (when `FOUNDRY_HOSTING_ENVIRONMENT`
is unset) it transparently falls back to an in-memory store, so the same
code runs in dev. Writes are a no-op — Foundry persists Responses turns
authoritatively as the runtime executes them.
For richer local scenarios (custom tools, history providers, run hooks,
Telegram, and Activity Protocol) see [`../local_telegram`](../local_telegram).
## Layout
```
foundry_hosted_agent/
├── app.py # the host (ResponsesChannel + InvocationsChannel)
├── call_server.py # client: openai SDK / agent framework / FoundryAgent
├── agent.yaml # Foundry Hosted Agents minimal definition
├── agent.manifest.yaml # Foundry Hosted Agents full deployment manifest
├── azure.yaml # azd service config (build context = this folder)
├── Dockerfile # built from this folder; uv fetches deps from GitHub
├── Dockerfile.dockerignore # BuildKit allowlist that trims the context
├── pyproject.toml # depends on the hosting packages via GitHub git refs
└── README.md # this file
```
## Run locally
```bash
export FOUNDRY_PROJECT_ENDPOINT=https://<your-project>.services.ai.azure.com
export MODEL_DEPLOYMENT_NAME=gpt-4.1-mini
az login # any DefaultAzureCredential source
uv sync
uv run python app.py # binds 0.0.0.0:8000
```
The env var names match `agent.manifest.yaml` so the same shell
environment works for both local runs and Hosted Agent deployments.
## Call locally
```bash
uv sync --group dev
# OpenAI SDK pointed at the local /responses endpoint.
uv run python call_server.py --via openai "hello there"
# The same call via the Agent Framework Agent + OpenAIChatClient stack.
uv run python call_server.py --via af "hello there"
# Once deployed as a Hosted Agent: target the Foundry-managed endpoint.
export FOUNDRY_HOSTED_AGENT_NAME=agent-framework-hosting-sample
uv run python call_server.py --via foundry "hello there"
```
## Docker
The Docker build context is **this sample folder**. `pyproject.toml`
declares the in-tree `agent-framework-hosting*` packages via
[`[tool.uv.sources]` git refs](./pyproject.toml) pointing at the
``feature/python-hosting`` branch of
[microsoft/agent-framework](https://github.com/microsoft/agent-framework),
so `uv sync` inside the image fetches them directly. No vendoring step is
required — the build just needs network access to GitHub. Once the
hosting packages publish to PyPI you can drop the `[tool.uv.sources]`
overrides and rely on PyPI resolution.
```bash
# From this folder — context = `.` (sample folder).
DOCKER_BUILDKIT=1 docker build -t hosting-sample-hosted-agent .
docker run -p 8000:8000 \
-e FOUNDRY_PROJECT_ENDPOINT -e MODEL_DEPLOYMENT_NAME \
-e AZURE_CLIENT_ID -e AZURE_TENANT_ID -e AZURE_CLIENT_SECRET \
hosting-sample-hosted-agent
```
## Hosted Agent deployment
`azure.yaml` keeps `project: .` and uses `docker.remoteBuild: true` —
the remote builder receives only this sample folder and runs
`uv sync` to pull the hosting packages from GitHub.
The two YAMLs follow the same convention as the
[`foundry-hosted-agents/`](../../foundry-hosted-agents/) reference
samples — `agent.yaml` is the minimal kind/protocols/resources card,
`agent.manifest.yaml` is the full template + environment-variable +
model-resource binding used during deployment.
```bash
azd up # provisions infra/ + builds + pushes + deploys
azd deploy # rebuild + redeploy only
```
### Required Foundry RBAC
The container runs as the Hosted Agent's managed identity. That identity
needs permission to call the Foundry project's agent/Responses endpoints
— without it the call returns 401 ``PermissionDenied``. Grant the
**Azure AI Project Manager** role (or the more granular
``Microsoft.CognitiveServices/accounts/AIServices/agents/*`` data
actions) on the Foundry project to the Hosted Agent's managed identity.
See <https://aka.ms/FoundryPermissions> for the full role list.
### Health probe
The Foundry Hosted Agents runtime probes ``GET /readiness``;
``AgentFrameworkHost`` exposes that route automatically (returns
``200 ok``). No extra wiring needed.
The host code never imports anything Foundry-specific beyond the chat
client itself — swapping `FoundryChatClient` for `OpenAIChatClient` (or
any other client) flips this sample from a Hosted Agent target to a
non-Foundry deployment without touching the channels.
@@ -0,0 +1,31 @@
name: agent-framework-hosting-sample
description: >
Minimal Agent Framework multi-channel hosting sample (Responses + Invocations)
packaged for the Foundry Hosted Agents runtime. Demonstrates that an
``AgentFrameworkHost`` configured with the Responses and Invocations channels
under their default mounts is a drop-in Hosted Agent image — no protocol
shim, no Foundry-specific server.
metadata:
tags:
- Agent Framework
- AI Agent Hosting
- Azure AI AgentServer
- Responses Protocol
- Invocations Protocol
- Streaming
- Multi-Channel
template:
name: agent-framework-hosting-sample
kind: hosted
protocols:
- protocol: responses
version: 1.0.0
- protocol: invocations
version: 1.0.0
environment_variables:
- name: MODEL_DEPLOYMENT_NAME
value: "{{MODEL_DEPLOYMENT_NAME}}"
resources:
- kind: model
id: gpt-5.4-nano
name: MODEL_DEPLOYMENT_NAME
@@ -0,0 +1,26 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
kind: hosted
name: agent-framework-hosting-sample
description: |
Minimal Agent Framework multi-channel hosting sample (Responses + Invocations) packaged for the Foundry Hosted Agents runtime. Demonstrates that an ``AgentFrameworkHost`` configured with the Responses and Invocations channels under their default mounts is a drop-in Hosted Agent image — no protocol shim, no Foundry-specific server.
metadata:
tags:
- Agent Framework
- AI Agent Hosting
- Azure AI AgentServer
- Responses Protocol
- Invocations Protocol
- Streaming
- Multi-Channel
protocols:
- protocol: responses
version: 1.0.0
- protocol: invocations
version: 1.0.0
resources:
cpu: "1"
memory: 2Gi
environment_variables:
- name: MODEL_DEPLOYMENT_NAME
value: gpt-5.4-nano
@@ -0,0 +1,185 @@
# Copyright (c) Microsoft. All rights reserved.
"""Smallest hosting sample — Responses + Invocations only.
This sample is intentionally minimal and is **runtime-compatible with the
Foundry Hosted Agents platform**: a host that exposes the Responses and
Invocations channels under their default endpoints can be packaged as a
container image and deployed to Foundry Hosted Agents without any protocol
shim. The same image runs locally, behind any ASGI server, or as a Hosted
Agent.
History
-------
The agent uses :class:`FoundryHostedAgentHistoryProvider` so that conversation
history is loaded from the Foundry Hosted Agent storage backend when the
container runs inside Foundry. When ``previous_response_id`` is supplied on
an incoming Responses request, the channel routes it through to the
provider as the ``session_id``, and the provider fetches the prior turn
chain from ``{FOUNDRY_PROJECT_ENDPOINT}/storage/...``. Locally
(``FOUNDRY_HOSTING_ENVIRONMENT`` unset) the provider falls back to an
in-memory store so the same code runs in dev.
Setup
-----
- ``FOUNDRY_PROJECT_ENDPOINT`` — Foundry project endpoint URL.
- ``MODEL_DEPLOYMENT_NAME`` — model deployment name (the same env var
the Foundry Hosted Agents manifest binds via the ``model`` resource —
see ``agent.manifest.yaml``).
- ``FOUNDRY_HOSTING_ENVIRONMENT`` — set automatically by the Hosted Agents
runtime; signals the history provider to talk to the Foundry storage API
instead of the local in-memory fallback.
- ``APPLICATIONINSIGHTS_CONNECTION_STRING`` — when present, the sample
wires Azure Monitor OpenTelemetry export at import time. Foundry Hosted
Agents inject this when an Application Insights resource is bound to
the project; locally it's optional.
Auth uses ``DefaultAzureCredential`` so any standard Azure auth chain
works (``az login`` locally, managed identity in Hosted Agents,
``AZURE_*`` env vars in CI, ...).
Run
---
- Local: ``python app.py`` (binds ``0.0.0.0:8000``)
- ASGI: ``hypercorn app:app --bind 0.0.0.0:8000``
- Docker: ``docker build -t hosting-sample-hosted-agent . && \\
docker run -p 8000:8000 \\
-e FOUNDRY_PROJECT_ENDPOINT -e MODEL_DEPLOYMENT_NAME \\
hosting-sample-hosted-agent``
- Hosted Agent: build & push the image, then deploy via ``agent.yaml`` /
``agent.manifest.yaml`` in this folder.
Routes
------
- ``POST /responses`` — OpenAI Responses-shaped surface.
- ``POST /invocations`` — host-native JSON envelope.
"""
from __future__ import annotations
import logging
import os
from agent_framework import Agent
from agent_framework.observability import enable_instrumentation
from agent_framework_foundry import FoundryChatClient
from agent_framework_foundry_hosting import (
FoundryHostedAgentHistoryProvider,
foundry_response_id,
)
from agent_framework_hosting import AgentFrameworkHost
from agent_framework_hosting_invocations import InvocationsChannel
from agent_framework_hosting_responses import ResponsesChannel
from azure.identity.aio import DefaultAzureCredential
# Configure root logging early so library log records (in particular
# ``agent_framework_foundry_hosting._history_provider``) are captured by
# the container's stderr stream and surfaced in the Foundry portal /
# Azure Monitor. ``LOG_LEVEL`` overrides this for production tightening.
logging.basicConfig(
level=os.environ.get("LOG_LEVEL", "INFO").upper(),
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
# Quiet noisy transports unless explicitly cranked up.
for _noisy in (
"httpx",
"httpcore",
"azure.core.pipeline.policies.http_logging_policy",
"urllib3",
):
logging.getLogger(_noisy).setLevel(logging.WARNING)
logger = logging.getLogger(__name__)
def _configure_observability() -> None:
"""Wire Azure Monitor OpenTelemetry when a connection string is present.
Foundry Hosted Agents inject ``APPLICATIONINSIGHTS_CONNECTION_STRING``
into the container at runtime when an Application Insights resource is
bound to the project. We honor the same env var locally so the same
code path lights up in both environments. When the var is absent
(typical local dev without an AI binding) we silently skip — the host
still serves traffic, just without OTel export.
"""
conn_str = os.environ.get("APPLICATIONINSIGHTS_CONNECTION_STRING")
if not conn_str:
logger.info(
"APPLICATIONINSIGHTS_CONNECTION_STRING not set — skipping Azure Monitor OpenTelemetry configuration.",
)
return
# Imported lazily so the sample still starts when the optional
# ``azure-monitor-opentelemetry`` dependency isn't installed (e.g. an
# ultra-thin local dev image stripped of observability extras).
from azure.monitor.opentelemetry import configure_azure_monitor
configure_azure_monitor(connection_string=conn_str)
logger.info("Azure Monitor OpenTelemetry configured.")
def build_host() -> AgentFrameworkHost:
# Single credential is shared by the chat client and the history
# provider so we only authenticate (and refresh tokens) once.
credential = DefaultAzureCredential()
project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"]
agent = Agent(
client=FoundryChatClient(
project_endpoint=project_endpoint,
model=os.environ["MODEL_DEPLOYMENT_NAME"],
credential=credential,
),
name="HostedAgentSample",
instructions="You are called Jarvis, a friendly assistant. Keep answers brief.",
# Loads history from Foundry storage when running inside a Hosted
# Agent (FOUNDRY_HOSTING_ENVIRONMENT set); falls back to an in-
# memory store for local dev.
context_providers=[
FoundryHostedAgentHistoryProvider(
credential=credential,
endpoint=project_endpoint,
),
],
)
return AgentFrameworkHost(
target=agent,
channels=[
# Mint Foundry-storage-compatible response ids
# (``caresp_{18charPartitionKey}{32charEntropy}``). The
# Foundry storage backend partitions records by extracting
# this segment from the id; free-form ``resp_<uuid>`` ids
# are rejected with an opaque ``HTTP 500 server_error``.
ResponsesChannel(response_id_factory=foundry_response_id),
InvocationsChannel(),
],
)
# `app` is the canonical ASGI surface — hand it to any ASGI server, or let
# the Foundry Hosted Agents runtime pick it up via the standard entry point.
# Observability is configured at import time so trace/log export is wired
# before the host starts handling requests. Per-request Foundry isolation
# (the platform-injected ``x-agent-{user,chat}-isolation-key`` headers)
# is read by the host's installed ASGI middleware off every inbound HTTP
# request and lifted into a contextvar that
# :class:`FoundryHostedAgentHistoryProvider` consults on each storage call.
# Multi-turn persistence works out of the box in both local dev and the
# Hosted Agents container — no manual middleware wiring needed.
_configure_observability()
enable_instrumentation(enable_sensitive_data=True)
app = build_host().app
if __name__ == "__main__":
# Serve the host's ASGI app directly. The Foundry isolation headers
# are read by the host's installed ASGI middleware and threaded
# through the storage provider via a contextvar; nothing extra to wire.
import asyncio
import hypercorn.asyncio
import hypercorn.config
config = hypercorn.config.Config()
config.bind = [f"0.0.0.0:{int(os.environ.get('PORT', '8000'))}"]
asyncio.run(hypercorn.asyncio.serve(app, config)) # type: ignore[arg-type]
@@ -0,0 +1,32 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json
requiredVersions:
extensions:
azure.ai.agents: '>=0.1.0-preview'
name: ai-foundry-starter-basic
services:
agent-framework-hosting-sample:
project: .
host: azure.ai.agent
language: docker
docker:
remoteBuild: true
config:
container:
resources:
cpu: "1"
memory: 2Gi
scale:
maxReplicas: 1
deployments:
- model:
format: OpenAI
name: gpt-5.4-nano
version: "2026-03-17"
name: gpt-5.4-nano
sku:
capacity: 250
name: GlobalStandard
infra:
provider: bicep
path: ./infra
@@ -0,0 +1,126 @@
# Copyright (c) Microsoft. All rights reserved.
"""Call the foundry_hosted_agent server three ways.
The foundry_hosted_agent host exposes ``POST /responses`` (OpenAI Responses-shaped) and
``POST /invocations`` (host-native), and that minimal contract is
**runtime-compatible with the Foundry Hosted Agents platform** — so the same
agent code that calls the local server also calls the same image deployed
as a Hosted Agent.
Modes
-----
``--via openai`` (default)
Plain ``openai`` SDK against the local ``/responses``. Uses
``api_key="not-needed"`` because the local sample has no auth.
``--via af``
Agent Framework ``Agent`` wrapping ``OpenAIChatClient`` pointed at the
local ``BASE_URL``. ``OpenAIChatClient`` already speaks the Responses
surface natively.
``--via foundry``
Agent Framework ``FoundryAgent`` against a Hosted Agent that this image
has been deployed as. Requires::
FOUNDRY_PROJECT_ENDPOINT=https://<project>.services.ai.azure.com
FOUNDRY_HOSTED_AGENT_NAME=<hosted-agent-name>
Auth uses ``AzureCliCredential`` (run ``az login`` first).
Start the server first (in another shell)::
uv run python app.py
Then::
uv run python call_server.py "Who are you?"
uv run python call_server.py --via af "What's the weather in Seattle?"
FOUNDRY_PROJECT_ENDPOINT=... FOUNDRY_HOSTED_AGENT_NAME=... \\
uv run python call_server.py --via foundry "Who are you?"
"""
from __future__ import annotations
import argparse
import asyncio
import os
from agent_framework import Agent
from agent_framework_foundry import FoundryAgent
from agent_framework_openai import OpenAIChatClient
from azure.identity.aio import AzureCliCredential
from openai import OpenAI
# Bare server origin — the OpenAI SDK / OpenAIChatClient append ``/responses`` themselves.
BASE_URL = "http://127.0.0.1:8000"
def call_via_openai_sdk(prompt: str) -> None:
client = OpenAI(base_url=BASE_URL, api_key="not-needed")
response = client.responses.create(model="agent", input=prompt)
print(f"User: {prompt}")
print(f"Agent: {response.output_text}")
async def call_via_agent_framework(prompt: str) -> None:
# Agent + OpenAIChatClient(base_url=...) is the Agent Framework way to
# talk to any Responses-shaped endpoint — including foundry_hosted_agent's `/responses`.
chat_client = OpenAIChatClient(base_url=BASE_URL, api_key="not-needed", model_id="agent")
agent = Agent(client=chat_client)
result = await agent.run(prompt)
print(f"User: {prompt}")
print(f"Agent: {result.text}")
async def call_via_foundry_hosted_agent(prompt: str) -> None:
# Once foundry_hosted_agent's image is deployed as a Foundry Hosted Agent, FoundryAgent
# keyed on ``agent_name`` is the AF-native client. The agent's runtime is
# the very same Responses + Invocations contract — Foundry just hosts it.
project_endpoint = os.environ.get("FOUNDRY_PROJECT_ENDPOINT")
if not project_endpoint:
raise SystemExit(
"FOUNDRY_PROJECT_ENDPOINT must be set; e.g. https://<project>.services.ai.azure.com/api/projects/agents"
)
agent_name = os.environ.get("FOUNDRY_HOSTED_AGENT_NAME", "agent-framework-hosting-sample")
# Optional: continue a prior conversation by passing FOUNDRY_HOSTED_SESSION_ID.
session_id = os.environ.get("FOUNDRY_HOSTED_SESSION_ID")
async with AzureCliCredential() as credential:
agent = FoundryAgent(
project_endpoint=project_endpoint,
agent_name=agent_name,
credential=credential,
allow_preview=True,
)
if session_id:
session = agent.get_session(service_session_id=session_id)
result = await agent.run(prompt, session=session)
else:
result = await agent.run(prompt)
print(f"User: {prompt}")
print(f"Agent: {result.text}")
print(f"Session ID (for history continuity): {result.response_id}")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument(
"--via",
choices=("openai", "af", "foundry"),
default="openai",
help="Calling client to use.",
)
parser.add_argument("prompt", nargs="*")
args = parser.parse_args()
prompt = " ".join(args.prompt) or "Who are you?"
if args.via == "openai":
call_via_openai_sdk(prompt)
elif args.via == "af":
asyncio.run(call_via_agent_framework(prompt))
else:
asyncio.run(call_via_foundry_hosted_agent(prompt))
if __name__ == "__main__":
main()
@@ -0,0 +1,28 @@
[project]
name = "agent-framework-hosting-sample-hosted-agent"
version = "0.0.1"
description = "Hosted-Agent-compatible hosting sample (Responses + Invocations)."
requires-python = ">=3.10"
dependencies = [
"agent-framework-foundry",
"agent-framework-foundry-hosting",
"agent-framework-hosting",
"agent-framework-hosting-invocations",
"agent-framework-hosting-responses",
"azure-identity",
"aiohttp>=3.13.5",
"hypercorn>=0.17",
"azure-monitor-opentelemetry>=1.6",
]
[dependency-groups]
dev = ["openai>=1.99"]
[tool.uv]
package = false
[tool.uv.sources]
agent-framework-foundry-hosting = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/foundry_hosting" }
agent-framework-hosting = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting" }
agent-framework-hosting-invocations = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting-invocations" }
agent-framework-hosting-responses = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting-responses" }
@@ -0,0 +1,19 @@
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
WORKDIR /app
# The sample depends on hosting packages from Git refs until they publish to
# PyPI, so the remote builder needs git available during `uv sync`.
RUN apt-get update \
&& apt-get install -y --no-install-recommends git \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml ./
COPY app.py ./
RUN uv sync --no-dev
ENV PORT=8000
EXPOSE 8000
CMD ["uv", "run", "python", "app.py"]
@@ -0,0 +1,4 @@
*
!app.py
!pyproject.toml
!Dockerfile
@@ -0,0 +1,66 @@
# foundry_telegram_invocations_weather
Telegram weather bot sample for validating a non-Responses channel on Foundry
Hosted Agents. The sample configures `TelegramChannel(path="/invocations")` so
the webhook handler runs at the container endpoint `POST /invocations`; Foundry
exposes that route publicly as:
```text
{FOUNDRY_PROJECT_ENDPOINT}/agents/agent-framework-telegram-invocations-weather/endpoint/protocols/invocations?api-version=2025-11-15-preview
```
| Route | Channel | Used by |
|---|---|---|
| `POST /responses` | `ResponsesChannel` | Quick hosted-agent sanity checks |
| `POST /invocations` | `TelegramChannel` | Telegram webhook payloads |
The agent uses `FoundryHostedAgentHistoryProvider` and a small
`lookup_weather` tool so Telegram requests exercise model calls, tool calls,
and Foundry-hosted storage.
## Important platform note
This is an intentional experiment. Current Foundry Hosted Agents behavior
requires Entra bearer auth before a request reaches the container. Telegram
cannot attach that bearer token to webhook deliveries, so webhook registration
can succeed while live Telegram deliveries fail at the Foundry front door with
`401`. Authenticated calls to the Invocations endpoint are still useful for
validating the channel and storage behavior inside the container.
The sample does not configure `TELEGRAM_WEBHOOK_SECRET` because prior probing
showed Foundry strips Telegram's `X-Telegram-Bot-Api-Secret-Token` header before
the request reaches the container.
## Run locally
```bash
export FOUNDRY_PROJECT_ENDPOINT=https://<your-project>.services.ai.azure.com
export MODEL_DEPLOYMENT_NAME=gpt-5.4-nano
export TELEGRAM_BOT_TOKEN=<telegram-bot-token>
export TELEGRAM_WEBHOOK_URL=https://<public-local-tunnel>/invocations
az login
uv sync
uv run python app.py
```
## Deploy
```bash
set -a
. ../../../../.env
set +a
azd env set TELEGRAM_BOT_TOKEN "$TELEGRAM_BOT_TOKEN"
azd env set MODEL_DEPLOYMENT_NAME "${MODEL_DEPLOYMENT_NAME:-gpt-5.4-nano}"
azd env set HOSTING_INVOCATIONS_API_VERSION 2025-11-15-preview
azd up
```
If you connect this sample to an existing Foundry project instead of running
`azd provision`, make sure the azd environment has `AZURE_AI_PROJECT_ID` and the
project's ACR connection values set before running `azd deploy`.
On startup, `TelegramChannel` calls `setWebhook` using the Foundry public
Invocations URL derived from `FOUNDRY_PROJECT_ENDPOINT` and
`FOUNDRY_AGENT_NAME`.
@@ -0,0 +1,38 @@
name: agent-framework-telegram-invocations-weather
description: >
Telegram weather bot sample hosted by Agent Framework. The Telegram webhook
handler is mounted at /invocations so the Foundry Hosted Agents Invocations
protocol endpoint can be registered as the bot's webhook URL.
metadata:
tags:
- Agent Framework
- AI Agent Hosting
- Azure AI AgentServer
- Responses Protocol
- Invocations Protocol
- Telegram
template:
name: agent-framework-telegram-invocations-weather
kind: hosted
protocols:
- protocol: responses
version: 1.0.0
- protocol: invocations
version: 1.0.0
environment_variables:
- name: MODEL_DEPLOYMENT_NAME
value: "{{MODEL_DEPLOYMENT_NAME}}"
- name: TELEGRAM_BOT_TOKEN
value: "{{TELEGRAM_BOT_TOKEN}}"
- name: HOSTING_INVOCATIONS_API_VERSION
value: "{{HOSTING_INVOCATIONS_API_VERSION}}"
resources:
- kind: model
id: gpt-5.4-nano
name: MODEL_DEPLOYMENT_NAME
parameters:
properties:
- name: TELEGRAM_BOT_TOKEN
secret: true
- name: HOSTING_INVOCATIONS_API_VERSION
secret: false
@@ -0,0 +1,31 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
kind: hosted
name: agent-framework-telegram-invocations-weather
description: |
Telegram weather bot sample hosted by Agent Framework. The Telegram webhook
handler is mounted at /invocations so the Foundry Hosted Agents Invocations
protocol endpoint can be registered as the bot's webhook URL.
metadata:
tags:
- Agent Framework
- AI Agent Hosting
- Azure AI AgentServer
- Responses Protocol
- Invocations Protocol
- Telegram
protocols:
- protocol: responses
version: 1.0.0
- protocol: invocations
version: 1.0.0
resources:
cpu: "1"
memory: 2Gi
environment_variables:
- name: MODEL_DEPLOYMENT_NAME
value: ${MODEL_DEPLOYMENT_NAME}
- name: TELEGRAM_BOT_TOKEN
value: ${TELEGRAM_BOT_TOKEN}
- name: HOSTING_INVOCATIONS_API_VERSION
value: ${HOSTING_INVOCATIONS_API_VERSION}
@@ -0,0 +1,193 @@
# Copyright (c) Microsoft. All rights reserved.
"""Telegram weather bot hosted behind Foundry Hosted Agents Invocations.
This sample intentionally mounts the Telegram webhook handler at the container's
``/invocations`` route so the Foundry public Invocations protocol URL can be
registered as the Telegram webhook URL:
``{FOUNDRY_PROJECT_ENDPOINT}/agents/{FOUNDRY_AGENT_NAME}/endpoint/protocols/invocations``
It uses ``FoundryHostedAgentHistoryProvider`` for conversation history and a
small weather tool to validate that a normal channel can run under the
Hosted Agents runtime. The sample also exposes Responses for a quick platform
sanity check.
Sample output after sending "weather in Amsterdam" to the Telegram bot:
Assistant:> Amsterdam is cloudy with a high of 16 C.
"""
from __future__ import annotations
import logging
import os
from dataclasses import replace
from typing import Annotated
from agent_framework import Agent, tool
from agent_framework.observability import enable_instrumentation
from agent_framework_foundry import FoundryChatClient
from agent_framework_foundry_hosting import FoundryHostedAgentHistoryProvider, foundry_response_id
from agent_framework_hosting import (
AgentFrameworkHost,
ChannelCommand,
ChannelCommandContext,
ChannelRequest,
)
from agent_framework_hosting_responses import ResponsesChannel
from agent_framework_hosting_telegram import TelegramChannel, telegram_isolation_key
from azure.identity.aio import DefaultAzureCredential
AGENT_NAME = "agent-framework-telegram-invocations-weather"
DEFAULT_MODEL_DEPLOYMENT = "gpt-5.4-nano"
DEFAULT_INVOCATIONS_API_VERSION = "2025-11-15-preview"
logging.basicConfig(
level=os.environ.get("LOG_LEVEL", "INFO").upper(),
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
for _noisy in (
"httpx",
"httpcore",
"azure.core.pipeline.policies.http_logging_policy",
"urllib3",
):
logging.getLogger(_noisy).setLevel(logging.WARNING)
logger = logging.getLogger(__name__)
@tool(approval_mode="never_require")
def lookup_weather(location: Annotated[str, "The city to look up weather for."]) -> str:
"""Return a deterministic weather report for a city."""
reports = {
"seattle": "Seattle is rainy with a high of 12 C.",
"amsterdam": "Amsterdam is cloudy with a high of 16 C.",
"tokyo": "Tokyo is clear with a high of 22 C.",
"london": "London is misty with a high of 11 C.",
}
normalized = location.strip().lower()
return reports.get(normalized, f"{location} is sunny with a high of 20 C.")
def _foundry_invocations_webhook_url() -> str:
"""Build the public Foundry Invocations URL used as Telegram's webhook."""
explicit = os.environ.get("TELEGRAM_WEBHOOK_URL")
if explicit:
return explicit
project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"].rstrip("/")
agent_name = os.environ.get("FOUNDRY_AGENT_NAME", AGENT_NAME)
api_version = os.environ.get("HOSTING_INVOCATIONS_API_VERSION", DEFAULT_INVOCATIONS_API_VERSION)
return f"{project_endpoint}/agents/{agent_name}/endpoint/protocols/invocations?api-version={api_version}"
def _configure_observability() -> None:
"""Wire Azure Monitor OpenTelemetry when Foundry injects a connection string."""
conn_str = os.environ.get("APPLICATIONINSIGHTS_CONNECTION_STRING")
if not conn_str:
logger.info("APPLICATIONINSIGHTS_CONNECTION_STRING not set; skipping Azure Monitor export.")
return
from azure.monitor.opentelemetry import configure_azure_monitor # pyright: ignore[reportUnknownVariableType]
configure_azure_monitor(connection_string=conn_str)
logger.info("Azure Monitor OpenTelemetry configured.")
def telegram_hook(request: ChannelRequest, **_: object) -> ChannelRequest:
"""Clamp request options for Telegram-originating runs."""
options = dict(request.options or {})
options.pop("store", None)
options["reasoning"] = {"effort": "high", "summary": "auto"}
return replace(request, options=options)
def make_commands() -> list[ChannelCommand]:
"""Create Telegram slash commands used by the sample."""
async def handle_start(ctx: ChannelCommandContext) -> None:
await ctx.reply("Hi! Ask me for weather in Seattle, Amsterdam, Tokyo, London, or any city.")
async def handle_help(ctx: ChannelCommandContext) -> None:
await ctx.reply(
"/weather <city> - call the weather tool directly\n"
"/whoami - show your Telegram session key\n"
"/help - show this message"
)
async def handle_whoami(ctx: ChannelCommandContext) -> None:
await ctx.reply(f"Your session key is {telegram_isolation_key(ctx.request.attributes.get('chat_id'))}.")
async def handle_weather(ctx: ChannelCommandContext) -> None:
command_text = ctx.request.input if isinstance(ctx.request.input, str) else ""
_, _, location = command_text.partition(" ")
await ctx.reply(lookup_weather(location=(location.strip() or "Seattle")))
return [
ChannelCommand("start", "Introduce the bot", handle_start),
ChannelCommand("help", "List available commands", handle_help),
ChannelCommand("whoami", "Show the Telegram session key", handle_whoami),
ChannelCommand("weather", "Call the weather tool: /weather <city>", handle_weather),
]
def build_host() -> AgentFrameworkHost:
"""Build the Foundry-hosted Telegram weather agent."""
# 1. Create a shared credential for model calls and Foundry storage.
credential = DefaultAzureCredential()
project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"]
# 2. Create the agent with a simple weather tool and Foundry-backed history.
agent = Agent(
client=FoundryChatClient(
project_endpoint=project_endpoint,
model=os.environ.get("MODEL_DEPLOYMENT_NAME", DEFAULT_MODEL_DEPLOYMENT),
credential=credential,
),
name="TelegramInvocationsWeatherAgent",
instructions=(
"You are a concise weather assistant. Use lookup_weather for weather questions "
"and answer in one short sentence."
),
tools=[lookup_weather],
context_providers=[
FoundryHostedAgentHistoryProvider(
credential=credential,
endpoint=project_endpoint,
),
],
)
# 3. Register Telegram at /invocations and keep Responses available for sanity checks.
return AgentFrameworkHost(
target=agent,
channels=[
ResponsesChannel(response_id_factory=foundry_response_id),
TelegramChannel(
bot_token=os.environ["TELEGRAM_BOT_TOKEN"],
path="/invocations",
transport="webhook",
webhook_url=_foundry_invocations_webhook_url(),
parse_mode="Markdown",
commands=make_commands(),
run_hook=telegram_hook,
),
],
)
_configure_observability()
enable_instrumentation(enable_sensitive_data=True)
app = build_host().app
if __name__ == "__main__":
import asyncio
import hypercorn.asyncio
import hypercorn.config
config = hypercorn.config.Config()
config.bind = [f"0.0.0.0:{int(os.environ.get('PORT', '8000'))}"]
asyncio.run(hypercorn.asyncio.serve(app, config)) # type: ignore[arg-type]
@@ -0,0 +1,18 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json
requiredVersions:
extensions:
azure.ai.agents: '>=0.1.0-preview'
name: ai-foundry-telegram-invocations-weather
services:
agent-framework-telegram-invocations-weather:
project: .
host: azure.ai.agent
language: docker
docker:
remoteBuild: true
config:
container:
resources:
cpu: "1"
memory: 2Gi
@@ -0,0 +1,26 @@
[project]
name = "agent-framework-hosting-foundry-telegram-invocations-weather"
version = "0.0.1"
description = "Foundry Hosted Agents Telegram weather sample using the Invocations path."
requires-python = ">=3.10"
dependencies = [
"agent-framework-foundry",
"agent-framework-foundry-hosting",
"agent-framework-hosting",
"agent-framework-hosting-responses",
"agent-framework-hosting-telegram",
"azure-identity",
"aiohttp>=3.13.5",
"hypercorn>=0.17",
"mcp>=1.24,<2",
"azure-monitor-opentelemetry>=1.6",
]
[tool.uv]
package = false
[tool.uv.sources]
agent-framework-foundry-hosting = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/foundry_hosting" }
agent-framework-hosting = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting" }
agent-framework-hosting-responses = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting-responses" }
agent-framework-hosting-telegram = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting-telegram" }
@@ -0,0 +1,49 @@
# local_responses — Responses-only with a settings-altering hook
The smallest end-to-end `agent-framework-hosting` shape: one Foundry
agent with a `@tool`, one `ResponsesChannel`, one `run_hook`. Useful as
the entry-point sample for understanding the **channel run-hook** seam
without any multi-channel or identity-link concerns.
What the run hook demonstrates:
- **Strips** caller-supplied `temperature` / `store` so the host owns
those settings.
- **Forces** a `reasoning` preset (`effort=medium`, `summary=auto`) on
every turn — caller-side overrides are ignored.
`app:app` is a module-level Starlette ASGI app; recommended local launch
is Hypercorn.
## Run
```bash
export FOUNDRY_PROJECT_ENDPOINT=https://<your-project>.services.ai.azure.com
export FOUNDRY_MODEL=gpt-5.4-nano
az login
uv sync
uv run hypercorn app:app --bind 0.0.0.0:8000
```
Single-process for quick iteration:
```bash
uv run python app.py
```
## Call locally
```bash
uv sync --group dev
# Plain call:
uv run python call_server.py "What is the weather in Tokyo?"
# Continue an existing conversation by its `response.id`:
uv run python call_server.py --previous-response-id <response-id> "And in Seattle?"
```
> This sample is **local-only** — no Dockerfile, no Foundry packaging.
> For a Foundry-Hosted-Agents-compatible packaging see
> [`../foundry_hosted_agent`](../foundry_hosted_agent).
@@ -0,0 +1,113 @@
# Copyright (c) Microsoft. All rights reserved.
"""Minimal Responses-only hosting sample.
Single agent with one ``@tool`` (``lookup_weather``), single channel
(``ResponsesChannel``), one ``run_hook`` that demonstrates the
settings-mutation seam over caller-supplied options.
What the hook does
------------------
On every Responses request the hook receives the ``ChannelRequest`` that
the channel built from the inbound HTTP body. It:
- strips ``store`` (this agent owns persistence) and ``temperature``
(the configured model may not honor it),
- forces a ``reasoning`` effort + summary preset so the deployed surface
is consistent regardless of what the caller sent.
The hook is the documented escape hatch over the uniform
``ChannelRequest`` envelope.
Run
---
``app`` is a module-level Starlette ASGI app. Recommended local launch::
uv sync
az login
export FOUNDRY_PROJECT_ENDPOINT=https://<your-project>.services.ai.azure.com
export FOUNDRY_MODEL=gpt-5.4-nano
uv run hypercorn app:app --bind 0.0.0.0:8000
Or use the ``__main__`` block (single-process Hypercorn) for quick
iteration::
uv run python app.py
Then call it::
uv run python call_server.py "What is the weather in Tokyo?"
"""
from __future__ import annotations
import os
from dataclasses import replace
from pathlib import Path
from random import randint
from typing import Annotated
from agent_framework import Agent, FileHistoryProvider, tool
from agent_framework_foundry import FoundryChatClient
from agent_framework_hosting import AgentFrameworkHost, ChannelRequest
from agent_framework_hosting_responses import ResponsesChannel
from azure.identity.aio import DefaultAzureCredential
SESSIONS_DIR = Path(__file__).resolve().parent / "storage" / "sessions"
SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
@tool(approval_mode="never_require")
def lookup_weather(
location: Annotated[str, "The city to look up weather for."],
) -> str:
"""Return a deterministic weather report for a city."""
high_temp = randint(5, 25)
reports = {
"Seattle": f"Seattle is rainy with a high of {high_temp}°C.",
"Amsterdam": f"Amsterdam is cloudy with a high of {high_temp}°C.",
"Tokyo": f"Tokyo is clear with a high of {high_temp}°C.",
}
return reports.get(location, f"{location} is sunny with a high of {high_temp}°C.")
def responses_hook(request: ChannelRequest, **_: object) -> ChannelRequest:
"""Strip caller-supplied options the host should own and force a
reasoning preset."""
options = dict(request.options or {})
# The agent's default_options own ``store``; the model may not honor
# ``temperature``. Strip both so the caller can't override.
options.pop("temperature", None)
options.pop("store", None)
# Force a consistent reasoning preset on every turn.
options["reasoning"] = {"effort": "medium", "summary": "auto"}
return replace(request, options=options or None)
def build_host() -> AgentFrameworkHost:
agent = Agent(
client=FoundryChatClient(credential=DefaultAzureCredential()),
name="WeatherAgent",
instructions=(
"You are a friendly weather assistant. Use the lookup_weather tool "
"for any weather question and answer in one short sentence."
),
tools=[lookup_weather],
context_providers=[FileHistoryProvider(SESSIONS_DIR)],
default_options={"store": False},
)
return AgentFrameworkHost(
target=agent,
channels=[ResponsesChannel(run_hook=responses_hook)],
debug=True,
)
app = build_host().app
if __name__ == "__main__":
build_host().serve(host="0.0.0.0", port=int(os.environ.get("PORT", "8000")))
@@ -0,0 +1,47 @@
# Copyright (c) Microsoft. All rights reserved.
"""Local client for the local_responses sample.
Posts to ``/responses`` using the standard ``openai`` SDK.
Pass ``--previous-response-id <id>`` to continue a conversation by its
``response.id`` (returned in the prior response).
Start the server first (in another shell)::
uv run python app.py
Then::
uv run python call_server.py "What is the weather in Tokyo?"
"""
from __future__ import annotations
import sys
from openai import OpenAI
BASE_URL = "http://127.0.0.1:8000"
def main() -> None:
args = sys.argv[1:]
previous_response_id: str | None = None
if len(args) >= 2 and args[0] == "--previous-response-id":
previous_response_id = args[1]
args = args[2:]
print(f"Resuming response: {previous_response_id}")
prompt = " ".join(args) or "What is the weather in Tokyo?"
client = OpenAI(base_url=BASE_URL, api_key="not-needed")
response = client.responses.create(
model="agent",
input=prompt,
previous_response_id=previous_response_id,
)
print(f"User: {prompt}")
print(f"Agent: {response.output_text}")
if __name__ == "__main__":
main()

Some files were not shown because too many files have changed in this diff Show More