Compare commits

..

19 Commits

Author SHA1 Message Date
eavanvalkenburg 3cdc751a70 Fix remaining Python hosting checks
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-17 18:41:10 +02:00
eavanvalkenburg eb2e0adc60 Fix Python hosting CI issues
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-17 17:40:41 +02:00
eavanvalkenburg 47ec0335ff Resolve Python hosting rebase conflicts
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-17 17:23:12 +02:00
eavanvalkenburg 2bc01b353f Cover workflow event stream mapping
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-17 17:14:09 +02:00
Eduard van Valkenburg 54b390dd17 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-17 17:14:09 +02:00
Eduard van Valkenburg 81db307dad 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-17 17:14:09 +02:00
Eduard van Valkenburg 109cff4d20 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-17 17:14:09 +02:00
Eduard van Valkenburg 8fa53a3a3f 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-17 17:14:09 +02:00
Eduard van Valkenburg db68bc65f3 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-06-17 17:14:08 +02:00
Eduard van Valkenburg 442585c104 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-06-17 17:13:41 +02:00
Eduard van Valkenburg 3af116e485 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-06-17 17:13:41 +02:00
Eduard van Valkenburg eec0714d12 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-06-17 17:13:40 +02:00
Eduard van Valkenburg fbcf0619d5 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-06-17 17:13:40 +02:00
Eduard van Valkenburg 07717d3124 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-06-17 17:13:40 +02:00
Eduard van Valkenburg 7bd68f8897 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-06-17 17:13:40 +02:00
Eduard van Valkenburg a83a8af5a7 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-06-17 17:13:40 +02:00
Eduard van Valkenburg a6027a1c91 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-06-17 17:12:30 +02:00
eavanvalkenburg 1e2599e68e docs: renumber hosting channels ADR
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-17 17:12:30 +02:00
Eduard van Valkenburg a30a10ba20 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-06-17 17:12:30 +02:00
786 changed files with 34422 additions and 32910 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
name: .NET Bug Report
description: Report a bug in the Agent Framework .NET SDK
title: ".NET: [Bug]: "
labels: [".NET"]
labels: ["bug", ".NET"]
type: bug
body:
- type: textarea
+1 -1
View File
@@ -1,7 +1,7 @@
name: Python Bug Report
description: Report a bug in the Agent Framework Python SDK
title: "Python: [Bug]: "
labels: ["Python"]
labels: ["bug", "Python"]
type: bug
body:
- type: textarea
+3 -5
View File
@@ -24,14 +24,12 @@ updates:
- ".NET"
- "dependencies"
# Maintain dependencies for python.
# TODO: Remove these Python Dependabot entries after we have confidence in the
# Python dependency-maintenance workflow.
# Maintain dependencies for python
- package-ecosystem: "pip"
directory: "python/"
schedule:
interval: "weekly"
day: "thursday"
day: "monday"
labels:
- "python"
- "dependencies"
@@ -39,7 +37,7 @@ updates:
directory: "python/"
schedule:
interval: "weekly"
day: "thursday"
day: "monday"
labels:
- "python"
- "dependencies"
+5 -3
View File
@@ -2,7 +2,7 @@ name: Issue Triage
on:
issues:
types: [opened, typed]
types: [opened, labeled]
permissions:
contents: read
@@ -12,7 +12,9 @@ permissions:
concurrency:
group: >-
issue-triage-${{ github.repository }}-${{
github.event.issue.type.name == 'Bug' && github.event.issue.number
((github.event.action == 'opened' && contains(github.event.issue.labels.*.name, 'bug'))
|| (github.event.action == 'labeled' && github.event.label.name == 'bug'))
&& github.event.issue.number
|| github.run_id
}}
cancel-in-progress: true
@@ -26,7 +28,7 @@ env:
jobs:
team_check:
runs-on: ubuntu-latest
if: ${{ github.event.issue.type.name == 'Bug' }}
if: ${{ (github.event.action == 'opened' && contains(github.event.issue.labels.*.name, 'bug')) || (github.event.action == 'labeled' && github.event.label.name == 'bug') }}
outputs:
is_team_member: ${{ steps.check.outputs.is_team_member }}
issue_number: ${{ steps.issue.outputs.issue_number }}
+3 -1
View File
@@ -90,7 +90,9 @@ jobs:
// Check for issue type from issue form dropdown
const issueTypeField = getFormFieldValue(body, 'Type of Issue')
if (issueTypeField) {
if (issueTypeField === 'Feature Request') {
if (issueTypeField === 'Bug') {
labels.push("bug")
} else if (issueTypeField === 'Feature Request') {
labels.push("enhancement")
} else if (issueTypeField === 'Question') {
labels.push("question")
+6 -4
View File
@@ -113,8 +113,8 @@ jobs:
- name: Run markdown code lint
run: uv run poe markdown-code-lint
test-typing:
name: Test Typing Checks
mypy:
name: Mypy Checks
if: "!cancelled()"
strategy:
fail-fast: false
@@ -139,5 +139,7 @@ jobs:
os: ${{ runner.os }}
env:
UV_CACHE_DIR: /tmp/.uv-cache
- name: Run tests/samples type checkers (mypy, pyrefly, ty)
run: uv run python scripts/workspace_poe_tasks.py ci-test-typing
- name: Run Mypy
env:
GITHUB_BASE_REF: ${{ github.event.pull_request.base.ref || github.base_ref || 'main' }}
run: uv run python scripts/workspace_poe_tasks.py ci-mypy
@@ -1,365 +0,0 @@
name: Python - Dependency Maintenance
on:
workflow_dispatch:
schedule:
- cron: "0 4 * * 1"
permissions:
contents: write
issues: write
pull-requests: write
concurrency:
group: python-dependency-maintenance
cancel-in-progress: false
env:
UV_CACHE_DIR: /tmp/.uv-cache
jobs:
dependency-maintenance:
name: Dependency Maintenance
runs-on: ubuntu-latest
env:
# Match the existing Python dependency maintenance workflows. Reevaluate if package
# installability starts differing across supported Python versions.
UV_PYTHON: "3.13"
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
- name: Set up python and install the project
uses: ./.github/actions/python-setup
with:
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
env:
UV_CACHE_DIR: /tmp/.uv-cache
- name: Set dependency release cutoff
run: |
cutoff="$(date -u -d '7 days ago' '+%Y-%m-%dT%H:%M:%SZ')"
echo "DEPENDENCY_RELEASE_CUTOFF=${cutoff}" >> "$GITHUB_ENV"
echo "Using dependency release cutoff: ${cutoff}"
- name: Repin dev dependency declarations
run: uv run poe upgrade-dev-dependency-pins
working-directory: ./python
- name: Refresh lockfile after dev pin updates
run: uv lock
working-directory: ./python
- name: Save dev dependency changes
run: |
DEV_PATCH="${RUNNER_TEMP}/python-dev-dependency-updates.patch"
git diff -- python/pyproject.toml "python/packages/*/pyproject.toml" python/uv.lock > "${DEV_PATCH}"
if [ -s "${DEV_PATCH}" ]; then
echo "has_dev_changes=true" >> "$GITHUB_OUTPUT"
else
echo "has_dev_changes=false" >> "$GITHUB_OUTPUT"
fi
echo "patch=${DEV_PATCH}" >> "$GITHUB_OUTPUT"
id: dev_changes
- name: Run dependency bounds test scenarios
id: validate_bounds_test
continue-on-error: true
run: uv run poe validate-dependency-bounds-test --package "*"
working-directory: ./python
- name: Run dependency upper-bound validation
id: validate_ranges
if: steps.validate_bounds_test.outcome == 'success'
continue-on-error: true
run: uv run poe validate-dependency-bounds-project --mode upper --package "*"
working-directory: ./python
- name: Upload dependency validation reports
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: dependency-maintenance-results
path: |
python/scripts/dependencies/dependency-bounds-test-results.json
python/scripts/dependencies/dependency-range-results.json
if-no-files-found: warn
- name: Create issue for failed dependency bounds test
if: steps.validate_bounds_test.outcome != 'success'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const fs = require("fs")
const reportPath = "python/scripts/dependencies/dependency-bounds-test-results.json"
const owner = context.repo.owner
const repo = context.repo.repo
const openIssues = await github.paginate(github.rest.issues.listForRepo, {
owner,
repo,
state: "open",
per_page: 100,
})
const openIssueTitles = new Set(
openIssues.filter((issue) => !issue.pull_request).map((issue) => issue.title)
)
const formatError = (message) => String(message || "No error output captured.").replace(/```/g, "'''")
const title = "Dependency bounds test failed"
if (openIssueTitles.has(title)) {
core.info(`Issue already exists: ${title}`)
return
}
const bodyLines = [
"Automated dependency bounds test mode failed before dependency upper-bound validation could run.",
"",
"The weekly dependency maintenance workflow kept only dev dependency updates for the generated PR, if any, and skipped dependency range updates for this run.",
"",
]
if (fs.existsSync(reportPath)) {
const report = JSON.parse(fs.readFileSync(reportPath, "utf8"))
const failedScenarios = (report.scenarios ?? []).filter((scenario) => scenario.status === "failed")
for (const scenario of failedScenarios) {
bodyLines.push(`### ${scenario.name} scenario (${scenario.resolution})`)
const failedPackages = (scenario.packages ?? []).filter((pkg) => pkg.status === "failed")
for (const pkg of failedPackages.slice(0, 10)) {
bodyLines.push(
"",
`- Package: \`${pkg.package_name}\``,
`- Project path: \`${pkg.project_path}\``,
"",
"```",
formatError(pkg.error).slice(0, 3500),
"```"
)
}
if (failedPackages.length > 10) {
bodyLines.push("", `_Additional failed packages omitted: ${failedPackages.length - 10}_`)
}
}
} else {
bodyLines.push(`No dependency bounds test report was found at \`${reportPath}\`.`)
}
bodyLines.push("", `Workflow run: ${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`)
await github.rest.issues.create({
owner,
repo,
title,
body: bodyLines.join("\n"),
})
core.info(`Created issue: ${title}`)
- name: Create issues for failed dependency candidates
if: always()
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const fs = require("fs")
const reportPath = "python/scripts/dependencies/dependency-range-results.json"
if (!fs.existsSync(reportPath)) {
core.info(`No dependency range report found at ${reportPath}`)
return
}
const report = JSON.parse(fs.readFileSync(reportPath, "utf8"))
const dependencyFailures = []
for (const packageResult of report.packages ?? []) {
for (const dependency of packageResult.dependencies ?? []) {
const candidateVersions = new Set(dependency.candidate_versions ?? [])
const failedAttempts = (dependency.attempts ?? []).filter(
(attempt) => attempt.status === "failed" && candidateVersions.has(attempt.trial_upper)
)
if (!failedAttempts.length) {
continue
}
const failuresByVersion = new Map()
for (const attempt of failedAttempts) {
const version = attempt.trial_upper || "unknown"
if (!failuresByVersion.has(version)) {
failuresByVersion.set(version, attempt.error || "No error output captured.")
}
}
dependencyFailures.push({
packageName: packageResult.package_name,
projectPath: packageResult.project_path,
dependencyName: dependency.name,
originalRequirements: dependency.original_requirements ?? [],
finalRequirements: dependency.final_requirements ?? [],
failedVersions: [...failuresByVersion.entries()].map(([version, error]) => ({ version, error })),
})
}
}
if (!dependencyFailures.length) {
core.info("No failing dependency candidates found.")
return
}
const owner = context.repo.owner
const repo = context.repo.repo
const openIssues = await github.paginate(github.rest.issues.listForRepo, {
owner,
repo,
state: "open",
per_page: 100,
})
const openIssueTitles = new Set(
openIssues.filter((issue) => !issue.pull_request).map((issue) => issue.title)
)
const formatError = (message) => String(message || "No error output captured.").replace(/```/g, "'''")
for (const failure of dependencyFailures) {
const title = `Dependency validation failed: ${failure.dependencyName} (${failure.packageName})`
if (openIssueTitles.has(title)) {
core.info(`Issue already exists: ${title}`)
continue
}
const visibleFailures = failure.failedVersions.slice(0, 5)
const omittedCount = failure.failedVersions.length - visibleFailures.length
const failureDetails = visibleFailures
.map(
(entry) =>
`- \`${entry.version}\`\n\n\`\`\`\n${formatError(entry.error).slice(0, 3500)}\n\`\`\``
)
.join("\n\n")
const body = [
"Automated dependency range validation found candidate versions that failed checks.",
"",
`- Package: \`${failure.packageName}\``,
`- Project path: \`${failure.projectPath}\``,
`- Dependency: \`${failure.dependencyName}\``,
`- Original requirements: ${
failure.originalRequirements.length
? failure.originalRequirements.map((value) => `\`${value}\``).join(", ")
: "_none_"
}`,
`- Final requirements after run: ${
failure.finalRequirements.length
? failure.finalRequirements.map((value) => `\`${value}\``).join(", ")
: "_none_"
}`,
"",
"### Failed versions and errors",
failureDetails,
omittedCount > 0 ? `\n_Additional failed versions omitted: ${omittedCount}_` : "",
"",
`Workflow run: ${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`,
].join("\n")
await github.rest.issues.create({
owner,
repo,
title,
body,
})
openIssueTitles.add(title)
core.info(`Created issue: ${title}`)
}
- name: Keep only dev updates when range validation fails
if: steps.validate_bounds_test.outcome != 'success' || steps.validate_ranges.outcome != 'success'
env:
DEV_PATCH: ${{ steps.dev_changes.outputs.patch }}
HAS_DEV_CHANGES: ${{ steps.dev_changes.outputs.has_dev_changes }}
run: |
git restore python/pyproject.toml python/packages/*/pyproject.toml python/uv.lock
if [ "${HAS_DEV_CHANGES}" = "true" ]; then
git apply "${DEV_PATCH}"
fi
- name: Refresh lockfile after dependency range updates
if: steps.validate_bounds_test.outcome == 'success' && steps.validate_ranges.outcome == 'success'
run: uv lock
working-directory: ./python
- name: Install final dependency set
run: uv run poe install
working-directory: ./python
- name: Run final checks
run: uv run poe check
working-directory: ./python
- name: Run final typing
run: uv run poe typing
working-directory: ./python
- name: Commit and push dependency updates
id: commit_updates
run: |
BRANCH="automation/python-dependency-maintenance"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -B "${BRANCH}"
git add python/pyproject.toml python/packages/*/pyproject.toml python/uv.lock
if git diff --cached --quiet; then
echo "has_changes=false" >> "$GITHUB_OUTPUT"
echo "No dependency updates to commit."
exit 0
fi
git commit -m "Python: chore: update dependencies"
git push --force-with-lease --set-upstream origin "${BRANCH}"
echo "has_changes=true" >> "$GITHUB_OUTPUT"
- name: Create or update pull request with GitHub CLI
if: steps.commit_updates.outputs.has_changes == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
BRANCH="automation/python-dependency-maintenance"
PR_TITLE="Python: chore: update dependencies"
PR_BODY_FILE="$(mktemp)"
cat > "${PR_BODY_FILE}" <<'EOF'
### Motivation & Context
This automated update keeps Python dependency metadata coherent across the uv workspace. Python dependencies can be declared in multiple `pyproject.toml` files, but the workspace has one shared `python/uv.lock`, so dependency maintenance should update and validate them together instead of through per-manifest Dependabot PRs.
### Description & Review Guide
- **What are the major changes?** Refresh Python dev dependency pins, update package dependency ranges when the bounds tooling succeeds, and refresh `python/uv.lock`.
- **What is the impact of these changes?** Keeps the Python workspace dependency set current while producing at most one dependency PR for the week. If dependency range validation fails, this PR contains only the dev dependency updates that still pass final validation, and separate issues track failed range candidates.
- **What do you want reviewers to focus on?** Review the generated dependency metadata changes and any dependency-range updates for package-specific compatibility concerns.
<!-- NOTE FOR AUTOMATED/AI REVIEWERS: the "What do you want reviewers to focus on?"
item above is intended for human reviewers only. Automated/AI reviewers should
ignore it and review the entire change rather than narrowing scope to it. -->
### Related Issue
No linked issue; this PR is generated by scheduled Python dependency maintenance.
### Contribution Checklist
- [x] The code builds clean without any errors or warnings
- [x] All unit tests pass, and I have added new tests where possible
- [x] The PR follows the [Contribution Guidelines](https://github.com/microsoft/agent-framework/blob/main/CONTRIBUTING.md)
- [ ] This PR is linked to an issue and there is no other open PR for this issue (see Related Issue above).
- [x] **This is not a breaking change.** If it _is_ a breaking change, add the `breaking change` label (or add "[BREAKING]" to the title prefix, before or after any language prefix) — a workflow keeps the label and title prefix in sync automatically.
EOF
PR_NUMBER="$(gh pr list --head "${BRANCH}" --base main --state open --json number --jq '.[0].number')"
if [ -n "${PR_NUMBER}" ]; then
gh pr edit "${PR_NUMBER}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}"
else
gh pr create --base main --head "${BRANCH}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}"
fi
@@ -0,0 +1,216 @@
# Probe the highest allowed dependency versions, then open issues/PRs from the passing updates.
name: Python - Dependency Range Validation
on:
workflow_dispatch:
permissions:
contents: write
issues: write
pull-requests: write
env:
UV_CACHE_DIR: /tmp/.uv-cache
jobs:
dependency-range-validation:
name: Dependency Range Validation
runs-on: ubuntu-latest
env:
# For now only run 3.13, if we do encounter situations where there are mismatches between packages and python versions (other then 3.10 and 3.14 which are known to not be able to install everything)
# then we will have to reevaluate.
UV_PYTHON: "3.13"
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
- name: Set up python and install the project
uses: ./.github/actions/python-setup
with:
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
env:
UV_CACHE_DIR: /tmp/.uv-cache
- name: Run dependency range validation
id: validate_ranges
# Keep workflow running so we can still publish diagnostics from this run.
continue-on-error: true
run: uv run poe validate-dependency-bounds-project --mode upper --package "*"
working-directory: ./python
- name: Upload dependency range report
# Always publish the report so failures are inspectable even when validation fails.
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: dependency-range-results
path: python/scripts/dependencies/dependency-range-results.json
if-no-files-found: warn
- name: Create issues for failed dependency candidates
# Always process the report so failed candidates create actionable tracking issues.
if: always()
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const fs = require("fs")
const reportPath = "python/scripts/dependencies/dependency-range-results.json"
if (!fs.existsSync(reportPath)) {
core.warning(`No dependency range report found at ${reportPath}`)
return
}
const report = JSON.parse(fs.readFileSync(reportPath, "utf8"))
const dependencyFailures = []
for (const packageResult of report.packages ?? []) {
for (const dependency of packageResult.dependencies ?? []) {
const candidateVersions = new Set(dependency.candidate_versions ?? [])
const failedAttempts = (dependency.attempts ?? []).filter(
(attempt) => attempt.status === "failed" && candidateVersions.has(attempt.trial_upper)
)
if (!failedAttempts.length) {
continue
}
const failuresByVersion = new Map()
for (const attempt of failedAttempts) {
const version = attempt.trial_upper || "unknown"
if (!failuresByVersion.has(version)) {
failuresByVersion.set(version, attempt.error || "No error output captured.")
}
}
dependencyFailures.push({
packageName: packageResult.package_name,
projectPath: packageResult.project_path,
dependencyName: dependency.name,
originalRequirements: dependency.original_requirements ?? [],
finalRequirements: dependency.final_requirements ?? [],
failedVersions: [...failuresByVersion.entries()].map(([version, error]) => ({ version, error })),
})
}
}
if (!dependencyFailures.length) {
core.info("No failing dependency candidates found.")
return
}
const owner = context.repo.owner
const repo = context.repo.repo
const openIssues = await github.paginate(github.rest.issues.listForRepo, {
owner,
repo,
state: "open",
per_page: 100,
})
const openIssueTitles = new Set(
openIssues.filter((issue) => !issue.pull_request).map((issue) => issue.title)
)
const formatError = (message) => String(message || "No error output captured.").replace(/```/g, "'''")
for (const failure of dependencyFailures) {
const title = `Dependency validation failed: ${failure.dependencyName} (${failure.packageName})`
if (openIssueTitles.has(title)) {
core.info(`Issue already exists: ${title}`)
continue
}
const visibleFailures = failure.failedVersions.slice(0, 5)
const omittedCount = failure.failedVersions.length - visibleFailures.length
const failureDetails = visibleFailures
.map(
(entry) =>
`- \`${entry.version}\`\n\n\`\`\`\n${formatError(entry.error).slice(0, 3500)}\n\`\`\``
)
.join("\n\n")
const body = [
"Automated dependency range validation found candidate versions that failed checks.",
"",
`- Package: \`${failure.packageName}\``,
`- Project path: \`${failure.projectPath}\``,
`- Dependency: \`${failure.dependencyName}\``,
`- Original requirements: ${
failure.originalRequirements.length
? failure.originalRequirements.map((value) => `\`${value}\``).join(", ")
: "_none_"
}`,
`- Final requirements after run: ${
failure.finalRequirements.length
? failure.finalRequirements.map((value) => `\`${value}\``).join(", ")
: "_none_"
}`,
"",
"### Failed versions and errors",
failureDetails,
omittedCount > 0 ? `\n_Additional failed versions omitted: ${omittedCount}_` : "",
"",
`Workflow run: ${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`,
].join("\n")
await github.rest.issues.create({
owner,
repo,
title,
body,
})
openIssueTitles.add(title)
core.info(`Created issue: ${title}`)
}
- name: Refresh lockfile
# Only refresh lockfile after a clean validation to avoid committing known-bad ranges.
if: steps.validate_ranges.outcome == 'success'
run: uv lock --upgrade
working-directory: ./python
- name: Commit and push dependency updates
id: commit_updates
if: steps.validate_ranges.outcome == 'success'
run: |
BRANCH="automation/python-dependency-range-updates"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -B "${BRANCH}"
git add python/packages/*/pyproject.toml python/uv.lock
if git diff --cached --quiet; then
echo "has_changes=false" >> "$GITHUB_OUTPUT"
echo "No dependency updates to commit."
exit 0
fi
git commit -m "chore: update dependency ranges"
git push --force-with-lease --set-upstream origin "${BRANCH}"
echo "has_changes=true" >> "$GITHUB_OUTPUT"
- name: Create or update pull request with GitHub CLI
# Only open/update PRs for validated updates to keep automation branches trustworthy.
if: steps.validate_ranges.outcome == 'success' && steps.commit_updates.outputs.has_changes == 'true'
run: |
BRANCH="automation/python-dependency-range-updates"
PR_TITLE="Python: chore: update dependency ranges"
PR_BODY_FILE="$(mktemp)"
cat > "${PR_BODY_FILE}" <<'EOF'
This PR was generated by the dependency range validation workflow.
- Ran `uv run poe validate-dependency-bounds-project --mode upper --package "*"`
- Updated package dependency bounds
- Refreshed `python/uv.lock` with `uv lock --upgrade`
EOF
PR_NUMBER="$(gh pr list --head "${BRANCH}" --base main --state open --json number --jq '.[0].number')"
if [ -n "${PR_NUMBER}" ]; then
gh pr edit "${PR_NUMBER}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}"
else
gh pr create --base main --head "${BRANCH}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}"
fi
@@ -0,0 +1,91 @@
name: Python - Dev Dependency Upgrade
on:
workflow_dispatch:
permissions:
contents: write
pull-requests: write
env:
UV_CACHE_DIR: /tmp/.uv-cache
jobs:
upgrade-dev-dependencies:
name: Upgrade Dev Dependencies
runs-on: ubuntu-latest
env:
UV_PYTHON: "3.13"
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
- name: Set up python and install the project
uses: ./.github/actions/python-setup
with:
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
env:
UV_CACHE_DIR: /tmp/.uv-cache
- name: Upgrade dev dependencies and validate workspace
run: uv run poe upgrade-dev-dependencies
working-directory: ./python
- name: Commit and push dev dependency updates
id: commit_updates
run: |
BRANCH="automation/python-dev-dependency-updates"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -B "${BRANCH}"
git add python/pyproject.toml python/packages/*/pyproject.toml python/uv.lock
if git diff --cached --quiet; then
echo "has_changes=false" >> "$GITHUB_OUTPUT"
echo "No dev dependency updates to commit."
exit 0
fi
git commit -F- <<'EOF'
Python: chore: upgrade dev dependencies
EOF
git push --force-with-lease --set-upstream origin "${BRANCH}"
echo "has_changes=true" >> "$GITHUB_OUTPUT"
- name: Create or update pull request with GitHub CLI
if: steps.commit_updates.outputs.has_changes == 'true'
run: |
BRANCH="automation/python-dev-dependency-updates"
PR_TITLE="Python: chore: upgrade dev dependencies"
PR_BODY_FILE="$(mktemp)"
cat > "${PR_BODY_FILE}" <<'EOF'
### Motivation and Context
This automated update refreshes Python dev dependency pins across the workspace and reruns the repo validation gates before opening a pull request.
### Description
- Ran `uv run poe upgrade-dev-dependencies`
- Refreshed dev dependency pins in workspace `pyproject.toml` files
- Refreshed `python/uv.lock` with `uv lock --upgrade`
- Reinstalled from the frozen lockfile and reran `check`, `typing`, and `test`
### Contribution Checklist
- [x] The code builds clean without any errors or warnings
- [x] The PR follows the [Contribution Guidelines](https://github.com/microsoft/agent-framework/blob/main/CONTRIBUTING.md)
- [x] All unit tests pass, and I have added new tests where possible
- [ ] **Is this a breaking change?** If yes, add "[BREAKING]" prefix to the title of the PR.
EOF
PR_NUMBER="$(gh pr list --head "${BRANCH}" --base main --state open --json number --jq '.[0].number')"
if [ -n "${PR_NUMBER}" ]; then
gh pr edit "${PR_NUMBER}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}"
else
gh pr create --base main --head "${BRANCH}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}"
fi
+3
View File
@@ -92,6 +92,9 @@ jobs:
- name: Run lab type checking
run: cd packages/lab && uv run poe pyright
- name: Run lab mypy
run: cd packages/lab && uv run poe mypy
# Surface failing tests
- name: Surface failing tests
if: always()
@@ -30,31 +30,21 @@ jobs:
merge-multiple: true
- name: Display structure of downloaded files
run: ls
- name: Read and validate PR number
# Keep the artifact handoff aligned with the workflow run that produced it.
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
- name: Read and set PR number
# Need to read the PR number from the file saved in the previous workflow
# because the workflow_run event does not have access to the PR number
# The PR number is needed to post the comment on the PR
run: |
if [ ! -s pr_number ]; then
echo "PR number file 'pr_number' is missing or empty"
exit 1
fi
ARTIFACT_PR_NUMBER=$(cat pr_number)
if ! [[ "$ARTIFACT_PR_NUMBER" =~ ^[0-9]+$ ]]; then
PR_NUMBER=$(cat pr_number)
if ! [[ "$PR_NUMBER" =~ ^[0-9]+$ ]]; then
echo "::error::PR number file contains invalid content"
exit 1
fi
PR_HEAD_SHA=$(gh pr view "$ARTIFACT_PR_NUMBER" --repo "$REPO" --json headRefOid --jq '.headRefOid')
if [ "$PR_HEAD_SHA" != "$RUN_HEAD_SHA" ]; then
echo "::error::PR head SHA does not match the triggering workflow run"
exit 1
fi
echo "PR_NUMBER=$ARTIFACT_PR_NUMBER" >> "$GITHUB_ENV"
echo "PR_NUMBER=$PR_NUMBER" >> "$GITHUB_ENV"
- name: Pytest coverage comment
id: coverageComment
uses: MishaKav/pytest-coverage-comment@26f986d2599c288bb62f623d29c2da98609e9cd4 # v1.6.0
+2 -1
View File
@@ -1,5 +1,5 @@
---
status: accepted
status: proposed
contact: eavanvalkenburg
date: 2026-06-11
deciders: eavanvalkenburg
@@ -141,4 +141,5 @@ Before this ADR is accepted:
## 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)
@@ -1,641 +0,0 @@
---
status: proposed
contact: sergeymenshykh
date: 2026-06-23
deciders: sergeymenshykh
---
# Skills Over MCP: Implementation Design Options
This document explores design options for two SEP-2640 features. The decisions are not yet finalized.
- **Part 1: MCP Resource Template Skills** - skills described by a URI template with variables that must be resolved before loading.
- **Part 2: Direct Skill References** - reading `skill://` URIs referenced directly (e.g., in server instructions) without being listed in the index.
## Part 1: MCP Resource Template Skills
### Context and Problem Statement
The `AgentMcpSkillsSource` currently only supports `skill-md` type entries from `skill://index.json` (support for `archive` type is planned). The SEP-2640 specification also defines `mcp-resource-template` entries: **parameterized skill namespaces** described by a URI template with variables (e.g., `{product}`) that resolve to concrete `SKILL.md` URIs. Rather than materializing every skill in the index, the template's variables must be resolved before a skill can be loaded.
### Index Entry Format
```json
{
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
"skills": [
{
"name": "git-workflow",
"type": "skill-md",
"description": "Follow this team's Git conventions for branching and commits",
"url": "skill://git-workflow/SKILL.md"
},
{
"type": "mcp-resource-template",
"description": "Per-product documentation skill",
"url": "skill://docs/{product}/SKILL.md"
}
]
}
```
Key differences from `skill-md`:
| Field | `skill-md` | `mcp-resource-template` |
|-------|------------|-------------------------|
| `name` | Required (the skill name) | **Omitted** (represents many skills) |
| `type` | `"skill-md"` | `"mcp-resource-template"` |
| `url` | Concrete URI to `SKILL.md` | URI template with variables |
| `description` | Describes the skill | Describes the addressable skill space |
### Use Cases
Template skills address two scenarios where listing concrete skills is impractical:
- **Large skill catalogs** - too many skills to enumerate every entry in the index.
- **Dynamically generated skills** - skill content generated on the fly from parameters, so the set of valid skills is not known at index-creation time.
### How Template Skills Are Consumed
Per SEP-2640, the consumption flow relies on the MCP `completion/complete` method:
1. **Server registers a resource template** - The MCP server registers the same `url` value (e.g., `skill://docs/{product}/SKILL.md`) as an MCP [resource template](https://modelcontextprotocol.io/specification/2025-11-25/server/resources#resource-templates), wiring template variables to the [completion API](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/completion).
2. **Host reads `skill://index.json`** - Discovers the template entry with `type: "mcp-resource-template"`.
3. **Host surfaces template in UI** - Presents the template as an interactive discovery point where the user fills in variables.
4. **Host calls `completion/complete`** - For each template variable (e.g., `{product}`), the host calls the MCP completion API to get possible values from the server:
```json
{
"method": "completion/complete",
"params": {
"ref": {
"type": "ref/resource",
"uri": "skill://docs/{product}/SKILL.md"
},
"argument": {
"name": "product",
"value": ""
}
}
}
```
The server responds with possible completions:
```json
{
"completion": {
"values": ["widgets", "billing", "auth", "payments"],
"hasMore": false,
"total": 4
}
}
```
5. **User selects a value** - The user picks a value (e.g., `"billing"`) from the list.
6. **Host resolves the URI** - The template `skill://docs/{product}/SKILL.md` becomes the concrete URI `skill://docs/billing/SKILL.md`.
7. **Host reads the resolved skill** - Calls `resources/read` with the concrete URI and proceeds as with any `skill-md` skill.
### Potential Implementation Options
### Option 1: Callback on `AgentMcpSkillsSource` for Variable Value Selection
Add a callback to `AgentMcpSkillsSource` (or its options) that is invoked for each `mcp-resource-template` entry to let the caller select variable values.
**Flow:**
1. `AgentMcpSkillsSource.GetSkillsAsync()` reads `skill://index.json`
2. For each entry with `type: "mcp-resource-template"`:
- Parse the URI template to extract variable names (e.g., `{product}`)
- Call the MCP `completion/complete` API to get possible values for each variable
- Invoke the caller-provided callback with the variable name, description, and possible values
- The callback returns a selected value and a `bool` indicating whether to include the skill
3. Resolve the URI template with the selected values
4. Create an `AgentMcpSkill` from the resolved URI and add it to the skills list
**API sketch:**
```csharp
public delegate Task<(string? SelectedValue, bool IncludeSkill)> McpTemplateVariableSelector(
string templateDescription,
string variableName,
IReadOnlyList<string> possibleValues,
CancellationToken cancellationToken);
// Usage via builder:
var provider = new AgentSkillsProviderBuilder()
.UseMcpSkills(mcpClient, options => {
options.TemplateVariableSelector = async (description, variable, values, ct) =>
{
// Present to user, return selection
var selected = PromptUser(variable, values);
return (selected, IncludeSkill: selected is not null);
};
})
.Build();
```
**Pros:**
- Simple implementation
- Easy to understand and use
**Cons:**
- Cannot be used in server-side scenarios where there is no interactive user at skill-discovery time
- Does not integrate with the agent's conversational flow
---
### Option 2: Integrate into Agent Conversation via `ChatClientAgent` Decorator
Model the template variable resolution as a request/response interaction within the agent's conversational loop.
**Flow:**
1. A `DelegatingAIAgent` decorator (e.g., `McpTemplateSkillResolutionAgent`) intercepts `RunAsync`/`RunStreamingAsync` calls and checks whether the inner agent has an `AgentSkillsProvider` with an `AgentMcpSkillsSource` containing unresolved template entries. The check is performed via `GetService<AgentMcpSkillsSource>()` on the `AgentSkillsProvider`, which delegates to a `GetService` method on the `AgentSkillsSource` base class.
2. The decorator calls an internal member on `AgentMcpSkillsSource` to get the list of `mcp-resource-template` entries from the index. The `AgentMcpSkillsSource` needs to be extended with an internal member that exposes unresolved template entries separately from concrete skills.
3. For each template entry, the decorator calls an internal member on `AgentMcpSkillsSource` to retrieve possible values for the template's variables via the MCP `completion/complete` API.
4. For each variable needing resolution, the decorator returns an `McpResourceTemplateValueRequestContent` (inherits from MEAI's `InputRequestContent`) in the agent response - bypassing the call to the inner agent. The content carries the template description, variable name, and possible values.
5. The user app receives the response, identifies the `McpResourceTemplateValueRequestContent` content type, and displays UI to the user showing the variable name and possible values, or forwards it further downstream if the user app is a service.
6. The user selects a value, and the user app calls the agent again with a corresponding `McpResourceTemplateValueResponseContent` (inherits from MEAI's `InputResponseContent`) containing the selected value. The `RequestId` property (inherited from the base classes) correlates the response with the original request.
7. The decorator identifies the response content and provides the resolved values to `AgentMcpSkillsSource` so it can use them when constructing concrete skills.
8. Having resolved all template variables, the decorator calls `RunAsync`/`RunStreamingAsync` on the inner agent.
9. The inner agent invokes the `AgentSkillsProvider`, which calls `AgentMcpSkillsSource.GetSkillsAsync()`. The source now has all resolved variable values and constructs concrete `AgentMcpSkill` instances from the resolved URIs, so it can provide the skill content if requested by the model.
**API sketch:**
```csharp
// New content types inheriting from MEAI's InputRequestContent/InputResponseContent:
public sealed class McpResourceTemplateValueRequestContent : InputRequestContent
{
public string TemplateDescription { get; }
public string VariableName { get; }
public IReadOnlyList<string> PossibleValues { get; }
public string TemplateUrl { get; }
}
public sealed class McpResourceTemplateValueResponseContent : InputResponseContent
{
public string SelectedValue { get; }
public string TemplateUrl { get; }
}
// Decorator usage:
var provider = new AgentSkillsProviderBuilder()
.UseMcpSkills(mcpClient)
.Build();
AIAgent agent = new ChatClientAgent(chatClient, new ChatClientAgentOptions
{
AIContextProviders = [provider],
});
agent = new McpTemplateSkillResolutionAgent(agent);
```
**Pros:**
- Works in server-side scenarios
- Fits the existing `DelegatingAIAgent` decorator pattern
- Can be composed with other decorators (tool approval, etc.)
**Cons:**
- Complex implementation
- Requires user app awareness of the new content types
- Users need to know that an additional decorator is required for handling MCP template skills, in addition to registering the MCP skills source
- Resolved template variable values must be persisted across conversation turns so the decorator does not re-prompt on subsequent agent runs within the same session
**Note:** This writeup is high-level and may miss details that could change the design. A POC would be needed to validate the approach.
### Open Questions
1. **Completion API limit** - The MCP completion API returns at most 100 values per request and provides no offset/cursor mechanism for enumeration. If a variable has more than 100 possible values, it's unclear how to retrieve the rest - the API only supports prefix-based filtering (typeahead), not bulk pagination.
2. **Multi-variable templates** - A template like `skill://{org}/{product}/SKILL.md` has multiple variables. Should they be resolved sequentially (org first, then product - since product values may depend on org) or presented together?
3. **Caching** - Should resolved template values be saved in the `AgentSession` so the user isn't re-prompted on every agent run? How should they be persisted between sessions?
---
## Part 2: Direct Skill References
This part covers how to let the model read `skill://` URIs referenced directly (e.g., in an MCP server's `instructions`, in a resource, or in another skill's content) without being listed in `skill://index.json`.
### How MCP Skills and Relative Links Work Today
The `AgentMcpSkillsSource` discovers skills by reading the well-known `skill://index.json` resource from the MCP server:
```json
{
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
"skills": [
{
"name": "unit-converter",
"type": "skill-md",
"description": "Convert between common units.",
"url": "skill://unit-converter/SKILL.md"
},
{
"name": "currency-converter",
"type": "skill-md",
"description": "Convert between world currencies using live rates.",
"url": "skill://currency-converter/SKILL.md"
}
]
}
```
For each `skill-md` entry it creates an `AgentMcpSkill` instance - frontmatter (name/description) comes straight from the entry. The `AgentSkillsProvider` lists the discovered skills in the model's context (name + description):
```xml
<available_skills>
<skill>
<name>unit-converter</name>
<description>Convert between common units.</description>
</skill>
<skill>
<name>currency-converter</name>
<description>Convert between world currencies using live rates.</description>
</skill>
</available_skills>
```
It also provides functions to the model so it can load a skill and access its resources:
```csharp
// Loads the full content of a specific skill.
load_skill(string skillName)
// Reads a resource associated with a skill (references, assets, dynamic data).
read_skill_resource(string skillName, string resourceName)
```
The model calls `load_skill("unit-converter")` and receives the skill content:
```markdown
---
name: unit-converter
description: Convert between common units.
---
## Usage
For the full conversion table, see references/units-table.md.
```
The skill body references `references/units-table.md` by relative path. The model calls `read_skill_resource("unit-converter", "references/units-table.md")` and receives the resource content:
```markdown
# Unit Conversion Table
| From | To | Factor |
| miles | km | 1.60934 |
| kg | lbs | 2.20462 |
```
### Direct Reference Examples
A `skill://` URI can appear in any of these locations:
**Server instructions** - the MCP server advertises a skill the model should load:
```text
Follow our coding standards. Load skill://code-standards/SKILL.md for details.
```
**A skill body** - a skill's `SKILL.md` links to a sibling resource:
```markdown
---
name: code-standards
description: Coding standards and conventions.
---
## Naming
Follow the naming rules in skill://code-standards/references/naming.md.
```
**A resource** - the linked resource holds the actual content:
```markdown
# Naming Rules
- Use PascalCase for public members and type names.
- Use camelCase for locals and parameters.
- Prefix interfaces with `I` (e.g. `ISkillReader`).
- Suffix async methods with `Async`.
For examples, see skill://code-standards/references/naming-examples.md.
```
How can the model access content by direct reference?
### Function for Reading Direct Skill References
### Option 1: Extend existing `load_skill` and `read_skill_resource` functions
```csharp
// Added optional 'origin' and a direct skill:// URI is passed in 'skillName'.
load_skill(string skillName, string? origin = null)
// Added optional 'origin', made 'skillName' optional, and a direct skill:// URI is passed in 'resourceName'.
read_skill_resource(string resourceName, string? skillName = null, string? origin = null)
```
The optional `origin` identifies the source/MCP server that should handle the direct URI.
| Case | Call |
|------|------|
| Load skill | `load_skill("commit-guidelines")` |
| Relative resource | `read_skill_resource("commit-guidelines", "examples/COMMIT_EXAMPLES.md")` |
| `skill://` link (skill) | `load_skill(skillName: "skill://commit-guidelines/SKILL.md", origin: "DirectRefServer")` |
| `skill://` link (resource) | `read_skill_resource(resourceName: "skill://commit-guidelines/examples/COMMIT_EXAMPLES.md", origin: "DirectRefServer")` |
**Pros:**
- No new functions added: existing tool surface stays at two functions.
**Cons:**
- Unreliable on some models (gpt-4o, gpt-4.1-mini): it often omits `origin` when it should not or calls the wrong function.
- Optional parameters create silent ambiguity - the model can pass `origin` for non-MCP skills or omit it for `skill://` URIs.
### Option 2 (Proposed): Add a dedicated `read_skill_uri` function alongside existing ones
```csharp
// Existing functions stay unchanged.
load_skill(string skillName)
read_skill_resource(string skillName, string resourceName)
// New function added alongside: reads content by direct skill:// URI.
read_skill_uri(string uri, string origin)
```
| Case | Call |
|------|------|
| Load skill | `load_skill("commit-guidelines")` |
| Relative resource | `read_skill_resource("commit-guidelines", "examples/COMMIT_EXAMPLES.md")` |
| `skill://` link (skill) | `read_skill_uri(uri: "skill://commit-guidelines/SKILL.md", origin:"DirectRefServer")` |
| `skill://` link (resource) | `read_skill_uri(uri: "skill://commit-guidelines/examples/COMMIT_EXAMPLES.md", origin: "DirectRefServer")` |
**Pros:**
- Purely additive - no changes to existing functions needed; `read_skill_uri` can be deferred and added later when direct `skill://` reference support is needed.
- Granular approval: each function can have its own approval gate (like the existing `ScriptApproval` for `run_skill_script`), making per-operation approval for skill loading, resource reading, and direct URI access straightforward to add.
- Both `uri` and `origin` are required - no silent misuse through optional parameters.
- Clean split: `load_skill`/`read_skill_resource` for named skills, `read_skill_uri` for `skill://` links - no parameter ambiguity.
**Cons:**
- Three read functions (`load_skill`, `read_skill_resource`, `read_skill_uri`), not counting `run_skill_script`: larger tool surface than a single-function design.
### Option 3: Collapse `load_skill` and `read_skill_resource` into a single `read_resource` function
```csharp
// Single entrypoint for all skill content. 'uri' is required; 'origin' is optional.
read_resource(string uri, string? origin = null)
```
- `uri` - what to read: a skill name, a relative resource path, or a `skill://` link.
- `origin` - determines how `uri` is interpreted:
- **omitted** → load skill by name (`uri` is the skill name).
- **skill name** → read a relative resource (`uri` is the path within that skill).
- **server name** → read content by the `skill://` link (`uri` is handled by the source identified by the `[Origin: X]` marker).
Dispatch is ordered: null `origin` routes to Case 1; if `origin` names a known skill, routes to Case 2; otherwise tries to find an `ISkillUriReader` whose `CanRead` returns true for `origin` (Case 3).
| Case | Call |
|------|------|
| Load skill | `read_resource(uri: "commit-guidelines")` |
| Relative resource | `read_resource(uri: "examples/COMMIT_EXAMPLES.md", origin: "commit-guidelines")` |
| `skill://` link (skill) | `read_resource(uri: "skill://commit-guidelines/SKILL.md", origin: "DirectRefServer")` |
| `skill://` link (resource) | `read_resource(uri: "skill://commit-guidelines/examples/COMMIT_EXAMPLES.md", origin: "DirectRefServer")` |
**Pros:**
- Minimal tool surface: one read function instead of two or three (not counting `run_skill_script`) reduces token usage and gives the model fewer choices.
**Cons:**
- No per-operation approval: all cases (skill loading, resource reading, direct URI access) share one function, so approval cannot be scoped to individual operations.
- Unreliable on gpt-4.1-mini: omits `origin` when reading `skill://` links, passes skill name as `origin` when loading a plain skill (should be omitted), and hallucinates resource names (e.g. `API_SPECIFICATION.md`) that do not exist.
---
### Origin Marker
A `skill://` URI does not carry an origin, but the model needs to provide one when reading it. The `origin` is what routes the read call to the source that can handle the URI - the provider uses it to pick the matching source. Since the URI itself carries no such hint, the MCP source injects an `[Origin: ...]` marker wherever a `skill://` URI appears, so the model can read it back and pass it as the `origin` argument.
The marker is only added when the content actually contains `skill://` references. If a piece of content (server instructions, a skill body, or a resource) has no `skill://` URIs, there is nothing for the model to read back, so no marker is injected.
Into **server instructions**, which may mention `skill://` URIs directly:
```
[Origin: code-standards-server]
Follow our coding standards. Load skill://code-standards/SKILL.md for details.
```
Into **skill bodies**, since a `SKILL.md` may reference other `skill://` URIs (a resource file or a related skill):
```
[Origin: code-standards-server]
# Code Standards
For naming conventions, load skill://code-standards/references/naming.md.
```
Into **skill resources**, since a resource may itself reference further `skill://` URIs:
```
[Origin: code-standards-server]
# Naming Rules
- Use PascalCase for public members and type names.
- Use camelCase for locals and parameters.
For examples, see skill://code-standards/references/naming-examples.md.
```
---
### Read-by-URI Capability: Interface vs Base Class Virtual Methods
Now let's look at how an `AgentSkillsSource` can opt in to reading `skill://` URIs and signal that capability to the provider.
### Option 1: New `ISkillUriReader` interface
```csharp
public interface ISkillUriReader
{
// Returns true if this reader can handle the given skill:// URI from the given origin.
bool CanRead(string uri, string origin);
// Reads and returns the content for the given skill:// URI.
Task<object?> ReadByUriAsync(string uri, string origin, CancellationToken cancellationToken = default);
}
```
Sources that support direct `skill://` URI reads - such as `AgentMcpSkillsSource` - implement this interface to opt in.
The provider discovers readers via a service locator and dispatches to the first that can handle the URI:
```csharp
// Discover all registered readers.
var readers = source.GetService<IEnumerable<ISkillUriReader>>();
// Pick the first reader that can handle the URI.
var reader = readers.FirstOrDefault(r => r.CanRead(uri, origin))
?? throw new InvalidOperationException($"No reader can handle URI '{uri}' from origin '{origin}'.");
// Delegate the read to it.
return await reader.ReadByUriAsync(uri, origin, cancellationToken);
```
The provider may treat a source implementing `ISkillUriReader` as the signal to advertise `read_skill_uri`: if at least one registered source implements the interface, the function is exposed to the model; otherwise it is not.
### Option 2 (Proposed): Virtual methods on `AgentSkillsSource` base class
```csharp
public abstract class AgentSkillsSource
{
// New members for reading by URI.
// Whether this source can read by URI; drives whether read_skill_uri is advertised. Off by default.
public virtual bool SupportsReadByUri => false;
// Returns true if this source can handle the given skill:// URI from the given origin.
public virtual bool CanReadByUri(string uri, string origin) => false;
// Reads and returns the content for the given skill:// URI.
public virtual Task<object?> ReadByUriAsync(string uri, string origin, CancellationToken cancellationToken = default)
=> Task.FromResult<object?>(null);
// Existing member.
public abstract Task<IList<AgentSkills>> GetSkillsAsync(CancellationToken cancellationToken = default);
}
```
Sources opt in by overriding, and the provider calls them directly:
```csharp
// AgentMcpSkillsSource opts in by overriding the virtuals.
public override bool SupportsReadByUri => true;
// Handles the URI when its origin matches this source's MCP server.
public override bool CanReadByUri(string uri, string origin)
=> string.Equals(origin, this.Origin, StringComparison.OrdinalIgnoreCase);
// Reads content by skill:// URI from the MCP server.
public override Task<string?> ReadByUriAsync(string uri, string origin, CancellationToken cancellationToken)
=> /* resolve uri via the MCP server identified by origin */;
```
All sources inherit the methods, so there is no type signal - `SupportsReadByUri` fills that role. The function is advertised when any registered source returns `true`.
### Comparison
| Aspect | Option 1: Interface | Option 2: Base class virtual methods |
|--------|---------------------|--------------------------------------|
| Discovery | Service locator | Direct call on source |
| Advertising signal | Interface implementation | `SupportsReadByUri` flag |
| Adding new members | Breaking change | Non-breaking |
| Complexity | Higher | Lower |
---
### Include MCP Server Instructions Into Agent Instructions
MCP server instructions may contain the `skill://` references the model needs, so we want to surface them in the agent's instructions. But they can also carry system prompts or behavioral directives irrelevant to the agent, polluting context - so inclusion is **opt-in** via the `IncludeServerInstructions` option:
```csharp
public sealed class AgentMcpSkillsSourceOptions
{
// When true, the MCP server's instructions are injected into the agent instructions. Off by default.
public bool IncludeServerInstructions { get; set; }
}
builder.UseMcpSkills(mcpClient, options => options.IncludeServerInstructions = true);
```
When enabled, the instructions travel alongside the discovered skills on `AgentSkillsResult`:
```csharp
public class AgentSkillsResult
{
// The skills discovered from the source.
public IList<AgentSkill> Skills { get; }
// The MCP server instructions, when IncludeServerInstructions is enabled; otherwise null.
public string? Instructions { get; }
}
```
The `AgentSkillsProvider` then appends them to its own skill-usage guidance when building the agent's instructions:
```csharp
var result = await source.GetSkillsAsync(cancellationToken);
var instructions = DefaultSkillsInstructionPrompt;
if (!string.IsNullOrWhiteSpace(result.Instructions))
{
// Combine the provider's skill-usage guidance with the server instructions.
instructions += Environment.NewLine + result.Instructions;
}
```
### Enabling Direct Skill References
Following direct `skill://` references is **disabled by default** and activated via an option. When enabled, the provider advertises the read function to the model, and the source injects the `[Origin: ...]` marker into all content provided by the MCP server that contains `skill://` references. When disabled, no function is advertised and no marker is injected.
```csharp
public sealed class AgentMcpSkillsSourceOptions
{
public bool EnableDirectReferences { get; set; }
}
builder.UseMcpSkills(mcpClient, options => options.EnableDirectReferences = true);
```
## Decision Outcome
### Template Variable Resolution: Callback vs Decorator (Part 1)
**Postponed.** Deferring this decision until:
- We have a concrete list of scenarios that require template variable resolution.
- The skills-over-MCP spec is released (it is still a draft, so the design may change).
- There is a strong signal of demand from users or the ecosystem.
### Function for Reading Direct Skill References (Part 2)
**Postponed.** Leaning toward **Option 2 - dedicated `read_skill_uri` function alongside existing ones** (purely additive, and each function can have its own approval gate for granular per-operation approval), but deferring the decision until:
- The skills-over-MCP spec is released (it is still a draft, so the design may change).
- There is a strong signal of demand from users or the ecosystem.
### Read-by-URI Capability: Interface vs Base Class (Part 2)
**Postponed.** Leaning toward **Option 2 - virtual methods on `AgentSkillsSource`** (non-breaking, lower complexity, and a natural fit with the existing base class hierarchy), but deferring the decision until:
- The skills-over-MCP spec is released (it is still a draft, so the design may change).
- There is a strong signal of demand from users or the ecosystem.
The method naming (`SupportsReadByUri`, `CanReadByUri`, `ReadByUriAsync`) should also be abstracted a little more before adoption, so the same members can be reused when a similar direct-reference concept is needed for other skill types (e.g. file skills).
## References
- [SEP-2640: Skills Extension](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2640) - Draft proposal
- [SEP-2640 Implementation Guidelines: Model-Driven Resource Loading](https://github.com/modelcontextprotocol/experimental-ext-skills/blob/main/docs/sep-draft-skills-extension.md#hosts-model-driven-resource-loading)
- [MCP Completion API](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/completion) - Used for template variable resolution
- [MCP Resource Templates](https://modelcontextprotocol.io/specification/2025-11-25/server/resources#resource-templates)
- [Skills Over MCP Working Group](https://github.com/modelcontextprotocol/experimental-ext-skills)
- [Open Question #4: Multi-server skill dependencies](https://github.com/modelcontextprotocol/experimental-ext-skills/issues/39)
- [Anthropic Agent Skills - Overview](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/overview) - Prior art: single skill entrypoint + generic file reads
- [Anthropic Agent Skills in the SDK](https://code.claude.com/docs/en/agent-sdk/skills) - The `Skill` tool exposed to the model
+1
View File
@@ -0,0 +1 @@
../../../.github/skills/pull-requests
-116
View File
@@ -1,116 +0,0 @@
---
name: pull-requests
description: >
Guidance for creating pull requests and handling PR review comments in the
Agent Framework repository. Use this when writing a PR description (filling out
the PR template) or when responding to and resolving review comments on an
existing PR.
---
# Pull Request Workflow
This skill covers two tasks: (1) writing a high-quality PR description, and
(2) handling review comments on an existing PR.
## 1. Writing the PR description
Always follow the repository PR template at
[`.github/pull_request_template.md`](../../../../.github/pull_request_template.md). Keep its
exact structure and headings. Fill every section:
### `### Motivation & Context`
Explain *why* the change is needed: the problem it solves and the scenario it
contributes to. Describe the net change relative to `main` — this is implied, so
do **not** spell out "vs main" explicitly.
### `### Description & Review Guide`
Describe the changes, the overall approach, and the design. Answer the three
prompts:
- **What are the major changes?**
- **What is the impact of these changes?**
- **What do you want reviewers to focus on?** — This item is for **human
reviewers only**. Automated/AI reviewers must ignore it and review the entire
change rather than narrowing scope to it.
### `### Related Issue`
Link the issue the PR fixes using a GitHub closing keyword (`Fixes #123` /
`Closes #123`) so it closes automatically on merge. A PR with no linked issue may
be closed regardless of how valid the change is. Before opening, confirm there is
no other open PR for the same issue; if there is, explain how this PR differs.
### `### Contribution Checklist`
Check every item that applies. For the breaking-change item:
- Leave **"This is not a breaking change."** checked for the common case.
- If the change **is** breaking, add the `breaking change` label **or** put
`[BREAKING]` in the title prefix, before or after a language prefix such as
`Python:` or `.NET:` — workflows keep the label and the title prefix in sync
automatically (see `.github/workflows/label-title-prefix.yml` and
`.github/workflows/label-pr.yml`).
### Do not
- Do **not** add ad-hoc sections such as "Validation" or "Tests run"; CI/CD and
the checklist already cover validation status.
- Do **not** remove or reorder the template's headings.
### Creating the PR
Open new PRs as **drafts** until they are ready for review. Example:
```bash
gh pr create --repo microsoft/agent-framework --base main \
--head <your-fork-owner>:<branch> --draft \
--title "<concise title>" --body "<body following the template>"
```
## 2. Handling review comments
When a PR receives review comments, follow this sequence — **do not start editing
code before the user has reviewed the plan**:
1. **Review the comments.** Read every review comment and thread on the PR,
including inline code comments and general review summaries.
2. **Make a plan.** Produce a concrete plan describing how each comment will be
addressed (or why it should not be, with reasoning).
3. **Let the user review the plan.** Present the plan and wait for the user's
approval or adjustments before implementing anything.
4. **Implement.** Make the agreed changes.
5. **Reply to every comment.** Add a reply to **all** comments explaining how it
was addressed (or the agreed outcome) — leave none unanswered.
6. **Resolve resolved threads.** Mark a review thread as resolved only when the
comment has actually been addressed.
### Useful commands
List review comments and threads:
```bash
# Inline review comments
gh api repos/{owner}/{repo}/pulls/{pr}/comments
# Review threads with resolution state (GraphQL)
gh api graphql -f query='
query($owner:String!,$repo:String!,$pr:Int!){
repository(owner:$owner,name:$repo){
pullRequest(number:$pr){
reviewThreads(first:100){
nodes{ id isResolved comments(first:50){ nodes{ id body author{login} } } }
}
}
}
}' -F owner={owner} -F repo={repo} -F pr={pr}
```
Reply to an inline review comment:
```bash
gh api repos/{owner}/{repo}/pulls/{pr}/comments/{comment_id}/replies \
-f body="Addressed in <commit>: <explanation>"
```
Resolve a review thread (needs the thread node id from the GraphQL query above):
```bash
gh api graphql -f query='
mutation($threadId:ID!){
resolveReviewThread(input:{threadId:$threadId}){ thread{ isResolved } }
}' -F threadId={thread_id}
```
+4 -4
View File
@@ -12,7 +12,7 @@
<ItemGroup>
<!-- Aspire.* -->
<PackageVersion Include="Anthropic" Version="12.20.0" />
<PackageVersion Include="Anthropic.Foundry" Version="0.6.0" />
<PackageVersion Include="Anthropic.Foundry" Version="0.5.0" />
<PackageVersion Include="Aspire.Hosting" Version="$(AspireAppHostSdkVersion)" />
<PackageVersion Include="Aspire.Azure.AI.OpenAI" Version="13.0.0-preview.1.25560.3" />
<PackageVersion Include="Aspire.Azure.AI.Inference" Version="13.1.0-preview.1.25616.3" />
@@ -27,10 +27,10 @@
<PackageVersion Include="Azure.AI.AgentServer.Invocations" Version="1.0.0-beta.4" />
<PackageVersion Include="Azure.AI.AgentServer.Responses" Version="1.0.0-beta.5" />
<PackageVersion Include="Azure.Search.Documents" Version="12.0.0" />
<PackageVersion Include="Azure.AI.Projects" Version="2.1.0-beta.3" />
<PackageVersion Include="Azure.AI.Projects" Version="2.1.0-beta.2" />
<PackageVersion Include="Azure.AI.Agents.Persistent" Version="1.2.0-beta.10" />
<PackageVersion Include="Azure.AI.OpenAI" Version="2.9.0-beta.1" />
<PackageVersion Include="Azure.Core" Version="1.57.0" />
<PackageVersion Include="Azure.Core" Version="1.56.0" />
<PackageVersion Include="Azure.Identity" Version="1.21.0" />
<PackageVersion Include="DotNetEnv" Version="3.1.1" />
<PackageVersion Include="Azure.Monitor.OpenTelemetry.Exporter" Version="1.5.0" />
@@ -45,7 +45,7 @@
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.8" />
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
<PackageVersion Include="Microsoft.Bcl.Memory" Version="10.0.5" />
<PackageVersion Include="System.ClientModel" Version="1.13.0" />
<PackageVersion Include="System.ClientModel" Version="1.12.0" />
<PackageVersion Include="System.CodeDom" Version="10.0.0" />
<PackageVersion Include="System.Collections.Immutable" Version="10.0.1" />
<PackageVersion Include="System.CommandLine" Version="2.0.0-rc.2.25502.107" />
-8
View File
@@ -120,7 +120,6 @@
</Folder>
<Folder Name="/Samples/02-agents/Harness/">
<File Path="samples/02-agents/Harness/README.md" />
<Project Path="samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step01_MeetYourClaw/Claw_Step01_MeetYourClaw.csproj" />
<Project Path="samples/02-agents/Harness/ConsoleReactiveComponents/ConsoleReactiveComponents.csproj" />
<Project Path="samples/02-agents/Harness/ConsoleReactiveFramework/ConsoleReactiveFramework.csproj" />
<Project Path="samples/02-agents/Harness/Harness_Shared_Console/Harness_Shared_Console.csproj" />
@@ -218,7 +217,6 @@
<Folder Name="/Samples/02-agents/ModelContextProtocol/">
<File Path="samples/02-agents/ModelContextProtocol/README.md" />
<Project Path="samples/02-agents/ModelContextProtocol/Agent_MCP_LongRunningTask_Client/Agent_MCP_LongRunningTask_Client.csproj" />
<Project Path="samples/02-agents/ModelContextProtocol/Agent_MCP_PerRun_AuthHeaders/Agent_MCP_PerRun_AuthHeaders.csproj" />
<Project Path="samples/02-agents/ModelContextProtocol/Agent_MCP_Server/Agent_MCP_Server.csproj" />
<Project Path="samples/02-agents/ModelContextProtocol/Agent_MCP_Server_Auth/Agent_MCP_Server_Auth.csproj" />
<Project Path="samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/FoundryAgent_Hosted_MCP.csproj" />
@@ -333,9 +331,6 @@
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/HostedLocalTools.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/HostedLocalCodeAct.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj" />
</Folder>
@@ -413,7 +408,6 @@
<Folder Name="/Samples/05-end-to-end/Evaluation/">
<Project Path="samples/05-end-to-end/Evaluation/Evaluation_ConversationSplits/Evaluation_ConversationSplits.csproj" />
<Project Path="samples/05-end-to-end/Evaluation/Evaluation_FoundryQuality/Evaluation_FoundryQuality.csproj" />
<Project Path="samples/05-end-to-end/Evaluation/Evaluation_FoundryRubric/Evaluation_FoundryRubric.csproj" />
<Project Path="samples/05-end-to-end/Evaluation/Evaluation_MixedProviders/Evaluation_MixedProviders.csproj" />
</Folder>
<Folder Name="/Samples/05-end-to-end/A2AClientServer/">
@@ -622,7 +616,6 @@
<Project Path="src/Microsoft.Agents.AI.Hosting.AspNetCore/Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hyperlight/Microsoft.Agents.AI.Hyperlight.csproj" />
<Project Path="src/Microsoft.Agents.AI.LocalCodeAct/Microsoft.Agents.AI.LocalCodeAct.csproj" />
<Project Path="src/Microsoft.Agents.AI.Mcp/Microsoft.Agents.AI.Mcp.csproj" />
<Project Path="src/Microsoft.Agents.AI.Mem0/Microsoft.Agents.AI.Mem0.csproj" />
<Project Path="src/Microsoft.Agents.AI.OpenAI/Microsoft.Agents.AI.OpenAI.csproj" />
@@ -678,7 +671,6 @@
<Project Path="tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.UnitTests/Microsoft.Agents.AI.Hosting.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hyperlight.UnitTests/Microsoft.Agents.AI.Hyperlight.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/Microsoft.Agents.AI.LocalCodeAct.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Mcp.UnitTests/Microsoft.Agents.AI.Mcp.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Mem0.UnitTests/Microsoft.Agents.AI.Mem0.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.OpenAI.UnitTests/Microsoft.Agents.AI.OpenAI.UnitTests.csproj" />
+10 -10
View File
@@ -26,8 +26,8 @@ internal static class GetStartedSamples
{
Name = "01_hello_agent",
ProjectPath = "samples/01-get-started/01_hello_agent",
RequiredEnvironmentVariables = ["FOUNDRY_PROJECT_ENDPOINT"],
OptionalEnvironmentVariables = ["FOUNDRY_MODEL"],
RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"],
OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"],
ExpectedOutputDescription =
[
"The output should contain a joke about a pirate.",
@@ -40,8 +40,8 @@ internal static class GetStartedSamples
{
Name = "02_add_tools",
ProjectPath = "samples/01-get-started/02_add_tools",
RequiredEnvironmentVariables = ["FOUNDRY_PROJECT_ENDPOINT"],
OptionalEnvironmentVariables = ["FOUNDRY_MODEL"],
RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"],
OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"],
MustContain = [],
ExpectedOutputDescription =
[
@@ -56,8 +56,8 @@ internal static class GetStartedSamples
{
Name = "03_multi_turn",
ProjectPath = "samples/01-get-started/03_multi_turn",
RequiredEnvironmentVariables = ["FOUNDRY_PROJECT_ENDPOINT"],
OptionalEnvironmentVariables = ["FOUNDRY_MODEL"],
RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"],
OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"],
ExpectedOutputDescription =
[
"The output should contain a joke about a pirate.",
@@ -71,8 +71,8 @@ internal static class GetStartedSamples
{
Name = "04_memory",
ProjectPath = "samples/01-get-started/04_memory",
RequiredEnvironmentVariables = ["FOUNDRY_PROJECT_ENDPOINT"],
OptionalEnvironmentVariables = ["FOUNDRY_MODEL"],
RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"],
OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"],
MustContain =
[
">> Use session with blank memory",
@@ -97,8 +97,8 @@ internal static class GetStartedSamples
{
Name = "06_host_your_agent",
ProjectPath = "samples/01-get-started/06_host_your_agent",
RequiredEnvironmentVariables = ["FOUNDRY_PROJECT_ENDPOINT"],
OptionalEnvironmentVariables = ["FOUNDRY_MODEL"],
RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"],
OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"],
SkipReason = "Requires Azure Functions Core Tools runtime and starts a web server.",
},
];
+3 -3
View File
@@ -1,14 +1,14 @@
<Project>
<PropertyGroup>
<!-- Central version prefix - applies to all nuget packages. -->
<VersionPrefix>1.11.0</VersionPrefix>
<VersionPrefix>1.10.0</VersionPrefix>
<RCNumber>1</RCNumber>
<DateSuffix>260623</DateSuffix>
<DateSuffix>260610</DateSuffix>
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).$(DateSuffix).1</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.$(DateSuffix).1</PackageVersion>
<PackageVersion Condition="'$(IsReleased)' == 'true'">$(VersionPrefix)</PackageVersion>
<GitTag>1.11.0</GitTag>
<GitTag>1.10.0</GitTag>
<Configurations>Debug;Release;Publish</Configurations>
<IsPackable>true</IsPackable>
@@ -9,12 +9,13 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -1,19 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample shows how to create and use a simple AI agent with AIProjectClient as the backend.
// This sample shows how to create and use a simple AI agent with Azure OpenAI as the backend.
using Azure.AI.Projects;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using OpenAI.Chat;
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
var model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
AIAgent agent = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential())
.AsAIAgent(model: model, instructions: "You are good at telling jokes.", name: "Joker");
AIAgent agent = new AzureOpenAIClient(
new Uri(endpoint),
new DefaultAzureCredential())
.GetChatClient(deploymentName)
.AsAIAgent(instructions: "You are good at telling jokes.", name: "Joker");
// Invoke the agent and output the text result.
Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate."));
@@ -9,12 +9,13 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -1,27 +1,31 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to use an AIProjectClient agent with function tools.
// It shows both non-streaming and streaming agent interactions using weather tools.
// This sample demonstrates how to use a ChatClientAgent with function tools.
// It shows both non-streaming and streaming agent interactions using menu-related tools.
using System.ComponentModel;
using Azure.AI.Projects;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI.Chat;
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
var model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
[Description("Get the weather for a given location.")]
static string GetWeather([Description("The location to get the weather for.")] string location)
=> $"The weather in {location} is cloudy with a high of 15°C.";
// Create the agent and provide the function tool to the agent.
// Create the chat client and agent, and provide the function tool to the agent.
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
AIAgent agent = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential())
.AsAIAgent(model: model, instructions: "You are a helpful assistant", tools: [AIFunctionFactory.Create(GetWeather)]);
AIAgent agent = new AzureOpenAIClient(
new Uri(endpoint),
new DefaultAzureCredential())
.GetChatClient(deploymentName)
.AsAIAgent(instructions: "You are a helpful assistant", tools: [AIFunctionFactory.Create(GetWeather)]);
// Non-streaming agent interaction with function tools.
Console.WriteLine(await agent.RunAsync("What is the weather like in Amsterdam?"));
@@ -9,12 +9,13 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -2,18 +2,22 @@
// This sample shows how to create and use a simple AI agent with a multi-turn conversation.
using Azure.AI.Projects;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using OpenAI.Chat;
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
var model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
AIAgent agent = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential())
.AsAIAgent(model: model, instructions: "You are good at telling jokes.", name: "Joker");
AIAgent agent = new AzureOpenAIClient(
new Uri(endpoint),
new DefaultAzureCredential())
.GetChatClient(deploymentName)
.AsAIAgent(instructions: "You are good at telling jokes.", name: "Joker");
// Invoke the agent with a multi-turn conversation, where the context is preserved in the session object.
AgentSession session = await agent.CreateSessionAsync();
@@ -9,12 +9,13 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -8,26 +8,23 @@
using System.Text;
using System.Text.Json;
using Azure.AI.Projects;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI.Chat;
using SampleApp;
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
var model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
var projectClient = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential());
// Get the underlying IChatClient to use for the memory component.
// The memory provider needs direct IChatClient access for structured extraction.
IChatClient chatClient = projectClient
.AsAIAgent(new ChatClientAgentOptions { ChatOptions = new() { ModelId = model } })
.GetService<IChatClient>()
?? throw new InvalidOperationException("Could not retrieve IChatClient from AIProjectClient agent.");
ChatClient chatClient = new AzureOpenAIClient(
new Uri(endpoint),
new DefaultAzureCredential())
.GetChatClient(deploymentName);
// Create the agent and provide a factory to add our custom memory component to
// all sessions created by the agent. Here each new memory component will have its own
@@ -39,7 +36,7 @@ IChatClient chatClient = projectClient
AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions()
{
ChatOptions = new() { Instructions = "You are a friendly assistant. Always address the user by their name." },
AIContextProviders = [new UserInfoMemory(chatClient)]
AIContextProviders = [new UserInfoMemory(chatClient.AsIChatClient())]
});
// Create a new session for the conversation.
@@ -21,10 +21,11 @@
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting.AzureFunctions\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -4,32 +4,36 @@
//
// Prerequisites:
// - Azure Functions Core Tools
// - Foundry project endpoint and credentials
// - Azure OpenAI resource
//
// Environment variables:
// FOUNDRY_PROJECT_ENDPOINT
// FOUNDRY_MODEL (defaults to "gpt-5.4-mini")
// AZURE_OPENAI_ENDPOINT
// AZURE_OPENAI_DEPLOYMENT_NAME (defaults to "gpt-5.4-mini")
//
// Run with: func start
// Then call: POST http://localhost:7071/api/agents/HostedAgent/run
using Azure.AI.Projects;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hosting.AzureFunctions;
using Microsoft.Azure.Functions.Worker.Builder;
using Microsoft.Extensions.Hosting;
using OpenAI.Chat;
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
var model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")
?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
// Set up an AI agent following the standard Microsoft Agent Framework pattern.
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
AIAgent agent = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential())
.AsAIAgent(model: model, instructions: "You are a helpful assistant hosted in Azure Functions.", name: "HostedAgent");
AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential())
.GetChatClient(deploymentName)
.AsAIAgent(
instructions: "You are a helpful assistant hosted in Azure Functions.",
name: "HostedAgent");
// Configure the function app to host the AI agent.
// This will automatically generate HTTP API endpoints for the agent.
@@ -56,13 +56,14 @@ try
// Inspect memory search results if available in raw response items.
foreach (var message in response.Messages)
{
if (message.RawRepresentation is MemorySearchToolCall memorySearchResult)
if (message.RawRepresentation is MemorySearchToolCallResponseItem memorySearchResult)
{
Console.WriteLine($"Memory Search Status: {memorySearchResult.Status}");
Console.WriteLine($"Memory Search Results Count: {memorySearchResult.Memories.Count}");
Console.WriteLine($"Memory Search Results Count: {memorySearchResult.Results.Count}");
foreach (var memoryItem in memorySearchResult.Memories)
foreach (var result in memorySearchResult.Results)
{
var memoryItem = result.MemoryItem;
Console.WriteLine($" - Memory ID: {memoryItem.MemoryId}");
Console.WriteLine($" Scope: {memoryItem.Scope}");
Console.WriteLine($" Content: {memoryItem.Content}");
@@ -31,4 +31,3 @@ dotnet run --project .\Evaluation_ExpectedOutputs
- [Evaluation_SimpleEval](../Evaluation_SimpleEval/) — Simplest evaluation with built-in and custom checks
- [Evaluation_FoundryQuality](../../../05-end-to-end/Evaluation/Evaluation_FoundryQuality/) — Cloud-based quality evaluation with Foundry evaluators
- [Evaluation_FoundryRubric](../../../05-end-to-end/Evaluation/Evaluation_FoundryRubric/) — Rubric (adaptive) evaluators with per-dimension scores
@@ -26,5 +26,4 @@ dotnet run --project .\Evaluation_Multimodal
- [Evaluation_SimpleEval](../Evaluation_SimpleEval/) — Simplest evaluation with built-in checks and `agent.EvaluateAsync()`
- [Evaluation_FoundryQuality](../../../05-end-to-end/Evaluation/Evaluation_FoundryQuality/) — Cloud-based quality evaluation with Foundry evaluators
- [Evaluation_FoundryRubric](../../../05-end-to-end/Evaluation/Evaluation_FoundryRubric/) — Rubric (adaptive) evaluators with per-dimension scores
- [Evaluation_ConversationSplits](../../../05-end-to-end/Evaluation/Evaluation_ConversationSplits/) — Multi-turn conversation split strategies
@@ -1,22 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Harness\Microsoft.Agents.AI.Harness.csproj" />
<ProjectReference Include="..\..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
<ProjectReference Include="..\..\Harness_Shared_Console_OpenAI\Harness_Shared_Console_OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -1,111 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
// "Meet your agent harness and claw" — Post 1 of the "Build your own claw with Microsoft Agent Framework" series.
// See: https://devblogs.microsoft.com/agent-framework/meet-your-agent-harness-and-claw.
//
// This sample builds the foundation of a personal finance / investing assistant on top of a
// HarnessAgent. The harness comes pre-configured with function invocation, per-service-call
// history persistence, and planning (TodoProvider + AgentModeProvider), plus web search — so
// all we add here is:
// 1. Finance-focused instructions.
// 2. A custom get_stock_price function tool.
//
// The agent can plan a multi-step request ("Review my watchlist and recommend some stocks to add"), create a todo list, switch
// between plan and execute modes, search the web for market news, and call our stock-price tool.
//
// Special commands (handled by the shared HarnessConsole):
// /todos — Display the current todo list without invoking the agent.
// /mode — Get or set the current agent mode.
// /exit — End the session.
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
using System.ClientModel.Primitives;
using Azure.AI.Projects;
using Azure.Identity;
using ClawSample;
using Harness.Shared.Console;
using Harness.Shared.Console.OpenAI;
using Harness.Shared.Console.ToolFormatters;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4";
// <instructions>
var instructions =
"""
## Personal Finance Assistant Instructions
You are a personal finance and investing assistant. You help the user understand their
watchlist and the markets. When asked about a stock, look up its current price with the
get_stock_price tool, and use web search for recent news, earnings, or analyst commentary.
### Working style
- Always verify numbers with a tool rather than relying on memory. Stock prices change.
- Cite web sources inline when you use them.
- Keep the user's watchlist in a memory file called watchlist.md: read it when reviewing the
watchlist, and update it whenever the user adds or removes a ticker.
### Important
You provide information and analysis only you are not a licensed financial advisor and you
must not present your output as personalized investment advice. Remind the user to do their
own research before making decisions.
""";
// </instructions>
// <create_client>
// Construct an IChatClient. Here we use a Microsoft Foundry project: the endpoint points at the
// project, DefaultAzureCredential handles auth, and the deployment name selects the model.
// The harness works with ANY IChatClient — see the AgentProviders samples for OpenAI, Azure
// OpenAI, Anthropic, Google Gemini, Ollama, ONNX, and more.
IChatClient chatClient =
new AIProjectClient(
new Uri(endpoint),
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
new DefaultAzureCredential(),
new AIProjectClientOptions { RetryPolicy = new ClientRetryPolicy(3) }) // Enable retries to improve resiliency.
.GetProjectOpenAIClient()
.GetResponsesClient()
.AsIChatClient(deploymentName);
// </create_client>
// <create_agent>
// Turn the chat client into a HarnessAgent. AsHarnessAgent pre-configures function invocation,
// per-service-call chat history persistence, TodoProvider, AgentModeProvider, and web search.
// We add finance instructions and our get_stock_price tool.
AIAgent agent = chatClient.AsHarnessAgent(new HarnessAgentOptions
{
ChatOptions = new ChatOptions
{
Instructions = instructions,
Tools = [StockTools.CreateGetStockPriceTool()],
Reasoning = new() { Effort = ReasoningEffort.Medium },
},
});
// </create_agent>
// <run>
// Run the interactive console session using the shared HarnessConsole helper.
await HarnessConsole.RunAgentAsync(
agent,
userPrompt: "Ask about a stock or say 'Review my watchlist and recommend some stocks to add' to get started.",
new HarnessConsoleOptions
{
Observers = [
new OpenAIResponsesWebSearchDisplayObserver(),
new OpenAIResponsesErrorObserver(),
.. HarnessConsoleOptions.BuildObserversWithPlanning(
agent,
planModeName: "plan",
executionModeName: "execute",
toolFormatters: ToolCallFormatter.BuildDefaultToolFormatters())],
CommandHandlers = HarnessConsoleOptions.BuildDefaultCommandHandlers(agent),
});
// </run>
@@ -1,52 +0,0 @@
# Meet your claw (Post 1) — .NET
The first runnable sample from the [**"Build your own agent harness and claw with Microsoft Agent Framework"** blog](https://devblogs.microsoft.com/agent-framework/build-your-own-claw-and-agent-harness-with-microsoft-agent-framework)
series. It builds the foundation of a personal finance / investing assistant on top of a
`HarnessAgent`.
## What this sample demonstrates
- **`AsHarnessAgent`** — turns an `IChatClient` into a batteries-included agent: function
invocation, per-service-call history persistence, planning
(`TodoProvider` + `AgentModeProvider`), and web search.
- **A custom function tool** — `get_stock_price` (see `StockTools.cs`), exposing local data to the
agent. Prices are illustrative mock data, not real quotes.
- **Web search** — provided automatically by the harness for market news and commentary.
- **Planning & modes** — the agent breaks a multi-step request ("Review my watchlist and recommend some stocks to add") into a todo
list and switches between *plan* and *execute* modes.
- **Shared harness console** — interactive streaming UI with `/todos`, `/mode`, and `/exit`
commands.
## Prerequisites
1. A Microsoft Foundry project with a deployed model (e.g. `gpt-5.4`).
2. Azure CLI installed and authenticated (`az login`).
## Environment variables
```bash
export FOUNDRY_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/api/projects/your-project"
# Optional (defaults to gpt-5.4)
export FOUNDRY_MODEL="gpt-5.4"
```
## Running
```bash
cd dotnet
dotnet run --project samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step01_MeetYourClaw
```
## What to expect
The sample starts an interactive loop. Try these in order:
1. `/mode execute` — switch out of the default plan mode; quick lookups don't need a plan.
2. `What's the price of MSFT?` — the agent calls the `get_stock_price` tool.
3. `Any recent news on NVDA?` — the agent uses web search.
4. `Add MSFT, NVDA and SPY to my watch list` — saved to `watchlist.md` in the session's memory.
5. `/mode plan` — switch back to plan mode for a bigger, multi-step task.
6. `Review my watchlist and recommend some stocks to add` — the agent plans, then executes. Type
`/todos` to see the list and `/mode` to inspect the current mode.
Output is colored by mode: **cyan** during planning, **green** during execution.
@@ -1,61 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ComponentModel;
using Microsoft.Extensions.AI;
namespace ClawSample;
/// <summary>
/// A custom function tool that gives our "claw" access to (illustrative) stock prices.
/// </summary>
/// <remarks>
/// The prices returned here are mock data for demonstration purposes only and are not real
/// market quotes. In a real assistant you would call a market-data API instead.
/// </remarks>
internal static class StockTools
{
// <stock_quote>
/// <summary>A delayed, illustrative stock quote.</summary>
public sealed record StockQuote(string Symbol, decimal Price, string Currency, DateTimeOffset AsOf);
// </stock_quote>
// A tiny in-memory price book so the sample runs without any external dependency.
private static readonly Dictionary<string, decimal> s_priceBook = new(StringComparer.OrdinalIgnoreCase)
{
["MSFT"] = 462.97m,
["AAPL"] = 229.35m,
["GOOGL"] = 178.12m,
["AMZN"] = 201.45m,
["NVDA"] = 134.81m,
};
// <get_stock_price>
/// <summary>
/// Gets the latest (delayed, illustrative) stock price for a ticker symbol.
/// </summary>
/// <param name="symbol">The stock ticker symbol, e.g. <c>MSFT</c> or <c>AAPL</c>.</param>
[Description("Gets the latest (delayed, illustrative) stock price for a ticker symbol.")]
public static StockQuote GetStockPrice(
[Description("The stock ticker symbol, e.g. MSFT or AAPL.")] string symbol)
{
if (!s_priceBook.TryGetValue(symbol, out var price))
{
// Deterministic pseudo-price for unknown symbols so the sample stays self-contained.
// Derive a stable seed from the characters — string.GetHashCode() is randomized per
// process and Math.Abs(int.MinValue) throws, so neither is safe for repeatable output.
var seed = 0;
foreach (var ch in symbol.ToUpperInvariant())
{
seed = (seed * 31 + ch) % 1_000_000;
}
price = 50m + seed % 45000 / 100m;
}
return new StockQuote(symbol.ToUpperInvariant(), price, "USD", DateTimeOffset.UtcNow);
}
// </get_stock_price>
/// <summary>Creates the <see cref="AIFunction"/> wrapper used to expose the tool to the agent.</summary>
public static AIFunction CreateGetStockPriceTool() => AIFunctionFactory.Create(GetStockPrice, "get_stock_price");
}
@@ -64,15 +64,21 @@ public sealed class HarnessAgentRunner : IDisposable
/// <summary>
/// Replaces the current session with the specified session. Used by the UX driver
/// when importing a serialized session. This method is always called from within
/// a command handler (which already holds the input gate), so no additional
/// synchronization is needed.
/// when importing a serialized session. Acquires the input gate to ensure no
/// concurrent agent turn is reading the session.
/// </summary>
/// <param name="newSession">The new session to use.</param>
internal Task ReplaceSessionAsync(AgentSession newSession)
internal async Task ReplaceSessionAsync(AgentSession newSession)
{
this._session = newSession;
return Task.CompletedTask;
await this._inputGate.WaitAsync().ConfigureAwait(false);
try
{
this._session = newSession;
}
finally
{
this._inputGate.Release();
}
}
/// <inheritdoc/>
@@ -6,22 +6,22 @@ using Microsoft.Extensions.AI;
namespace Harness.Shared.Console.ToolFormatters;
/// <summary>
/// Formats <c>background_agents_*</c> tool calls with human-readable details
/// Formats <c>BackgroundAgents_*</c> tool calls with human-readable details
/// for task start, continue, wait, and result retrieval operations.
/// </summary>
public sealed class BackgroundAgentToolFormatter : ToolCallFormatter
{
/// <inheritdoc/>
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("background_agents_", StringComparison.Ordinal);
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("BackgroundAgents_", StringComparison.Ordinal);
/// <inheritdoc/>
public override string? FormatDetail(FunctionCallContent call) => call.Name switch
{
"background_agents_start_task" => FormatStartBackgroundTask(call),
"background_agents_wait_for_first_completion" => FormatIdList(call, "taskIds", "Wait for"),
"background_agents_get_task_results" => FormatSingleId(call, "taskId"),
"background_agents_continue_task" => FormatContinueTask(call),
"background_agents_clear_completed_task" => FormatSingleId(call, "taskId"),
"BackgroundAgents_StartTask" => FormatStartBackgroundTask(call),
"BackgroundAgents_WaitForFirstCompletion" => FormatIdList(call, "taskIds", "Wait for"),
"BackgroundAgents_GetTaskResults" => FormatSingleId(call, "taskId"),
"BackgroundAgents_ContinueTask" => FormatContinueTask(call),
"BackgroundAgents_ClearCompletedTask" => FormatSingleId(call, "taskId"),
_ => null,
};
@@ -5,21 +5,21 @@ using Microsoft.Extensions.AI;
namespace Harness.Shared.Console.ToolFormatters;
/// <summary>
/// Formats <c>file_memory_*</c> tool calls, showing file names and search patterns
/// Formats <c>FileMemory_*</c> tool calls, showing file names and search patterns
/// with tree-view corners for save operations.
/// </summary>
public sealed class FileMemoryToolFormatter : ToolCallFormatter
{
/// <inheritdoc/>
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("file_memory_", StringComparison.Ordinal);
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("FileMemory_", StringComparison.Ordinal);
/// <inheritdoc/>
public override string? FormatDetail(FunctionCallContent call) => call.Name switch
{
"file_memory_save_file" => FormatSaveFile(call),
"file_memory_read_file" => FormatStringArg(call, "fileName"),
"file_memory_delete_file" => FormatStringArg(call, "fileName"),
"file_memory_search_files" => FormatSearchFiles(call),
"FileMemory_SaveFile" => FormatSaveFile(call),
"FileMemory_ReadFile" => FormatStringArg(call, "fileName"),
"FileMemory_DeleteFile" => FormatStringArg(call, "fileName"),
"FileMemory_SearchFiles" => FormatSearchFiles(call),
_ => null,
};
@@ -89,13 +89,6 @@ AIAgent agent =
OpenTelemetrySourceName = TracingSourceName, // Use our custom source name so spans are captured by the TracerProvider above.
FileMemoryStore = new FileSystemAgentFileStore( // Configure the file memory provider to store files in a local folder called "agent-files".
Path.Combine(AppContext.BaseDirectory, "agent-files")),
// The built in ModeProvider has two default modes: "plan" and "execute".
// Adding a loop evaluator so that in "execute" mode, the harness keeps re-invoking itself until every todo item is complete.
LoopEvaluators =
[
new TodoCompletionLoopEvaluator(new TodoCompletionLoopEvaluatorOptions { Modes = ["execute"] }),
],
LoopAgentOptions = new LoopAgentOptions { MaxIterations = 10 }, // Safety cap on the number of autonomous passes per turn.
ChatOptions = new ChatOptions
{
Instructions = instructions,
@@ -9,7 +9,6 @@ Key features showcased:
- **Web Search** — the agent can search the web for current information via `ResponseTool.CreateWebSearchTool()`
- **TodoProvider** — the agent creates and manages a todo list to track research questions
- **AgentModeProvider** — the agent switches between "plan" mode (breaking down the topic) and "execute" mode (answering each research question)
- **TodoCompletionLoopEvaluator** — in "execute" mode the agent loops automatically, re-invoking itself until every todo item is complete (capped by `LoopAgentOptions.MaxIterations`). The loop is scoped to "execute" mode, so "plan" mode stays interactive. The `HarnessAgent` wraps itself in a `LoopAgent` automatically whenever `LoopEvaluators` is supplied.
- **Interactive conversation** — you can review the agent's plan, provide feedback, and approve before execution begins
- **Streaming output** — responses are streamed token-by-token for a natural experience
- **`/todos` command** — view the current todo list at any time without invoking the agent
@@ -48,7 +47,7 @@ The sample starts an interactive conversation loop. You can:
1. **Enter a research topic** — the agent will analyze it and create a plan with todos
2. **Review and adjust** — provide feedback on the plan, ask for changes, or approve it
3. **Type `/todos`** — to see the current todo list at any time
4. **Watch execution** — once approved, the agent will switch to "execute" mode and process each todo autonomously until the whole plan is complete
4. **Watch execution** — once approved, tell the agent to proceed and it will work through each todo
5. **Type `exit`** — to end the session
The prompt and agent output are colored by the current mode: **cyan** during planning, **green** during execution.
@@ -56,9 +56,9 @@ AIAgent webSearchAgent =
OpenTelemetrySourceName = TracingSourceName,
DisableTodoProvider = true,
DisableAgentModeProvider = true,
DisableFileMemory = true, // If enabled, this would allow the agent to store memories as files in a directory associated with the current session
DisableFileAccess = true, // If enabled, this would allow the agent to read/write files in a working directory
DisableToolAutoApproval = true, // If true, this disables the don't-ask-again approval functionality.
DisableFileMemory = true, // If enabled, this would allow the agent to store memories as files in a directory associated with the current session
DisableFileAccess = true, // If enabled, this would allow the agent to read/write files in a working directory
DisableToolApproval = true, // If enabled, this allows don't-ask-again approval functionality.
ChatOptions = new ChatOptions
{
Instructions = "You are a web search assistant. When asked to find information, use the web search tool to look it up and return a concise, factual answer.",
@@ -106,9 +106,9 @@ AIAgent parentAgent =
OpenTelemetrySourceName = TracingSourceName,
DisableTodoProvider = true,
DisableAgentModeProvider = true,
DisableFileMemory = true, // If enabled, this would allow the agent to store memories as files in a directory associated with the current session
DisableFileAccess = true, // If enabled, this would allow the agent to read/write files in a working directory
DisableToolAutoApproval = true, // If true, this disables the don't-ask-again approval functionality.
DisableFileMemory = true, // If enabled, this would allow the agent to store memories as files in a directory associated with the current session
DisableFileAccess = true, // If enabled, this would allow the agent to read/write files in a working directory
DisableToolApproval = true, // If enabled, this allows don't-ask-again approval functionality.
DisableWebSearch = true,
BackgroundAgents = [webSearchAgent],
ChatOptions = new ChatOptions
@@ -9,16 +9,16 @@ A parent agent receives a list of stock tickers and uses a web-search background
### Architecture
```
┌──────────────────────────────────────────────────
│ StockPriceResearcher
│ (Parent Agent)
│ BackgroundAgentsProvider
│ ├─ background_agents_start_task
│ ├─ background_agents_wait_for_first_completion
│ ├─ background_agents_get_task_results
│ └─ ...
└─────────────┬────────────────────────────────────┘
┌────────────────────────────────────────┐
│ StockPriceResearcher │
│ (Parent Agent) │
│ │
│ BackgroundAgentsProvider │
│ ├─ BackgroundAgents_StartTask │
│ ├─ BackgroundAgents_WaitFor...
│ ├─ BackgroundAgents_GetTaskResults │
│ └─ ... │
└───────────────────────────────────────┘
│ delegates to
┌─────────────────────────────────┐
@@ -79,13 +79,6 @@ AIAgent agent =
Description = "A data analyst assistant that reads, analyzes, and processes data files.",
OpenTelemetrySourceName = TracingSourceName,
FileAccessStore = new FileSystemAgentFileStore(Path.Combine(AppContext.BaseDirectory, "working")),
ToolApprovalAgentOptions = new ToolApprovalAgentOptions()
{
// The HarnessAgent's FileAccessProvider requires approval for all file access operations.
// Add an auto-approval rule to skip prompts for specific operations (e.g., read-only access).
// You can also supply your own rule to implement custom approval logic.
AutoApprovalRules = [FileAccessProvider.ReadOnlyToolsAutoApprovalRule]
},
DisableTodoProvider = true,
DisableAgentModeProvider = true,
DisableFileMemory = true, // If enabled, this would allow the agent to store memories as files in a directory associated with the current session
@@ -116,7 +116,7 @@ async Task TodoLoopAsync()
{
var todoProvider = context.Agent.GetService<TodoProvider>()
?? throw new InvalidOperationException("The agent did not expose a TodoProvider.");
var remaining = await todoProvider.GetRemainingTodosAsync(context.Session, cancellationToken).ConfigureAwait(false);
var remaining = await todoProvider.GetRemainingTodosAsync(context.Session).ConfigureAwait(false);
return remaining.Count > 0
? LoopEvaluation.Continue($"Not all todos are complete yet ({remaining.Count} remaining). Please complete the remaining todo items.")
: LoopEvaluation.Stop();
@@ -10,11 +10,3 @@ Samples demonstrating the [Harness AIContextProviders](../../../src/Microsoft.Ag
| [Harness_Step02_Research_WithBackgroundAgents](./Harness_Step02_Research_WithBackgroundAgents/README.md) | Using BackgroundAgentsProvider to delegate stock price lookups to a web-search background agent concurrently |
| [Harness_Step03_DataProcessing](./Harness_Step03_DataProcessing/README.md) | Using FileAccessProvider to give an agent access to CSV data files for reading, analysis, and output generation |
| [Harness_Step05_Loop](./Harness_Step05_Loop/README.md) | Wrapping a HarnessAgent with the LoopAgent decorator to re-invoke it until a configured LoopEvaluator (completion marker, predicate, AI judge, or approval-aware loop) decides to stop |
## Build your own claw blog series
Samples accompanying the [*Build your own agent harness or claw with Microsoft Agent Framework*](https://devblogs.microsoft.com/agent-framework/build-your-own-claw-and-agent-harness-with-microsoft-agent-framework) blog series, which builds a personal finance assistant step by step.
| Sample | Description |
| --- | --- |
| [Claw_Step01_MeetYourClaw](./BuildYourOwnClaw/Claw_Step01_MeetYourClaw/README.md) | Post 1 — a minimal HarnessAgent with a custom `get_stock_price` tool, web search, and planning |
@@ -1,21 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" />
<PackageReference Include="ModelContextProtocol" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
</ItemGroup>
</Project>
@@ -1,136 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample shows how to attach per-run (refreshable) authentication headers to MCP requests.
//
// The agent connects to an MCP server with a custom HttpClient. A DelegatingHandler reads a token
// for the current run from an AsyncLocal scope and stamps it on each outbound MCP request, so a
// short-lived token (for example an OBO or cloud identity token that expires) can be refreshed on
// every run without rebuilding the agent or the MCP connection.
//
// The agent backend is Microsoft Foundry via the Responses API (RAPI). The MCP server is the public
// Microsoft Learn MCP server, which ignores the demonstration token; in production you point the
// handler at your own protected MCP server and mint a real token per run.
using System.Net.Http.Headers;
using Azure.AI.Projects;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using ModelContextProtocol.Client;
var projectEndpoint = new Uri(Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."));
var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
var serverEndpoint = new Uri("https://learn.microsoft.com/api/mcp");
// Custom HttpClient for the MCP transport. The per-run handler attaches the bearer; the inner
// handler disables cookies (no cross-context state), disables auto-redirect (so a redirect cannot
// carry the bearer past the origin re-check), and checks certificate revocation.
using var httpClient = new HttpClient(new PerRunAuthHeaderHandler(serverEndpoint)
{
InnerHandler = new HttpClientHandler
{
UseCookies = false,
AllowAutoRedirect = false,
CheckCertificateRevocationList = true,
},
});
Console.WriteLine($"Connecting to MCP server at {serverEndpoint} ...");
await using var mcpClient = await McpClient.CreateAsync(new HttpClientTransport(new()
{
Endpoint = serverEndpoint,
Name = "Microsoft Learn MCP",
TransportMode = HttpTransportMode.StreamableHttp,
}, httpClient));
IList<McpClientTool> mcpTools = await mcpClient.ListToolsAsync();
Console.WriteLine($"MCP tools available: {string.Join(", ", mcpTools.Select(t => t.Name))}");
// Build the agent from Microsoft Foundry using the Responses API (RAPI).
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
AIAgent agent = new AIProjectClient(projectEndpoint, new DefaultAzureCredential())
.AsAIAgent(
model: deploymentName,
instructions: "You answer Microsoft documentation questions using the available tools.",
name: "DocsAgent",
tools: [.. mcpTools.Cast<AITool>()]);
// Run the same agent twice under two different contexts. Each run gets a freshly minted token,
// proving the auth header is per-run rather than bound when the agent or MCP connection was created.
await RunForContextAsync(agent, "tenant-a", "How do I create an Azure storage account with az cli?");
await RunForContextAsync(agent, "tenant-b", "What is Azure Functions?");
static async Task RunForContextAsync(AIAgent agent, string label, string prompt)
{
// Stand-in for a real per-run token (for example an OBO or cloud identity token).
// It carries no PII and is regenerated on every run. The label is non-secret and used for logging.
McpRunContext? previous = McpRunScope.Current;
McpRunScope.Current = new McpRunContext(label, $"{label}.{Guid.NewGuid():N}");
try
{
Console.WriteLine($"\n=== Run for '{label}' (fresh per-run token) ===");
Console.WriteLine(await agent.RunAsync(prompt));
}
finally
{
// Restore the prior scope (stack-like) so this is safe to call from within an outer scope.
McpRunScope.Current = previous;
}
}
/// <summary>
/// Carries the context for the current run. <see cref="Label"/> is a non-secret identifier safe to
/// log; <see cref="Token"/> is the secret that must never be logged or persisted.
/// </summary>
internal sealed record McpRunContext(string Label, string Token);
/// <summary>
/// Flows the current <see cref="McpRunContext"/> to the MCP <see cref="DelegatingHandler"/> without
/// threading it through every call. Set it before a run and reset it afterwards.
/// </summary>
internal static class McpRunScope
{
private static readonly AsyncLocal<McpRunContext?> s_current = new();
public static McpRunContext? Current
{
get => s_current.Value;
set => s_current.Value = value;
}
}
/// <summary>
/// Attaches the current run's bearer token to outbound MCP requests. The token is read fresh on
/// every request, so refreshing it between runs needs no agent or connection rebuild.
/// </summary>
/// <remarks>
/// Security: the bearer is attached only over HTTPS and only when the request targets the configured
/// MCP server origin, which prevents the credential from leaking over plaintext or to a redirect
/// target on another origin. Only the non-secret label is logged, never the token.
/// </remarks>
internal sealed class PerRunAuthHeaderHandler(Uri serverEndpoint) : DelegatingHandler
{
private readonly string _serverOrigin = serverEndpoint.GetLeftPart(UriPartial.Authority);
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
McpRunContext? context = McpRunScope.Current;
Uri? requestUri = request.RequestUri;
if (context is not null
&& requestUri is not null
&& requestUri.Scheme == Uri.UriSchemeHttps
&& string.Equals(requestUri.GetLeftPart(UriPartial.Authority), this._serverOrigin, StringComparison.OrdinalIgnoreCase))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", context.Token);
Console.WriteLine($"[mcp-auth] attached bearer for '{context.Label}' -> {request.Method} {requestUri.AbsolutePath}");
}
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
}
}
@@ -1,89 +0,0 @@
# Per-Run MCP Authentication Headers
This sample shows how to attach per-run (refreshable) authentication headers to Model Context
Protocol (MCP) requests using existing Agent Framework primitives. It addresses scenarios where the
header value changes from one run to the next, for example a short-lived On-Behalf-Of (OBO) or cloud
identity token that expires and must be refreshed.
The agent backend is Microsoft Foundry accessed through the Responses API (RAPI). The MCP server is
the public Microsoft Learn MCP server.
## What this sample demonstrates
- A custom `HttpClient` on the MCP transport whose `DelegatingHandler` stamps an `Authorization`
header on every outbound MCP request.
- An `AsyncLocal` scope (`McpRunScope`) that carries the current run's context to the handler, set
immediately before each run and cleared in a `finally` block.
- Running the same agent twice under two different contexts, each with a freshly minted token, so the
header is per-run rather than fixed when the agent or the MCP connection was created.
Because the handler reads the token fresh on every request, an expiring token is refreshed simply by
placing a new value in scope before the next run. No agent or connection rebuild is required.
## How it works
```text
RunForContextAsync sets McpRunScope.Current
-> agent.RunAsync invokes an MCP tool
-> PerRunAuthHeaderHandler reads McpRunScope.Current
-> stamps Authorization: Bearer <token> on the MCP request
RunForContextAsync clears McpRunScope.Current in finally
```
The public Microsoft Learn MCP server is anonymous and ignores the demonstration token. In production
you point the handler at your own protected MCP server and mint a real token per run.
## Prerequisites
- .NET 10 SDK or later
- A Microsoft Foundry project endpoint and a model deployment
- An authenticated Azure identity (for example, sign in with `az login`)
Set the following environment variables:
```powershell
$env:FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project"
$env:FOUNDRY_MODEL="gpt-5.4-mini"
```
## Run the sample
```powershell
dotnet run
```
## Security considerations
This sample is written to demonstrate the pattern safely. When you adapt it, keep these in place:
- **Never log the token.** Only the non-secret label is printed. Avoid printing the token even in a
masked form.
- **Attach the header over HTTPS only.** The handler skips the header when the request is not HTTPS,
so a credential is never sent over plaintext.
- **Scope the header to the MCP server origin.** The handler attaches the header only when the
request targets the configured server origin (scheme, host, and port). Auto-redirect is also
disabled (`AllowAutoRedirect = false`) so a redirect cannot carry the token to another origin
below the handler before the origin check runs.
- **Reset the scope after each run.** `McpRunScope.Current` is restored to its prior value in a
`finally` block so a token does not bleed into later, unrelated work and nesting stays safe.
- **Disable cookies on the shared handler.** `UseCookies = false` avoids cross-context state on a
shared client, and `CheckCertificateRevocationList = true` validates the server certificate.
- **Use non-identifying labels and tokens.** The labels and tokens here carry no personal data and are
regenerated per run.
- **Do not persist secrets in serialized session state.** Agent session state is serializable, so keep
raw tokens in memory or mint them per run rather than storing them there.
## Production notes
- Replace the demonstration token with a real per-request exchange inside the handler, for example an
Azure `TokenCredential`, MSAL OBO flow, or a cloud identity token. Performing the exchange per
request lets expiry self-heal because each request obtains a current token.
- The `AsyncLocal` scope isolates concurrent runs from each other, so parallel runs with different
tokens do not interfere.
- As an alternative carrier, the token can be read from `AgentSession` state by an `AIContextProvider`
that copies it into the scope at the start of each invocation. Remember the serialized-state warning
above and avoid persisting the raw secret.
- For MCP servers that implement standard OAuth, `HttpClientTransportOptions.OAuth` already handles the
authorization and refresh flow, so a custom handler is unnecessary.
- This sample attaches the same header for every tool call in a run. Selecting different headers based
on the specific tool or its arguments is intentionally out of scope here.
@@ -21,7 +21,6 @@ Before you begin, ensure you have the following prerequisites:
|---|---|
|[Agent with MCP server tools](./Agent_MCP_Server/)|This sample demonstrates how to use MCP server tools with a simple agent|
|[Agent with MCP server tools and authorization](./Agent_MCP_Server_Auth/)|This sample demonstrates how to use MCP Server tools from a protected MCP server with a simple agent|
|[Agent with per-run MCP authentication headers](./Agent_MCP_PerRun_AuthHeaders/)|This sample demonstrates how to attach per-run, refreshable authentication headers to MCP requests using a custom HttpClient handler and an AsyncLocal scope. Uses Microsoft Foundry (`FOUNDRY_PROJECT_ENDPOINT` / `FOUNDRY_MODEL`) rather than the Azure OpenAI variables in the prerequisites above.|
|[Responses Agent with Hosted MCP tool](./ResponseAgent_Hosted_MCP/)|This sample demonstrates how to use the Hosted MCP tool with the Responses Service, where the service invokes any MCP tools directly|
|[Agent with long-running MCP task (transparent polling)](./Agent_MCP_LongRunningTask_Client/)|This sample demonstrates how an agent transparently drives a long-running MCP task (SEP-2663) to completion. The wrapper polls the task internally on both `RunAsync` and `RunStreamingAsync` invocations.|
@@ -27,7 +27,7 @@ public static class Program
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
var client = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName).AsIChatClient();
Console.Write("Choose workflow type ('sequential', 'sequential-chain-only', 'concurrent', 'handoffs', 'groupchat'): ");
Console.Write("Choose workflow type ('sequential', 'concurrent', 'handoffs', 'groupchat'): ");
switch (Console.ReadLine())
{
case "sequential":
@@ -36,14 +36,6 @@ public static class Program
[new(ChatRole.User, "Hello, world!")]);
break;
case "sequential-chain-only":
await RunWorkflowAsync(
AgentWorkflowBuilder.BuildSequential(
chainOnlyAgentResponses: true,
from lang in (string[])["French", "Spanish", "English"] select GetTranslationAgent(lang, client)),
[new(ChatRole.User, "Hello, world!")]);
break;
case "concurrent":
await RunWorkflowAsync(
AgentWorkflowBuilder.BuildConcurrent(from lang in (string[])["French", "Spanish", "English"] select GetTranslationAgent(lang, client)),
@@ -20,6 +20,7 @@
#pragma warning disable AAIP001 // ProjectAgentSkills is experimental
using System.ClientModel;
using System.IO.Compression;
using Azure.AI.Projects;
using Azure.AI.Projects.Agents;
using Azure.Core;
@@ -120,8 +121,8 @@ app.Run();
// ── Helpers ──────────────────────────────────────────────────────────────────
// Downloads each named skill from Foundry into a separate subdirectory under the target directory.
// GetSkillContentAsync downloads the skill package and unzips it into the destination directory.
// Downloads each named skill from Foundry and extracts the ZIP archive into a
// separate subdirectory under the target directory.
static async Task DownloadSkillsAsync(ProjectAgentSkills skillsClient, string[] skillNames, string targetDir)
{
if (Directory.Exists(targetDir))
@@ -134,16 +135,56 @@ static async Task DownloadSkillsAsync(ProjectAgentSkills skillsClient, string[]
foreach (string name in skillNames)
{
Console.WriteLine($"Downloading skill '{name}' from Foundry...");
BinaryData zipData = await skillsClient.DownloadSkillAsync(name);
string skillDir = Path.Combine(targetDir, name);
Directory.CreateDirectory(skillDir);
await skillsClient.GetSkillContentAsync(name, skillDir);
using var zipStream = zipData.ToStream();
using var archive = new ZipArchive(zipStream, ZipArchiveMode.Read);
SafeExtractZip(archive, skillDir);
if (!File.Exists(Path.Combine(skillDir, "SKILL.md")))
{
throw new InvalidOperationException(
$"Downloaded skill '{name}' did not contain a SKILL.md at the root.");
$"Downloaded archive for '{name}' did not contain a SKILL.md at the root.");
}
}
}
// Extracts a ZIP archive into a destination directory, rejecting entries that would
// escape the target path (zip-slip guard).
static void SafeExtractZip(ZipArchive archive, string destinationDir)
{
string destRoot = Path.GetFullPath(destinationDir);
string destRootWithSep = Path.EndsInDirectorySeparator(destRoot)
? destRoot
: destRoot + Path.DirectorySeparatorChar;
// Use ordinal comparison on Unix (case-sensitive FS) and ordinal-ignore-case on Windows.
var comparison = OperatingSystem.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;
foreach (ZipArchiveEntry entry in archive.Entries)
{
string entryPath = Path.GetFullPath(Path.Combine(destRoot, entry.FullName));
if (!entryPath.StartsWith(destRootWithSep, comparison)
&& !string.Equals(entryPath, destRoot, comparison))
{
throw new InvalidOperationException(
$"Refusing to extract unsafe path '{entry.FullName}' outside of '{destRoot}'.");
}
if (string.IsNullOrEmpty(entry.Name))
{
// Directory entry — ensure it exists.
Directory.CreateDirectory(entryPath);
}
else
{
Directory.CreateDirectory(Path.GetDirectoryName(entryPath)!);
entry.ExtractToFile(entryPath, overwrite: true);
}
}
}
@@ -170,8 +211,8 @@ static async Task EnsureSkillsProvisionedAsync(ProjectAgentSkills skillsClient,
catch (ClientResultException ex) when (ex.Status == 404)
{
Console.WriteLine($"Provisioning skill '{name}' from {skillPath}...");
AgentsSkill imported = (await skillsClient.CreateSkillVersionFromFilesAsync(name, skillPath)).Value;
Console.WriteLine($" Imported skill '{imported.Name}' (id={imported.Id}, version={imported.LatestVersion}).");
AgentsSkill imported = await skillsClient.CreateSkillFromPackageAsync(skillPath);
Console.WriteLine($" Imported skill '{imported.Name}' (id={imported.SkillId}, has_blob={imported.HasBlob}).");
}
}
}
@@ -42,7 +42,7 @@ The agent is hosted using the [Agent Framework](https://github.com/microsoft/age
## Prerequisites
- A Foundry project with a deployed model (e.g., `gpt-4o`)
- An Azure AI Foundry project with a deployed model (e.g., `gpt-4o`)
- Azure CLI logged in (`az login`)
### Required RBAC
@@ -7,7 +7,7 @@ This sample is the Azure AI Search counterpart to `Hosted-TextRag`. Where `Hoste
## Prerequisites
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
- A Foundry project with a deployed model (e.g., `gpt-4o`)
- An Azure AI Foundry project with a deployed model (e.g., `gpt-4o`)
- An Azure AI Search service ([create one](https://learn.microsoft.com/azure/search/search-create-service-portal))
- **A pre-provisioned search index** with the schema and content described in the next section
- Azure CLI logged in (`az login`)
@@ -5,7 +5,7 @@ A simple general-purpose AI assistant hosted as a Foundry Hosted Agent using the
## Prerequisites
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
- A Foundry project with a deployed model (e.g., `gpt-4o`)
- An Azure AI Foundry project with a deployed model (e.g., `gpt-4o`)
- Azure CLI logged in (`az login`)
## Configuration
@@ -16,7 +16,7 @@ Copy the template and fill in your project endpoint:
cp .env.example .env
```
Edit `.env` and set your Foundry project endpoint:
Edit `.env` and set your Azure AI Foundry project endpoint:
```env
FOUNDRY_PROJECT_ENDPOINT=https://<your-account>.services.ai.azure.com/api/projects/<your-project>
@@ -20,7 +20,7 @@
// indirect prompt injection in an uploaded file.
//
// Required environment variables:
// FOUNDRY_PROJECT_ENDPOINT - Foundry project endpoint
// FOUNDRY_PROJECT_ENDPOINT - Azure AI Foundry project endpoint
// FOUNDRY_MODEL - Model deployment name (default: gpt-4o)
//
// Optional:
@@ -43,7 +43,7 @@ The end-to-end alpha-SDK round trip (client uploads via `AgentSessionFiles.Uploa
## Prerequisites
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
- A Foundry project with a deployed model (e.g., `gpt-4o`)
- An Azure AI Foundry project with a deployed model (e.g., `gpt-4o`)
- Azure CLI logged in (`az login`)
## Configuration
@@ -7,7 +7,7 @@ This is the **Foundry hosting** pattern — the agent's behavior is configured i
## Prerequisites
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
- A Foundry project with a **registered agent** (created via Foundry UI, CLI, or API)
- An Azure AI Foundry project with a **registered agent** (created via Foundry UI, CLI, or API)
- Azure CLI logged in (`az login`)
## Configuration
@@ -18,7 +18,7 @@ Copy the template and fill in your project endpoint:
cp .env.example .env
```
Edit `.env` and set your Foundry project endpoint:
Edit `.env` and set your Azure AI Foundry project endpoint:
```env
FOUNDRY_PROJECT_ENDPOINT=https://<your-account>.services.ai.azure.com/api/projects/<your-project>
@@ -1,6 +0,0 @@
FOUNDRY_PROJECT_ENDPOINT=<your-azure-ai-project-endpoint>
ASPNETCORE_URLS=http://+:8088
ASPNETCORE_ENVIRONMENT=Development
FOUNDRY_MODEL=gpt-4o
AZURE_BEARER_TOKEN=DefaultAzureCredential
LOCAL_CODEACT_PYTHON=python3
@@ -1,23 +0,0 @@
# Use the official .NET 10.0 ASP.NET runtime as a parent image
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
WORKDIR /app
# Install Python 3 so LocalCodeAct can spawn the embedded runner / validator.
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 \
&& rm -rf /var/lib/apt/lists/*
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet restore
RUN dotnet publish -c Release -o /app/publish
# Final stage
FROM base AS final
WORKDIR /app
COPY --from=build /app/publish .
EXPOSE 8088
ENV ASPNETCORE_URLS=http://+:8088
ENV LOCAL_CODEACT_PYTHON=python3
ENTRYPOINT ["dotnet", "HostedLocalCodeAct.dll"]
@@ -1,24 +0,0 @@
# Dockerfile for contributors building from the agent-framework repository source.
#
# This project uses ProjectReference to the local Microsoft.Agents.AI.Foundry and
# Microsoft.Agents.AI.LocalCodeAct sources, which means a standard multi-stage
# Docker build cannot resolve dependencies outside this folder. Instead, pre-publish
# the app targeting the container runtime and copy the output into the container:
#
# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
# docker build -f Dockerfile.contributor -t hosted-local-codeact .
# docker run --rm -p 8088:8088 -e AGENT_NAME=hosted-local-codeact -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN --env-file .env hosted-local-codeact
#
# For end-users consuming the NuGet package (not ProjectReference), use the standard
# Dockerfile which performs a full dotnet restore + publish inside the container.
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
WORKDIR /app
# Install Python 3 so LocalCodeAct can spawn the embedded runner / validator.
RUN apk add --no-cache python3
COPY out/ .
EXPOSE 8088
ENV ASPNETCORE_URLS=http://+:8088
ENV LOCAL_CODEACT_PYTHON=python3
ENTRYPOINT ["dotnet", "HostedLocalCodeAct.dll"]
@@ -1,33 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
<NoWarn>$(NoWarn);</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="DotNetEnv" />
</ItemGroup>
<!-- For contributors: uses ProjectReference to build against local source -->
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.LocalCodeAct\Microsoft.Agents.AI.LocalCodeAct.csproj" />
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
</ItemGroup>
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
<PackageReference Include="Microsoft.Agents.AI.LocalCodeAct" Version="1.6.1-preview.260514.1" />
</ItemGroup>
-->
</Project>
@@ -1,120 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
// Hosted Local CodeAct sample. Wires Microsoft.Agents.AI.LocalCodeAct into a
// Foundry hosted agent. The model only sees a single `execute_code` tool;
// `compute` and `fetch_data` are registered as sandbox-only host tools that
// generated Python reaches via `await call_tool(...)`. This mirrors the Python
// `foundry_hosted_agent.py` sample for the local-codeact package.
//
// SECURITY: LocalCodeAct executes LLM-generated Python in the agent process.
// Only deploy this sample to an externally sandboxed environment such as a
// Foundry hosted-agent container.
using System.ComponentModel;
using Azure.AI.Projects;
using Azure.Core;
using Azure.Identity;
using DotNetEnv;
using Hosted_Shared_Contributor_Setup;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Agents.AI.LocalCodeAct;
using Microsoft.Extensions.AI;
// Load .env file if present (for local development)
Env.TraversePath().Load();
string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-4o";
string pythonExecutable = Environment.GetEnvironmentVariable("LOCAL_CODEACT_PYTHON")
?? (OperatingSystem.IsWindows() ? "python.exe" : "python3");
TokenCredential credential = new ChainedTokenCredential(
new DevTemporaryTokenCredential(),
new DefaultAzureCredential());
// ── Sandbox-only tools (model never sees these directly) ─────────────────────
[Description("Perform a math operation: add, subtract, multiply, or divide.")]
static double Compute(
[Description("Operation: add, subtract, multiply, or divide.")] string operation,
[Description("First numeric operand.")] double a,
[Description("Second numeric operand.")] double b) => operation switch
{
"add" => a + b,
"subtract" => a - b,
"multiply" => a * b,
"divide" => b == 0 ? double.PositiveInfinity : a / b,
_ => throw new ArgumentException($"Unknown operation '{operation}'.", nameof(operation)),
};
[Description("Fetch records from a named simulated table (users or products).")]
static IReadOnlyList<IReadOnlyDictionary<string, object>> FetchData(
[Description("Name of the simulated table to query.")] string table)
{
Dictionary<string, IReadOnlyList<IReadOnlyDictionary<string, object>>> data = new()
{
["users"] =
[
new Dictionary<string, object> { ["id"] = 1, ["name"] = "Alice", ["role"] = "admin" },
new Dictionary<string, object> { ["id"] = 2, ["name"] = "Bob", ["role"] = "user" },
new Dictionary<string, object> { ["id"] = 3, ["name"] = "Charlie", ["role"] = "admin" },
],
["products"] =
[
new Dictionary<string, object> { ["id"] = 101, ["name"] = "Widget", ["price"] = 9.99 },
new Dictionary<string, object> { ["id"] = 102, ["name"] = "Gadget", ["price"] = 19.99 },
],
};
return data.TryGetValue(table, out var rows) ? rows : [];
}
// ── LocalCodeAct provider with sandbox-only host tools ───────────────────────
var codeActOptions = new LocalCodeActProviderOptions
{
Tools =
[
AIFunctionFactory.Create(Compute, name: "compute"),
AIFunctionFactory.Create(FetchData, name: "fetch_data"),
],
ExecutionLimits = new ProcessExecutionLimits { TimeoutSeconds = 5 },
};
var codeAct = new LocalCodeActProvider(pythonExecutable, codeActOptions);
// ── Build the hosted agent ───────────────────────────────────────────────────
AIAgent agent = new AIProjectClient(new Uri(endpoint), credential)
.AsAIAgent(new ChatClientAgentOptions
{
Name = Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-local-codeact",
Description = "Hosted CodeAct agent with sandbox-only compute and fetch_data tools.",
ChatOptions = new ChatOptions
{
ModelId = deploymentName,
Instructions =
"""
You are a helpful assistant. Keep your answers brief. Prefer orchestrating your work
in a single `execute_code` block using `await call_tool(...)` over issuing many
direct tool calls. The sandbox exposes `compute` and `fetch_data` via `call_tool`.
""",
},
AIContextProviders = [codeAct],
});
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddFoundryResponses(agent);
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
var app = builder.Build();
app.MapFoundryResponses();
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses
// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
app.MapDevTemporaryLocalAgentEndpoint();
app.Run();
@@ -1,159 +0,0 @@
# Hosted-LocalCodeAct
A hosted agent that uses [`Microsoft.Agents.AI.LocalCodeAct`](../../../../../src/Microsoft.Agents.AI.LocalCodeAct/README.md)
to give the model a single `execute_code` tool. Two sandbox-only host tools,
`compute` and `fetch_data`, are registered on `LocalCodeActProvider` and are
reachable from inside generated Python via `await call_tool(...)` — never as
direct LLM tool calls.
This mirrors the Python
[`foundry_hosted_agent.py`](https://github.com/microsoft/agent-framework/blob/main/python/packages/local_codeact/samples/foundry_hosted_agent.py)
sample for the `agent-framework-local-codeact` package.
> **⚠️ Security:** LocalCodeAct executes LLM-generated Python in the agent
> process. The package is not a sandbox — it relies on the Foundry hosted-agent
> container (or another externally sandboxed environment) for process,
> filesystem, and network isolation. Do not run this outside of a sandbox.
## Prerequisites
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
- Python 3 available on `PATH` (used by `LocalCodeActProvider` to execute the
embedded runner and validator). Override with the `LOCAL_CODEACT_PYTHON`
environment variable if you need a specific interpreter path.
- A Foundry project with a deployed model (e.g., `gpt-4o`)
- Azure CLI logged in (`az login`)
## Configuration
Copy the template and fill in your project endpoint:
```bash
cp .env.example .env
```
Edit `.env` and set your Foundry project endpoint:
```env
FOUNDRY_PROJECT_ENDPOINT=https://<your-account>.services.ai.azure.com/api/projects/<your-project>
ASPNETCORE_URLS=http://+:8088
ASPNETCORE_ENVIRONMENT=Development
FOUNDRY_MODEL=gpt-4o
LOCAL_CODEACT_PYTHON=python3
```
> **Note:** `.env` is gitignored. The `.env.example` template is checked in as a reference.
## Running directly (contributors)
This project uses `ProjectReference` to build against the local Agent Framework
source, including the `Microsoft.Agents.AI.LocalCodeAct` package.
```bash
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct
AGENT_NAME=hosted-local-codeact dotnet run
```
The agent will start on `http://localhost:8088`.
### Test it
Using the Azure Developer CLI:
```bash
azd ai agent invoke --local "Fetch all users, find the admins, multiply 7 by 6, and print the users, admins, and the multiplication result. Use execute_code with await call_tool(...)."
```
Or with curl:
```bash
curl -X POST http://localhost:8088/responses \
-H "Content-Type: application/json" \
-d '{"input": "Fetch all users, find the admins, multiply 7 by 6, and print the users, admins, and the multiplication result. Use execute_code with await call_tool(...).", "model": "hosted-local-codeact"}'
```
## Running with Docker
Since this project uses `ProjectReference`, use `Dockerfile.contributor` which
takes a pre-published output. The image installs Python 3 so the embedded
runner and validator scripts can execute.
### 1. Publish for the container runtime (Linux Alpine)
```bash
dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
```
### 2. Build the Docker image
```bash
docker build -f Dockerfile.contributor -t hosted-local-codeact .
```
### 3. Run the container
Generate a bearer token on your host and pass it to the container:
```bash
# Generate token (expires in ~1 hour)
export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
# Run with token
docker run --rm -p 8088:8088 \
-e AGENT_NAME=hosted-local-codeact \
-e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \
--env-file .env \
hosted-local-codeact
```
### 4. Test it
```bash
azd ai agent invoke --local "Fetch all users and print the admins."
```
## How CodeAct works here
`LocalCodeActProvider` is registered as an `AIContextProvider`. On every run it
injects:
- A single `execute_code` tool that the model can call with a Python snippet.
- CodeAct instructions that teach the model to use `await call_tool(...)` for
the provider-owned host tools, rather than asking for direct tool calls.
The provider-owned host tools in this sample:
| Tool | Description |
|------|-------------|
| `compute(operation, a, b)` | Math operation: `add`, `subtract`, `multiply`, `divide`. |
| `fetch_data(table)` | Returns rows from a simulated `users` or `products` table. |
`execute_code` runs the generated Python in a separate Python process governed
by `ProcessExecutionLimits` (5 second timeout in this sample) and the
default-on AST allow-list validator that rejects disallowed imports, builtins,
and dynamic-eval constructs before execution.
## Deploying to Foundry (azd spec)
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent
spec (`agent.yaml`) for deployment to Foundry.
Initialize an `azd` project from this sample's manifest:
```bash
mkdir hosted-local-codeact && cd hosted-local-codeact
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/agent.manifest.yaml
```
Then deploy:
```bash
azd deploy
```
## NuGet package users
If you are consuming the Agent Framework as a NuGet package (not building from
source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See
the commented section in `HostedLocalCodeAct.csproj` for the `PackageReference`
alternative.
@@ -1,30 +0,0 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml
name: hosted-local-codeact
displayName: "Hosted Local CodeAct Agent"
description: >
A hosted agent that uses the CodeAct pattern via
Microsoft.Agents.AI.LocalCodeAct. The model only sees an `execute_code`
tool and orchestrates `compute` and `fetch_data` sandbox-only host tools
via `await call_tool(...)` from inside generated Python.
metadata:
tags:
- AI Agent Hosting
- Azure AI AgentServer
- Responses Protocol
- Local CodeAct
- Agent Framework
template:
name: hosted-local-codeact
kind: hosted
protocols:
- protocol: responses
version: 1.0.0
resources:
cpu: "0.5"
memory: 1Gi
parameters:
properties: []
resources: []
@@ -1,9 +0,0 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
kind: hosted
name: hosted-local-codeact
protocols:
- protocol: responses
version: 1.0.0
resources:
cpu: "0.5"
memory: 1Gi
@@ -7,7 +7,7 @@ The agent specializes in finding hotels in Seattle, with a `GetAvailableHotels`
## Prerequisites
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
- A Foundry project with a deployed model (e.g., `gpt-4o`)
- An Azure AI Foundry project with a deployed model (e.g., `gpt-4o`)
- Azure CLI logged in (`az login`)
## Configuration
@@ -18,7 +18,7 @@ Copy the template and fill in your project endpoint:
cp .env.example .env
```
Edit `.env` and set your Foundry project endpoint:
Edit `.env` and set your Azure AI Foundry project endpoint:
```env
FOUNDRY_PROJECT_ENDPOINT=https://<your-account>.services.ai.azure.com/api/projects/<your-project>
@@ -19,7 +19,7 @@ A hosted agent demonstrating **two layers of MCP (Model Context Protocol) tool i
## Prerequisites
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
- A Foundry project with a deployed model (e.g., `gpt-4o`)
- An Azure AI Foundry project with a deployed model (e.g., `gpt-4o`)
- Azure CLI logged in (`az login`)
## Configuration
@@ -17,7 +17,7 @@ This sample exists to demonstrate two things together:
## Prerequisites
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
- A Foundry project with at least one chat model deployment and one embedding model deployment
- An Azure AI Foundry project with at least one chat model deployment and one embedding model deployment
- Azure CLI logged in (`az login`)
## Configuration
@@ -27,7 +27,7 @@ Foundry injects `APPLICATIONINSIGHTS_CONNECTION_STRING` when the agent runs in t
## Prerequisites
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
- A Foundry project with a deployed model (e.g., `gpt-4o`)
- An Azure AI Foundry project with a deployed model (e.g., `gpt-4o`)
- Azure CLI logged in (`az login`)
## Configuration
@@ -36,7 +36,7 @@ Foundry injects `APPLICATIONINSIGHTS_CONNECTION_STRING` when the agent runs in t
cp .env.example .env
```
Edit `.env` and set your Foundry project endpoint:
Edit `.env` and set your Azure AI Foundry project endpoint:
```env
FOUNDRY_PROJECT_ENDPOINT=https://<your-account>.services.ai.azure.com/api/projects/<your-project>
@@ -7,7 +7,7 @@ This sample demonstrates how to add knowledge grounding to a hosted agent withou
## Prerequisites
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
- A Foundry project with a deployed model (e.g., `gpt-4o`)
- An Azure AI Foundry project with a deployed model (e.g., `gpt-4o`)
- Azure CLI logged in (`az login`)
## Configuration
@@ -18,7 +18,7 @@ Copy the template and fill in your project endpoint:
cp .env.example .env
```
Edit `.env` and set your Foundry project endpoint:
Edit `.env` and set your Azure AI Foundry project endpoint:
```env
FOUNDRY_PROJECT_ENDPOINT=https://<your-account>.services.ai.azure.com/api/projects/<your-project>
@@ -1,4 +1,4 @@
# Foundry project endpoint (auto-injected in hosted containers).
# Azure AI Foundry project endpoint (auto-injected in hosted containers).
AZURE_AI_PROJECT_ENDPOINT=https://<your-foundry-account>.services.ai.azure.com/api/projects/<your-project>
# Model deployment name. Must exist in the Foundry project above.
@@ -11,7 +11,7 @@
//
// Required environment variables:
// AZURE_AI_PROJECT_ENDPOINT (local-dev) OR FOUNDRY_PROJECT_ENDPOINT (hosted runtime)
// - Foundry project endpoint. The Foundry hosted
// - Azure AI Foundry project endpoint. The Foundry hosted
// runtime auto-injects FOUNDRY_PROJECT_ENDPOINT; locally
// set AZURE_AI_PROJECT_ENDPOINT (the AF-repo convention).
// TOOLBOX_NAME - Name of the Foundry Toolbox to load
@@ -1,16 +0,0 @@
# Foundry project endpoint (auto-injected in hosted containers).
AZURE_AI_PROJECT_ENDPOINT=https://<your-foundry-account>.services.ai.azure.com/api/projects/<your-project>
# Model deployment name. Must exist in the Foundry project above.
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
# Name of the Foundry Toolbox you provisioned in the portal (see README.md).
TOOLBOX_NAME=my-toolset
# Agent name advertised over the wire. Must be unique if running side-by-side with
# other Hosted-* samples (e.g. Hosted-Toolbox-AuthPaths), otherwise the REPL client
# cannot disambiguate which agent to chat with.
AGENT_NAME=hosted-toolbox-agent
# Application Insights connection string (auto-injected in hosted containers; optional locally).
# APPLICATIONINSIGHTS_CONNECTION_STRING=InstrumentationKey=...
@@ -1,17 +0,0 @@
# Use the official .NET 10.0 ASP.NET runtime as a parent image
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
WORKDIR /app
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet restore
RUN dotnet publish -c Release -o /app/publish
# Final stage
FROM base AS final
WORKDIR /app
COPY --from=build /app/publish .
EXPOSE 8088
ENV ASPNETCORE_URLS=http://+:8088
ENTRYPOINT ["dotnet", "HostedToolbox.dll"]
@@ -1,21 +0,0 @@
# Dockerfile for contributors building from the agent-framework repository source.
#
# This project uses ProjectReference to the local source, which means a standard
# multi-stage Docker build cannot resolve dependencies outside this folder.
# Pre-publish the app targeting the container runtime and copy the output:
#
# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
# docker build -f Dockerfile.contributor -t hosted-toolbox .
# docker run --rm -p 8088:8088 \
# -e AGENT_NAME=hosted-toolbox-agent \
# -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \
# --env-file .env hosted-toolbox
#
# For end-users consuming the NuGet package (not ProjectReference), use the standard
# Dockerfile which performs a full dotnet restore + publish inside the container.
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
WORKDIR /app
COPY out/ .
EXPOSE 8088
ENV ASPNETCORE_URLS=http://+:8088
ENTRYPOINT ["dotnet", "HostedToolbox.dll"]
@@ -7,19 +7,20 @@
//
// Required environment variables:
// FOUNDRY_PROJECT_ENDPOINT (hosted runtime) OR AZURE_AI_PROJECT_ENDPOINT (local-dev)
// - Foundry project endpoint. The Foundry hosted
// - Azure AI Foundry project endpoint. The Foundry hosted
// runtime auto-injects FOUNDRY_PROJECT_ENDPOINT; locally
// set AZURE_AI_PROJECT_ENDPOINT.
// FOUNDRY_MODEL - Model deployment name (default: gpt-4o)
//
// Optional:
// FOUNDRY_MODEL (or AZURE_AI_MODEL_DEPLOYMENT_NAME)
// - Model deployment name (default: gpt-4o)
// TOOLBOX_NAME - Name of the toolbox to load (default: my-toolset).
// NOTE: All FOUNDRY_* and AGENT_* env-var prefixes (other
// than the platform-injected ones above) are reserved by the
// Foundry container platform and rejected at agent-create.
// Use TOOLBOX_NAME, not FOUNDRY_TOOLBOX_NAME, for the
// sample-owned toolbox name so it survives deployment.
// FOUNDRY_TOOLBOX_NAME - Name of the toolbox to load (default: my-toolset)
// FOUNDRY_AGENT_TOOLSET_ENDPOINT - Foundry Toolsets proxy base URL
// (injected automatically by Foundry platform at runtime)
// FOUNDRY_AGENT_NAME - Client name reported to MCP server (auto-injected in hosted runtime)
// FOUNDRY_AGENT_VERSION - Client version reported to MCP server (auto-injected in hosted runtime)
// FOUNDRY_AGENT_TOOLSET_FEATURES - Additional Foundry-Features header flags (the mandatory
// Toolboxes=V1Preview flag is always sent; this env var
// appends additional flags if present).
//
// The Foundry.Hosting package builds the toolbox proxy URL from FOUNDRY_PROJECT_ENDPOINT
// per tools-integration-spec.md §2–§3.
@@ -42,7 +43,8 @@ string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
"nor AZURE_AI_PROJECT_ENDPOINT (local-dev convention) is set.");
string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL")
?? Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o";
string toolboxName = Environment.GetEnvironmentVariable("TOOLBOX_NAME") ?? "my-toolset";
string toolboxName = Environment.GetEnvironmentVariable("FOUNDRY_TOOLBOX_NAME")
?? Environment.GetEnvironmentVariable("TOOLBOX_NAME") ?? "my-toolset";
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
@@ -1,111 +1,27 @@
# Hosted-Toolbox
A hosted Foundry agent that loads tools from a single Foundry Toolbox via the AF Foundry hosting bridge.
A hosted Foundry agent that loads tools from a Foundry Toolbox via the AF Foundry hosting bridge.
`AddFoundryToolboxes(name)` registers a `FoundryToolboxService` that connects to the Foundry Toolboxes MCP proxy at startup, discovers the toolbox's bundled tools via `tools/list`, and makes them available to the agent on every request. The agent code does nothing per request; the toolbox is baked in on the server.
This is the minimal toolbox intro. For a richer walkthrough where a single toolbox bundles three MCP tools each authenticated differently, see [`Hosted-Toolbox-AuthPaths/`](../Hosted-Toolbox-AuthPaths/).
The agent declares one `FoundryAITool.CreateHostedMcpToolbox(name)` marker; `AddFoundryToolboxes(name)` registers a `FoundryToolboxService` that resolves the marker into the individual MCP tools the toolbox bundles, connecting to the Foundry Toolboxes MCP proxy at startup and discovering tools via `tools/list`.
## Prerequisites
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
- A Foundry project with a deployed model (e.g., `gpt-4o`) and a Toolbox configured
- Azure CLI logged in (`az login`)
## Configuration
Copy the template and fill in your values:
```powershell
Copy-Item .env.example .env
```
Edit `.env`:
```env
AZURE_AI_PROJECT_ENDPOINT=https://<your-account>.services.ai.azure.com/api/projects/<your-project>
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
TOOLBOX_NAME=my-toolset
```
Configuration notes:
- `AZURE_AI_PROJECT_ENDPOINT` (local-dev) or `FOUNDRY_PROJECT_ENDPOINT` (auto-injected in hosted containers).
- `AZURE_AI_MODEL_DEPLOYMENT_NAME` (default `gpt-4o`).
- `TOOLBOX_NAME` (default `my-toolset`). Use `TOOLBOX_NAME`, not `FOUNDRY_TOOLBOX_NAME`: all `FOUNDRY_*` env-var names are reserved by the Foundry platform and rejected at agent-create, so a `FOUNDRY_*`-named value would not survive deployment.
- A Microsoft Foundry project with a Toolbox configured.
- Azure CLI logged in (`az login`).
- Set environment variables:
- `AZURE_AI_PROJECT_ENDPOINT` (local-dev) or `FOUNDRY_PROJECT_ENDPOINT` (auto-injected in hosted containers)
- `AZURE_AI_MODEL_DEPLOYMENT_NAME` (default `gpt-4o`)
- `TOOLBOX_NAME` (default `my-toolbox`)
The `Foundry.Hosting` package builds the toolbox proxy URL from `FOUNDRY_PROJECT_ENDPOINT` as `{FOUNDRY_PROJECT_ENDPOINT}/toolboxes/{TOOLBOX_NAME}/mcp?api-version=v1` per [`tools-integration-spec.md`](https://github.com/microsoft/AgentSchema/blob/main/specs/agents/hosted_agents/container-spec/docs/tools-integration-spec.md) §2–§3.
## Running directly (contributors)
## Run
```powershell
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox
dotnet run --tl:off
```
### Test it
Using the Azure Developer CLI:
```powershell
azd ai agent invoke --local "What tools do you have available, and what can they do?"
```
## Running with Docker
### 1. Publish for the container runtime
```powershell
dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
```
### 2. Build and run
```powershell
docker build -f Dockerfile.contributor -t hosted-toolbox .
$env:AZURE_BEARER_TOKEN = (az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
docker run --rm -p 8088:8088 `
-e AGENT_NAME=hosted-toolbox-agent `
-e AZURE_BEARER_TOKEN=$env:AZURE_BEARER_TOKEN `
--env-file .env `
hosted-toolbox
```
## Deploying to Foundry (azd spec)
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
Initialize an `azd` project from this sample's manifest:
```powershell
mkdir hosted-toolbox; cd hosted-toolbox
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/agent.manifest.yaml
```
Then deploy:
```powershell
azd deploy
```
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
```powershell
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
azd env set TOOLBOX_NAME my-toolset
```
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
---
## NuGet package users
Use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedToolbox.csproj` for the `PackageReference` alternative.
## Related samples
- [`Hosted-Toolbox-AuthPaths/`](../Hosted-Toolbox-AuthPaths/) — same hosting bones as this sample, but the toolbox bundles three MCP tools each authenticated differently (key, Entra agent identity, inline `Authorization`), driven by the shared `Using-Samples/SimpleAgent/` REPL.
- [`Hosted-Toolbox-AuthPaths/`](../Hosted-Toolbox-AuthPaths/) — extends this pattern with a three-tool toolbox demonstrating different MCP-tool authentication paths (key, Entra agent identity, inline `Authorization`), driven by the shared `Using-Samples/SimpleAgent/` REPL.
- [`Hosted-McpTools/`](../Hosted-McpTools/) — contrasts client-side `McpClient` vs server-side `HostedMcpServerTool` for non-toolbox MCP servers.
@@ -1,47 +0,0 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml
name: hosted-toolbox
displayName: "Hosted Toolbox"
description: >
A hosted agent that loads its tools from a single Foundry Toolbox via the
AF Foundry hosting bridge. AddFoundryToolboxes(name) connects to the Foundry
Toolboxes MCP proxy at startup and exposes the toolbox's bundled tools to the
agent on every request. The toolbox itself is provisioned out of band; see this
sample's README for the portal walkthrough.
metadata:
tags:
- AI Agent Hosting
- Azure AI AgentServer
- Responses Protocol
- Agent Framework
- Foundry Toolbox
- MCP
template:
name: hosted-toolbox
kind: hosted
protocols:
- protocol: responses
version: 1.0.0
resources:
cpu: "0.25"
memory: 0.5Gi
environment_variables:
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}"
- name: TOOLBOX_NAME
value: "{{TOOLBOX_NAME}}"
parameters:
properties:
- name: TOOLBOX_NAME
type: string
default: "my-toolset"
description: "Name of the Foundry Toolbox to load at runtime."
resources:
- kind: model
id: gpt-4o
name: AZURE_AI_MODEL_DEPLOYMENT_NAME
- kind: toolbox
name: "{{TOOLBOX_NAME}}"
tools: []
@@ -1,9 +0,0 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
kind: hosted
name: hosted-toolbox
protocols:
- protocol: responses
version: 1.0.0
resources:
cpu: "0.25"
memory: 0.5Gi
@@ -2,5 +2,5 @@ FOUNDRY_PROJECT_ENDPOINT=<your-azure-ai-project-endpoint>
ASPNETCORE_URLS=http://+:8088
ASPNETCORE_ENVIRONMENT=Development
FOUNDRY_MODEL=gpt-5
TOOLBOX_NAME=<your-toolbox-name>
FOUNDRY_TOOLBOX_NAME=<your-toolbox-name>
AZURE_BEARER_TOKEN=DefaultAzureCredential
@@ -7,16 +7,9 @@
// AgentSkillsProviderBuilder.UseMcpSkills().
//
// Required environment variables:
// FOUNDRY_PROJECT_ENDPOINT - Foundry project endpoint
// TOOLBOX_NAME - Name of the Foundry Toolbox to connect to
//
// Optional:
// FOUNDRY_MODEL - Model deployment name (default: gpt-5)
//
// NOTE: All FOUNDRY_* and AGENT_* env-var prefixes (other than the platform-injected ones
// listed above) are reserved by the Foundry container platform and rejected at agent-create.
// Use TOOLBOX_NAME, not FOUNDRY_TOOLBOX_NAME, for the sample-owned toolbox name so it
// survives deployment.
// FOUNDRY_PROJECT_ENDPOINT - Azure AI Foundry project endpoint
// FOUNDRY_TOOLBOX_NAME - Name of the Foundry Toolbox to connect to
// FOUNDRY_MODEL - Model deployment name (default: gpt-5)
using System.Net.Http.Headers;
using Azure.AI.Projects;
@@ -34,8 +27,8 @@ Env.TraversePath().Load();
var projectEndpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
var deployment = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5";
var toolboxName = Environment.GetEnvironmentVariable("TOOLBOX_NAME")
?? throw new InvalidOperationException("TOOLBOX_NAME is not set.");
var toolboxName = Environment.GetEnvironmentVariable("FOUNDRY_TOOLBOX_NAME")
?? throw new InvalidOperationException("FOUNDRY_TOOLBOX_NAME is not set.");
// Build the Toolbox MCP URL from the project endpoint and toolbox name.
var toolboxMcpServerUrl = $"{projectEndpoint.TrimEnd('/')}/toolboxes/{toolboxName}/mcp?api-version=v1";
@@ -13,7 +13,7 @@ This way the full skill body and resources are only loaded when the agent actual
## Prerequisites
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
- A Foundry project with a deployed model (e.g., `gpt-5`)
- An Azure AI Foundry project with a deployed model (e.g., `gpt-5`)
- A Foundry Toolbox already configured with skills provisioned
- Azure CLI logged in (`az login`)
@@ -25,14 +25,14 @@ Copy the template and fill in your values:
cp .env.example .env
```
Edit `.env` and set your Foundry project endpoint and toolbox name:
Edit `.env` and set your Azure AI Foundry project endpoint and toolbox name:
```env
FOUNDRY_PROJECT_ENDPOINT=https://<your-account>.services.ai.azure.com/api/projects/<your-project>
ASPNETCORE_URLS=http://+:8088
ASPNETCORE_ENVIRONMENT=Development
FOUNDRY_MODEL=gpt-5
TOOLBOX_NAME=my-toolbox
FOUNDRY_TOOLBOX_NAME=my-toolbox
```
> **Note:** `.env` is gitignored. The `.env.example` template is checked in as a reference.
@@ -30,11 +30,11 @@ template:
environment_variables:
- name: FOUNDRY_MODEL
value: "{{FOUNDRY_MODEL}}"
- name: TOOLBOX_NAME
value: "{{TOOLBOX_NAME}}"
- name: FOUNDRY_TOOLBOX_NAME
value: "{{FOUNDRY_TOOLBOX_NAME}}"
parameters:
properties:
- name: TOOLBOX_NAME
- name: FOUNDRY_TOOLBOX_NAME
secret: false
description: Name of the Foundry Toolbox to connect to for MCP skill discovery
resources:
@@ -10,5 +10,5 @@ resources:
environment_variables:
- name: FOUNDRY_MODEL
value: ${FOUNDRY_MODEL}
- name: TOOLBOX_NAME
value: ${TOOLBOX_NAME}
- name: FOUNDRY_TOOLBOX_NAME
value: ${FOUNDRY_TOOLBOX_NAME}
@@ -7,7 +7,7 @@ A hosted agent server demonstrating two patterns in a single app:
Both agents are served over the Responses protocol. The server also exposes interactive web demos at `/tool-demo` and `/workflow-demo`.
> Unlike the other samples in this folder, this one connects to an **Azure OpenAI** resource directly (not a Foundry project endpoint).
> Unlike the other samples in this folder, this one connects to an **Azure OpenAI** resource directly (not an Azure AI Foundry project endpoint).
## Prerequisites
@@ -5,7 +5,7 @@ A hosted agent that demonstrates **multi-agent workflow orchestration**. Three t
## Prerequisites
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
- A Foundry project with a deployed model (e.g., `hosted-workflow-simple`)
- An Azure AI Foundry project with a deployed model (e.g., `hosted-workflow-simple`)
- Azure CLI logged in (`az login`)
## Configuration
@@ -16,7 +16,7 @@ Copy the template and fill in your project endpoint:
cp .env.example .env
```
Edit `.env` and set your Foundry project endpoint:
Edit `.env` and set your Azure AI Foundry project endpoint:
```env
FOUNDRY_PROJECT_ENDPOINT=https://<your-account>.services.ai.azure.com/api/projects/<your-project>
@@ -1,51 +0,0 @@
# Using-Samples — client REPLs for the hosted agents
This folder holds small **client** console apps that connect to the **server** samples in the
sibling `Hosted-*` folders. Each `Hosted-*` project is an agent you host (locally with
`dotnet run` or deployed to Foundry); the projects here are the thing that *talks* to them.
## Why these exist
A hosted Foundry agent is an HTTP server, not a chat UI. It exposes only the per-agent OpenAI
endpoint shape that the platform routes to:
```
{FOUNDRY_PROJECT_ENDPOINT}/agents/{AZURE_AI_AGENT_NAME}/endpoint/protocols/openai
```
There is no built-in console to poke it with. To actually exercise an agent — send a prompt,
watch it call its tools, read the streamed answer — you need a client that builds a
`FoundryAgent` against that endpoint and drives a conversation. That is all these REPLs do:
1. Read `FOUNDRY_PROJECT_ENDPOINT` + `AZURE_AI_AGENT_NAME` from the environment.
2. Derive the per-agent OpenAI endpoint URL.
3. `AIProjectClient(...).AsAIAgent(agentEndpoint)``FoundryAgent`.
4. Loop: read a line, `RunStreamingAsync`, print the streamed reply.
The client is deliberately dumb. It knows nothing about tools, files, toolboxes, or auth — all
of that is the hosted agent's concern on the server side. Swapping which agent you chat with is
just a matter of changing `AZURE_AI_AGENT_NAME`.
## Local HTTP dev
When the target is a local `http://localhost:8088` dev server, the REPLs install a small
`HttpSchemeRewritePolicy`: `AIProjectClient`/`BearerTokenPolicy` require HTTPS, so the client
presents the endpoint as `https://` to satisfy the TLS check, then rewrites the scheme back to
`http://` right before the request hits the wire. This is local-development only.
## The clients
| Client | What it targets | Notes |
|---|---|---|
| [`SimpleAgent/`](./SimpleAgent/) | Any hosted agent | Generic, agent-agnostic REPL. Point it at any `Hosted-*` server via `AZURE_AI_AGENT_NAME`. Used by `Hosted-Toolbox`, `Hosted-Toolbox-AuthPaths`, and `Hosted-McpTools`. |
| [`SessionFilesClient/`](./SessionFilesClient/) | [`Hosted-Files`](../Hosted-Files/) | Same shape as `SimpleAgent`, framed around the bundled-files demo. |
## Configuration (common to all clients)
```env
FOUNDRY_PROJECT_ENDPOINT=https://<host>/api/projects/<project>
AZURE_AI_AGENT_NAME=<registered-server-side-agent-name>
```
Both are required. Authenticate with `az login` before running. See each client's own README for
its end-to-end walkthrough.
@@ -1,63 +0,0 @@
# SimpleAgent
A generic, agent-agnostic chat REPL for any hosted Foundry agent. Point it at a running
`Hosted-*` agent via `AZURE_AI_AGENT_NAME`, and it builds a `FoundryAgent` against that agent's
per-agent OpenAI endpoint and streams replies. This is the shared client that `Hosted-Toolbox`,
`Hosted-Toolbox-AuthPaths`, and `Hosted-McpTools` reference for their end-to-end demos.
It knows nothing about the agent's tools, toolboxes, files, or auth — those are entirely the
server's concern. Changing which agent you chat with is just a different `AZURE_AI_AGENT_NAME`.
See [`../README.md`](../README.md) for why these client REPLs exist at all.
## Prerequisites
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
- A running hosted agent (any `Hosted-*` sample, locally via `dotnet run` or deployed to Foundry)
- Azure CLI logged in (`az login`)
## Configuration
```env
FOUNDRY_PROJECT_ENDPOINT=https://<host>/api/projects/<project>
AZURE_AI_AGENT_NAME=<registered-server-side-agent-name>
```
Both are required. `FOUNDRY_PROJECT_ENDPOINT` is the Foundry project endpoint URL and
`AZURE_AI_AGENT_NAME` is the registered server-side agent name. The sample builds the per-agent
OpenAI endpoint URL (`{FOUNDRY_PROJECT_ENDPOINT}/agents/{AZURE_AI_AGENT_NAME}/endpoint/protocols/openai`)
from these.
## Run
Against a local Hosted-Toolbox agent listening on `http://localhost:8088`:
```powershell
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent
$env:FOUNDRY_PROJECT_ENDPOINT = "http://localhost:8088/api/projects/local"
$env:AZURE_AI_AGENT_NAME = "hosted-toolbox-agent"
dotnet run
```
When the project endpoint is `http://`, the client presents it as `https://` to satisfy the
bearer-token TLS check, then rewrites the scheme back to `http://` right before transport
(local-development only).
## End-to-end demo
With a hosted agent running:
```text
══════════════════════════════════════════════════════════
Simple Agent Sample
Connected to: https://localhost:8088/api/projects/local/agents/hosted-toolbox-agent/endpoint/protocols/openai
Type a message or 'quit' to exit
══════════════════════════════════════════════════════════
You> What tools do you have available, and what can they do?
Agent> I have the following tools from the toolbox: ...
You> quit
Goodbye!
```
The client only sent a chat prompt; the agent resolved its toolbox tools server-side and answered.
@@ -101,15 +101,10 @@ else
throw new ArgumentException("Either A2AServer:ApiKey or A2AServer:ConnectionString & agentName must be provided");
}
// IMPORTANT: In production, register a SessionIsolationKeyProvider to isolate sessions by authenticated caller.
// Without this, contextId alone is the session key — any caller who knows a contextId can access that session.
// Example using claims-based identity:
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
// if using Claims-based Identity for Authentication/Authorization
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// By default, NoopAgentSessionStore is used — sessions are not persisted across requests.
// To enable multi-turn conversations, register a session store explicitly, e.g.:
// builder.Services.AddKeyedSingleton<AgentSessionStore>(hostA2AAgent.Name, new InMemoryAgentSessionStore());
builder.AddA2AServer(hostA2AAgent);
var app = builder.Build();
@@ -28,15 +28,10 @@ builder.AddDevUI();
builder.AddOpenAIChatCompletions();
builder.AddOpenAIResponses();
// IMPORTANT: In production, register a SessionIsolationKeyProvider to isolate sessions by authenticated caller.
// Without this, contextId alone is the session key — any caller who knows a contextId can access that session.
// Example using claims-based identity:
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
// if using Claims-based Identity for Authentication/Authorization
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// By default, NoopAgentSessionStore is used — sessions are not persisted across requests.
// To enable multi-turn conversations, register a session store explicitly, e.g.:
// agentBuilder.WithInMemorySessionStore();
var pirateAgentBuilder = builder.AddAIAgent(
"pirate",
instructions: "You are a pirate. Speak like a pirate",
@@ -157,9 +152,8 @@ builder.Services.AddKeyedSingleton<AIAgent>("my-di-matchingname-agent", (sp, nam
pirateAgentBuilder.AddA2AServer();
knightsKnavesAgentBuilder.AddA2AServer();
// IMPORTANT: In production, register a SessionIsolationKeyProvider to isolate sessions by authenticated caller.
// Without this, contextId alone is the session key — any caller who knows a contextId can access that session.
// Example using claims-based identity:
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
// if using Claims-based Identity for Authentication/Authorization
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
var app = builder.Build();
@@ -1,15 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
</ItemGroup>
</Project>
@@ -1,141 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample evaluates a pre-existing Azure AI Foundry agent against a rubric evaluator
// that was authored in the Foundry portal.
//
// Rubric evaluators are LLM-as-judge evaluators with custom scoring dimensions you define
// for your domain. agent-framework consumes pre-existing rubric evaluators — they are
// authored in the Foundry portal (or via the dedicated SDK / REST surface) and referenced
// here by name and version.
//
// Prerequisites:
// - An Azure AI Foundry project with a deployed model.
// - A registered Foundry agent in that project (the rubric was created against this agent).
// - A rubric evaluator already created in the Foundry portal.
// - .env (or environment) populated with the FOUNDRY_* variables below.
//
// IMPORTANT: FOUNDRY_PROJECT_ENDPOINT must be the project-scoped URL
// https://<resource>.services.ai.azure.com/api/projects/<project>
// A bare Azure OpenAI endpoint silently fails eval submission with HTTP 500.
using Azure.AI.Projects;
using Azure.AI.Projects.Agents;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry;
using FoundryEvals = Microsoft.Agents.AI.Foundry.FoundryEvals;
string projectEndpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
string model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL")
?? throw new InvalidOperationException("FOUNDRY_MODEL is not set.");
string agentName = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_NAME")
?? throw new InvalidOperationException("FOUNDRY_AGENT_NAME is not set.");
string? agentVersion = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_VERSION");
string rubricName = Environment.GetEnvironmentVariable("FOUNDRY_RUBRIC_NAME")
?? throw new InvalidOperationException("FOUNDRY_RUBRIC_NAME is not set.");
string? rubricVersion = Environment.GetEnvironmentVariable("FOUNDRY_RUBRIC_VERSION");
// WARNING: DefaultAzureCredential is convenient for development but requires careful
// consideration in production. Prefer ManagedIdentityCredential (or a specific credential)
// to avoid latency, unintended credential probing, and fallback security risks.
AIProjectClient projectClient = new(new Uri(projectEndpoint), new DefaultAzureCredential());
// 1. Connect to the pre-existing Foundry agent the rubric was created against.
FoundryAgent agent;
if (agentVersion is null)
{
ProjectsAgentRecord agentRecord = await projectClient.AgentAdministrationClient.GetAgentAsync(agentName);
agent = projectClient.AsAIAgent(agentRecord);
}
else
{
ProjectsAgentVersion versionRecord = await projectClient.AgentAdministrationClient.GetAgentVersionAsync(agentName, agentVersion);
agent = projectClient.AsAIAgent(versionRecord);
}
// 2. Reference the pre-existing rubric evaluator by name + version.
// Always pin a version for reproducible CI runs; a versionless ref resolves to the
// current version at run time and emits a Trace.TraceWarning on each criterion build.
GeneratedEvaluatorRef rubric = rubricVersion is null
? GeneratedEvaluatorRef.Latest(rubricName)
: new GeneratedEvaluatorRef(rubricName, rubricVersion);
// 3. Mix the rubric with built-in evaluators in a single FoundryEvals config.
// The implicit conversion lets you pass strings and refs interchangeably.
FoundryEvals evals = new(
projectClient,
model,
rubric,
FoundryEvals.Relevance,
FoundryEvals.Coherence);
// 4. Run two example queries against the agent and evaluate the outputs in one call.
string[] queries =
[
"What's the weather like in Seattle?",
"Should I bring an umbrella to London tomorrow?",
];
Console.WriteLine(new string('=', 60));
Console.WriteLine($"Evaluating '{agent.Name}' with rubric '{rubricName}' (version {rubricVersion ?? "latest"})");
Console.WriteLine(new string('=', 60));
AgentEvaluationResults results = await agent.EvaluateAsync(queries, evals);
Console.WriteLine($"Status: {results.Status}");
Console.WriteLine($"Results: {results.Passed}/{results.Total} passed");
if (results.ReportUrl is not null)
{
Console.WriteLine($"Portal: {results.ReportUrl}");
}
Console.WriteLine(results.Passed == results.Total ? "[PASS] All passed" : $"[FAIL] {results.Failed} failed");
// 5. Print per-dimension breakdown for each evaluated item — this is the unique value
// of a rubric evaluator over the built-in numeric ones.
Console.WriteLine();
Console.WriteLine(new string('=', 60));
Console.WriteLine("Per-dimension scores");
Console.WriteLine(new string('=', 60));
if (results.DetailedItems is { Count: > 0 })
{
for (int i = 0; i < results.DetailedItems.Count; i++)
{
EvalItemResult item = results.DetailedItems[i];
Console.WriteLine($"Item {i + 1}{(i < queries.Length ? $" \"{queries[i]}\"" : string.Empty)}");
foreach (EvalScoreResult score in item.Scores)
{
Console.WriteLine($" {score.Name}: {score.Score:F1}{(score.Passed is bool p ? (p ? " (pass)" : " (fail)") : string.Empty)}");
if (score.Dimensions is { Count: > 0 } dims)
{
foreach (RubricScore d in dims)
{
string scoreStr = d.Score is int s ? s.ToString() : "n/a";
Console.WriteLine($" - {d.Id}: {scoreStr} (weight={d.Weight}, applicable={d.Applicable})");
}
}
}
Console.WriteLine();
}
}
// 6. CI quality gate — fail the build if a critical dimension drops below threshold.
// Replace "general_quality" with whatever dimension id your rubric actually defines.
Console.WriteLine(new string('=', 60));
Console.WriteLine("Per-dimension quality gate");
Console.WriteLine(new string('=', 60));
try
{
results.AssertDimensionScoreAtLeast("general_quality", minScore: 3.0, evaluator: rubricName, requireApplicable: true);
Console.WriteLine($"[PASS] {results.ProviderName}: general_quality >= 3 on every item");
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"[FAIL] {results.ProviderName}: dimension gate tripped: {ex.Message}");
System.Environment.ExitCode = 1;
}
@@ -1,55 +0,0 @@
# Evaluation — Foundry Rubric
This sample evaluates a pre-existing Azure AI Foundry agent against a **rubric evaluator**
authored in the Foundry portal. Rubric evaluators are LLM-as-judge evaluators with custom
scoring dimensions you define for your domain; agent-framework references them by name and
version, mixes them with built-in evaluators, and exposes per-dimension scores you can gate
CI on.
## What this sample demonstrates
- Connecting to a pre-existing Foundry agent (`AgentAdministrationClient.GetAgentAsync`).
- Referencing a pre-existing rubric evaluator via `GeneratedEvaluatorRef(name, version)`.
- Mixing the rubric with built-in evaluators (`Relevance`, `Coherence`) in one
`FoundryEvals` run.
- Reading per-dimension breakdowns from `EvalScoreResult.Dimensions`.
- Gating CI on a per-dimension threshold via
`AgentEvaluationResults.AssertDimensionScoreAtLeast(...)`.
## Prerequisites
- .NET 10 SDK or later.
- Azure CLI installed and authenticated (`az login`).
- An Azure AI Foundry project with a deployed model.
- A registered Foundry agent in that project (the agent the rubric was created against).
- A rubric evaluator created in the Foundry portal. Creating rubrics through the portal
currently requires picking a Foundry agent as the generation context, so this
prerequisite is implied by having a rubric at all.
> [!IMPORTANT]
> `FOUNDRY_PROJECT_ENDPOINT` **must** be the project-scoped URL
> `https://<resource>.services.ai.azure.com/api/projects/<project>`. A bare Azure OpenAI
> endpoint silently fails eval submission with HTTP 500.
> [!NOTE]
> An **Eval Definition** (a saved bundle of testing_criteria with `"object": "eval"`) is
> not the same as a **Rubric Evaluator** (a standalone evaluator with dimensions, weights,
> and a version). `GeneratedEvaluatorRef` points at the latter.
## Environment variables
```powershell
$env:FOUNDRY_PROJECT_ENDPOINT="https://your-resource.services.ai.azure.com/api/projects/your-project"
$env:FOUNDRY_MODEL="gpt-4o-mini"
$env:FOUNDRY_AGENT_NAME="your-agent-name"
$env:FOUNDRY_AGENT_VERSION="1" # optional; omit for latest
$env:FOUNDRY_RUBRIC_NAME="your-rubric-name"
$env:FOUNDRY_RUBRIC_VERSION="1" # optional; omit for latest (CI: pin this)
```
## Run the sample
```powershell
cd dotnet/samples/05-end-to-end/Evaluation
dotnet run --project .\Evaluation_FoundryRubric
```
+13 -14
View File
@@ -80,35 +80,33 @@ dotnet/samples/
## Default provider
All canonical samples (01-get-started) use **Microsoft Foundry** via `AIProjectClient.AsAIAgent()` with `DefaultAzureCredential`:
All canonical samples (01-get-started) use **Azure OpenAI** via `AzureOpenAIClient`
with `DefaultAzureCredential`:
```csharp
using Azure.AI.Projects;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using OpenAI.Chat;
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
var model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")
?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
AIAgent agent = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential())
.AsAIAgent(model: model, instructions: "...", name: "...");
AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential())
.GetChatClient(deploymentName)
.AsAIAgent(instructions: "...", name: "...");
```
Environment variables:
- `FOUNDRY_PROJECT_ENDPOINT` — Your Foundry project endpoint
- `FOUNDRY_MODEL` — Model name (defaults to `gpt-5.4-mini`)
- `AZURE_OPENAI_ENDPOINT` — Your Azure OpenAI endpoint
- `AZURE_OPENAI_DEPLOYMENT_NAME` — Model deployment name (defaults to `gpt-5.4-mini`)
For authentication, run `az login` before running samples.
**Note:** Use `FoundryAgent` only when demonstrating Foundry-managed (prompt) agents specifically — see `02-agents/AgentsWithFoundry/`. For all other samples, use `AIProjectClient.AsAIAgent()`.
**Note:** For samples demonstrating other providers (Azure OpenAI, OpenAI, Anthropic, etc.), see `02-agents/AgentProviders/`.
## Snippet tags for docs integration
Samples embed named snippet regions for future `:::code` integration:
@@ -137,3 +135,4 @@ dotnet run
- Azure Functions hosting uses `ConfigureDurableAgents(options => options.AddAIAgent(agent))`
- Workflows use `WorkflowBuilder` with `Executor<TIn, TOut>` and edge connections
@@ -2,7 +2,6 @@
## [Unreleased]
- Bind MCP threadId to the current agent and guard cross-agent session dispatch ([#6531](https://github.com/microsoft/agent-framework/pull/6531))
- Added support for durable workflows ([#4436](https://github.com/microsoft/agent-framework/pull/4436))
## v1.0.0-preview.260219.1
@@ -79,15 +79,6 @@ internal class DurableAIAgentProxy(string name, IDurableAgentClient agentClient)
RunRequest request = new([.. messages], responseFormat, enableToolCalls, enableToolNames);
AgentSessionId sessionId = durableSession.SessionId;
// The session must belong to this agent.
if (!string.Equals(sessionId.Name, this.Name, StringComparison.OrdinalIgnoreCase))
{
throw new ArgumentException(
$"The provided session belongs to agent '{sessionId.Name}' but was passed to agent '{this.Name}'. " +
"Sessions cannot be reused across agents.",
paramName: nameof(session));
}
AgentRunHandle agentRunHandle = await this._agentClient.RunAgentAsync(sessionId, request, cancellationToken);
if (isFireAndForget)
@@ -3,7 +3,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using System.Text;
@@ -46,7 +45,6 @@ internal static class OutputConverter
OutputItemMessageBuilder? currentMessageBuilder = null;
TextContentBuilder? currentTextBuilder = null;
StringBuilder? accumulatedText = null;
List<Annotation>? accumulatedAnnotations = null;
string? previousMessageId = null;
bool hasTerminalEvent = false;
var executorItemIds = new Dictionary<string, string>();
@@ -62,7 +60,7 @@ internal static class OutputConverter
if (update.RawRepresentation is WorkflowEvent workflowEvent && update.Contents.Count == 0)
{
// Close any open message builder before emitting workflow items
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText, accumulatedAnnotations))
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
{
yield return evt;
}
@@ -70,7 +68,6 @@ internal static class OutputConverter
currentTextBuilder = null;
currentMessageBuilder = null;
accumulatedText = null;
accumulatedAnnotations = null;
previousMessageId = null;
foreach (var evt in EmitWorkflowEvent(stream, workflowEvent, executorItemIds))
@@ -89,7 +86,7 @@ internal static class OutputConverter
{
if (!IsSameMessage(update.MessageId, previousMessageId) && currentMessageBuilder is not null)
{
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText, accumulatedAnnotations))
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
{
yield return evt;
}
@@ -97,7 +94,6 @@ internal static class OutputConverter
currentTextBuilder = null;
currentMessageBuilder = null;
accumulatedText = null;
accumulatedAnnotations = null;
}
previousMessageId = update.MessageId;
@@ -119,14 +115,6 @@ internal static class OutputConverter
yield return currentTextBuilder!.EmitDelta(textContent.Text);
}
if (textContent.Annotations is { Count: > 0 })
{
foreach (var sdkAnnotation in ConvertToSdkAnnotations(textContent.Annotations))
{
(accumulatedAnnotations ??= []).Add(sdkAnnotation);
}
}
break;
}
@@ -137,7 +125,7 @@ internal static class OutputConverter
break;
}
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText, accumulatedAnnotations))
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
{
yield return evt;
}
@@ -145,7 +133,6 @@ internal static class OutputConverter
currentTextBuilder = null;
currentMessageBuilder = null;
accumulatedText = null;
accumulatedAnnotations = null;
previousMessageId = null;
var arguments = functionCall.Arguments is not null
@@ -162,7 +149,7 @@ internal static class OutputConverter
case TextReasoningContent reasoningContent:
{
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText, accumulatedAnnotations))
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
{
yield return evt;
}
@@ -170,7 +157,6 @@ internal static class OutputConverter
currentTextBuilder = null;
currentMessageBuilder = null;
accumulatedText = null;
accumulatedAnnotations = null;
previousMessageId = null;
var reasoningBuilder = stream.AddOutputItemReasoningItem();
@@ -190,7 +176,7 @@ internal static class OutputConverter
case ToolApprovalRequestContent approvalRequest when approvalRequest.ToolCall is FunctionCallContent approvalFunctionCall:
{
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText, accumulatedAnnotations))
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
{
yield return evt;
}
@@ -198,7 +184,6 @@ internal static class OutputConverter
currentTextBuilder = null;
currentMessageBuilder = null;
accumulatedText = null;
accumulatedAnnotations = null;
previousMessageId = null;
// The Responses API only standardizes the MCP-flavored approval primitive.
@@ -252,7 +237,7 @@ internal static class OutputConverter
case ErrorContent errorContent:
{
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText, accumulatedAnnotations))
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
{
yield return evt;
}
@@ -260,7 +245,6 @@ internal static class OutputConverter
currentTextBuilder = null;
currentMessageBuilder = null;
accumulatedText = null;
accumulatedAnnotations = null;
previousMessageId = null;
hasTerminalEvent = true;
@@ -285,7 +269,7 @@ internal static class OutputConverter
break;
}
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText, accumulatedAnnotations))
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
{
yield return evt;
}
@@ -293,7 +277,6 @@ internal static class OutputConverter
currentTextBuilder = null;
currentMessageBuilder = null;
accumulatedText = null;
accumulatedAnnotations = null;
previousMessageId = null;
var outputText = EncodeFunctionResultAsJsonStringPayload(functionResult.Result);
@@ -321,7 +304,7 @@ internal static class OutputConverter
}
// Close any remaining open message
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText, accumulatedAnnotations))
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
{
yield return evt;
}
@@ -335,8 +318,7 @@ internal static class OutputConverter
private static IEnumerable<ResponseStreamEvent> CloseCurrentMessage(
OutputItemMessageBuilder? messageBuilder,
TextContentBuilder? textBuilder,
StringBuilder? accumulatedText,
List<Annotation>? annotations = null)
StringBuilder? accumulatedText)
{
if (messageBuilder is null)
{
@@ -347,16 +329,6 @@ internal static class OutputConverter
{
var finalText = accumulatedText?.ToString() ?? string.Empty;
yield return textBuilder.EmitTextDone(finalText);
// Annotations must be emitted after EmitTextDone and before EmitDone.
if (annotations is not null)
{
foreach (var annotation in annotations)
{
yield return textBuilder.EmitAnnotationAdded(annotation);
}
}
yield return textBuilder.EmitDone();
}
@@ -366,41 +338,6 @@ internal static class OutputConverter
private static bool IsSameMessage(string? currentId, string? previousId) =>
currentId is not { Length: > 0 } || previousId is not { Length: > 0 } || currentId == previousId;
/// <summary>
/// Converts MEAI <see cref="AIAnnotation"/> instances to Responses SDK <see cref="Annotation"/> objects.
/// Only <see cref="CitationAnnotation"/> with a URL and at least one <see cref="TextSpanAnnotatedRegion"/>
/// with explicit start/end indices is converted; all other shapes are skipped.
/// </summary>
private static IEnumerable<Annotation> ConvertToSdkAnnotations(IList<AIAnnotation> annotations)
{
foreach (var ann in annotations)
{
if (ann is not CitationAnnotation citation || citation.Url is null)
{
continue;
}
var regions = citation.AnnotatedRegions?
.OfType<TextSpanAnnotatedRegion>()
.Where(r => r.StartIndex is not null && r.EndIndex is not null)
.ToList();
if (regions is not { Count: > 0 })
{
continue;
}
foreach (var region in regions)
{
yield return new UrlCitationBody(
citation.Url,
region.StartIndex!.Value,
region.EndIndex!.Value,
citation.Title ?? string.Empty);
}
}
}
private static ResponseUsage ConvertUsage(UsageDetails details, ResponseUsage? existing)
{
var inputTokens = details.InputTokenCount ?? 0;
@@ -148,68 +148,19 @@ internal static class FoundryEvalConverter
/// <summary>
/// Builds the <c>testing_criteria</c> array for <c>evals.create()</c>.
/// </summary>
/// <param name="evaluators">
/// Evaluator specs — built-in evaluator names (short or fully-qualified) and/or
/// <see cref="GeneratedEvaluatorRef"/> instances for pre-existing rubric evaluators.
/// </param>
/// <param name="evaluators">Evaluator names (short or fully-qualified).</param>
/// <param name="model">Model deployment name for the LLM judge.</param>
/// <param name="includeDataMapping">
/// Whether to include field-level data mapping (required for JSONL data source).
/// </param>
/// <param name="includeToolDefinitions">
/// Whether the mapped data items include tool definitions. Used to add a
/// <c>tool_definitions</c> mapping entry for rubric evaluators (built-in evaluators
/// derive this from their own <see cref="ToolEvaluators"/> membership).
/// </param>
internal static List<WireTestingCriterion> BuildTestingCriteria(
IEnumerable<FoundryEvaluatorSpec> evaluators,
IEnumerable<string> evaluators,
string model,
bool includeDataMapping = false,
bool includeToolDefinitions = false)
bool includeDataMapping = false)
{
var criteria = new List<WireTestingCriterion>();
foreach (var spec in evaluators)
foreach (var name in evaluators)
{
if (spec.IsRubric)
{
var @ref = spec.GeneratedRef!;
Dictionary<string, string>? refMapping = null;
if (includeDataMapping)
{
// Rubric evaluators accept conversation arrays like agent evaluators,
// plus tool_definitions when items are tool-aware.
refMapping = new Dictionary<string, string>
{
["query"] = "{{item.query_messages}}",
["response"] = "{{item.response_messages}}",
};
if (includeToolDefinitions)
{
refMapping["tool_definitions"] = "{{item.tool_definitions}}";
}
}
criteria.Add(new WireTestingCriterion
{
Name = @ref.DisplayName ?? @ref.Name,
EvaluatorName = @ref.Name,
EvaluatorVersion = @ref.Version,
InitializationParameters = new WireInitParams { DeploymentName = model },
DataMapping = refMapping,
});
if (@ref.Version is null)
{
System.Diagnostics.Trace.TraceWarning(
"GeneratedEvaluatorRef '{0}' has no pinned version; the eval run will resolve to whichever version is current at execution time. Pin the version for reproducible runs.",
@ref.Name);
}
continue;
}
var name = spec.BuiltinName!;
var qualified = ResolveEvaluator(name);
var shortName = name.StartsWith("builtin.", StringComparison.Ordinal)
? name.Substring("builtin.".Length)
@@ -297,12 +248,8 @@ internal static class FoundryEvalConverter
/// Returns the subset of <paramref name="evaluators"/> that require a ground-truth
/// (reference) value but cannot be evaluated because no item provided one.
/// </summary>
/// <remarks>
/// Rubric references (<see cref="GeneratedEvaluatorRef"/>) are skipped — they are not
/// ground-truthdependent on the wire.
/// </remarks>
internal static List<string> FindMissingGroundTruthEvaluators(
IEnumerable<FoundryEvaluatorSpec> evaluators,
IEnumerable<string> evaluators,
bool hasGroundTruth)
{
if (hasGroundTruth)
@@ -311,14 +258,8 @@ internal static class FoundryEvalConverter
}
var missing = new List<string>();
foreach (var spec in evaluators)
foreach (var name in evaluators)
{
if (spec.IsRubric)
{
continue;
}
var name = spec.BuiltinName!;
if (GroundTruthEvaluators.Contains(ResolveEvaluator(name)))
{
missing.Add(name);
@@ -137,9 +137,6 @@ internal sealed class WireTestingCriterion
[JsonPropertyName("evaluator_name")]
public required string EvaluatorName { get; init; }
[JsonPropertyName("evaluator_version")]
public string? EvaluatorVersion { get; init; }
[JsonPropertyName("initialization_parameters")]
public required WireInitParams InitializationParameters { get; init; }

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