Compare commits

...

126 Commits

Author SHA1 Message Date
Giles Odigwe e39a8a2e79 Python: Bump Python package versions for 1.13.0 release (#7443)
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
* Bump Python package versions for 1.13.0 release

Bump all 37 Python package projects because the CHANGELOG-driven release includes cross-package feature-usage telemetry, with core and root advancing to 1.13.0, OpenAI to 1.12.0, patch bumps for other stable packages, and 260730 stamps for alpha and beta packages. No optional beta cohort bump was applied; every prerelease package changed. Raise core floors conservatively across co-released packages.

Copilot-Session: e234a28b-c2fd-4ff4-a51d-3d8917936541

* Align co-released Python package dependencies

Update the four hosting adapter pins to the co-released agent-framework-hosting alpha and raise the Azure Functions Durable Task floor to the co-released beta.

Copilot-Session: e234a28b-c2fd-4ff4-a51d-3d8917936541

* Minimize Python release lockfile updates

Regenerate uv.lock with the pre-commit hook pinned uv version so the release changes only workspace package versions while preserving platform markers and agentlightning 0.3.0.

Copilot-Session: e234a28b-c2fd-4ff4-a51d-3d8917936541

---------

Copilot-Session: e234a28b-c2fd-4ff4-a51d-3d8917936541
2026-07-30 22:47:07 +00:00
Giles Odigwe 25ec4c3b5c Python: Support archive-type MCP skills (source, toolbox, sample) (#7121)
* Python: Support archive-type MCP skills in MCPSkillsSource

Add `archive`-type skill support to `MCPSkillsSource` so an MCP server can
advertise packaged skills (ZIP / TAR / gzip-compressed TAR) that are
downloaded, safely unpacked to a local directory, and served like file-based
skills, while keeping the guarantee that MCP-delivered scripts are never
executed.

- Dispatch `skill://index.json` entries by `type`: `skill-md` (existing,
  fetched on demand) and `archive` (new). Unknown types are skipped.
- `_ArchiveEntryLoader` downloads, extracts, and prunes archive skills and
  delegates discovery to an internal `FileSkillsSource` created with no
  script extensions and no runner, so bundled scripts surface as read-only
  resources only.
- Hardened stdlib extraction: path-traversal (zip-slip) guard, non-regular
  TAR member skipping, and file-count / uncompressed-size / download-size
  limits.
- Configure via `archive_*` constructor kwargs (no options object, per Python
  conventions); use `CachingSkillsSource` for refresh rather than a source
  level refresh interval.
- Fix `FileSkillsSource` to treat `None` extensions as "use defaults" and an
  empty tuple as "discover none" (an empty tuple previously fell back to
  defaults).

Port of .NET PR #6631.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9e358a4e-538f-46be-8c58-128b6182352d

* Propagate non-not-found archive download errors in MCPSkillsSource

Only swallow "resource not found" MCP errors when downloading an archive
resource; re-raise every other error (auth failure, INTERNAL_ERROR,
connection drop, timeout) so a transient transport failure is not silently
turned into a missing skill. This matches the existing failure model used by
`_try_read_index` and `MCPSkill.get_resource`, and avoids a failed
`CachingSkillsSource` refresh overwriting a previously cached list with a
partial result.

Add tests asserting archive-download INTERNAL_ERROR and ConnectionError
propagate out of `get_skills`.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9e358a4e-538f-46be-8c58-128b6182352d

* Python: Expose archive skill options on FoundryToolbox and demo in sample

- FoundryToolbox.as_skills_provider() now forwards the MCPSkillsSource archive
  options (archive_skills_directory, archive_resource_extensions,
  archive_resource_search_depth, archive_max_file_count, archive_max_size_bytes,
  archive_max_uncompressed_size_bytes). Only explicitly-set options are
  forwarded so unset ones keep the MCPSkillsSource defaults. This lets a hosted
  toolbox agent redirect archive extraction to a writable directory (the default
  is under the cwd, which may be read-only in a container).
- Add unit tests covering default (no options forwarded) and override forwarding.
- Update the 12_foundry_toolbox_mcp_skills sample to demonstrate all three
  progressive-disclosure stages with an archive skill: escalation-policy now
  ships a references/refund-matrix.md resource and is uploaded as a ZIP archive;
  main.py disables load_skill and read_skill_resource approval and points
  archive extraction at a temp directory. README, toolbox.yaml, and ignore files
  updated accordingly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4f14f83d-1868-45c1-be1a-12f49a58ac36

* Python: Fix ty type error in toolbox archive-option test

Cast provider._source to _FoundryToolboxSkillsSource before accessing the
private _archive_options, so the ty checker (which runs over tests) resolves
the concrete type instead of the SkillsSource base. Replaces the mypy-style
type: ignore that ty did not honor.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4f14f83d-1868-45c1-be1a-12f49a58ac36

* Rework archive-type skill support in MCPSkillsSource to unpack archives
entirely in memory instead of extracting them to a local directory, and
apply reviewer feedback.

* Python: Raise on archive member path-traversal (zip-slip)

Treat a `..` path-traversal member in an archive skill as a hostile archive
and reject the whole skill, matching how the file-count and uncompressed-size
limits reject a malformed archive (previously the member was silently skipped
while the rest of the skill still loaded).

- `_normalize_archive_member_name` now raises `ValueError` on a `..` escape;
  benign degenerate entries (empty, `.`, `/`) still return None (skipped) and
  absolute paths are still neutralized to relative. The raise propagates to
  `_ArchiveEntryLoader._build_skill`, which already skips the skill on error.
- Update tests: traversal cases now assert a raise, and add an end-to-end test
  that a zip-slip archive drops the whole skill.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9e358a4e-538f-46be-8c58-128b6182352d

* Python: Revert archive skill demo in toolbox MCP skills sample

Restore the 12_foundry_toolbox_mcp_skills sample to its pre-PR, skill-md-only
form (matching the .NET Agent_Step26_FoundryToolboxMcpSkills sample, which uses
skill-md and no ZIP archive):

- Revert main.py, toolbox.yaml, README.md, .azdignore, .dockerignore, and
  escalation-policy/SKILL.md to the single-file SKILL.md version.
- Remove the archive demo files added by this PR (.gitignore and
  escalation-policy/references/refund-matrix.md).
- Soften two README notes so they no longer claim archive skills are
  unsupported/silently dropped (this PR adds archive support); instead frame
  single-file SKILL.md as a focus choice and point to the archive_* options.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9e358a4e-538f-46be-8c58-128b6182352d

* Python: Clarify archive framing in mcp_based_skill sample README

The mcp_based_skill sample is a generic MCP consumer that discovers whatever
the server advertises; it does not itself demonstrate archive skills. Reword
the archive note so it reads as an MCPSkillsSource capability rather than a
sample feature, and fix the stale "unpacked to a local directory" claim to
"unpacked in memory" (matching the in-memory extraction implementation).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9e358a4e-538f-46be-8c58-128b6182352d

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9e358a4e-538f-46be-8c58-128b6182352d
Copilot-Session: 4f14f83d-1868-45c1-be1a-12f49a58ac36
2026-07-30 20:57:51 +00:00
SergeyMenshykh 3ad861f0b2 reference code of conduct in readme (#4998)
Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com>
2026-07-30 18:39:05 +00:00
SergeyMenshykh 2aa267e028 .NET: Updating version for dotnet release 1.16.0 (#7441)
Co-authored-by: SergeyMenshykh <SergeMenshikh@outlook.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 28ce674d-8c40-4d49-864c-5d02894fe762
2026-07-30 16:36:36 +00:00
Peter Ibekwe 6a3d535204 .NET: Add regression tests and sample guidance for stable agent IDs in checkpointed workflows (#7415)
* Add regression tests and sample guidance for stable agent IDs in checkpointed workflows

* Updated tests to address PR comments

* Improve test for checkpoint state.
2026-07-30 16:21:18 +00:00
westey 73f48d255e .NET: Add FileMemoryProvider sample to 02-agents/AgentWithMemory (#7401)
* Add FileMemoryProvider sample

* Address PR comments
2026-07-30 15:26:51 +00:00
Eduard van Valkenburg 28389df805 Python: Move SessionStore to core and persist Foundry Responses sessions (#7306)
* Python: Move session persistence into core

Move SessionStore and durable msgspec-backed storage into core, restore sessions in Foundry Responses hosting with per-user isolation, and document the serialization design.

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

Copilot-Session: 3e5c81ad-75e8-4e92-a883-8bbd676c6c8c

* Python: Address session persistence review feedback

Harden scoped file paths and corruption recovery, preserve session serialization compatibility, clarify dependency placement, and add reproducible benchmark evidence.

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

Copilot-Session: 3e5c81ad-75e8-4e92-a883-8bbd676c6c8c

* Python: Preserve session snapshot compatibility

Deep-copy in-memory session writes and retain existing Telegram session keys so stored conversations continue resolving.

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

Copilot-Session: 3e5c81ad-75e8-4e92-a883-8bbd676c6c8c

* Python: Simplify Foundry session isolation

Add experimental FoundrySessionStore backed by Agent Server request context, remove resolver plumbing, and centralize v2 user isolation for sessions, checkpoints, and approvals.

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

Copilot-Session: 3e5c81ad-75e8-4e92-a883-8bbd676c6c8c

* Python: Reduce Foundry session helper layering

Inline the single-use request user accessor while keeping separate context validation, fingerprint, and directory helpers for their distinct callers.

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

Copilot-Session: 3e5c81ad-75e8-4e92-a883-8bbd676c6c8c

* Python: Clarify Foundry request context validation

Separate fail-fast request validation from context retrieval so Responses no longer appears to discard a returned context.

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

Copilot-Session: 3e5c81ad-75e8-4e92-a883-8bbd676c6c8c

* Python: Share Foundry request context helpers

Move protocol validation and user-scope derivation into a dedicated request-context module, leaving the session-store module focused on storage.

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

Copilot-Session: 3e5c81ad-75e8-4e92-a883-8bbd676c6c8c

* Restore Foundry checkpoint storage paths

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

Copilot-Session: 3e5c81ad-75e8-4e92-a883-8bbd676c6c8c

* Simplify Foundry session storage paths

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

Copilot-Session: 3e5c81ad-75e8-4e92-a883-8bbd676c6c8c

* Persist Foundry sessions under hosted home

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

Copilot-Session: 3e5c81ad-75e8-4e92-a883-8bbd676c6c8c

* Make hosted path test platform independent

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

Copilot-Session: 3e5c81ad-75e8-4e92-a883-8bbd676c6c8c

* Address session persistence review feedback

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

Copilot-Session: 3e5c81ad-75e8-4e92-a883-8bbd676c6c8c

* Isolate Foundry session path handling

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

Copilot-Session: 3e5c81ad-75e8-4e92-a883-8bbd676c6c8c

* Clarify Foundry session path terminology

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

Copilot-Session: 3e5c81ad-75e8-4e92-a883-8bbd676c6c8c

* Align Foundry sessions with Responses continuity

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

Copilot-Session: 3e5c81ad-75e8-4e92-a883-8bbd676c6c8c

* Finalize Foundry Responses session persistence

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

Copilot-Session: 3e5c81ad-75e8-4e92-a883-8bbd676c6c8c

* Add session store feature usage telemetry

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

Copilot-Session: 3e5c81ad-75e8-4e92-a883-8bbd676c6c8c

* Fix hosted per-call history persistence

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

Copilot-Session: 3e5c81ad-75e8-4e92-a883-8bbd676c6c8c

---------

Copilot-Session: 3e5c81ad-75e8-4e92-a883-8bbd676c6c8c
2026-07-30 13:04:08 +00:00
HaoJun 143386fecc Python: fix(core): restrict unpickler module-prefix allowlist to types only (#5923)
* fix(core): harden restricted pickle attribute resolution

* fix(core): validate nested pickle types against allowlist

---------

Co-authored-by: White-Mouse <15983334+White-Mouse@users.noreply.github.com>
Co-authored-by: Evan Mattson <evan.mattson@microsoft.com>
Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com>
2026-07-30 10:46:15 +00:00
westey 47c8e29b64 Python: Add FileMemoryProvider context provider sample (#7428)
* Add FileMemoryProvider sample

* Address PR comments
2026-07-30 10:46:11 +00:00
Evan Mattson 51615d5468 Fix DevFlow review comment trigger (#7434)
Use the exact /review command without mentioning an unrelated GitHub user account.

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

Copilot-Session: d9fa4e9c-c32d-42fb-8ee4-4772473e6479
2026-07-30 19:41:21 +09:00
Giles Odigwe 12b2893bac Python: Apply header_provider headers to the MCP initialize handshake and other ambient requests (#7305)
* Python: Apply header_provider headers to ambient MCP requests

MCPStreamableHTTPTool.header_provider was only invoked from call_tool(),
so the initialize handshake, load_tools/load_prompts discovery, and
background pings all went out with no headers. MCP servers that require
auth on initialize (e.g. Azure AI Search knowledge-base MCP endpoints)
therefore returned 401 before any tool call could run.

Add an ambient fallback in the _inject_headers httpx request hook: when
neither the per-call ContextVar nor the active-call snapshot is set, the
hook invokes header_provider({}) so every ambient request is
authenticated. Providers that require per-call kwargs raise on the empty
dict; that is caught, logged, and the request proceeds unauthenticated,
preserving prior behavior. Calling the provider on demand also keeps
dynamic token refresh working for post-connect requests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: cf0c1dbf-99bc-4f3f-bcf4-7791ce7dbe6a

* Python: address review - distinguish unset vs empty headers, warn once

Review feedback on the ambient header_provider fallback:

- Distinguish 'unset' (no active call) from 'set but empty' (call_tool
  produced no headers). Use _mcp_call_headers.get(None) and the None-ness
  of the snapshot instead of a truthiness check, so a provider that
  legitimately returns {} during a real call is no longer re-invoked by
  the ambient fallback mid-call.
- A kwargs-dependent provider raises on every ambient request (initialize,
  discovery, recurring pings). Warn once per tool instance with a
  traceback via _ambient_header_warning_emitted and drop subsequent
  occurrences to DEBUG to avoid log spam.

Add regression tests for both behaviors.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: cf0c1dbf-99bc-4f3f-bcf4-7791ce7dbe6a

* Python: narrow ambient header_provider catch to KeyError

Only the missing-per-call-kwargs case (KeyError, e.g. the
mcp_api_key_auth.py sample indexing kwargs['mcp_api_key']) is tolerated
during ambient requests. Any other exception - a token-refresh failure
or a provider bug - now propagates instead of being silently converted
into unauthenticated traffic, matching the call_tool path which does not
catch header_provider exceptions.

Add a regression test asserting a non-KeyError provider failure
surfaces from the request hook.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: cf0c1dbf-99bc-4f3f-bcf4-7791ce7dbe6a

* Python: address review - raise instead of assert, simplify ambient logging

- Reword the ambient-fallback comment to describe the kwargs-dependent
  provider pattern generically instead of naming a sample file, which
  would go stale if the sample is renamed (also in a test docstring).
- Replace the type-narrowing assert with a RuntimeError carrying a
  concise message for the unreachable no-provider state.
- Drop the warn-once/_ambient_header_warning_emitted machinery; the
  KeyError ambient case is expected and benign, so log a single DEBUG
  line and proceed without headers.

Update the corresponding test to assert behavior (request proceeds
without an Authorization header and no WARNING is emitted) instead of
log-count.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: cf0c1dbf-99bc-4f3f-bcf4-7791ce7dbe6a

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com>
Copilot-Session: cf0c1dbf-99bc-4f3f-bcf4-7791ce7dbe6a
2026-07-30 10:31:11 +00:00
Yufeng He 93e8cb2de3 Python: make SerializationMixin.from_dict enforce the documented type check (#7256)
from_dict resolved the expected type identifier from the payload itself
(_get_type_identifier(value) prefers value["type"]), so the mismatch
guard could never fire: any supplied 'type' matched itself, and a payload
like {"type": "function_tool", ...} silently deserialized into a Message,
getting its type rewritten on the next to_dict. The docstring has always
promised a ValueError on mismatch.

Resolve the identifier from the class instead, matching what to_dict
emits, so a mismatched or foreign 'type' now raises as documented.
Payloads without a 'type' field and dependency-injection lookups are
unchanged: in every previously valid case the class-resolved identifier
is the same string the payload carried.

Fixes #7255

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com>
2026-07-30 10:28:27 +00:00
Eduard van Valkenburg b64a2e2f82 Python: add feature-usage User-Agent telemetry (#7420)
* Python: add first-pass feature usage telemetry

Add the 128-bit feature accumulator, package-local indexes, activation markers, and destination-scoped User-Agent emission for the initial Python implementation slice.

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

Copilot-Session: 346bf168-b668-4c4a-a8db-a67282ee5e5f

* Python: track declarative feature usage

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

Copilot-Session: 346bf168-b668-4c4a-a8db-a67282ee5e5f

* Python: complete feature usage telemetry

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

Copilot-Session: 346bf168-b668-4c4a-a8db-a67282ee5e5f

* Python: report core version in User-Agent

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

Copilot-Session: 346bf168-b668-4c4a-a8db-a67282ee5e5f

* Python: configure Lab telemetry import path

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

Copilot-Session: 346bf168-b668-4c4a-a8db-a67282ee5e5f

* Python: preserve telemetry transport behavior

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

Copilot-Session: 346bf168-b668-4c4a-a8db-a67282ee5e5f

* Python: preserve caller-owned Foundry transports

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

Copilot-Session: 346bf168-b668-4c4a-a8db-a67282ee5e5f

* Python: remove stale Anthropic test import

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

Copilot-Session: 346bf168-b668-4c4a-a8db-a67282ee5e5f

---------

Copilot-Session: 346bf168-b668-4c4a-a8db-a67282ee5e5f
2026-07-30 10:24:34 +00:00
Yufeng He 962b86ddbb Python: preserve model emission order in AG-UI MESSAGES_SNAPSHOT (#7239)
* Preserve model emission order in AG-UI messages snapshot

* Address moonbox3's review: cover the remaining snapshot gaps

- Preopened message ids (tool-only path) now open a text segment when
  the first text arrives, so their content can't drop out of the snapshot.
- A tool result closes the current tool-call segment, so
  call A -> result A -> call B snapshots as two pairs in stream order.
- emitted_call_ids only marks calls actually emitted, keeping stale
  segment ids eligible for the leftover fallback.
- The leftover path carries its tool results too instead of dropping them.

* Python: narrow leftover tool-call ids so pyright accepts the update

---------

Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com>
2026-07-30 10:19:40 +00:00
Tao Chen 80eb2570c7 Remove indices in FHA sample names (#7405) 2026-07-30 10:17:26 +00:00
Dineshsuriya D d42c78c8cf Python: Add GitHub Copilot BYOK sample (#7336)
* Python: Add GitHub Copilot BYOK sample

Demonstrates routing GitHubCopilotAgent requests through a custom OpenAI-compatible
endpoint via ProviderConfig instead of the default GitHub Copilot backend.

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Python: Address BYOK sample review feedback

- Make the provider type configurable via BYOK_PROVIDER_TYPE (default "openai") instead
  of hardcoding "openai" — a partial autofix commit had already updated the docstring to
  document this env var but left the code hardcoded, which this finishes.
- Stop calling the endpoint "OpenAI-compatible" everywhere; Anthropic isn't OpenAI-wire-
  compatible, so reword to "your own endpoint" and list the actual supported providers
  (mirrors the equivalent .NET sample fix).

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-30 10:15:17 +00:00
dependabot[bot] d9e1990484 Bump postcss (#7315)
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.15 to 8.5.23.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.15...8.5.23)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.23
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-30 10:06:27 +00:00
dependabot[bot] fa7cc021c6 Bump postcss (#7314)
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.15 to 8.5.23.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.15...8.5.23)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.23
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-30 10:06:05 +00:00
Thota Sai Karthik 4d67eefa5f Python: Fix FoundryAgent inheriting OPENAI_CHAT_MODEL for agent-reference requests (#7283)
* Python: Fix FoundryAgent inheriting OPENAI_CHAT_MODEL for agent-reference requests (#7272)

* Python: Fix FoundryAgent inheriting OPENAI_CHAT_MODEL for agent-reference requests

* fix(foundry): update test typing annotations to pass mypy, pyrefly, and ty

---------

Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com>
2026-07-30 10:05:33 +00:00
Scarab Systems 32928e645b Python: Bound summarization input before provider call (#7375)
* Bound summarization input before provider call

SummarizationStrategy now selects complete message groups that fit a configurable summary input token budget before calling the summary client. Only messages actually sent to the summarizer are annotated and excluded, leaving oversized later groups for a later compaction pass instead of shipping the whole transcript unbounded.

Validation: uv run pytest packages/core/tests/core/test_compaction.py -k bounds_summary_input -m "not integration" failed before the implementation and passed after it; uv run pytest packages/core/tests/core/test_compaction.py -m "not integration" passed; uv run poe test -P core passed; uv run poe install completed; uv run poe check -P core passed.

* Handle oversized leading summary groups

Skip individually over-budget leading groups when selecting summarization input so a large early transcript item does not prevent later compactable groups from being summarized.

Validation: uv run pytest packages/core/tests/core/test_compaction.py -k skips_oversized_first_group -q; uv run pytest packages/core/tests/core/test_compaction.py -q; uv run poe check -P core.

* Escalate repeated summary failures

Track consecutive SummarizationStrategy failures and emit a single error once the strategy has failed three times without a successful summary. Reset the escalation state after a successful summary so only persistent failures become loud.

Validation: uv run pytest packages/core/tests/core/test_compaction.py -k 'repeated_summary_failures or resets_failure_escalation' -q; uv run pytest packages/core/tests/core/test_compaction.py -q; uv run poe check -P core.

* Refine summary input selection

Avoid rebuilding and re-tokenizing the full selected summary transcript on every candidate group while preserving complete-group selection and oversized leading group skipping.

Tighten the scripted summarizer test helper to expected Exception failures instead of BaseException.

Verification: uv run pytest packages/core/tests/core/test_compaction.py -q; uv run poe syntax -P core.

---------

Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com>
2026-07-30 10:01:57 +00:00
Evan Mattson d07edffaed Python: Fix Actions token environment (#7427)
* Fix Copilot Actions token environment

Expose workflow tokens through GITHUB_TOKEN so Copilot CLI uses native Actions authentication, while preserving user-token integration test support.

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

Copilot-Session: d9fa4e9c-c32d-42fb-8ee4-4772473e6479

* Gate Copilot integration tests explicitly

Use GitHub Actions authentication only when both GITHUB_ACTIONS and GITHUB_TOKEN are present, and require an explicit local opt-in that relies on stored Copilot login.

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

Copilot-Session: d9fa4e9c-c32d-42fb-8ee4-4772473e6479

---------

Copilot-Session: d9fa4e9c-c32d-42fb-8ee4-4772473e6479
2026-07-30 18:57:23 +09:00
Nadjib Attig 59fe8bedeb Python: Sanitize author_name for the Chat Completions message name field (#7127)
OpenAI validates the Chat Completions message 'name' against
^[^\s<|\/>]+$, so an agent display name containing a space (or
< | \ / >) failed every request with a 400. Sanitize at the three
assignment sites, mirroring SanitizeAuthorName in the .NET client
(dotnet/extensions): remove characters outside [a-zA-Z0-9_], omit the
name when nothing remains, truncate to 64 characters.

Fixes #7126

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 09:47:57 +00:00
Yufeng He 31d6af1447 Python: fix Anthropic streaming double-counting token usage (#7162)
* Python: fix Anthropic streaming double-counting token usage

* Python: address review on the Anthropic usage increment helper

- accumulate the emitted totals in a plain dict instead of string-cast
  TypedDict views, so static checkers see real types throughout
- compute the increment through _types.add_usage_details with negated
  emitted totals instead of a hand-rolled subtraction loop; keys absent
  from a snapshot stay untouched, matching the partial-delta semantics
2026-07-30 09:45:21 +00:00
Chinedum Echeta ae6923c8b1 Python: feat(observability): add support for OpenAI cache write tokens in usage details (#7369)
* feat(observability): add support for OpenAI cache write tokens in usage details

* feat(openai): add cache write tokens handling in usage details

* Fix test
2026-07-30 09:12:30 +00:00
Eduard van Valkenburg 99dcf3c133 Python: Preserve declaration-only streaming metadata (#7409)
* Python: Preserve declaration-only streaming metadata

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

Copilot-Session: 1ee1d250-d1e2-4c6f-8c36-aae0d94fe7a1

* Chore: retrigger PR checks

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1ee1d250-d1e2-4c6f-8c36-aae0d94fe7a1

* Python: Reconcile remaining function-loop spec gaps

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1ee1d250-d1e2-4c6f-8c36-aae0d94fe7a1

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1ee1d250-d1e2-4c6f-8c36-aae0d94fe7a1
2026-07-30 09:11:51 +00:00
Eduard van Valkenburg 95ec5b7d36 Python: Preserve approval decisions under OpenAI continuation (#7407)
* Python: Preserve approval decisions under OpenAI continuation

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

Copilot-Session: 1ee1d250-d1e2-4c6f-8c36-aae0d94fe7a1

* Chore: retrigger PR checks

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1ee1d250-d1e2-4c6f-8c36-aae0d94fe7a1

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1ee1d250-d1e2-4c6f-8c36-aae0d94fe7a1
2026-07-30 08:29:05 +00:00
Eduard van Valkenburg 0937233d86 Python: Remove tool content returned after invocation limits (#7408)
* Python: Remove tool content returned after invocation limits

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

Copilot-Session: 1ee1d250-d1e2-4c6f-8c36-aae0d94fe7a1

* Chore: retrigger PR checks

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1ee1d250-d1e2-4c6f-8c36-aae0d94fe7a1

* Python: Preserve provider-owned content after limits

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1ee1d250-d1e2-4c6f-8c36-aae0d94fe7a1

* Python: Isolate post-limit spec update

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1ee1d250-d1e2-4c6f-8c36-aae0d94fe7a1

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1ee1d250-d1e2-4c6f-8c36-aae0d94fe7a1
2026-07-30 08:27:57 +00:00
Eduard van Valkenburg 572a9621bd Python: Keep call and result occurrences atomic in compaction (#7406)
* Python: Keep call and result occurrences atomic in compaction

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

Copilot-Session: 1ee1d250-d1e2-4c6f-8c36-aae0d94fe7a1

* Python: Clarify ambiguous compaction reannotation

Document why incremental reannotation retains all prior duplicate candidates and strengthen the regression that keeps ambiguous results unpaired without changing existing groups.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1ee1d250-d1e2-4c6f-8c36-aae0d94fe7a1

* Python: Handle assistant-embedded compaction results

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1ee1d250-d1e2-4c6f-8c36-aae0d94fe7a1

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1ee1d250-d1e2-4c6f-8c36-aae0d94fe7a1
2026-07-30 07:58:07 +00:00
Evan Mattson df723d768f Update GH Actions workflows (#7424)
* Use Actions token for DevFlow Copilot auth

Grant the review job Copilot request permission and remove the user token fallback so organization-billed GitHub Actions authentication is exercised directly.

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

Copilot-Session: d9fa4e9c-c32d-42fb-8ee4-4772473e6479

* Enable DevFlow PR review comparisons

Pass the dedicated DevFlow repository token for A/B artifact branches while keeping the built-in Actions token as the only Copilot credential.

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

Copilot-Session: d9fa4e9c-c32d-42fb-8ee4-4772473e6479

* Allow team-triggered DevFlow reviews

Accept an exact @devflow /review PR comment only from organization members, verify the commenter against the developer team with the GitHub App, and react after authorization.

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

Copilot-Session: d9fa4e9c-c32d-42fb-8ee4-4772473e6479

* Use Actions token for issue triage Copilot auth

Grant the triage job Copilot request permission and remove the user PAT so issue reproduction exercises organization-billed GitHub Actions authentication.

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

Copilot-Session: d9fa4e9c-c32d-42fb-8ee4-4772473e6479

* Use tracked DevFlow CI model configuration

Point PR review and issue triage runs at the dashboard's tracked GPT-5.6 Sol and Claude Opus 5 model configuration.

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

Copilot-Session: d9fa4e9c-c32d-42fb-8ee4-4772473e6479

* Use Actions tokens for Copilot test workflows

Remove Copilot PAT secrets from integration and sample validation workflows, grant Copilot request permission at the required caller and job boundaries, and preserve the environment variable expected by the tests.

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

Copilot-Session: d9fa4e9c-c32d-42fb-8ee4-4772473e6479

---------

Copilot-Session: d9fa4e9c-c32d-42fb-8ee4-4772473e6479
2026-07-30 16:59:28 +09:00
Eduard van Valkenburg e344f456ae Python: Correlate AG-UI confirm_changes snapshots by call id (#7411)
* Python: Correlate AG-UI confirm_changes snapshots by call id

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

Copilot-Session: 1ee1d250-d1e2-4c6f-8c36-aae0d94fe7a1

* Chore: retrigger PR checks

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1ee1d250-d1e2-4c6f-8c36-aae0d94fe7a1

* Python: Require real results for accepted confirmations

Keep accepted confirm_changes snapshot payloads inert unless approval resolution produced a matching function result, while retaining explicit rejection cleanup.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1ee1d250-d1e2-4c6f-8c36-aae0d94fe7a1

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1ee1d250-d1e2-4c6f-8c36-aae0d94fe7a1
2026-07-30 07:08:03 +00:00
Eduard van Valkenburg e18a64569c Python: Defer provider-injected approvals to in-run execution (#7410)
* Python: Defer provider-injected approvals to in-run execution

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

Copilot-Session: 1ee1d250-d1e2-4c6f-8c36-aae0d94fe7a1

* Python: Remove vacuous AG-UI approval test

Drop the forged-approval test that was stripped by pending-approval validation; the real pause-approve-resume regression remains the authoritative provider-injected coverage.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1ee1d250-d1e2-4c6f-8c36-aae0d94fe7a1

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1ee1d250-d1e2-4c6f-8c36-aae0d94fe7a1
2026-07-30 06:48:36 +00:00
pratik wayase ce5ee8a9c7 Python: fix(foundry-hosting): root hosted checkpoints under durable home dire… (#7220)
* fix(foundry-hosting): root hosted checkpoints under durable home directory

* fix: add None guard for _checkpoint_storage_path in test

* Disable Foundry image test

---------

Co-authored-by: Tao Chen <taochen@microsoft.com>
2026-07-30 00:35:19 +00:00
Giles Odigwe 4a7af303af Bump .NET SDK from 10.0.301 to 10.0.302 (#7376)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 63ac3381-c9ca-47af-b0e7-9a09c0a5b2be
2026-07-29 20:44:42 +00:00
Eduard van Valkenburg 5987a6791b Python: Improve function approval resume and replay (#7345)
* Python: Harden function approval resume and replay

Make approval resume immutable and occurrence-aware, return grouped approved and rejected results consistently, preserve pending approval history without model-orphaned calls, and align streaming, non-streaming, and AG-UI result boundaries.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1ee1d250-d1e2-4c6f-8c36-aae0d94fe7a1

* Python: Clarify function invocation orchestration

Simplify approval-resolution setup and add phase-level comments around the key function invocation orchestration paths.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1ee1d250-d1e2-4c6f-8c36-aae0d94fe7a1
EOF && git push origin python-approval-resume-contract

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1ee1d250-d1e2-4c6f-8c36-aae0d94fe7a1
2026-07-29 20:01:42 +00:00
Tao Chen 0e6a104192 [BREAKING] Python: Allow workflow checkpoint full replayability (#7374)
* Allow workflow checkpoint full replayability

Seed the initial run input through the start executor's internal self-edge and record an entry checkpoint (iteration 0) before any executor runs, plus a response-entry checkpoint when responses are delivered, so a run is fully replayable from its checkpoints. Simplify the runner to only checkpoint after each superstep. Drop stale events in apply_checkpoint on restore, and deprecate the unused RunnerContext.reset_for_new_run.

* Fix type

* Add max iteration detailed doc string

* Refine comments
2026-07-29 19:25:35 +00:00
Binit Mohanty ce2208c62c Python: Fix OpenAIChatCompletionClient passing raw JSON-Schema dict response_format through unwrapped (#7199)
* Python: Fix OpenAIChatCompletionClient passing raw JSON-Schema dict response_format through unwrapped

Raw schema dicts (e.g. {"type": "object", ...}) were forwarded to the
Chat Completions API verbatim, which OpenAI rejects with a 400. The
Responses client already auto-wraps the same input. Mirror its raw-schema
detection (primitive types / schema keywords), wrap into the
{"type": "json_schema", "json_schema": {...}} envelope with
additionalProperties: false injection and title -> name promotion, and
leave already-valid response_format dicts untouched.

Fixes #7197

(cherry picked from commit dce5c3b06328fbde45eb2a9a25638af5b1ec85e3)

* Python: Add live integration coverage for raw JSON-Schema response_format dicts

Adds a response_format_raw_json_schema param to test_integration_options in
both the Chat Completions and Responses client test suites, proving the same
bare schema dict (title set, additionalProperties omitted) round-trips through
both live APIs and yields parsed structured output.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Python: Fix response format dict typing

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com>
2026-07-29 16:35:02 +00:00
Roger Barreto 0ca6a3e652 .NET: Add source (ZIP) deploy oriented hosted agent samples (#7372)
* Add zip/code-deploy POC for Hosted-ChatClientAgent (.NET)

Migrate the sample to Foundry source (ZIP) deployment as the default: add azure.yaml with codeConfiguration (remote_build, dotnet_10) and the tool-generated .agentignore, make the csproj self-contained (single target, CPM off, published PackageReferences), and simplify Program.cs to the pristine end-user hosting path. Container files are kept for now; contributor and remaining samples handled in follow-ups.

* .NET: Auto-bind Foundry hosted port for zip/code deploy; migrate Hosted-ChatClientAgent to source (ZIP)

Foundry.Hosting: AddFoundryResponses now binds Kestrel to FoundryEnvironment.Port (the PORT env var, default 8088) for a plain WebApplication.CreateBuilder (Tier 3) host, mirroring AgentHostBuilder. This lets a source/ZIP-deployed .NET agent pass the readiness probe with no Dockerfile. It respects an explicit ASPNETCORE_URLS override and is idempotent. Adds FoundryListenPortTests plus a serialized env-var collection.

Hosted-ChatClientAgent: migrate to source (ZIP) deploy as the default. Add azure.yaml with codeConfiguration (remote_build, dotnet_10) and the tool-generated .agentignore, make the csproj self-contained (single target, CPM off) with a local Directory.Packages.props, embed the local-dev per-agent route so the Using-Samples REPL can reach the local server, and rewrite the README around the azd flow. Documents AZURE_TOKEN_CREDENTIALS=dev for local runs.

Using-Samples/SimpleAgent: fix the per-agent endpoint scheme rewrite so the local HTTP dev port is preserved (the policy now lives on the per-agent ProjectOpenAIClientOptions that actually serves the request).

* .NET: Bind Foundry hosted port unconditionally; drop container files and the local-only agent route

Zip/code deploy runs the sample as a plain ASP.NET app, so the Foundry readiness
port was never bound and every invoke returned HTTP 424 session_not_ready. The
first attempt skipped the binding when ASPNETCORE_URLS was already set, but the
.NET base image always sets it to port 80, so the skip always tripped. Kestrel
ListenAnyIP overrides ASPNETCORE_URLS, so the binding is now unconditional and
PORT stays the only knob.

Sample cleanup for zip deploy:
* Remove Dockerfile, Dockerfile.contributor, agent.manifest.yaml and agent.yaml.
  Source deploy needs none of them.
* Remove LocalDevEndpoint.cs and the invented per-agent local route. The local
  server already serves the standard POST /responses route, so the client can
  reach it directly.
* Trim .env.example: the port and environment variables are no longer needed.
* Exclude .checkpoints/ from the upload so local session state does not ship.

SimpleAgent now asks at startup whether to chat with the local server or the
deployed agent, the same choice azd ai agent invoke exposes through --local.
Local uses an OpenAI responses client pointed at http://localhost:8088; Foundry
uses the per-agent endpoint.

Add scripts/New-ContributorStage.ps1, which stages a sample to a temp folder with
the local Agent Framework source packed into a feed inside the upload, so
contributors can deploy framework changes through the same azd flow end users run.

* Pin hosted agent listen port in azure.yaml

* Use the documented env map in azure.yaml

* Make the contributor flow an extra step inside the end-user flow

* Keep contributor scaffolding out of the sample project file

* Document the full deploy walkthrough and add a bash contributor script

* Trim troubleshooting detail from the sample README

* Pass the model deployment name to the hosted container

* Add --local and --remote flags to the SimpleAgent REPL

* Use central package management in the hosted sample

* Clarify where the contributor step fits in the deploy walkthrough

* Restore the HTTP scheme rewrite for local AIProjectClient runs

* Keep the sample package versions in the project file

* Drop the sample Directory.Packages.props

* Add a container deploy variant of the hosted chat client agent sample

* Treat a blank model deployment variable as unset

* Let azd prompt for the Foundry project and expand the contributor section

* Document the stale conversation 404 in the hosted agent samples

* Remove using directives already covered by global usings

* Bind the Foundry listen port only inside a hosted container

* Resolve the Foundry listen port from IConfiguration
2026-07-29 15:34:54 +00:00
Peter Ibekwe 5543bc94fc Fix and re-enable flaky InputWaiter timeout test (#7377) 2026-07-29 15:24:54 +00:00
Eduard van Valkenburg 5f84917f15 docs: ADR-0033 feature-usage bitmask in the User-Agent (#6500)
* docs: ADR-0027 feature-usage bitmask in the User-Agent

Add an ADR, design spec, and per-language bit registry for a lightweight
feature-usage signal: a 64-bit mask, emitted as a `(feat=vN.<hex>)` User-Agent
comment, stamped per request on first-party (Azure/Foundry) clients only.

- docs/decisions/0027-feature-usage-bitmask-user-agent.md — ADR (options-first,
  with Limitations, Open Questions, and v1->v2 migration)
- docs/specs/002-feature-usage-telemetry.md — design spec + implementation plan
- docs/specs/feature-usage-bit-registry.md — per-language bit tables + governance

Granularity is per package with core broken out per feature (each orchestration
pattern and built-in context/history provider). Registries are per language
(decoder selects by the language already in the UA). OpenTelemetry emission is
deferred (privacy). Docs only; no code changes.

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

* docs: fix dead links to removed registry JSON in ADR-0027

The registry JSON was consolidated into feature-usage-bit-registry.md; point
the ADR's two remaining links at the markdown instead of the deleted file
(fixes markdown-link-check 404s).

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

* docs: address review — drop JSON-parity wording, clarify per-language decode

- ADR option J: the parity test compares the enum against the per-language table
  in the registry doc, not a (now-removed) JSON file.
- Spec .NET mapping: the wire format is shared, but the mask is decoded
  per-language (select the table via the UA product token) — fixes the
  "decoded numbers mean the same thing in both SDKs" wording that conflicted
  with the per-language, non-synchronized bit indexes.

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

* docs: add dedicated mask-only opt-out env var (AGENT_FRAMEWORK_FEATURE_MASK_DISABLED)

Re-introduce a dedicated opt-out that disables only the feature mask while keeping
the base agent-framework-<lang>/{version} User-Agent, alongside the existing
AGENT_FRAMEWORK_USER_AGENT_DISABLED (whole UA). Updates the spec accumulator gate,
API surface, opt-out table and examples; the registry opt-out section; and the
ADR (decision outcome, consequences, open questions -> decided).

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

* docs: add prior-art comparison (AWS botocore m/, Stainless, Azure, etc.)

Add a Prior art section to ADR-0027 surveying how comparable SDKs encode
identity/usage in the User-Agent or sidecar headers, with citations:

- AWS botocore `m/` feature-code list — the direct analog (per-request,
  usage-based feature flags in the UA); contrasts short-code set vs our hex
  bitmask.
- OpenAI/Anthropic Stainless `X-Stainless-*` headers (static identity).
- Azure azure-core UserAgentPolicy + AZURE_TELEMETRY_DISABLED.
- Google x-goog-api-client; LangSmith version token + tracing opt-in.

Also add an Open Question on honoring the cross-tool DO_NOT_TRACK convention.

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

* docs: fold in botocore lessons; record accumulation-scope decision

botocore's m/ feature list scopes features to a per-request contextvars set that
resets between calls — clean per-call attribution, but it assumes every feature
lives inside a service request. That holds for an SDK natively bound to its own
services; it does not for us, where many features (agent/workflow/provider
construction, session setup) are not bound to any request.

- ADR: add Accumulation scope options — P (process-global monotonic, chosen) vs
  Q (botocore per-request set, rejected) with the request-binding rationale;
  reference P in the decision; reframe the "no per-call attribution" limitation
  as a deliberate scope choice.
- ADR Prior art: bitmask gives bounded token size for free (vs botocore's
  1024-byte cap + truncation); mechanism is private, wire format is the contract;
  fix a duplicated phrase.
- Spec: note the mask is process-global, monotonic, never reset (intentional,
  lock/Interlocked.Or-safe), the token is safe-by-construction (no sanitization),
  and the helpers are private API.

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

* docs: update feature mask ADR

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

* docs: refresh feature usage telemetry design

Rebase the proposal on current main, renumber it to ADR-0033/SPEC-004, and reconcile the registry and implementation notes with current Python and .NET surfaces.

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

* docs: expand feature usage mask to 128 bits

Repartition the v1 registries with additional skill categories, define the bit-allocation tenet, and document the two-lane .NET accumulator and 128-bit decoder contract.

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

Copilot-Session: 346bf168-b668-4c4a-a8db-a67282ee5e5f

* docs: tighten feature telemetry activation and scoping

Require approved pipeline and actual-origin classification, preserve OpenAI transport defaults, use activation-based marking, and move index ownership into packages with parity and no-overlap validation.

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

Copilot-Session: 346bf168-b668-4c4a-a8db-a67282ee5e5f

* docs: preserve SDK transport defaults for telemetry

Record the transport-preservation requirement at the ADR decision level.

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

Copilot-Session: 346bf168-b668-4c4a-a8db-a67282ee5e5f

* docs: split declarative agent and workflow usage

Allocate separate adjacent v1 indexes for declarative agents and declarative workflows in Python and .NET, shifting later unreleased rows.

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

Copilot-Session: 346bf168-b668-4c4a-a8db-a67282ee5e5f

* docs: accept feature usage telemetry ADR

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

Copilot-Session: 346bf168-b668-4c4a-a8db-a67282ee5e5f

* docs: record feature telemetry ADR participants

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

Copilot-Session: 346bf168-b668-4c4a-a8db-a67282ee5e5f

* docs: expand feature telemetry ADR consultation

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

Copilot-Session: 346bf168-b668-4c4a-a8db-a67282ee5e5f

* docs: clarify feature telemetry semantics

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

Copilot-Session: 346bf168-b668-4c4a-a8db-a67282ee5e5f

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 346bf168-b668-4c4a-a8db-a67282ee5e5f
2026-07-29 14:37:13 +00:00
Yufeng He 33bc9c063c Python: extract keywords from non-English text for topic selection (#7130)
_WORD_PATTERN matched only ASCII (`[a-z0-9]...`), so a message written in
CJK, Cyrillic or any other non-Latin script produced an empty keyword set.
_select_topics returns early on an empty keyword set, so non-English users
never had memory topic files loaded automatically.

Make the pattern Unicode-aware (`[^\W_][\w-]+`, a letter/digit start plus
word chars/hyphen), which is the exact Unicode generalization of the old
pattern: English tokenization is unchanged and CJK/Cyrillic text now yields
keywords.
2026-07-29 02:34:58 +00:00
Alexander Nachtmann 8d22bb9177 .NET: Add Anthropic-backed live tests for OpenAI Responses hosting helpers (#7362)
Mirrors OpenAIResponsesHostingLiveTests with the hosted agent backed by an
Anthropic chat client, confirming the app-owned hosting helper surface
(OpenAIResponses + AgentSessionStore) is provider-agnostic end to end.
Skipped unless ANTHROPIC_API_KEY is configured.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 21:12:42 +00:00
Dineshsuriya D ad20ab009b .NET: Add GitHub Copilot BYOK sample (#7337)
* .NET: Add GitHub Copilot BYOK sample

Demonstrates routing GitHubCopilotAgent requests through a custom OpenAI-compatible
endpoint via SessionConfig.Provider instead of the default GitHub Copilot backend.

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Update dotnet/samples/02-agents/AgentProviders/github-copilot/Agent_With_GitHubCopilot_BYOK/README.md

Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>

* Update dotnet/samples/02-agents/AgentProviders/github-copilot/Agent_With_GitHubCopilot_BYOK/README.md

Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>

* .NET: Address remaining BYOK sample review feedback

- Make the provider type configurable via BYOK_PROVIDER_TYPE (default "openai") instead
  of hardcoding "openai", since the sample already documents Azure/Anthropic support.
- Stop calling the endpoint "OpenAI-compatible" everywhere; Anthropic isn't OpenAI-wire-
  compatible, so reword to "your own endpoint" and list the actual supported providers.
- Move the "About BYOK" explainer to the top of the README so the term is introduced
  before it's used, and finish applying the WireApi/ModelId comment suggestions.
- Reword the AgentProviders/README.md entry to match (not OpenAI-specific).

* .NET: Fix UTF-8 BOM on BYOK sample Program.cs

The repo's .editorconfig requires utf-8-bom for .cs files; check-format was
failing because the new file was written without one.

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>
2026-07-28 20:48:57 +00:00
Peter Ibekwe aa53182dc0 .NET: Preserve table state across declarative EditTable operations  (#7353)
* Preserve table state across declarative EditTable operations 

* Address PR comments.
2026-07-28 18:44:38 +00:00
Scarab Systems 3daa3b2c2d Python: Fix Gemini harness tool declarations (#7322)
* Fix Gemini harness tool declarations

Forward Agent Framework FunctionTool JSON Schemas to the Gemini SDK parameters_json_schema field and enable Developer API server-side tool invocation reporting when native Gemini tools are mixed with function declarations.

Preserves Vertex AI behavior and existing function-calling tool_choice config.

Validation:

- uv run --directory python poe check -P gemini

- uv run --directory python poe build -P gemini

- uv run --directory python poe test -A -m 'not integration'

- uv run --directory python pytest packages/gemini/tests/test_gemini_client.py -q -m integration (8 skipped: credential-gated)

* Python: Use typing_extensions TypedDict in Gemini tests

Use typing_extensions.TypedDict for the Gemini JSON Schema test helper so Pydantic can build the model on Python 3.11.

This keeps the CI fix scoped to the failing test compatibility issue without changing Gemini client behavior.

---------

Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com>
2026-07-28 17:49:14 +00:00
Eduard van Valkenburg 7514122d59 Python: isolate dependency-bound validation (#7342)
* Python: isolate dependency-bound validation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e446e833-ea7c-44e8-8b73-e730e30160af

* Python: remove unused validator import

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e446e833-ea7c-44e8-8b73-e730e30160af

* Python: keep core dependency validation isolated

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e446e833-ea7c-44e8-8b73-e730e30160af

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e446e833-ea7c-44e8-8b73-e730e30160af
2026-07-28 17:15:42 +00:00
Roger Barreto 859f56fb49 .NET: Skip flaky InputWaiterTests timeout test blocking the merge queue (#7361)
InputWaiter_WaitForInputAsync_CompletesWhenTimeoutExpiresAsync races a
300ms SemaphoreSlim timeout against a 5s Task.Delay guard and asserts
which one won by object identity. On the loaded net472/windows-latest
leg, thread pool starvation can delay the 300ms continuation past the
5s guard, so Task.Delay wins and the assertion fails.

It failed in 7 of the last 18 failed dotnet-build-and-test runs, always
on net472/windows-latest and always in merge_group, blocking PRs that
do not touch the workflows code.

Quarantine it following the existing convention used for #5845, and
track the real fix in #7360.

Copilot-Session: 0be6f810-51de-4f49-b9c7-8d1c7efa2c43
2026-07-28 15:50:47 +00:00
SergeyMenshykh 2694120383 Forward A2A MessageSendParams.Configuration in the A2A adapter (#7365)
The A2A hosting layer now forwards the caller-supplied
SendMessageConfiguration from RequestContext.Configuration into
AgentRunOptions.AdditionalProperties under the key
'a2a.configuration'. This covers all three handler paths:
non-streaming, streaming, and task continuation.

The server-configured AgentRunMode remains authoritative for
AllowBackgroundResponses — the caller's ReturnImmediately is
forwarded but does not override the server decision.

Closes microsoft/agent-framework#5869

Co-authored-by: SergeyMenshykh <SergeMenshikh@outlook.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5ee820de-3e34-493b-a19c-1db6bc04871d
2026-07-28 15:25:38 +00:00
westey 7b6d257988 Python: Add TodoProvider and AgentModeProvider samples (#7309)
* Python: Add TodoProvider and AgentModeProvider context provider samples

Add two Python samples under samples/02-agents/context_providers/ mirroring the
.NET samples from #7262:
- todo_provider.py: scripted walkthrough of TodoProvider that plans multi-step
  work and prints the evolving todo list after each turn.
- agent_mode_provider.py: interactive loop using AgentModeProvider with a /mode
  slash command, demonstrating built-in plan/execute and custom modes.

Also index both samples in the context_providers README.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8725831c-086b-475f-90e6-cdba41d59c33

* Python: Address review comments on AgentModeProvider sample

- Replace the AGENT_MODE_USE_CUSTOM env var with an in-file USE_CUSTOM_MODES
  constant for choosing between built-in and custom modes.
- Use plain input() in the interactive loop instead of asyncio.to_thread.
- Update the README prerequisites to reference the in-file toggle.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8725831c-086b-475f-90e6-cdba41d59c33

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8725831c-086b-475f-90e6-cdba41d59c33
2026-07-27 20:28:58 +00:00
Henry Su acbcdaa086 Python: fix(python): handle callable class middleware safely in _determine_middleware_type (#6697) (#7333)
* fix(python): handle callable class middleware safely in _determine_middleware_type (#6697)

* test(python): type-annotate test middleware lists to pass test-typing checks
2026-07-27 19:39:47 +00:00
KXH 0b0dcaa5af fix(dotnet): preserve table after EditTable add (#7324)
Signed-off-by: KXH <shepherdlaurie238@gmail.com>
2026-07-27 18:29:32 +00:00
Copilot 35a8891d67 .NET: Add Microsoft.Agents.AI.LocalCodeAct to release solution filter (#7343)
* Initial plan

* Add Microsoft.Agents.AI.LocalCodeAct to release solution filter

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-27 14:18:31 +00:00
westey 28e02d4669 Create session for tool approval agent when non-present (#7310) 2026-07-27 11:07:43 +00:00
Sriraj 5ccd7784a6 Python: Reject Windows junctions in FileSystemAgentFileStore (#7291)
* Python: reject file-store junctions during enumeration

* Python: clarify file-store probe diagnostics
2026-07-27 10:47:57 +00:00
pratik wayase 754cbe5976 Python: [Feature]: Support OpenAI instructions in Responses API (#7292)
* Python: Support OpenAI instructions in Responses API

* fix: address PR comments and fix typing for OpenAIChatOptions
2026-07-27 10:30:34 +00:00
Giles Odigwe c6442de528 .NET: Graduate GitHub Copilot agent to stable (#7313)
Promote Microsoft.Agents.AI.GitHub.Copilot from release candidate to
released by replacing IsReleaseCandidate=true with IsReleased=true, so the
package builds with the stable central version (no -rc suffix). Also clears
the package-validation baseline and disables package validation for this
first stable release, since the package has never shipped a stable NuGet to
validate against (mirrors the Microsoft.Agents.AI.Harness graduation in
#7119). Non-breaking: the package exposes no [Experimental] APIs to un-mark.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f523064c-60b4-4d18-bf95-c16c5fda9126
2026-07-24 22:46:19 +00:00
Tao Chen 0df184e7dd Python: Fix sub-workflow checkpoint restore to preserve sub-workflow state (#7097)
* Fix sub-workflow checkpoint restore to preserve sub-workflow state

Add Runner.capture_checkpoint_object/restore_from_checkpoint_object (quiescent-only nested checkpoint) and embed a sub_workflow_checkpoint in WorkflowExecutor.on_checkpoint_save/on_checkpoint_restore so a resumed parent restores each sub-workflow's mid-progress state instead of only replaying pending request-info events. Keeps a backward-compat fallback when sub_workflow_checkpoint is absent.

* Move checkpoint-object construction into the runner context

Add RunnerContext.create_checkpoint_object alongside create_checkpoint (create_checkpoint now delegates to it and persists), so Runner.capture_checkpoint_object builds the snapshot via the context instead of a one-off get_messages peek primitive. In-flight messages are captured non-destructively (per-source lists copied). The checkpoint-less capturing contexts (azurefunctions, durabletask) raise NotImplementedError to match create_checkpoint.

* Remove per-execution bookkeeping from WorkflowExecutor

The sub-workflow is a single shared instance, so per-execution ExecutionContext/request routing never provided real isolation. Delegate request/response tracking to the sub-workflow itself: can_handle accepts targeted propagated responses, _handle_response validates against the sub-workflow's pending requests and forwards responses immediately, and on_checkpoint_save embeds only the sub-workflow checkpoint (on_checkpoint_restore keeps a legacy reader for older checkpoints). Also emit the fresh-message/checkpoint-while-pending warning from FunctionalWorkflow.run to match Workflow.run.

* Drop redundant decode in WorkflowExecutor.on_checkpoint_restore

The storage backend already materializes the full checkpoint on load (FileCheckpointStorage decodes recursively; InMemoryCheckpointStorage deep-copies), so the embedded sub_workflow_checkpoint (and legacy execution_contexts) arrive already decoded - like every other executor's on_checkpoint_restore state. Remove the no-op decode_checkpoint_value calls and the now-unused import.

* Clean up

* Do not allow checkpoint storage in sub workflow

* Address comments

* Fix syntax check

* Add warning

---------

Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com>
2026-07-24 17:21:41 +00:00
westey d0a0d5a3df .NET: Add TodoProvider and AgentModeProvider samples (#7262)
* Add samples for todo and mode providers

* Address PR review: add Step21 to samples index and trim slash-command input

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

* Print agent mode after each turn in AgentMode sample

Reflects mode changes the agent makes itself via the mode_set tool.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-24 12:57:10 +00:00
westey c59a65da5e .NET: fix InMemoryChatHistoryProvider persisting when service stores history (#7284)
* Fix chat history storage bug

* Improve error messaging

* Address PR comment
2026-07-24 10:44:52 +00:00
Evan Mattson e90b6de5a7 Python: Improve python package management operations (#7274)
* improve package mgmt timings

* Address Python release validation review feedback
2026-07-23 22:14:10 +00:00
Atharva Vichare d98ac29115 Python: Fix duplicate function call on approval round-trip (#7267) (#7271)
* Python: Fix duplicate function call on approval round-trip (#7267)

`_replace_approval_contents_with_results` deduped restored function calls
against only the message currently being scanned. On an approval round-trip
the hosting layer replays the stored `function_call` item and its
`mcp_approval_request` item as two separate assistant messages, so the
per-message check never fired and the approval request restored a second
copy of the call.

Only one copy received the function result; the orphaned copy was left
unanswered, which the Responses API rejects with
"No tool output found for function call call_<id>".

Collect existing call ids across all messages instead, and add a restored
call to that set so two approval requests for the same call cannot both
expand.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Refactor approval placeholder result handling

Refactor approval handling logic to improve clarity and maintainability.

* Refactor test to support reused call IDs after completion

Updated the test to allow reused call IDs after completion, ensuring that a completed call does not suppress later approval requests with the same ID. Adjusted assertions to reflect the new behavior.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 21:27:26 +00:00
Giles Odigwe 040e2705aa Promote agent-framework-github-copilot to 1.0.0 (released) (#7302)
Promote the GitHub Copilot package from release candidate (1.0.0rc4) to released (1.0.0): bump the version, switch the classifier to Production/Stable, update PACKAGE_STATUS.md, and drop the --pre install flag from the package and sample READMEs. Add a github-copilot-1.0.0 CHANGELOG section covering the promotion and the input-attachment forwarding shipped in this release. No core/root bump: this is a standalone package promotion and the core[all] extra references the package without a version pin.

Copilot-Session: f523064c-60b4-4d18-bf95-c16c5fda9126
2026-07-23 21:06:31 +00:00
Giles Odigwe 8c057507f4 Python: Forward GitHub Copilot input attachments as inline blobs (#7300)
* Python: Forward GitHub Copilot input attachments as inline blobs

The Python GitHubCopilotAgent built the prompt from message text only, so
DataContent (images/documents) passed on input was silently dropped. The .NET
provider already forwards these as attachments.

Map input data content to the Copilot SDK's inline BlobAttachment (base64,
no temp files) in both the streaming and non-streaming send paths. Data content
without a media type is dropped with a warning instead of silently.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: ac85c429-4115-42ef-a18a-f576e3cf03f7

* Python: Handle non-base64 data URIs and fix attachment docstring

Address PR review feedback:
- Guard _get_data_bytes_as_str against ContentError so a non-base64 data:
  URI (which _validate_uri still classifies as type="data") is skipped with a
  warning instead of failing the entire Copilot request.
- Correct the docstring: remote URIs and non-base64 data URIs are neither
  attached nor added to the prompt (the prompt is built from text content only).
- Add tests for the non-base64 data URI path.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: ac85c429-4115-42ef-a18a-f576e3cf03f7

* Python: Fix flaky attachment test under telemetry

The end-to-end non-base64 data URI test failed in CI because GitHubCopilotAgent's
telemetry layer serializes message content (observability._to_otel_part ->
_get_data_bytes_as_str), which raises ContentError on a non-base64 data: URI
before the attachment code runs. That is an unrelated core-observability
limitation, not attachment behavior.

Use RawGitHubCopilotAgent (no telemetry layer) for that test so it isolates the
provider's send path. The direct helper test still covers the ContentError guard.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: ac85c429-4115-42ef-a18a-f576e3cf03f7

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: ac85c429-4115-42ef-a18a-f576e3cf03f7
2026-07-23 21:05:52 +00:00
Peter Ibekwe 0d5c0f8fa0 .NET: Add language and prompt customization to Magentic orchestration (#7263)
* Add language and prompt customization to Magentic orchestration

* Update default prompts formatting
2026-07-23 18:19:12 +00:00
Chinedum Echeta 217912a2c0 Python: Support async credentials in FoundryToolbox (#7208)
* Python: Support async credentials in `FoundryToolbox`

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Refactor: Use AzureCredentialTypes for credential type annotations in Toolbox classes

* Remove auth_flow method from _ToolboxAuth class

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-23 17:57:31 +00:00
pratik wayase 0841116330 Python: fix(foundry_hosting): preserve auth credentials across FoundryToolbox reconnections (#7202)
* fix(foundry_hosting): preserve auth credentials across FoundryToolbox reconnections

* Address copilot comments

* fix syntax check

* Fix tests

* Fix formatting

* Fix formatting

---------

Co-authored-by: Tao Chen <taochen@microsoft.com>
2026-07-23 17:15:19 +00:00
Evan Mattson cd6345e91e Python: Align AG-UI workflow cache scoping (#7277)
* snapshot scope resolver isolation

* Fix AG-UI scope resolver test typing
2026-07-23 17:13:27 +00:00
Amit Dhawan 59b979213a Python: Fix stale agent.json references in A2A sample (#7281)
Co-authored-by: Amit Dhawan <amit.dhawan@barco.com>
2026-07-23 15:59:01 +00:00
westey ad26cfe8c7 .NET: Switch to using new community toolkit VectorData packages (#5694)
* Switch to using new community toolkit VectorData packages

* Fix formatting.

* Update dotnet/Directory.Packages.props

Co-authored-by: Adam Sitnik <adam.sitnik@gmail.com>

* Fix build error.

* Upgrade MEAI

* Upgrade additional dependencies

* Address rename after package upgrade.

* Revert some packages versions due to version mismatches

---------

Co-authored-by: Adam Sitnik <adam.sitnik@gmail.com>
2026-07-23 15:02:24 +00:00
dependabot[bot] 0c8bf5b6c0 Bump brace-expansion in /python/packages/devui/frontend (#7232)
Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 1.1.12 to 1.1.16.
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.12...v1.1.16)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 1.1.16
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-23 11:57:16 +00:00
Eduard van Valkenburg cb2914fa7e Bump agent-framework-hosting-a2a to 1.0.0a260723 (#7282)
Prepare the focused alpha release for the progressive A2A adapters from #7258. No other package versions or dependency bounds change.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 003e02dd-dba0-40a5-9ebf-083901aefb57
2026-07-23 10:56:15 +00:00
Eduard van Valkenburg 0796af0c26 Python: add progressive A2A hosting adapters (#7258)
* Python: add progressive A2A hosting adapters

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

Copilot-Session: 5d6987cd-1b67-4ba1-8b54-3c50da6e7607

* Python: address A2A adapter review feedback

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

Copilot-Session: 5d6987cd-1b67-4ba1-8b54-3c50da6e7607

---------

Copilot-Session: 5d6987cd-1b67-4ba1-8b54-3c50da6e7607
2026-07-23 07:43:30 +00:00
Evan Mattson 711d6f24ae Bump Python package versions for 1.12.1 release (#7273)
Bump root and core to 1.12.1, OpenAI to 1.11.0 for new public prompt-cache options, Foundry to 1.10.3, and Gemini and Foundry Hosting to beta 260722 based on CHANGELOG entries. Promote AG-UI from 1.0.0rc9 to stable 1.0.0. No beta cohort bump was applied, and core floors remain unchanged under the strict affected-dependency policy because the connectors do not require a new core API.
2026-07-23 13:29:20 +09:00
Evan Mattson bd17a64697 Restore dedicated DevFlow Copilot authentication (#7276)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d9fa4e9c-c32d-42fb-8ee4-4772473e6479
2026-07-23 13:23:39 +09:00
Eduard van Valkenburg 5147579992 Python: Enforce package coverage by lifecycle (#7261)
* Enforce Python coverage by package lifecycle

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9ae5ad8e-6b66-41b3-a862-4e2a3fae1cd0

* Fix Python CI and deprecation usage

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

Copilot-Session: 9ae5ad8e-6b66-41b3-a862-4e2a3fae1cd0

* Make POSIX kill-tree test portable

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

Copilot-Session: 9ae5ad8e-6b66-41b3-a862-4e2a3fae1cd0

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9ae5ad8e-6b66-41b3-a862-4e2a3fae1cd0
2026-07-23 02:38:28 +00:00
Evan Mattson 2d34deeb82 Reduce workflow credential exposure (#7270)
* Mask workflow authentication configuration

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d9fa4e9c-c32d-42fb-8ee4-4772473e6479

* Use run-scoped Copilot authentication

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d9fa4e9c-c32d-42fb-8ee4-4772473e6479

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d9fa4e9c-c32d-42fb-8ee4-4772473e6479
2026-07-23 08:57:40 +09:00
Evan Mattson a2927c1c09 Python: Fix stateless replay of reasoning-paired tool calls (#7233)
* Python: Fix reasoning-paired client tool replay

* Python: Handle middleware-terminated reasoning tool loops

* Python: Replay encrypted reasoning function groups

Key decisions:
- Request encrypted reasoning on client-managed Responses calls while preserving caller include values.
- Store encrypted payloads in Content.protected_data and reconstruct one provider reasoning item per reasoning id.
- Replay active and completed function call/result groups; retain continuation-owned history behavior and the existing orphan-safe MCP path.

Files changed:
- python/packages/openai/agent_framework_openai/_chat_client.py
- python/packages/openai/tests/openai/test_openai_chat_client.py

Next iteration:
- Extend encrypted reasoning preservation to streaming and framework serialization boundaries.

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

* Python: Preserve encrypted reasoning through streaming

Key decisions:
- Capture encrypted reasoning from terminal streamed output items in Content.protected_data.
- Preserve summary and private reasoning as distinct framework contents while reconstructing one provider reasoning item per id.
- Prove replay after Message JSON and workflow checkpoint round trips, including encrypted-only and completed function groups.

Files changed:
- python/packages/core/agent_framework/_types.py
- python/packages/openai/agent_framework_openai/_chat_client.py
- python/packages/openai/tests/openai/test_openai_chat_client.py

Next iteration:
- Extend lossless stateless reasoning replay to hosted MCP call/output groups.

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

* Python: Replay hosted MCP reasoning groups

Key decisions:
- Preserve hosted MCP call/output groups in client-managed history instead of deleting them when reasoning cannot be reconstructed.
- Keep call/result coalescing and orphan-result exclusion intact, while retaining continuation-owned duplicate avoidance.
- Cover completed, active, and multi-call reasoning groups plus the public outgoing request boundary.

Files changed:
- python/packages/openai/agent_framework_openai/_chat_client.py
- python/packages/openai/tests/openai/test_openai_chat_client.py

Next iteration:
- Preserve middleware-terminated and parallel function groups atomically.
- Add preflight rejection for non-replayable reasoning groups in the dedicated validation slice.

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

* Python: Preserve terminated parallel reasoning groups

Key decisions:
- Return ordinary function results when middleware terminates a loop, removing the provider-specific durable marker.
- Preserve every parallel call and available sibling result as one encrypted reasoning group in stateless replay.
- Prove successful and policy-blocked batches through the public two-agent Foundry workflow and outgoing HTTP boundary.

Files changed:
- python/packages/core/agent_framework/_tools.py
- python/packages/core/tests/core/test_function_invocation_logic.py
- python/packages/openai/tests/openai/test_openai_chat_client.py
- python/packages/foundry/tests/foundry/test_foundry_agent.py

Next iteration:
- Add preflight rejection for non-replayable and partially compacted reasoning groups.

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

* Python: Reject unsafe stateless reasoning replay

Key decisions:
- Validate client-managed reasoning groups after compaction and report every affected reasoning and call identifier before transport.
- Permit service-owned continuation and fully excluded atomic groups while rejecting partial compaction projections.
- Surface encrypted-reasoning capability failures without lossy retries.

Files changed:
- python/packages/openai/agent_framework_openai/_chat_client.py
- python/packages/openai/tests/openai/test_openai_chat_client.py

Next iteration:
- Run the resource-specific Foundry proof and finish PR #7233; that live proof remains intentionally local and requires the configured developer resource.

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

* Python: Preserve reasoning metadata in Foundry hosting

* Python: Avoid duplicating reasoning text metadata

* Python: Gate encrypted reasoning for Foundry agents

* Python: Type stateless reasoning integration test

* Python: Narrow Foundry mock call arguments

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-22 23:51:22 +00:00
Ben Thomas c68c099347 .NET: Fix expensive logging (#7268)
* .NET: Guard workflow warning logging

Avoid unnecessary structured logging argument evaluation when warning logging is disabled, resolving CA1873 in release builds.

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

Copilot-Session: 0bc01e26-22ba-42ce-ac1e-6fe166500f4f

* .NET: Use generated workflow logging

Align the no-progress warning with the repository-standard LoggerMessage source generator pattern.

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

Copilot-Session: 0bc01e26-22ba-42ce-ac1e-6fe166500f4f

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Ben Thomas <25218250+alliscode@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Copilot-Session: 0bc01e26-22ba-42ce-ac1e-6fe166500f4f
2026-07-22 23:03:32 +00:00
Yunus Emre Gültepe 61802723ff Python: Support prompt cache breakpoints for GPT-5.6 models in OpenAI clients (#7163)
* Python: Support prompt cache breakpoints for GPT-5.6 models in OpenAI clients

Add request-level prompt_cache_options to OpenAIChatOptions and
OpenAIChatCompletionOptions, and forward a per-part prompt_cache_breakpoint
from Content.additional_properties onto the content blocks each API supports.
Text parts that carry a breakpoint keep typed list content, since the
plain-string form cannot hold one; without a breakpoint the existing string
forms are unchanged.

* Clarify system-message content-shape comment

* Address review: SDK prompt cache types, private helper, add sample

Replace the custom PromptCacheOptions TypedDict with the openai SDK's own
types for each API, which raises the openai floor to 2.45.0 where those
types were introduced. Make the breakpoint helper private to the two chat
clients. Add a prompt caching sample with a README entry, and unquote the
helper's Content annotation so the pyupgrade hook passes.

* Guard the prompt cache options import for older openai versions

The SDK's PromptCacheOptions types only exist in openai 2.45.0 and
later, so each client falls back to a local mirror when the import
fails and the dependency floor stays at 2.25.0. A TYPE_CHECKING-only
import is not enough because the options classes are introspected with
get_type_hints() at runtime. Verified against openai 2.25.0: the
package imports, the fallback resolves, and part-level breakpoints
still work; sending the option itself requires 2.45.0, which the field
docstrings now note.

* Make the old-openai fallback for PromptCacheOptions deliberately empty

Assigning None instead, as suggested in review, trips pyright's
reportInvalidTypeForm on the field annotation (the symbol becomes
type | None after the try/except). An empty TypedDict gives the same
effect for users on older openai versions: any content they put in
prompt_cache_options is flagged by their type checker, since the
option cannot be sent on those versions anyway, while
get_type_hints() on the options classes keeps working at runtime.

* Guard prompt_cache_options at runtime instead of via an empty fallback type

The empty-TypedDict fallback flagged valid `prompt_cache_options` usage under
pyright on every openai version — including this PR's own
`client_prompt_caching.py` sample (`poe check -S`) — because pyright resolves the
try/except symbol to the fallback shape regardless of the installed openai, while
mypy/ty resolve the failed import to `Any` and never warn. So a type-only "warn on
old openai" signal is not achievable cleanly across type checkers.

Restore the faithful fallback (mirrors the SDK's `mode`/`ttl` shape) so the option
type-checks identically on every supported openai version, and add a runtime guard:
setting `prompt_cache_options` on openai < 2.45 now raises a clear
ChatClientInvalidRequestException instead of forwarding an unusable option to the
SDK. This keeps the option non-silent for all users regardless of type checker,
without forcing an openai upgrade. Adds tests covering the guard for both clients.

* Gate system/developer breakpoint shape on a real mapping value

The system/developer branch switched to list-form content whenever
prompt_cache_breakpoint was set to any non-None value, but the option is
only attached when the value is a mapping. A malformed value (e.g. a
string) therefore changed the message shape without adding a breakpoint.
Decide the shape from the built part instead, matching the user-role path.
2026-07-22 21:43:19 +00:00
Whit Waldo ddb0622f9c .NET: Added GettingStarted example demonstrating Dapr as an agent provider (#1615)
* Added example demonstrating creating an AIAgent using the Microsoft.AI.Extensions implementation of IChatClient using Dapr as the inference backend provider - in this example, using Ollama

Signed-off-by: Whit Waldo <whit.waldo@innovian.net>

* Update dotnet/samples/GettingStarted/AgentProviders/Agent_With_Dapr/README.md

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

* Added copyright statement at top of file

Signed-off-by: Whit Waldo <whit.waldo@innovian.net>

* Update dotnet/agent-framework-dotnet.slnx

That's odd the IDE added it a second time.

Co-authored-by: westey <164392973+westey-m@users.noreply.github.com>

* Address review nits: configurable Dapr gRPC endpoint and document VersionOverride

Make the Dapr sidecar gRPC endpoint configurable via the DAPR_GRPC_ENDPOINT environment variable
(defaulting to http://localhost:3501) and document it in the README. Add a comment explaining why the
Microsoft.Extensions.* VersionOverride entries are needed and when they can be removed.

---------

Signed-off-by: Whit Waldo <whit.waldo@innovian.net>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: westey <164392973+westey-m@users.noreply.github.com>
Co-authored-by: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com>
2026-07-22 19:28:00 +00:00
Ben Thomas 12b23250f4 Updating dotnet version for release. (#7265)
Co-authored-by: Ben Thomas <25218250+alliscode@users.noreply.github.com>
2026-07-22 10:05:59 -07:00
Ben Thomas bfc73a5b14 .NET: Fix declarative autosend output (#7217)
* Fix declarative workflow auto-send output

Restore completed responses for workflow-conversation agents while preventing hosted workflow adapters from materializing streamed responses twice.

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

Copilot-Session: c2d86826-ead0-40bc-b84b-a513ac4d325f

* Correlate streamed workflow responses by message

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

Copilot-Session: c2d86826-ead0-40bc-b84b-a513ac4d325f

* Handle empty streaming message IDs

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

Copilot-Session: c2d86826-ead0-40bc-b84b-a513ac4d325f

* Restore workflow conversation auto-send

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

Copilot-Session: c2d86826-ead0-40bc-b84b-a513ac4d325f

* Address workflow response review feedback

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

Copilot-Session: c2d86826-ead0-40bc-b84b-a513ac4d325f

* Ignore whitespace workflow message IDs

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

Copilot-Session: c2d86826-ead0-40bc-b84b-a513ac4d325f

* Correlate all content-bearing agent updates

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

Copilot-Session: c2d86826-ead0-40bc-b84b-a513ac4d325f

---------

Co-authored-by: Ben Thomas <25218250+alliscode@users.noreply.github.com>
2026-07-22 15:11:59 +00:00
Roger Barreto 1f1da1bddb .NET: [BREAKING] Hosting OpenAI Responses protocol helpers and optional execution state (#7000)
* .NET: Add OpenAI Responses protocol helpers and optional execution state (ADR-0032)

* Fix netstandard2.0/net472 build; harden helpers and workflow checkpoint key per review

* .NET: Migrate hosting Responses samples to Azure.AI.Projects and fix workflow resume

Migrate HostingResponsesAgent and HostingResponsesWorkflow samples from
Azure.AI.OpenAI to Azure.AI.Projects (AIProjectClient.AsAIAgent), using the
FOUNDRY_PROJECT_ENDPOINT/FOUNDRY_MODEL convention.

Fix HostedWorkflowState.RunOrResumeAsync: on subsequent turns, restore the
session's latest checkpoint and run the workflow forward with the new turn's
input (mirroring the Python hosting host's restore-then-run semantics) instead
of resuming a halted run with no input, which waited on input indefinitely.
Add round-trip resume tests and update ADR-0032/spec-003 wording.

* .NET: Fix HostedWorkflowState resume hang on unserviced external requests

On resume, HostedWorkflowState.RunOrResumeAsync drained the workflow with the
blocking WatchStreamAsync overload, so a workflow that halts at an unserviced
RequestInfoEvent (human-in-the-loop / approval) blocked forever — asymmetric
with the first-turn RunAsync path, which returns at the same halt. Break the
drain when a superstep completes with HasPendingRequests, restoring symmetry
with turn 1. Add a HITL approval-gate workflow and a resume-does-not-block test.

* .NET: Warn when a HostedWorkflowState resume makes no progress

Add an optional ILoggerFactory to HostedWorkflowState and log a warning when a
resumed turn produces no events, mirroring the Python host's zero-event restore
warning (a stale checkpoint or an input that does not match the workflow's
expected type leaves session state unprogressed). Add a non-chat string workflow
helper, a capturing logger, and a red/green test.

* .NET: Resume HostedWorkflowState from durable checkpoint on cursor miss

Add CheckpointManager.GetLatestCheckpointAsync(sessionId) and have
HostedWorkflowState fall back to it when its in-memory head cursor misses, so a
durable CheckpointManager resumes a session across a process restart or a new
holder instead of restarting from the workflow's start executor. Mirrors the
Python host's per-turn get_latest read-through. Add a counting workflow that
proves resume-vs-fresh via accumulated state, plus a red/green test, and update
ADR-0032/spec-003 and the XML remarks.

* .NET: Serialize HostedWorkflowState turns through a workflow lock

A single workflow instance backs the holder and workflow instances do not
support concurrent runs (the runner throws "already owned by another runner"),
so concurrent turns could fault or race the head cursor. Serialize all turns
through one SemaphoreSlim (mirroring the Python host's workflow lock) and make
HostedWorkflowState IDisposable to own it. Add a gated workflow and a
deterministic concurrency red/green test.

* .NET: Cover non-chat resume and multi-turn checkpoint advance

Add tests for HostedWorkflowState resuming a non-chat-protocol workflow (no
TurnToken) and for a third turn continuing to advance the head checkpoint,
closing the coverage gaps the parity review flagged.

* .NET: Add streaming workflow resume path and stream the workflow sample

Add HostedWorkflowState.RunOrResumeStreamingAsync, which yields the turn's
WorkflowEvents as they occur (fresh run or checkpoint resume) under the same
serialization lock and records the head checkpoint after the stream drains,
keeping the blocking and streaming workflow paths in lockstep with the Python
host. Honor stream:true in the HostingResponsesWorkflow sample by projecting
AgentResponseUpdateEvent updates over the Responses SSE wire. Add a streaming
resume test and update the README/spec.

* .NET: Cover Responses input adaptation to a typed workflow start executor

Demonstrate that HostedWorkflowState's generic RunOrResumeAsync<TInput> is the
input-adaptation seam (parity with Python's ResponsesChannel run hook): the app
adapts the Responses input into the workflow start executor's own type at the
call site. Add a typed-brief workflow and a test, and note the seam in spec-003.

* .NET: Drain workflow resume non-blocking to prevent hang and truncation

The resume drain used a SuperStepCompletedEvent{HasPendingRequests} proxy over
the blocking public WatchStreamAsync. That proxy (a) truncated a resumed turn
when a superstep both emitted a request and queued downstream work, and (b)
could fail to fire at all — re-introducing the indefinite hang — when a resume
input drove no superstep (e.g. a rejected non-chat input).

Make StreamingRun.WatchStreamAsync(bool blockOnPendingRequest, CancellationToken)
public and drain both the blocking and streaming resume paths with
blockOnPendingRequest:false, exactly matching the first-turn RunAsync semantics
(Run.RunToNextHaltAsync). Add guard tests: resume with a rejected input does not
hang, and a resume superstep with a request plus downstream work is not
truncated (verified red against the old proxy).

* .NET: Return file-store checkpoint index in commit order

CheckpointManager.GetLatestCheckpointAsync takes the last entry of a store's
index as the head checkpoint. FileSystemJsonCheckpointStore backed its index
with a HashSet, whose enumeration order is not contractual: after a rollback
frees and reuses a slot, enumeration can diverge from commit order, so the
durable read-through could resume a stale checkpoint. Mirror the HashSet with an
insertion-ordered list and enumerate it from RetrieveIndexAsync so 'latest' is
reliable. Add a CheckpointManager.GetLatestCheckpointAsync contract test over the
file store.

Note: the HashSet disorder is only reachable via the internal rollback path, so
the test locks the ordering contract rather than reproducing the rare disorder.

* .NET: Advance cursor when a streaming resume is abandoned

RunOrResumeStreamingAsync recorded the head checkpoint only after the stream was
fully enumerated. If an SSE consumer disconnected mid-turn after supersteps had
committed, the in-memory cursor kept the previous turn's head; because the next
turn is then a cursor hit, durable read-through could not self-heal, so it
resumed pre-disconnect state. Record the run's last committed checkpoint in a
finally so an abandoned stream still advances the cursor. Add a red/green test.

* .NET: Stream only the final agent's updates in the workflow sample

ExtractUpdates streamed every agent's updates, so the sequential Writer->Reviewer
sample streamed the intermediate draft and the final answer over SSE, differing
from the non-streaming response (final message only). Filter the streamed updates
to the final agent so streaming and non-streaming produce the same response.
Live-verified against Foundry: one output item streamed instead of two.

* .NET: Isolate the holder lock in the concurrency test

The concurrency test asserted the second same-session turn did not enter the
workflow, which also passes via the engine's concurrent-run ownership guard
(which faults) rather than the holder lock (which waits). Assert instead that the
second turn is not completed while the first holds the lock: a fault would
complete the task, so a pending task isolates the holder lock from the engine
guard. Verified red with the lock removed.

* Fix IDE1006 naming in tests; address review feedback and add hosting/live tests

* Document commit-order contract for ICheckpointStore.RetrieveIndexAsync

* Restructure hosting samples under af-hosting with client/server split matching Python parity

* Clarify hosting sample README wording and drop Python comparisons

* Make AgentSessionStore.DeleteSessionAsync abstract and rename session id parameter to sessionStoreId

* Rename OpenAIResponses id helpers and parse the request once for id extraction

* Reclaim per-session locks in HostedAgentState and demonstrate session locking in the agent sample

* Internalize per-session locking in HostedAgentState (automatic, on by default) and remove mirroring-Python wording from code and spec

* Remove HostedAgentState; app-owned routes use AgentSessionStore directly

HostedAgentState only bundled an AIAgent with an AgentSessionStore and, after
the per-session lock was removed, its GetOrCreateSessionAsync/SaveSessionAsync/
DeleteSessionAsync were pass-throughs that just bound the agent argument.
Create-on-miss already lives in the store (unlike Python, whose get/set-only
SessionStore justifies its AgentState holder), so the type earned its place
only via the lock.

Each AgentSessionStore.GetSessionAsync now returns an independent session
instance per call, so concurrent gets fork the same stored state (e.g.
branching from previous_response_id or managing several conversation ids)
without sharing an instance. The store does no cross-call locking; serializing
concurrent runs against the same id is the application's concern.

- Delete HostedAgentState and its unit tests.
- Rewire the local_responses sample and the OpenAI hosting unit/integration
  tests to call AgentSessionStore (GetSessionAsync/SaveSessionAsync) directly.
- Update ADR-0032, spec-003, and the af-hosting sample READMEs.

* Isolate hosted session snapshots and distinguish conversation vs response continuation

Mirrors the Python hosted-session isolation work: a hosted session read must be
an independent copy, and the app-owned route must persist under the right
continuation key depending on how the caller continued the thread.

- AgentSessionStore.GetSessionAsync: document the isolation invariant (each
  call returns an independent AgentSession so concurrent branches from one
  previous_response_id do not observe each other's mutations or alter stored
  state); fix the stale "or null if not found" wording (in-box stores return a
  fresh created session on miss). The in-box stores already satisfy this via a
  serialize/deserialize snapshot round-trip.
- local_responses sample + hosting unit-test route: choose the save key by
  channel. A stable conversation id is a mutable head (write back under the
  same id; app owns single-writer coordination). A previous_response_id
  continuation or first turn is an immutable snapshot (save under the new
  response id so branches from the same prior response stay independent).
- Add regression tests: independent get returns a distinct instance
  (InMemoryAgentSessionStore); previous_response_id supports independent
  branches ([1,2,2,3,3]); conversation id advances the mutable head ([1,2]).
- Update the sample README and ADR-0032 wording.

* Add workflow-factory support to HostedWorkflowState for concurrent sessions

HostedWorkflowState backed every session with one shared Workflow instance and
serialized all turns through a lock, so independent sessions could not run
concurrently. Add a workflow-factory constructor and remove the run lock.

- New constructor HostedWorkflowState(Func<CancellationToken, ValueTask<Workflow>>
  workflowFactory, ..., bool cacheWorkflow = false):
  - cacheWorkflow: false (default) builds a fresh instance per run, so independent
    sessions run in parallel. A resume rehydrates a fresh instance from the
    session's checkpoint in the shared store.
  - cacheWorkflow: true builds the workflow once, lazily on first use, and reuses
    it (a deferred, cached target that, like a shared instance, cannot run
    concurrent turns).
- Remove the internal SemaphoreSlim run lock and IDisposable; the instance
  constructor is unchanged in behaviour (one shared instance still cannot run
  concurrent turns). Turns are no longer serialized by the holder; a single
  writer per session is the application's responsibility.
- Switch the local_responses_workflow sample to the factory constructor with an
  explicit cacheWorkflow: false, and document the option.
- Add tests: parallel independent sessions (factory), fresh-instance resume,
  cached factory builds once and reuses, uncached factory builds per run.
- Update ADR-0032, spec-003, and the sample README.

* Clarify in ADR-0032 how .NET covers AgentState factory and async-setup via DI

* Rebuild cached workflow after a faulted build and add checkpoint index dedup tests
2026-07-22 10:32:44 +00:00
Giles Odigwe 83ba938d1e Python: preserve Gemini 3 thought_signature across function-call replays (#7095)
* Python: preserve Gemini 3 thought_signature across function-call replays

Gemini 3 requires the opaque thought_signature attached to each functionCall
part to be echoed back on every replay of that call, or the request is rejected
with 400 INVALID_ARGUMENT. The signature previously survived only via
raw_representation, so any layer that reconstructs a FunctionCallContent (e.g.
harness tool approval) dropped it and broke the next step of the tool loop.

Capture the signature into additional_properties on parse and replay it when
building the Gemini Part, independent of raw_representation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 33233834-bc6e-4ad2-a6f3-6f1d6e57b1d2

* Store Gemini thought_signature as base64 for JSON-safe persistence

Content.additional_properties is serialized via json.dumps(message.to_dict())
by history providers (e.g. RedisHistoryProvider), which fails on raw bytes.
Store the thought_signature as a base64 string on parse and decode it back to
bytes when building the Gemini Part. Also narrow call_id/name in the round-trip
test to satisfy the type checkers.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 33233834-bc6e-4ad2-a6f3-6f1d6e57b1d2

* Harden Gemini thought_signature decode against corrupted history

Guard the untyped additional_properties value with an isinstance(str) check and
decode with validate=True, degrading gracefully (warn + drop the signature) on
malformed data instead of raising binascii.Error mid tool loop. Matches the
defensive base64 handling already used for data URIs in this file.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 33233834-bc6e-4ad2-a6f3-6f1d6e57b1d2

* Carry Gemini thought_signature on reasoning content via protected_data

Represent the signature as a text_reasoning content's protected_data (base64)
immediately preceding the function call, instead of a bespoke additional_properties
key. This uses the framework's first-class opaque-signature field (as Anthropic
does), survives streaming accumulation, and stays intact when the harness
reconstructs the function call. Replay correlates the signature by adjacency.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 33233834-bc6e-4ad2-a6f3-6f1d6e57b1d2

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-22 07:10:45 +00:00
Evan Mattson d97c901301 Harden workflow credential selection (#7249)
* Harden workflow credential selection

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d9fa4e9c-c32d-42fb-8ee4-4772473e6479

* Address workflow authentication review feedback

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d9fa4e9c-c32d-42fb-8ee4-4772473e6479

* Fail safely on membership lookup errors

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d9fa4e9c-c32d-42fb-8ee4-4772473e6479

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-22 15:51:08 +09:00
Tao Chen d1d2610b28 Add MCPStreamableHTTPTool security guidance for custom http client (#7245) 2026-07-22 04:57:09 +00:00
Tao Chen 848443ac68 [BREAKING] Python: Ensure session isolation for FHA invocation impl (#7158)
* Ensure session isolation for FHA invocation impl

* Fix type check errors

* Add user isolation to sample
2026-07-21 19:33:01 +00:00
Giles Odigwe 1466d68cf1 Python: make FoundryToolbox.as_skills_provider() disable_caching effective (#7135)
* Python: make FoundryToolbox.as_skills_provider() disable_caching effective

as_skills_provider() forwarded disable_caching to SkillsProvider, which
ignores it for a caller-supplied SkillsSource, so it was a no-op and the
toolbox re-read skill://index.json on every agent run.

Compose caching in as_skills_provider() instead: wrap the context-independent
_FoundryToolboxSkillsSource in DeduplicatingSkillsSource(CachingSkillsSource(...)).
Add a cache_refresh_interval param, fix the docstring, and add tests covering
cached, disabled, and refresh-interval behavior.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 84150ec4-6f7c-4ef8-b9fb-12fa11652773

* Clarify caller-invariant skill-set wording in as_skills_provider docs

Emphasize that the toolbox advertises the same skill set to every caller (the
per-request call-id governs execution/authorization, not which skills are
listed) rather than leaning on 'ignores SkillsSourceContext'.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 84150ec4-6f7c-4ef8-b9fb-12fa11652773

* Make MCP skills reconnect-safe via session_provider

Cached MCPSkill objects captured the MCP ClientSession at construction, so
after a FoundryToolbox reconnect (which replaces its session) load_skill and
read_skill_resource would fail against the closed session. This regressed once
as_skills_provider() started caching discovery by default.

Add an optional session_provider callable to MCPSkillsSource and MCPSkill
(exactly one of client or session_provider). When supplied, the session is
resolved on every fetch, mirroring how MCPTool resolves self.session live at
call time. _FoundryToolboxSkillsSource now passes a provider that returns the
toolbox's current session, so cached skills always use the live session.

The fixed client= path is unchanged and backward-compatible. Update core tests,
foundry_hosting tests, and core AGENTS.md.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 84150ec4-6f7c-4ef8-b9fb-12fa11652773

* Fix ty error: type captured session_provider as Callable in test

ty could not call the provider narrowed from \object\ (Top callable). Type the
captured value as Callable[[], object] and drop the redundant callable() assert.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 84150ec4-6f7c-4ef8-b9fb-12fa11652773

* Simplify _resolve_mcp_session_provider per review

Address review feedback: replace the dense (client is None) == (session_provider
is None) guard with explicit branches, and drop the cast by binding the narrowed
client to a typed local. Keeps strict 'exactly one' semantics (raises on both and
on neither), matching the codebase convention (e.g. security.py mcp_tool/url).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 84150ec4-6f7c-4ef8-b9fb-12fa11652773

* Add PR #7135 entries to the 1.12.0 changelog

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 84150ec4-6f7c-4ef8-b9fb-12fa11652773

* Drop redundant @pytest.mark.asyncio from MCP skills tests

asyncio_mode is 'auto', so the marker is unnecessary. Remove it from the whole
file for consistency with the async-by-default convention. Per review feedback.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 84150ec4-6f7c-4ef8-b9fb-12fa11652773

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-21 18:02:38 +00:00
Eduard van Valkenburg d08200d00e Python: Bump package versions for 1.12.0 release (#7238)
* Bump Python package versions for 1.12.0 release

Bump packages represented in the 1.12.0 changelog, promote Foundry Hosting, Azure Content Understanding, Gemini, Mistral, Monty, and Tools to beta, and apply the requested beta cohort date stamp. Root and core move to 1.12.0, released and RC packages use their selected increments, alpha packages including Hosting MCP use the 260721 stamp, and core floors are raised only for proven consumers.

Copilot-Session: 2dd9980a-b869-4c16-8642-75b7a6d6ebdf

* fix version in readme

* Add Responses conversation ID changes to release notes

Include the breaking Hosting Responses conversation ID helper changes from #7234 in the Python 1.12.0 changelog.

Copilot-Session: 2dd9980a-b869-4c16-8642-75b7a6d6ebdf
2026-07-21 15:45:00 +00:00
Binit Mohanty fb38b1d10a Python: Fix PropertySchema.to_json_schema() not recursing into nested array items / object properties (#7200)
* Python: Fix PropertySchema.to_json_schema() not recursing into nested schemas

Nested array 'items' and object 'properties' kept the declarative 'kind'
key and empty 'enum' placeholders, producing JSON Schema OpenAI rejects
('schema must have a type key'). Recursively apply the same conversion the
top-level properties loop performs, including the serialized named-list
properties shape and nested required arrays.

Fixes #7198

(cherry picked from commit c156ffd05924fb5a1884625f2fc3d9bdc3e152b1)

* Python: Validate nested properties list before mutating to avoid partial conversion

Review feedback: the list-shaped properties branch popped name/required from
each element and returned on the first unexpected one, leaving earlier
elements half-converted. Validate the whole list first so an unexpected
shape leaves the node fully untouched.

* Python: Type nested-properties normalization for strict Pyright and drop unreachable dict branch

ObjectProperty always stores nested properties as a named list, so the
elif-dict branch in _normalize_nested_schemas was unreachable; remove it
and flatten the list conversion behind an early return. Cast the narrowed
items/props values so strict Pyright no longer reports unknown types.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Python: Emit additionalProperties: false on nested object nodes in PropertySchema.to_json_schema()

OpenAI strict structured outputs require additionalProperties: false on
every object node, but the chat clients only inject it at the schema
root, so declarative schemas with nested objects (e.g. array items)
failed with a schema-validation 400. Route the top-level properties loop
through _normalize_schema_node so all object nodes get the key, and add
a live OpenAI integration test covering the nested array-of-objects
response_format shape.

Verified live against the Responses API: the previous emission fails
with "In context=('properties', 'issues', 'items'),
'additionalProperties' is required to be supplied and to be false";
the new emission returns valid structured output.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 15:38:38 +00:00
Eduard van Valkenburg a70fe21298 [BREAKING] Python: add Responses conversation ID helper (#7234)
* Python: add Responses conversation ID helper

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

Copilot-Session: 2dd9980a-b869-4c16-8642-75b7a6d6ebdf

* Python: make Responses session flag optional

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

Copilot-Session: 2dd9980a-b869-4c16-8642-75b7a6d6ebdf

* Python: correlate Responses session return types

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

Copilot-Session: 2dd9980a-b869-4c16-8642-75b7a6d6ebdf

* Python: clarify Responses conversation parameter

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

Copilot-Session: 2dd9980a-b869-4c16-8642-75b7a6d6ebdf

* Python: clarify streaming conversation parameter

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

Copilot-Session: 2dd9980a-b869-4c16-8642-75b7a6d6ebdf

* Python: include conversation in created event

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

Copilot-Session: 2dd9980a-b869-4c16-8642-75b7a6d6ebdf

* Python: warn on nonstandard Responses IDs

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

Copilot-Session: 2dd9980a-b869-4c16-8642-75b7a6d6ebdf
2026-07-21 15:17:14 +00:00
Roger Barreto f6a3c43e9a .NET: Add source-type-agnostic consent regression test for a2a_preview (#7229) 2026-07-21 15:08:06 +00:00
westey e6f7b3e9be Version bump for .net release (#7237) 2026-07-21 14:59:07 +00:00
Eduard van Valkenburg a1f3e536bc Python: Add MCP hosting helpers (#7209)
* Python: Add MCP hosting helpers

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

Copilot-Session: 5d6987cd-1b67-4ba1-8b54-3c50da6e7607

* Python: Address MCP hosting review comments

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

Copilot-Session: 5d6987cd-1b67-4ba1-8b54-3c50da6e7607

* renamed to AgentMCPTool
2026-07-21 14:46:46 +00:00
westey c033adb1f4 .NET: [BREAKING] Graduate HarnessAgent (#7119)
* Graduate HarnessAgent

* Switch harness project to released and remove unreleased shell dependency

* Address PR comments.
2026-07-21 14:24:19 +00:00
Roger Barreto 09473fa7ed .NET: [BREAKING] Bind tool-approval responses to surfaced approval requests (#7111)
* .NET: Bind tool-approval responses to surfaced approval requests

Harden the tool-approval flow so an approved tool call always matches the
request the framework surfaced for approval.

Add ApprovalResponseBindingChatClient as the outermost decorator above
FunctionInvokingChatClient. It records each model-originated
ToolApprovalRequestContent in the session state and, on the next request,
binds every ToolApprovalResponseContent to its recorded request: the
response tool call is rebound to the recorded call, matched entries are
consumed for one-time use, and only approvals tied to a framework-issued
request take effect.

Apply the same binding in the ToolApprovalAgent harness by tracking the
requests it surfaces and binding collected responses to them during a
queue cycle.

Add ChatClientAgentOptions.DisableApprovalResponseBinding (default off) and
a UseApprovalResponseBinding builder extension for custom chat client stacks.
Includes unit tests for the decorator and the harness.

* .NET: Bind approval responses once per turn and avoid re-enumeration

Address review feedback on the approval-response binding decorator:
consume a matched request from the per-turn lookup so a duplicate response
with the same request id in one turn is honored only once, and return the
materialized message list instead of the original enumerable so a single-use
sequence is not enumerated twice. Rename the local pending list to
pendingRequests for clarity. Adds a duplicate-response regression test.

* .NET: Snapshot recorded approval requests and consume duplicates in the harness

Address review feedback on ToolApprovalAgent:
store a snapshot of each surfaced/pending approval request (cloned tool call
with a copied arguments dictionary) so a later mutation of the caller-visible
instance cannot change the recorded call used to bind the response, and consume
a surfaced request on match so a duplicate response with the same request id in
one pass is honored only once. Apply both symmetrically in the harness and the
ApprovalResponseBindingChatClient decorator. Adds regression tests for the
snapshot and duplicate-response cases.

* .NET: Address review feedback on approval-response binding

- Harness: store surfaced approval requests in a dictionary and consume matches directly, drop the extra hashset and the redundant record-time dedup; replace clear-on-resolution with a debug assert.
- Harness pipeline: add UseApprovalResponseBinding() as the outermost decorator in HarnessAgent (it uses UseProvidedChatClientAsIs) behind a new DisableApprovalResponseBinding option, with tests.
- Decorator: avoid message/content allocations when nothing changes, keep the original content when a response already matches the recorded call, clear pending each inbound turn, and shorten helpers.

* .NET: Compare tool calls by fields instead of serializing

Replace the JSON-serialization comparison in the approval-response binding
decorator with a direct field comparison. Fast-path FunctionCallContent by
comparing CallId, Name, and arguments field by field; any other tool call
shape rebinds. The comparison only skips an allocation (the call is always
rebound to the recorded request otherwise), so a miss just triggers a safe
rebuild. Adds a test that a matching response is forwarded unchanged.

* .NET: Bind approval responses against requests present in history

Fix a merge-queue regression where AG-UI mixed server/client tool invocation
stopped executing the server tool. The binding decorator validated approval
responses only against its own recorded pending state, so a matched approval
request/response pair replayed from conversation history was treated as
unbound and dropped, and the auto-approved server tool never ran.

Treat known requests as the recorded pending state plus any approval requests
already present in the current messages, and stop dropping approval requests
(a request in history is the pairing authority). A response with no known
request anywhere is still dropped, so a forged approval cannot execute.

Also address review feedback: return the mutable contents buffer from a
helper instead of a null-forgiving operator, and use clearer naming
(PrepareMutableContentsBuffer / mutableContentsBuffer). Adds regression tests
for a request in history and a response bound to a history request with empty
pending state.
2026-07-21 14:23:48 +00:00
Robbie Walmsley a4f02aabf0 Python: Fix header_provider headers not reaching streamable HTTP transport requests (#7218)
* Python: fix header_provider headers not reaching streamable HTTP requests

MCPStreamableHTTPTool.call_tool stores header_provider output in a
ContextVar, but the streamable HTTP transport sends requests from tasks
spawned at connect time, whose contexts never observe values set later.
The request hook therefore always read an empty dict on real connections
and the per-call headers (e.g. Authorization) were silently dropped.

Keep the ContextVar for in-context reads and add an instance-level
snapshot of the active call's headers that the request hook falls back
to across tasks.

* Python: serialize header_provider tool calls to prevent cross-call header mixing

Parallel tool invocations run concurrently per function-invocation batch,
so two call_tool invocations on the same MCPStreamableHTTPTool could
overwrite each other's active-header snapshot while requests were still
in flight, attaching the wrong per-call credentials. Hold a per-instance
lock for the duration of a header-bearing call, add a regression test
that fails without the lock, and normalize captured header casing in the
transport-task test.
2026-07-21 13:20:27 +00:00
HaoFeng Zhao afdf8af400 Python: Prevent compaction from emitting empty projections (#7219)
* Python: preserve a non-empty compaction projection

* Python: annotate compaction regression input

* Python: document compaction retention floor
2026-07-21 13:16:43 +00:00
Scarab Systems 9e836f7b42 Python: Return MCP tool-use sampling results (#7189)
* Python: support MCP sampling tool-use results

* Python: align MCP sampling test tool schemas

* Python: centralize MCP sampling content type
2026-07-21 12:27:34 +00:00
安妮的心动录 9cf5143321 .NET: Populate AgentResponse metadata in CopilotStudioAgent (#6791)
* .NET: Populate AgentResponse metadata in CopilotStudioAgent

Map CreatedAt, FinishReason, RawRepresentation and AdditionalProperties onto
AgentResponse and AgentResponseUpdate, and map the activity timestamp and
properties onto ChatMessage, so Copilot Studio agents expose the same metadata
surface as other AIAgent implementations. Streaming sets the finish reason on
the terminal update while still emitting already-received content if the source
faults. Add unit tests covering the metadata mapping.

* fix: add Async suffix to async test methods (IDE1006)
2026-07-20 21:38:24 +00:00
Giles Odigwe b6b16ddb75 Python: forward GitHubCopilotOptions verbatim to create_session (#7155)
* Python: forward GitHubCopilotOptions verbatim to create_session

Refactor the GitHub Copilot agent to forward the full options dict to the
Copilot SDK's create_session/resume_session instead of hand-mapping a fixed
subset. GitHubCopilotOptions stays as the curated, typed surface, but any
other create_session parameter (reasoning_effort, context_tier,
enable_citations, ...) is now passed through verbatim. Unknown keys surface
as TypeError from the SDK instead of being silently dropped.

De-duplicates the near-identical _create_session/_resume_session bodies into
a shared _build_session_kwargs helper.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f9d016d5-4d8c-43f0-a8fb-f3cf3d1ad7eb

* Python: address review feedback on GHCP options passthrough

- Strip agent-internal/client-level keys (on_pre_tool_use, on_function_approval,
  timeout, cli_path, log_level, base_directory) from the forwarded kwargs so they
  cannot leak into create_session/resume_session and raise TypeError.
- Source caller tools from the merged options layer so tools supplied via
  default_options are honored instead of silently dropped.
- Honor a caller-supplied native 'hooks' dict in _build_session_hooks (composing
  with the on_pre_tool_use shortcut) instead of unconditionally overwriting it.
- Validate mock create_session/resume_session calls against the real SDK
  signatures in tests so invalid kwargs surface as TypeError, and add regression
  tests for the passthrough contract.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f9d016d5-4d8c-43f0-a8fb-f3cf3d1ad7eb

* Python: avoid redundant re-read of model in _build_session_kwargs

model is popped from default_options into settings at init, so a per-run
model already lands in the merged kwargs. Keep that value when present and
only fall back to the resolved setting otherwise, instead of re-reading opts.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f9d016d5-4d8c-43f0-a8fb-f3cf3d1ad7eb

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-20 18:08:55 +00:00
Evan Mattson c218067646 Python: Consolidate dependency updates (#7204)
* Bump uv from 0.11.28 to 0.11.29 in /python

Bumps [uv](https://github.com/astral-sh/uv) from 0.11.28 to 0.11.29.
- [Release notes](https://github.com/astral-sh/uv/releases)
- [Changelog](https://github.com/astral-sh/uv/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/uv/compare/0.11.28...0.11.29)

---
updated-dependencies:
- dependency-name: uv
  dependency-version: 0.11.29
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* Bump zuban from 0.8.2 to 0.9.0 in /python

Bumps [zuban](https://github.com/zubanls/zubanls-python) from 0.8.2 to 0.9.0.
- [Release notes](https://github.com/zubanls/zubanls-python/releases)
- [Commits](https://github.com/zubanls/zubanls-python/compare/v0.8.2...v0.9.0)

---
updated-dependencies:
- dependency-name: zuban
  dependency-version: 0.9.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* PR #7146: Bump ty from 0.0.55 to 0.0.60 in /python

* Bump ruff from 0.15.20 to 0.15.22 in /python

Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.20 to 0.15.22.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.15.20...0.15.22)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.15.21
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* Bump mypy from 2.2.0 to 2.3.0 in /python

Bumps [mypy](https://github.com/python/mypy) from 2.2.0 to 2.3.0.
- [Changelog](https://github.com/python/mypy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/python/mypy/compare/v2.2.0...v2.3.0)

---
updated-dependencies:
- dependency-name: mypy
  dependency-version: 2.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* Bump prek from 0.4.8 to 0.4.10 in /python

Bumps [prek](https://github.com/j178/prek) from 0.4.8 to 0.4.10.
- [Release notes](https://github.com/j178/prek/releases)
- [Changelog](https://github.com/j178/prek/blob/master/CHANGELOG.md)
- [Commits](https://github.com/j178/prek/compare/v0.4.8...v0.4.10)

---
updated-dependencies:
- dependency-name: prek
  dependency-version: 0.4.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* Bump azure-ai-projects from 2.2.0 to 2.3.0 in /python

Bumps azure-ai-projects from 2.2.0 to 2.3.0.

---
updated-dependencies:
- dependency-name: azure-ai-projects
  dependency-version: 2.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* Bump types-python-dateutil in /python

Bumps [types-python-dateutil](https://github.com/python/typeshed) from 2.9.0.20260518 to 2.9.0.20260716.
- [Commits](https://github.com/python/typeshed/commits)

---
updated-dependencies:
- dependency-name: types-python-dateutil
  dependency-version: 2.9.0.20260716
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* Bump mypy from 2.2.0 to 2.3.0 in /python

Bumps [mypy](https://github.com/python/mypy) from 2.2.0 to 2.3.0.
- [Changelog](https://github.com/python/mypy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/python/mypy/compare/v2.2.0...v2.3.0)

---
updated-dependencies:
- dependency-name: mypy
  dependency-version: 2.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* Bump botocore from 1.43.45 to 1.43.49 in /python

Bumps [botocore](https://github.com/boto/botocore) from 1.43.45 to 1.43.49.
- [Commits](https://github.com/boto/botocore/compare/1.43.45...1.43.49)

---
updated-dependencies:
- dependency-name: botocore
  dependency-version: 1.43.49
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* PRs #7144/#7147: Align lab uv and ruff pins

* Regenerate lockfile for Python Dependabot PRs #7144-#7153

* Python: Support azure-ai-projects 2.3 session operations (#7150)

* Python: Apply Ruff 0.15.22 suppression updates (#7147)

* Python: Update tests for ty 0.0.60 (#7146)

* Python: Update Foundry samples for azure-ai-projects 2.3 (#7150)

* Python: Address dependency rollup review comments

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-20 18:01:24 +00:00
westey ac474100ce Python: [BREAKING] Graduate create_harness_agent out of experimental (#7120)
* Graduate harness agent

* Add agents.md update

* Fix build errors

* Address PR comments

* Fix build error
2026-07-20 18:00:28 +00:00
Theo van Kraay a057cd505c Python: Add agent-framework-azure-cosmos-memory context provider (#6719)
* Add agent-framework-azure-cosmos-memory context provider (draft)

Introduces CosmosMemoryContextProvider, a ContextProvider that wraps the azure-cosmos-agent-memory toolkit to give agents long-term, Cosmos DB-backed memory (fact/procedural recall + user summaries). Includes package scaffolding, unit tests (mocked client), live Azure integration tests (marked), samples, README, and AGENTS.md.

Draft: uv.lock is intentionally left unchanged. This package depends on azure-cosmos-agent-memory (requires Python >=3.11), which is unsatisfiable against the workspace's current >=3.10 floor, so adding it to the shared lock requires a workspace decision (raise floor to 3.11 or exclude from workspace). Test coverage to be expanded.

* ci: exclude azure-cosmos-memory from uv workspace resolution

The package depends on azure-cosmos-agent-memory which requires Python
>=3.11 and a prompty pre-release (>=2.0.0a9). Both are unsatisfiable
against the workspace's >=3.10 floor and pre-release policy, causing
uv sync to fail in every Python CI job. Exclude the package from the
shared workspace so it is resolved and tested as a standalone package.

* ci: fix code-quality failures for azure-cosmos-memory

- Strip trailing whitespace from package files (pre-commit trailing-whitespace hook)
- Exclude the package README from markdown-code-lint: the package is excluded
  from the uv workspace, so its README snippets import a module that is not
  installed in the workspace env and Pyright cannot resolve it

* Exclude azure-cosmos-memory README from markdown-code-lint task

* Address PR review comments on cosmos-memory context provider

- Wire credential into Cosmos and AI Foundry clients; let toolkit own
  DefaultAzureCredential when none supplied (remove dead import).
- Honor auto_extract=False by zeroing extraction/summary cadence thresholds.
- Skip whitespace-only conversation turns and store stripped content.
- Show confidence 0.0 and coerce confidence to float in _format_memories.
- Register both 'integration' and 'azure' pytest markers accurately.
- Fix duplicated install block in README.
- Update and extend unit tests for new credential wiring and fixes.

* Include azure-cosmos-memory in the uv workspace

Follow the github_copilot pattern for a package with a Python 3.11-only
dependency: lower requires-python to >=3.10 and gate azure-cosmos-agent-memory
behind a python_version >= '3.11' marker. Add a direct, gated prompty
pre-release dependency so the workspace's if-necessary-or-explicit prerelease
policy permits the toolkit's transitive prompty requirement. Guard the test
modules with pytest.importorskip so the 3.10 CI leg skips cleanly. Remove the
workspace exclude and the markdown-code-lint exclude, and regenerate uv.lock.

* Address review feedback on cosmos-memory provider

Rename provider parameters to match Agent Framework conventions:
foundry_endpoint (was ai_foundry_endpoint) and embedding_model/chat_model
(were *_deployment_name). Move DEFAULT_* to module-level constants, type
memory_types as a Literal, use DEFAULT_CONTEXT_PROMPT as the default value,
and add ProcessorConfig/CosmosMemorySettings TypedDicts. Resolve connection
settings via agent_framework load_settings with required-field validation,
replacing the manual getenv/raise blocks. Scope user_id/thread_id to the
provider state and drop the unpreventable first-turn warning.

Rewrite the samples around Agent (not raw SessionContext), provider-scoped
state, and session-id threading; use PEP 723 inline dependencies instead of a
samples dependency group; use a plain input() loop; remove the dead custom
processor stub. Update README/AGENTS for the renamed parameters and env vars.
Add a samples ruff per-file-ignores entry now that the package is linted in CI.

* Add emulator-backed vector search integration test

Bump azure-cosmos-agent-memory to >=0.2.0b2 (adds the embeddings/chat client
injection seam) and add tests/test_emulator.py: an integration (not azure)
suite that exercises real Cosmos vector search with a quantizedFlat index
against a local Cosmos DB emulator, using deterministic in-memory fakes for
embeddings and chat so no Azure AI Foundry account or LLM is required.

To run on a stock emulator the fixture strips the toolkit's full-text index
(the provider only does pure vector search) and requests provisioned autoscale
throughput instead of serverless. The suite skips cleanly when no emulator is
reachable.

* Fix CI typing and package checks for azure-cosmos-memory

The package recently joined the uv workspace, so its source and tests are now covered by the Test Typing Checks and Package Checks gates for the first time.

tests: rename stale constructor kwargs to the current provider API (foundry_endpoint/embedding_model/chat_model); use a typed _STUB_AGENT for the unused agent param so pyright/pyrefly/ty/zuban all accept it; make processor_config values ints; assert non-None memory_client in the emulator tests.

source: relax reportUnknown*/reportOptional* for this package only (the toolkit ships no py.typed; mirrors the hosting-telegram precedent); decouple the conditional toolkit import from the annotation type; use settings.get(); fix memory_types list invariance; drop a redundant None guard; read role via getattr.

* Apply pyupgrade: single-arg AsyncGenerator in test_integration

* Make Cosmos memory extraction drain transparently on provider exit

The provider now drains in-flight background memory extraction in __aexit__, so applications no longer need to call flush() in their own control flow; the client's close() would otherwise cancel pending extraction tasks. flush() is hardened against clients that expose no usable background-task registry.

sample: interactive_chat reads input via asyncio.to_thread so the event loop stays free and background extraction runs during the session; removes the manual flush now that the provider drains on exit.

tests: add explicit transparent-extraction integration tests (emulator: after_run schedules extraction and __aexit__ drains it; live Azure: a fact is extracted and recalled in a later session with no manual flush). Emulator tests reuse a single fixed database to avoid exhausting the emulator's partition budget across runs.

* Add custom extraction-prompt seam and sample to cosmos-memory provider

Adds a prompts_dir option to CosmosMemoryContextProvider that points the Agent Memory Toolkit pipeline at a caller-supplied directory of Prompty templates, so callers can override extract_memories.prompty to control what the extraction LLM produces. The toolkit exposes no public prompts-directory seam, so the provider contains the one internal touch (swapping the pipeline's template loader after the store connects); applies to both provider-built and supplied clients.

sample: interactive_chat_custom_extraction.py - the interactive chat wired with a custom coding-assistant extraction rubric. It derives a complete prompts directory at runtime (copies the bundled templates and augments extract_memories.prompty) so it stays schema-compatible with the installed toolkit.

tests: unit tests assert the provider redirects the pipeline loader only when prompts_dir is set; an emulator integration test proves end to end that a unique marker in a custom extract_memories.prompty reaches the extraction LLM call.

* docs: document prompts_dir custom-extraction seam in cosmos-memory README

Replaces the stale, non-functional CustomMemoryProcessor snippet with the working prompts_dir approach, lists the new interactive_chat_custom_extraction.py sample, and corrects the interactive-sample feature list.

* Address review: rename _new_session, drop defensive toolkit import guard

Sample (comment): rename _new_thread to _new_session in both interactive samples (a new session is the new thread).

Provider (comment): replace the _memory_toolkit_available flag + __init__ ImportError guard with a plain guarded import that re-raises a clear ImportError, matching the github_copilot package's pattern for its 3.11-only SDK. Kept requires-python >=3.10 (bumping this one workspace member to 3.11 would force the entire uv workspace lock floor to 3.11). Tests now run importorskip before importing the package, mirroring github_copilot.

* Pass cadence via cadence_thresholds instead of mutating os.environ

* Mark package alpha and drop private naming in samples

* Require Python 3.11 and inject user summary as untrusted context

* CI: exclude azure-cosmos-memory from uv sync on Python 3.10

* Re-trigger CI (flaky external link check)

* Require chat/embedding models instead of silent defaults

* Fix pyright: narrow resolved chat/embedding models to str

---------

Co-authored-by: Theo van Kraay <thvankra@microsoft.com>
2026-07-20 09:44:11 +00:00
Evan Mattson c66bb39ea2 Normalize durable workflow inputs (#7205) 2026-07-20 08:33:20 +00:00
Yufeng He 7c6b1e975f Python: fix compaction token count inflating non-ASCII text (#7124)
TokenBudgetComposedStrategy estimates tokens by feeding a JSON-serialized
message to the tokenizer, but _serialize_message() used ensure_ascii=True.
That escapes non-ASCII text into \uXXXX sequences, so CJK and other
non-Latin content is token-counted as the escape sequences rather than the
characters the model actually sees, inflating the estimate (~1.6x for mixed
Japanese, more for pure CJK) and skewing compaction/token-budget decisions.

Serialize with ensure_ascii=False, matching the ensure_ascii=False already
used elsewhere in this module. Only affects token estimation; the serialized
string is never stored or transmitted.

Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com>
2026-07-19 12:44:40 +00:00
Yufeng He 0d2925037d Python: preserve explicit null arguments in auto function calling (#7108)
* Python: preserve explicit null arguments in auto function calling

FunctionTool.invoke dumped validated arguments with model_dump(exclude_none=True),
which strips any argument the model set to null. A required nullable parameter
(e.g. unit: Literal["C","F"] | None) that the model deliberately sets to null was
therefore dropped, and the function failed to invoke on the missing argument.

Use exclude_unset instead: keep the arguments the model actually provided (null
included) and omit only the ones it left out, so the function's own defaults still
apply. Because the input model is generated from the function signature, its field
defaults match the signature defaults, so omitted optionals are unchanged.

Fixes #5934

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>

* Python: extend null-arg fix to the auto function-calling path

The earlier change fixed FunctionTool.invoke, but _auto_invoke_function
(the path a model-emitted function_call actually takes) still ran
model_dump(exclude_none=True), so an explicit null for a required
nullable argument was still dropped and the call failed with a missing
argument. Switch it to exclude_unset to match invoke, and add a
regression test that drives _auto_invoke_function with an explicit null.

---------

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com>
2026-07-19 12:43:41 +00:00
Eduard van Valkenburg b5e635ed4d Python: isolate hosted session snapshots (#7141)
* Python: isolate hosted session snapshots

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

Copilot-Session: 75d26ffd-7dc7-46b3-9966-9aaebb7b6bc3

* Python: avoid duplicate conversation snapshots

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

Copilot-Session: 75d26ffd-7dc7-46b3-9966-9aaebb7b6bc3

* added some notes in the docstring
2026-07-18 11:59:24 +00:00
Eduard van Valkenburg 1036fa7438 Python: docs: add self-hosting sample snippets (#7104)
* docs: add self-hosting sample snippets

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 636b7fb2-381a-4d62-b5cf-d029efa3ad20

* docs: use stable hosting sample ranges

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 636b7fb2-381a-4d62-b5cf-d029efa3ad20

* docs: address hosting sample review feedback

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 636b7fb2-381a-4d62-b5cf-d029efa3ad20

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-17 22:10:28 +00:00
Eduard van Valkenburg 62da382082 Python: Optimize shared serialization paths (#7165)
* Optimize core serialization paths

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

Copilot-Session: cda42f21-2500-4f78-a527-d6eeaeef922b

* Streamline AG-UI serialization

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

Copilot-Session: cda42f21-2500-4f78-a527-d6eeaeef922b

* Document shared serialization guidance

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

Copilot-Session: cda42f21-2500-4f78-a527-d6eeaeef922b

* Bound serialization protocol cache

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

Copilot-Session: cda42f21-2500-4f78-a527-d6eeaeef922b
2026-07-17 22:09:53 +00:00
Eduard van Valkenburg 3604ba70f6 Python: Normalize chat finish reasons (#7105)
* Python: Normalize chat finish reasons

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

Copilot-Session: 45b65bfa-8e36-47b0-99d9-ec58ec60ace1

* Preserve Copilot finish reasons

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

Copilot-Session: 45b65bfa-8e36-47b0-99d9-ec58ec60ace1

* fix claude finish reasons
2026-07-17 22:05:36 +00:00
Marco Minerva 3ab2630243 .NET: Refactor Workflows MessageMerger to preserve message order and structure (#6826)
* Refactor MessageMerger to preserve message order

Refactored MessageMerger to delegate update grouping and merging to M.E.AI, preserving the correct order and structure of assistant messages, especially for reasoning content without message IDs. Removed per-message bucketing and CreatedAt-based sorting. Added tests to verify message order and correct merging of reasoning and text updates.

* Set CreatedAt from merged responses preservation of original message timestamps during merging.

* Set merged message CreatedAt to current UTC time

Removed logic for tracking unique creation times and now always assign DateTimeOffset.UtcNow to the merged response's CreatedAt property. This simplifies timestamp handling during message merging.

* Refactor MessageMerger id-less folding logic

Refactored MessageMerger to fold identifierless reasoning segments into the following id'd message at the flattened-message level, ensuring correct merging across response buckets (fixes #6329). Updated ComputeMerged to merge id-less messages with the next message of the same role. Removed redundant per-bucket folding logic. Added unit tests to verify correct folding behavior and role matching.

* Remove unused property

 Removed the unused Role property from MessageMergeState for code cleanliness.

* Refactor MessageMerger to iterate backward for merging

Changed MessageMerger to iterate messages in reverse order, ensuring all consecutive messages without IDs preceding a message with an ID are merged correctly. Updated merging logic, index handling, and comments to reflect this new approach.

* Update code comment to better reflect its behavior.

---------

Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>
2026-07-17 17:10:14 +00:00
Eduard van Valkenburg bc59c72170 Python: Add A2A hosting helpers (#7050)
* Python: Add A2A hosting helpers

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5d6987cd-1b67-4ba1-8b54-3c50da6e7607

* Python: Preserve final A2A streaming output

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5d6987cd-1b67-4ba1-8b54-3c50da6e7607

* Python: Clarify A2A conversion boundary

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

Copilot-Session: 5d6987cd-1b67-4ba1-8b54-3c50da6e7607

* Python: Document A2A sample auth boundary

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

Copilot-Session: 5d6987cd-1b67-4ba1-8b54-3c50da6e7607

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-17 16:58:39 +00:00
ssccinng d5f2c77b35 .NET: Honor terminal workflow outputs in Workflow.AsAIAgent responses (#6212)
* Fix workflow agent terminal output responses

* Address workflow agent response review feedback

---------

Co-authored-by: Peter Ibekwe <109177538+peibekwe@users.noreply.github.com>
2026-07-17 15:36:26 +00:00
VectorPeak 6afae2f9b4 Python: raise ValueError for malformed data URIs (#6916)
* Python: raise ValueError for malformed data URIs

* Address malformed data URI review feedback

---------

Co-authored-by: VectorPeak <VectorPeak@users.noreply.github.com>
Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com>
2026-07-17 11:40:20 +00:00
Benke Qu cad81923e3 Python: fix: concurrent_agents sample incorrectly treats output as list[Message] (#6548)
* Python: fix: concurrent_agents sample treats output as list[Message] but it is AgentResponse

The default ConcurrentBuilder aggregator yields AgentResponse, not
list[Message]. The sample incorrectly cast the output to list[Message]
and iterated it directly. Fix by checking isinstance(output, AgentResponse)
and iterating output.messages instead.

* fix: remove warning print per review feedback

* fix: address review - remove unused Message import, update docs and sample output

- Remove unused Message import
- Update docstring: default aggregator yields AgentResponse objects, not list[Message]
- Fix sample output: remove user prompt entry (aggregator returns only assistant messages)
- Renumber sample output entries (researcher=01, marketer=02, legal=03)

---------

Co-authored-by: Benke Qu <bequ@microsoft.com>
Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com>
2026-07-17 11:38:04 +00:00
Ahmed Muhsin 5ab8877ba5 Python: HITL respond-URL addressing from inside workflows (#7001)
* feat(durabletask): surface HITL respond-URL addressing to workflow executors

Let a workflow notify a human reviewer (e.g. email an approval link) from inside the
graph, without the caller threading the instanceId/requestId by hand.

- durabletask: the orchestrator injects host_context {instance_id, workflow_name,
  request_path_prefix} into each activity input; CapturingRunnerContext surfaces it as
  host_metadata. No new core API.
- azurefunctions: WorkflowHitlContext.from_context(ctx) builds the canonical
  respond/status URLs (returns None in-process so callers degrade gracefully).
  Re-exported through the agent_framework.azure lazy namespace.
- Nested sub-workflows: the address context (root instance + workflow name +
  accumulated {executor}~{ordinal}~ prefix) propagates down call_sub_orchestrator via a
  new SUBWORKFLOW_ADDRESS_KEY marker, so an executor at any depth builds a URL that
  targets the addressable top-level instance with a qualified request id. The per-child
  ordinal matches the read-side enumerate() index used by the status/respond endpoints.
  The marker is stripped from untrusted input alongside SUBWORKFLOW_INPUT_KEY
  (confused-deputy / info-leak guard).
- Samples 12 and 13 reworked into the retry-safe two-step notify pattern: the emitter
  generates an explicit request id and a downstream NotifyExecutor builds the URL and
  notifies, so failed upstream retries never produce a dead link.

Tests: unit coverage for the metadata round-trip, address/ordinal agreement (fan-out at
depth and nested prefix accumulation), marker stripping, and URL building; integration
tests assert the helper-built URL equals the server respondUrl and resumes the run, for
both the flat (12) and nested (13) samples.

* refactor(durabletask): read back request_info id instead of generating one in samples

Add WorkflowHitlContext.pending_request_id(ctx), an async helper that returns the
id request_info just generated (read from the runner context's pending request-info
events). This works on any host via the core RunnerContext protocol method, so it
needs no core change.

Samples 12 and 13 now call request_info() and read the id back to forward to the
NotifyExecutor, instead of minting a uuid by hand and passing request_id=. The
read-back happens in the same activity execution that generated the id, so the
pending request event and the notify message still commit together with the same id
(retry-safe; failed upstream retries notify no one).

* docs(azurefunctions): document request_info id read-back and notify safety

Tighten pending_request_id docstring to require calling it immediately after request_info, and explain why that is safe on the durable host (each executor runs in its own activity with its own runner context, so the pending set only holds this executor's requests and the newest is the one just emitted). Document the two-step notify pattern in the 12 and 13 sample READMEs, including the downstream-notifier retry safety and the nested address-prefix propagation.

* fix(python): resolve ty typing error and address PR review comments

- test_subworkflow_orchestration: replace the mypy-only type:ignore[arg-type] with a cast so the ty checker passes too (the other four checkers already honored the ignore).

- samples 12/13 README: guard the notify snippet against None before build_respond_url to match the documented graceful-degradation behavior.

- integration tests 12/13: reword comments that implied request_info now generates an explicit uuid4; it generates the id internally by default.

* fix(python): honor configurable Functions route prefix and address HITL PR review

- Resolve the route prefix from host.json (extensions.http.routePrefix, default api) in a new azurefunctions _routes module, used by both the server endpoints and WorkflowHitlContext, so a custom or empty routePrefix no longer 404s respond/status URLs (was hardcoded /api/ in four places).

- Extract respond/status URL construction into one shared builder called from _app.py and _hitl_context.py, removing the sync-by-test duplication.

- Broaden loopback detection (localhost, 127.0.0.0/8, 0.0.0.0, ::1, [::1]) via a _is_loopback helper so local links use http.

- Pin the host_context key names as shared constants in durabletask so producer and azurefunctions consumer cannot drift.

- Reword a stale base_url comment to reference WEBSITE_HOSTNAME.

- Add unit tests for the route module and loopback handling.

* refactor(azurefunctions): derive server-side HITL URLs from the request URL

The run and status endpoints now derive the base URL and route prefix from the incoming request URL (the value the host actually routed) via split_request_url, so the caller-visible respond/status URLs no longer depend on reading host.json on the server. The in-workflow helper keeps reading host.json since it has no request context. Replaces strip_route_prefix and updates its tests.

* test(durabletask): enforce sub-workflow ordinal and read-index agreement

Extract the read-side subworkflows grouping into a shared _index_subworkflows helper (used by the orchestrator) and add test_readside_index_matches_dispatch_ordinal, which round-trips a fan-out through that helper and asserts subworkflows[executor][ordinal] resolves to the child the dispatch stamped that ordinal onto. Turns the previously comment-only write-ordinal / read-index invariant into a shared, CI-enforced one.

* fix(azurefunctions): suppress bandit B104 on loopback host set
2026-07-16 22:06:25 +00:00
Jose Luis Latorre Millas f4e49958f3 samples: add AgentMemory (Neo4j-agent memory reimplemented in NET ) shopping assistant sample (#7096)
* samples: add Neo4j Shopping Assistant (standalone, published AgentMemory 1.0.1)

The .NET port of the official Neo4j Agent Memory "retail assistant" example
(neo4j-labs/agent-memory examples/microsoft_agent_retail_assistant, referenced
from the Learn integration page), which is currently Python-only.

Wires Neo4jMemoryContextProvider (AIContextProvider), MemoryToolFactory
memory tools, and a ProductCatalog of retail tools over a Neo4j :Product
graph, via the published AgentMemory + AgentMemory.AgentFramework 1.0.1
NuGet packages.

Lives at the repo root rather than under dotnet/samples/: that tree is
.NET 10 + Central Package Management + Microsoft.Agents.AI ~1.13 with
source ProjectReferences, while AgentMemory currently targets net9.0 +
Microsoft.Agents.AI 1.9.0. A repo-native version needs AgentMemory bumped
to track the newer Agents.AI/Extensions.AI line first. Cross-linked from
dotnet/samples/02-agents/AgentWithMemory/README.md as a "See also" entry,
same pattern already used for the cross-folder Custom Memory Implementation
link.

Verified: dotnet build succeeds (0 warnings, 0 errors) against the published
packages, proving the AgentMemory public surface is package-consumable.
Matches sibling AgentWithMemory samples' conventions (BOM + copyright file
header on .cs files, README sections: Features Demonstrated / Prerequisites
/ Environment Variables / Run the Sample / Expected Output).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* samples: move Neo4j shopping assistant into AgentWithMemory as Step06

Relocates the standalone shopping-assistant sample from the repo root into
dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory,
following that folder's naming/README/solution conventions. Renames its identity
from "Neo4j" to "AgentMemory" (the library it actually demonstrates) since this
is a community .NET port, not an officially recognized Neo4j integration - Neo4j
is still referenced where it's a genuine technical detail (the graph backing
store, env vars, Cypher). Adds empty Directory.Build.props/targets markers so it
stays isolated from the repo's net10.0/CPM build, and registers it (skipped, like
the Mem0 sample) in the CI sample-verification list since it needs a live Neo4j
instance.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Update dotnet/samples/02-agents/AgentWithMemory/README.md

Co-authored-by: westey <164392973+westey-m@users.noreply.github.com>

* Update dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory.csproj

Co-authored-by: westey <164392973+westey-m@users.noreply.github.com>

* cleanup :)

* DefaultAzureCredential warning

* fixes - simplification userId

* minor doc fix

* NU1015 fix

* PR review fixes-improvements

* Bump AgentMemory to 1.2.0, let the context provider surface memory tools

WithMemoryOwnerScoping(sp) (1.1.0) already removed the need to manually
wrap agent.RunAsync in ownerContext.BeginOwnerScope(userId). This picks
up 1.2.0's ExposeMemoryToolsFromContextProvider option, so
Neo4jMemoryContextProvider now appends the memory tools to AIContext.Tools
itself on every model call — no more separate MemoryToolFactory wiring,
AIContextProviders = [memoryProvider] is enough.

Addresses westey-m's PR review suggestion.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* improvements according to pr review comments

* Fix CI: use plural TargetFrameworks to actually restrict this sample to net10.0

Directory.Build.props sets a repo-wide TargetFrameworks (plural) list
before this project's own properties are evaluated, and the SDK decides
multi-targeting from that plural property at Sdk.props time. The prior
singular TargetFramework=net10.0 override didn't take effect early
enough, so restore still ran against net9.0/net8.0/netstandard2.0/net472
too - frameworks the published AgentMemory 1.2.0 packages don't support
(NU1202), plus surfaced an OpenTelemetry.Api advisory as an error
(NU1902) since TreatWarningsAsErrors is on repo-wide.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Fix CI: pin OpenTelemetry.Api to unblock NU1902 audit failure

The sample opts out of central package management, so it was pulling in
OpenTelemetry.Api 1.12.0 transitively (via Microsoft.Agents.AI), which has
a known moderate-severity vulnerability (GHSA-g94r-2vxg-569j). The repo
treats NuGet audit warnings as errors, so restore failed outright and took
down every dotnet-build matrix leg plus check-format.

Pinned OpenTelemetry.Api to 1.15.3, matching Directory.Packages.props.
With restore succeeding, previously-masked analyzer/format issues surfaced
and are fixed too: RCS1118 (const local for immutable Cypher queries),
CA1859 (List<IRecord> param instead of IReadOnlyList<IRecord>), and IDE1006
naming violations (s_seed field prefix, PascalCase Cypher/Shopper consts).

Verified locally with the same mcr.microsoft.com/dotnet/sdk:10.0 image CI
uses: dotnet build --warnaserror and dotnet format --verify-no-changes both
pass clean, and a full solution build completed ~24 min with zero errors.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: westey <164392973+westey-m@users.noreply.github.com>
2026-07-16 15:39:23 +00:00
Eduard van Valkenburg 5282c158aa .NET: Fix LocalCodeAct validation and package checks (#7138)
* Fix LocalCodeAct validation and package checks

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

Copilot-Session: aecf332f-b940-41a7-ac3a-6fbbe9892141

* Address LocalCodeAct alias review feedback

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

Copilot-Session: aecf332f-b940-41a7-ac3a-6fbbe9892141
2026-07-16 15:17:52 +00:00
feiyun0112 dde7635760 .NET: [Feature]: .NET Improve ChatClientAgentSession constructor (#7142)
* .NET: [Feature]: .NET Improve ChatClientAgentSession constructor

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* test: make deserialize test actually reproduce issue #7109

VerifyDeserializeWithWhenWritingNullOptions passed against both the old
and the fixed constructor, so it did not guard against the regression.

The bug only reproduces when required constructor parameters are respected
(the issue uses RespectRequiredConstructorParametersDefault=true). With
WhenWritingNull a null conversationId is omitted from the JSON, and STJ then
throws 'missing required properties including: conversationId' because the
constructor parameter had no default value.

Adding RespectRequiredConstructorParameters = true to the test options makes
the test red against the parameter-without-default constructor and green with
the default-valued constructor parameters, so it now protects the fix.

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com>
2026-07-16 15:06:37 +00:00
King Star 85c00fc55b .NET: preserve HeadTailBuffer UTF-8 order (#7128)
Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>
2026-07-16 10:03:43 +00:00
westey f19a129b55 Graduate FileMemoryProvider (#7114) 2026-07-16 09:38:59 +00:00
westey a376577263 Gradudate FileMemoryProvider (#7113) 2026-07-15 22:57:21 +00:00
Tao Chen b2549337ff Python: Best effort to serialize tool def to Json for observability (#7029)
* Best effort to serialize tool def to Json

* Fix formatting

* Fix tests

* Optimize json serialization

* Best effort: Add secret filtering

* Remove frozen set and only convert required fields

* Fix tests

* Address comments

* Fix typing
2026-07-15 20:43:36 +00:00
Peter Ibekwe 05834b56e3 Fix message ordering in workflow-hosted agents (#7123) 2026-07-15 19:40:47 +00:00
Roger Barreto 42ae534a07 CI: resolve PR author in community team check (#7129)
* CI: resolve PR author in community team check

pull_request_target events expose the author on payload.pull_request, not payload.issue. Read that field first and fall back to pulls.get so limit-community-prs no longer calls issues.get and fails with 401.

* Docs: clarify issueNumber accepts PR numbers
2026-07-15 18:14:02 +00:00
Evan Mattson a17102f9f5 Harden manual integration test trust boundary (#7081)
* Harden manual integration test workflow

Require two write-capable approvals for the exact PR head SHA, pin all targets to immutable commits, and narrow secret and OIDC access.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: b8c7d85f-7576-4fc7-a7b5-c77833344088

* Require integration workflow credentials

Declare credentials consumed by the reusable integration workflows as required while retaining the explicitly optional Foundry models key.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: b8c7d85f-7576-4fc7-a7b5-c77833344088

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-15 17:54:52 +00:00
882 changed files with 51077 additions and 6998 deletions
+112
View File
@@ -0,0 +1,112 @@
name: Get GitHub automation token
description: Creates a GitHub App installation token with a temporary PAT fallback
inputs:
mode:
description: Authentication mode (app, app-with-fallback, or pat)
required: false
default: app-with-fallback
azure-client-id:
description: Client ID of the Azure workload identity
required: false
azure-tenant-id:
description: Azure tenant ID
required: false
azure-subscription-id:
description: Azure subscription containing the Key Vault
required: false
key-vault-name:
description: Azure Key Vault name
required: false
key-name:
description: Key Vault key used to sign the GitHub App JWT
required: false
github-app-client-id:
description: GitHub App client ID
required: false
github-app-installation-id:
description: GitHub App installation ID
required: false
repository:
description: Repository to include in the installation token
required: false
fallback-token:
description: PAT used temporarily when app authentication is unavailable
required: false
outputs:
token:
description: GitHub App installation token or fallback PAT
value: ${{ steps.select-token.outputs.token }}
source:
description: Selected authentication source
value: ${{ steps.select-token.outputs.source }}
runs:
using: composite
steps:
- name: Validate authentication mode
shell: bash
env:
AUTH_MODE: ${{ inputs.mode || 'app-with-fallback' }}
run: |
if [[ "$AUTH_MODE" != "app" && "$AUTH_MODE" != "app-with-fallback" && "$AUTH_MODE" != "pat" ]]; then
echo "::error::Unsupported GitHub authentication mode."
exit 1
fi
- name: Sign in to Azure
id: azure-login
if: ${{ (inputs.mode || 'app-with-fallback') != 'pat' }}
continue-on-error: true
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
with:
client-id: ${{ inputs.azure-client-id }}
tenant-id: ${{ inputs.azure-tenant-id }}
subscription-id: ${{ inputs.azure-subscription-id }}
- name: Create GitHub App installation token
id: app-token
if: ${{ (inputs.mode || 'app-with-fallback') != 'pat' && steps.azure-login.outcome == 'success' }}
continue-on-error: true
shell: bash
env:
AZURE_SUBSCRIPTION_ID: ${{ inputs.azure-subscription-id }}
KEY_VAULT_NAME: ${{ inputs.key-vault-name }}
KEY_NAME: ${{ inputs.key-name }}
GITHUB_APP_CLIENT_ID: ${{ inputs.github-app-client-id }}
GITHUB_APP_INSTALLATION_ID: ${{ inputs.github-app-installation-id }}
TARGET_REPOSITORY: ${{ inputs.repository }}
run: |
token="$(node "$GITHUB_ACTION_PATH/create-token.js")"
echo "::add-mask::$token"
echo "token=$token" >> "$GITHUB_OUTPUT"
- name: Select authentication token
id: select-token
shell: bash
env:
AUTH_MODE: ${{ inputs.mode || 'app-with-fallback' }}
APP_TOKEN: ${{ steps.app-token.outputs.token }}
FALLBACK_TOKEN: ${{ inputs.fallback-token }}
run: |
if [[ "$AUTH_MODE" != "pat" && -n "$APP_TOKEN" ]]; then
token="$APP_TOKEN"
source="app"
echo "::notice::GitHub authentication source: app"
elif [[ "$AUTH_MODE" == "app-with-fallback" && -n "$FALLBACK_TOKEN" ]]; then
token="$FALLBACK_TOKEN"
source="pat-fallback"
echo "::warning::GitHub authentication source: PAT fallback"
elif [[ "$AUTH_MODE" == "pat" && -n "$FALLBACK_TOKEN" ]]; then
token="$FALLBACK_TOKEN"
source="pat-forced"
echo "::warning::GitHub authentication source: PAT (forced rollout mode)"
else
echo "::error::GitHub App authentication is unavailable and no fallback PAT was provided."
exit 1
fi
echo "::add-mask::$token"
echo "token=$token" >> "$GITHUB_OUTPUT"
echo "source=$source" >> "$GITHUB_OUTPUT"
@@ -0,0 +1,133 @@
// Copyright (c) Microsoft. All rights reserved.
const crypto = require('node:crypto');
const { execFileSync } = require('node:child_process');
function base64Url(value) {
return Buffer.from(value).toString('base64url');
}
function base64ToBase64Url(value) {
return Buffer.from(value, 'base64').toString('base64url');
}
function createJwtSigningInput(clientId, nowSeconds) {
const header = base64Url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
const payload = base64Url(JSON.stringify({
iat: nowSeconds - 60,
exp: nowSeconds + 540,
iss: clientId,
}));
return `${header}.${payload}`;
}
function signJwt(signingInput, config, execute = execFileSync) {
const digest = crypto.createHash('sha256').update(signingInput).digest('base64');
const signature = execute(
'az',
[
'keyvault', 'key', 'sign',
'--subscription', config.azureSubscriptionId,
'--vault-name', config.keyVaultName,
'--name', config.keyName,
'--algorithm', 'RS256',
'--digest', digest,
'--query', 'signature',
'--output', 'tsv',
'--only-show-errors',
],
{ encoding: 'utf8' },
).trim();
if (!signature) {
throw new Error('Key Vault returned an empty signature.');
}
return `${signingInput}.${base64ToBase64Url(signature)}`;
}
async function createInstallationToken(config, dependencies = {}) {
const execute = dependencies.execute ?? execFileSync;
const request = dependencies.fetch ?? fetch;
const nowSeconds = dependencies.nowSeconds ?? Math.floor(Date.now() / 1000);
const repositoryParts = config.targetRepository.split('/');
if (repositoryParts.length !== 2 || repositoryParts.some((part) => part.length === 0)) {
throw new Error('TARGET_REPOSITORY must use the owner/repository format.');
}
const [, repository] = repositoryParts;
const signingInput = createJwtSigningInput(config.githubAppClientId, nowSeconds);
const jwt = signJwt(signingInput, config, execute);
const response = await request(
`https://api.github.com/app/installations/${config.githubAppInstallationId}/access_tokens`,
{
method: 'POST',
headers: {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${jwt}`,
'X-GitHub-Api-Version': '2022-11-28',
},
body: JSON.stringify({
repositories: [repository],
permissions: {
contents: 'read',
issues: 'write',
members: 'read',
pull_requests: 'write',
},
}),
},
);
if (!response.ok) {
throw new Error(`GitHub installation token request failed with HTTP ${response.status}.`);
}
const result = await response.json();
if (typeof result.token !== 'string' || result.token.length === 0) {
throw new Error('GitHub returned an empty installation token.');
}
return result.token;
}
function readConfig(environment) {
const config = {
azureSubscriptionId: environment.AZURE_SUBSCRIPTION_ID,
keyVaultName: environment.KEY_VAULT_NAME,
keyName: environment.KEY_NAME,
githubAppClientId: environment.GITHUB_APP_CLIENT_ID,
githubAppInstallationId: environment.GITHUB_APP_INSTALLATION_ID,
targetRepository: environment.TARGET_REPOSITORY,
};
if (Object.values(config).some((value) => !value)) {
throw new Error('Required GitHub App authentication configuration is missing.');
}
return config;
}
async function main() {
try {
const token = await createInstallationToken(readConfig(process.env));
process.stdout.write(token);
} catch {
console.error('GitHub App token generation failed.');
process.exitCode = 1;
}
}
if (require.main === module) {
void main();
}
module.exports = {
base64ToBase64Url,
createInstallationToken,
createJwtSigningInput,
readConfig,
signJwt,
};
+25 -10
View File
@@ -1,25 +1,40 @@
// Copyright (c) Microsoft. All rights reserved.
/**
* Resolve the issue author and check their team membership.
* Resolve the issue or pull request author and check their team membership.
*
* @param {object} opts
* @param {object} opts.github - Octokit REST client from actions/github-script
* @param {object} opts.context - GitHub Actions context
* @param {object} opts.core - GitHub Actions core toolkit
* @param {string} opts.teamSlug - Team slug to check membership against
* @param {string|number} opts.issueNumber - Issue number to resolve author for
* @param {string|number} opts.issueNumber - Issue or pull request number to resolve author for
* @param {string} [opts.username] - Explicit user to check instead of the issue or pull request author
* @returns {Promise<{author: string|null, isTeamMember: boolean}>}
*/
async function checkTeamMembership({ github, context, core, teamSlug, issueNumber }) {
let author = context.payload.issue?.user?.login;
async function checkTeamMembership({ github, context, core, teamSlug, issueNumber, username = '' }) {
let author = username.trim() || (
context.payload.issue?.user?.login ??
context.payload.pull_request?.user?.login
);
if (!author) {
const { data: issue } = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: Number(issueNumber),
});
author = issue.user?.login;
const number = Number(issueNumber);
if (context.payload.pull_request) {
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: number,
});
author = pr.user?.login;
} else {
const { data: issue } = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: number,
});
author = issue.user?.login;
}
}
if (!author) {
+212
View File
@@ -0,0 +1,212 @@
# Copyright (c) Microsoft. All rights reserved.
"""Enforce Python package coverage according to package lifecycle."""
# ruff:file-ignore[print]
# ruff:file-ignore[implicit-namespace-package]
from __future__ import annotations
import re
import sys
import xml.etree.ElementTree as ET # ruff:ignore[suspicious-xml-etree-import]
from dataclasses import dataclass
from pathlib import Path
import tomllib
DEVELOPMENT_STATUS_PREFIX = "Development Status :: "
ENFORCED_DEVELOPMENT_STATUS = 4
EXEMPT_PACKAGES = {"devui", "lab"}
@dataclass(frozen=True)
class PackagePolicy:
"""Coverage policy derived from a package's project metadata."""
directory: str
distribution_name: str
development_status: int
development_status_label: str
enforced: bool
exempt: bool
@dataclass
class CoverageStats:
"""Line and branch coverage counters."""
lines_valid: int = 0
lines_covered: int = 0
branches_valid: int = 0
branches_covered: int = 0
@property
def line_coverage_percent(self) -> float:
"""Return line coverage as a percentage."""
if not self.lines_valid:
return 0
return self.lines_covered / self.lines_valid * 100
def normalize_coverage_path(path: str) -> str:
"""Normalize a coverage path for matching."""
return path.replace("\\", "/").lstrip("./")
def load_package_policies(packages_dir: Path) -> list[PackagePolicy]:
"""Load lifecycle-based coverage policies from package pyproject files."""
policies: list[PackagePolicy] = []
for pyproject_path in sorted(packages_dir.glob("*/pyproject.toml")):
with pyproject_path.open("rb") as pyproject_file:
pyproject = tomllib.load(pyproject_file)
project = pyproject.get("project", {})
distribution_name = str(project.get("name", "")).strip()
if not distribution_name:
raise ValueError(f"{pyproject_path}: project.name is required")
status_classifiers = [
classifier
for classifier in project.get("classifiers", [])
if classifier.startswith(DEVELOPMENT_STATUS_PREFIX)
]
if len(status_classifiers) != 1:
raise ValueError(
f"{pyproject_path}: expected exactly one Development Status classifier, found {len(status_classifiers)}"
)
match = re.fullmatch(r"Development Status :: (\d+) - (.+)", status_classifiers[0])
if match is None:
raise ValueError(f"{pyproject_path}: malformed Development Status classifier")
directory = pyproject_path.parent.name
development_status = int(match.group(1))
exempt = directory in EXEMPT_PACKAGES
policies.append(
PackagePolicy(
directory=directory,
distribution_name=distribution_name,
development_status=development_status,
development_status_label=match.group(2),
enforced=development_status >= ENFORCED_DEVELOPMENT_STATUS and not exempt,
exempt=exempt,
)
)
if not policies:
raise ValueError(f"No package pyproject.toml files found below {packages_dir}")
return policies
def parse_coverage_xml(xml_path: Path) -> tuple[dict[str, CoverageStats], float, float]:
"""Parse Cobertura XML and aggregate coverage by package directory."""
root = ET.parse(xml_path).getroot() # ruff:ignore[suspicious-xml-element-tree-usage] # Trusted CI-generated coverage report.
package_stats: dict[str, CoverageStats] = {}
for class_elem in root.findall(".//class"):
file_path = normalize_coverage_path(class_elem.get("filename", ""))
path_parts = file_path.split("/")
try:
packages_index = path_parts.index("packages")
package_directory = path_parts[packages_index + 1]
except (ValueError, IndexError):
continue
stats = package_stats.setdefault(package_directory, CoverageStats())
for line in class_elem.findall(".//line"):
stats.lines_valid += 1
if int(line.get("hits", 0)) > 0:
stats.lines_covered += 1
if line.get("branch") != "true":
continue
condition_coverage = line.get("condition-coverage", "")
match = re.search(r"\((\d+)/(\d+)\)", condition_coverage)
if match is not None:
stats.branches_covered += int(match.group(1))
stats.branches_valid += int(match.group(2))
return (
package_stats,
float(root.get("line-rate", 0)) * 100,
float(root.get("branch-rate", 0)) * 100,
)
def check_coverage(xml_path: Path, threshold: float, packages_dir: Path) -> bool:
"""Check all lifecycle-enforced packages against the coverage threshold."""
policies = load_package_policies(packages_dir)
package_stats, overall_line_coverage, overall_branch_coverage = parse_coverage_xml(xml_path)
print("\n" + "=" * 110)
print("PYTHON PACKAGE TEST COVERAGE")
print("=" * 110)
print(f"Overall Line Coverage: {overall_line_coverage:.1f}%")
print(f"Overall Branch Coverage: {overall_branch_coverage:.1f}%")
print(f"Enforced Threshold: {threshold:.1f}%")
print("-" * 110)
print(f"{'Package':<48} {'Stage':<20} {'Policy':<14} {'Lines':<12} {'Line Cov':<10}")
print("-" * 110)
failed_packages: list[str] = []
for policy in sorted(policies, key=lambda item: (not item.enforced, item.distribution_name)):
stats = package_stats.get(policy.directory)
if policy.exempt:
policy_label = "EXEMPT"
elif policy.enforced:
policy_label = "ENFORCED"
else:
policy_label = "REPORT ONLY"
if stats is None:
lines = "-"
coverage = "missing"
if policy.enforced:
failed_packages.append(f"{policy.distribution_name} (missing from coverage report)")
else:
lines = f"{stats.lines_covered}/{stats.lines_valid}"
coverage = f"{stats.line_coverage_percent:.1f}%"
if policy.enforced and stats.line_coverage_percent < threshold:
failed_packages.append(f"{policy.distribution_name} ({coverage})")
stage = f"{policy.development_status} - {policy.development_status_label}"
print(f"{policy.distribution_name:<48} {stage:<20} {policy_label:<14} {lines:<12} {coverage:<10}")
print("-" * 110)
if failed_packages:
print(f"\nFAILED: Enforced packages below {threshold:.1f}% or missing:")
for package in failed_packages:
print(f" - {package}")
return False
print(f"\nPASSED: All non-exempt Beta-or-higher packages meet {threshold:.1f}% line coverage.")
return True
def main() -> int:
"""Run the coverage policy check."""
if len(sys.argv) != 3:
print(f"Usage: {sys.argv[0]} <coverage-xml-path> <threshold>")
return 1
try:
threshold = float(sys.argv[2])
except ValueError:
print(f"Error: Invalid threshold value: {sys.argv[2]}")
return 1
repository_root = Path(__file__).resolve().parents[2]
try:
passed = check_coverage(
Path(sys.argv[1]),
threshold,
repository_root / "python" / "packages",
)
except (FileNotFoundError, ET.ParseError, ValueError) as error:
print(f"Error: {error}")
return 1
return 0 if passed else 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,170 @@
// Copyright (c) Microsoft. All rights reserved.
const DECISIVE_REVIEW_STATES = new Set(['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED']);
const SHA_PATTERN = /^[0-9a-f]{40}$/;
const BRANCH_PATTERN = /^[a-zA-Z0-9_./-]+$/;
function assertValidSha(sha, description) {
if (!SHA_PATTERN.test(sha)) {
throw new Error(`GitHub returned an invalid ${description} SHA.`);
}
}
function hasWritePermission(permissionData) {
return permissionData.user?.permissions?.push === true
|| ['admin', 'maintain', 'write'].includes(permissionData.permission);
}
function latestDecisiveReviews(reviews) {
const latestByReviewer = new Map();
const sortedReviews = [...reviews].sort((left, right) => {
const submittedComparison = (left.submitted_at || '').localeCompare(right.submitted_at || '');
return submittedComparison || Number(left.id) - Number(right.id);
});
for (const review of sortedReviews) {
const state = review.state?.toUpperCase();
const reviewer = review.user?.login?.toLowerCase();
if (reviewer && DECISIVE_REVIEW_STATES.has(state)) {
latestByReviewer.set(reviewer, review);
}
}
return latestByReviewer;
}
async function resolvePullRequest({ github, context, core, prNumber, requiredApprovals }) {
if (!/^[0-9]+$/.test(prNumber)) {
throw new Error('Invalid PR number. Only numeric values are allowed.');
}
const pullNumber = Number(prNumber);
const { data: pullRequest } = await github.rest.pulls.get({
...context.repo,
pull_number: pullNumber,
});
if (pullRequest.state !== 'open') {
throw new Error(`PR #${pullNumber} is not open (state: ${pullRequest.state}).`);
}
const headSha = pullRequest.head.sha;
const baseSha = pullRequest.base.sha;
assertValidSha(headSha, 'PR head');
assertValidSha(baseSha, 'PR base');
const reviews = await github.paginate(github.rest.pulls.listReviews, {
...context.repo,
pull_number: pullNumber,
per_page: 100,
});
const latestReviews = latestDecisiveReviews(reviews);
const author = pullRequest.user?.login?.toLowerCase();
const approvalCandidates = [...latestReviews.entries()]
.filter(([, review]) => review.state.toUpperCase() === 'APPROVED')
.filter(([, review]) => review.commit_id === headSha)
.filter(([reviewer]) => reviewer !== author);
const approvedMaintainers = [];
for (const [reviewer] of approvalCandidates) {
const { data: permissionData } = await github.rest.repos.getCollaboratorPermissionLevel({
...context.repo,
username: reviewer,
});
if (hasWritePermission(permissionData)) {
approvedMaintainers.push(reviewer);
} else {
core.info(`Ignoring approval from ${reviewer}: reviewer does not have write permission.`);
}
}
if (approvedMaintainers.length < requiredApprovals) {
throw new Error(
`PR #${pullNumber} head ${headSha} requires ${requiredApprovals} approvals from unique `
+ `write-capable maintainers; found ${approvedMaintainers.length}.`,
);
}
core.info(
`PR #${pullNumber} head ${headSha} approved by: ${approvedMaintainers.join(', ')}.`,
);
return {
baseRef: baseSha,
checkoutRef: headSha,
description: `PR #${pullNumber}`,
};
}
async function resolveBranch({ github, context, core, branch }) {
if (!BRANCH_PATTERN.test(branch)) {
throw new Error(
'Invalid branch name. Only alphanumeric characters, hyphens, underscores, dots, and slashes '
+ 'are allowed.',
);
}
const [{ data: repository }, { data: targetBranch }] = await Promise.all([
github.rest.repos.get(context.repo),
github.rest.repos.getBranch({ ...context.repo, branch }),
]);
const { data: baseBranch } = await github.rest.repos.getBranch({
...context.repo,
branch: repository.default_branch,
});
const checkoutRef = targetBranch.commit.sha;
const baseRef = baseBranch.commit.sha;
assertValidSha(checkoutRef, 'branch head');
assertValidSha(baseRef, 'default branch');
core.info(`Branch ${branch} resolved to immutable commit ${checkoutRef}.`);
return {
baseRef,
checkoutRef,
description: `branch ${branch}`,
};
}
/**
* Resolve a manually requested integration-test target to an immutable commit.
*
* Pull requests must have fresh approvals from two unique write-capable
* maintainers for the exact head commit. Branches are limited to branches in
* the base repository and are pinned to their current commit.
*/
async function resolveIntegrationTestTarget({
github,
context,
core,
prNumber = '',
branch = '',
requiredApprovals = 2,
}) {
const normalizedPrNumber = prNumber.trim();
const normalizedBranch = branch.trim();
if (normalizedPrNumber && normalizedBranch) {
throw new Error('Please provide either a PR number or a branch name, not both.');
}
if (!normalizedPrNumber && !normalizedBranch) {
throw new Error('Please provide either a PR number or a branch name.');
}
if (normalizedPrNumber) {
return resolvePullRequest({
github,
context,
core,
prNumber: normalizedPrNumber,
requiredApprovals,
});
}
return resolveBranch({
github,
context,
core,
branch: normalizedBranch,
});
}
module.exports = resolveIntegrationTestTarget;
+73 -2
View File
@@ -16,7 +16,12 @@ const checkTeamMembership = require('../scripts/check_team_membership.js');
// Helpers
// ---------------------------------------------------------------------------
function createMocks({ payloadIssue = undefined, apiUser = 'api-user', teamState = 'active' } = {}) {
function createMocks({
payloadIssue = undefined,
payloadPullRequest = undefined,
apiUser = 'api-user',
teamState = 'active',
} = {}) {
const core = {
_infoMessages: [],
_failedMessages: [],
@@ -24,8 +29,16 @@ function createMocks({ payloadIssue = undefined, apiUser = 'api-user', teamState
setFailed(msg) { this._failedMessages.push(msg); },
};
const payload = {};
if (payloadIssue !== undefined) {
payload.issue = payloadIssue;
}
if (payloadPullRequest !== undefined) {
payload.pull_request = payloadPullRequest;
}
const context = {
payload: { issue: payloadIssue },
payload,
repo: { owner: 'test-org', repo: 'test-repo' },
};
@@ -36,6 +49,11 @@ function createMocks({ payloadIssue = undefined, apiUser = 'api-user', teamState
data: { user: apiUser ? { login: apiUser } : null },
}),
},
pulls: {
get: async () => ({
data: { user: apiUser ? { login: apiUser } : null },
}),
},
teams: {
getByName: async () => ({}),
getMembershipForUserInOrg: async () => ({
@@ -56,6 +74,28 @@ const BASE_OPTS = { teamSlug: 'my-team', issueNumber: '123' };
// ---------------------------------------------------------------------------
describe('author resolution', () => {
it('uses an explicit username instead of the issue author', async () => {
const { github, context, core } = createMocks({
payloadIssue: { user: { login: 'issue-author' } },
});
let issuesGetCalled = false;
github.rest.issues.get = async () => {
issuesGetCalled = true;
return { data: { user: { login: 'api-user' } } };
};
const result = await checkTeamMembership({
github,
context,
core,
...BASE_OPTS,
username: 'comment-author',
});
assert.equal(result.author, 'comment-author');
assert.equal(issuesGetCalled, false);
});
it('resolves author from event payload', async () => {
const { github, context, core } = createMocks({
payloadIssue: { user: { login: 'payload-user' } },
@@ -64,6 +104,37 @@ describe('author resolution', () => {
assert.equal(result.author, 'payload-user');
});
it('resolves author from pull_request event payload', async () => {
const { github, context, core } = createMocks({
payloadPullRequest: { user: { login: 'pr-author' } },
});
let issuesGetCalled = false;
github.rest.issues.get = async () => {
issuesGetCalled = true;
return { data: { user: { login: 'api-user' } } };
};
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
assert.equal(result.author, 'pr-author');
assert.equal(issuesGetCalled, false);
});
it('resolves author via pulls API when pull_request payload user is null', async () => {
const { github, context, core } = createMocks({
payloadPullRequest: { user: null },
apiUser: 'fetched-pr-author',
});
let pullsGetCalled = false;
github.rest.pulls.get = async () => {
pullsGetCalled = true;
return { data: { user: { login: 'fetched-pr-author' } } };
};
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
assert.equal(result.author, 'fetched-pr-author');
assert.equal(pullsGetCalled, true);
});
it('resolves author via API when payload issue is absent', async () => {
const { github, context, core } = createMocks({ apiUser: 'api-user' });
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
@@ -0,0 +1,125 @@
// Copyright (c) Microsoft. All rights reserved.
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const {
base64ToBase64Url,
createInstallationToken,
createJwtSigningInput,
readConfig,
} = require('../actions/github-app-token/create-token.js');
const CONFIG = {
azureSubscriptionId: 'subscription-id',
keyVaultName: 'vault-name',
keyName: 'key-name',
githubAppClientId: 'client-id',
githubAppInstallationId: '12345',
targetRepository: 'microsoft/agent-framework',
};
describe('GitHub App token creation', () => {
it('creates a short-lived GitHub App JWT', () => {
const signingInput = createJwtSigningInput('client-id', 1_000);
const [encodedHeader, encodedPayload] = signingInput.split('.');
const header = JSON.parse(Buffer.from(encodedHeader, 'base64url').toString());
const payload = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString());
assert.deepEqual(header, { alg: 'RS256', typ: 'JWT' });
assert.deepEqual(payload, { iat: 940, exp: 1_540, iss: 'client-id' });
});
it('converts Key Vault signatures to unpadded base64url', () => {
assert.equal(base64ToBase64Url('+/8='), '-_8');
});
it('requests a repository-scoped installation token', async () => {
let request;
const token = await createInstallationToken(CONFIG, {
nowSeconds: 1_000,
execute: (command, args) => {
assert.equal(command, 'az');
assert.ok(args.includes('RS256'));
return '+/8=\n';
},
fetch: async (url, options) => {
request = { url, options };
return {
ok: true,
json: async () => ({ token: 'installation-token' }),
};
},
});
assert.equal(token, 'installation-token');
assert.equal(request.url, 'https://api.github.com/app/installations/12345/access_tokens');
assert.match(request.options.headers.Authorization, /^Bearer [^.]+\.[^.]+\.-_8$/);
assert.deepEqual(JSON.parse(request.options.body), {
repositories: ['agent-framework'],
permissions: {
contents: 'read',
issues: 'write',
members: 'read',
pull_requests: 'write',
},
});
});
it('rejects incomplete configuration', () => {
assert.throws(
() => readConfig({}),
/Required GitHub App authentication configuration is missing/,
);
});
it('rejects repository values with extra path segments before signing', async () => {
let signed = false;
await assert.rejects(
createInstallationToken(
{ ...CONFIG, targetRepository: 'microsoft/agent-framework/extra' },
{
execute: () => {
signed = true;
return '+/8=\n';
},
},
),
/TARGET_REPOSITORY must use the owner\/repository format/,
);
assert.equal(signed, false);
});
it('rejects an empty Key Vault signature', async () => {
await assert.rejects(
createInstallationToken(CONFIG, {
execute: () => '\n',
}),
/Key Vault returned an empty signature/,
);
});
it('rejects a failed GitHub token request', async () => {
await assert.rejects(
createInstallationToken(CONFIG, {
execute: () => '+/8=\n',
fetch: async () => ({ ok: false, status: 403 }),
}),
/GitHub installation token request failed with HTTP 403/,
);
});
it('rejects an empty GitHub installation token', async () => {
await assert.rejects(
createInstallationToken(CONFIG, {
execute: () => '+/8=\n',
fetch: async () => ({
ok: true,
json: async () => ({ token: '' }),
}),
}),
/GitHub returned an empty installation token/,
);
});
});
+134
View File
@@ -0,0 +1,134 @@
# Copyright (c) Microsoft. All rights reserved.
# ruff:file-ignore[implicit-namespace-package, undocumented-public-class, undocumented-public-method]
from __future__ import annotations
import importlib.util
import sys
import tempfile
import unittest
from pathlib import Path
SCRIPT_PATH = Path(__file__).parents[1] / "scripts" / "python_check_coverage.py"
SPEC = importlib.util.spec_from_file_location("python_check_coverage", SCRIPT_PATH)
if SPEC is None or SPEC.loader is None:
raise RuntimeError(f"Unable to load {SCRIPT_PATH}")
coverage_checker = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = coverage_checker
SPEC.loader.exec_module(coverage_checker)
class CoveragePolicyTests(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.root = Path(self.temp_dir.name)
self.packages_dir = self.root / "packages"
self.packages_dir.mkdir()
def tearDown(self) -> None:
self.temp_dir.cleanup()
def write_package(self, directory: str, name: str, status: str) -> None:
package_dir = self.packages_dir / directory
package_dir.mkdir()
(package_dir / "pyproject.toml").write_text(
f"""
[project]
name = "{name}"
classifiers = ["Development Status :: {status}"]
""".strip()
)
def write_coverage(self, files: dict[str, list[int]]) -> Path:
classes = []
total_lines = 0
covered_lines = 0
for file_path, hits in files.items():
lines = []
for line_number, hit_count in enumerate(hits, start=1):
total_lines += 1
covered_lines += hit_count > 0
lines.append(f'<line number="{line_number}" hits="{hit_count}"/>')
classes.append(f'<class filename="{file_path}"><lines>{"".join(lines)}</lines></class>')
line_rate = covered_lines / total_lines if total_lines else 0
xml_path = self.root / "coverage.xml"
xml_path.write_text(
f"""
<coverage line-rate="{line_rate}" branch-rate="0">
<packages>
<package name="test">
<classes>{"".join(classes)}</classes>
</package>
</packages>
</coverage>
""".strip()
)
return xml_path
def test_load_package_policies_uses_lifecycle_exemptions(self) -> None:
self.write_package("alpha", "agent-framework-alpha", "3 - Alpha")
self.write_package("beta", "agent-framework-beta", "4 - Beta")
self.write_package("stable", "agent-framework-stable", "5 - Production/Stable")
self.write_package("devui", "agent-framework-devui", "4 - Beta")
self.write_package("lab", "agent-framework-lab", "4 - Beta")
policies = {policy.directory: policy for policy in coverage_checker.load_package_policies(self.packages_dir)}
self.assertFalse(policies["alpha"].enforced)
self.assertTrue(policies["beta"].enforced)
self.assertTrue(policies["stable"].enforced)
self.assertTrue(policies["devui"].exempt)
self.assertFalse(policies["devui"].enforced)
self.assertTrue(policies["lab"].exempt)
self.assertFalse(policies["lab"].enforced)
def test_load_package_policies_rejects_missing_lifecycle(self) -> None:
package_dir = self.packages_dir / "missing"
package_dir.mkdir()
(package_dir / "pyproject.toml").write_text('[project]\nname = "agent-framework-missing"\n')
with self.assertRaisesRegex(ValueError, "exactly one Development Status"):
coverage_checker.load_package_policies(self.packages_dir)
def test_parse_coverage_aggregates_nested_modules_by_distribution(self) -> None:
xml_path = self.write_coverage({
"packages/core/agent_framework/_agents.py": [1, 0],
"packages/core/agent_framework/_workflows/_workflow.py": [1, 1],
})
package_stats, _, _ = coverage_checker.parse_coverage_xml(xml_path)
self.assertEqual(package_stats["core"].lines_valid, 4)
self.assertEqual(package_stats["core"].lines_covered, 3)
def test_beta_package_below_threshold_fails(self) -> None:
self.write_package("beta", "agent-framework-beta", "4 - Beta")
xml_path = self.write_coverage({"packages/beta/agent_framework_beta/client.py": [1, 0]})
self.assertFalse(coverage_checker.check_coverage(xml_path, 85, self.packages_dir))
def test_missing_beta_package_fails(self) -> None:
self.write_package("beta", "agent-framework-beta", "4 - Beta")
xml_path = self.write_coverage({})
self.assertFalse(coverage_checker.check_coverage(xml_path, 85, self.packages_dir))
def test_alpha_and_exempt_packages_do_not_fail(self) -> None:
self.write_package("alpha", "agent-framework-alpha", "3 - Alpha")
self.write_package("devui", "agent-framework-devui", "4 - Beta")
self.write_package("lab", "agent-framework-lab", "4 - Beta")
xml_path = self.write_coverage({})
self.assertTrue(coverage_checker.check_coverage(xml_path, 85, self.packages_dir))
def test_beta_package_at_threshold_passes(self) -> None:
self.write_package("beta", "agent-framework-beta", "4 - Beta")
xml_path = self.write_coverage({"packages/beta/agent_framework_beta/client.py": [1] * 17 + [0] * 3})
self.assertTrue(coverage_checker.check_coverage(xml_path, 85, self.packages_dir))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,212 @@
// Copyright (c) Microsoft. All rights reserved.
/**
* Tests for resolve_integration_test_target.js.
*
* Run with: node --test .github/tests/test_resolve_integration_test_target.js
*/
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const resolveIntegrationTestTarget = require('../scripts/resolve_integration_test_target.js');
const HEAD_SHA = 'a'.repeat(40);
const BASE_SHA = 'b'.repeat(40);
function review({
id,
login,
state = 'APPROVED',
commitId = HEAD_SHA,
submittedAt = `2026-07-13T00:00:${String(id).padStart(2, '0')}Z`,
}) {
return {
id,
state,
commit_id: commitId,
submitted_at: submittedAt,
user: { login },
};
}
function createMocks({
pullState = 'open',
pullAuthor = 'contributor',
reviews = [],
permissions = {},
} = {}) {
const core = {
infoMessages: [],
info(message) {
this.infoMessages.push(message);
},
};
const context = {
repo: { owner: 'microsoft', repo: 'agent-framework' },
};
const github = {
paginate: async () => reviews,
rest: {
pulls: {
get: async () => ({
data: {
state: pullState,
user: { login: pullAuthor },
head: { sha: HEAD_SHA },
base: { sha: BASE_SHA },
},
}),
listReviews: async () => {},
},
repos: {
get: async () => ({ data: { default_branch: 'main' } }),
getBranch: async ({ branch }) => ({
data: { commit: { sha: branch === 'main' ? BASE_SHA : HEAD_SHA } },
}),
getCollaboratorPermissionLevel: async ({ username }) => ({
data: permissions[username] || {
permission: 'read',
user: { permissions: { push: false } },
},
}),
},
},
};
return { core, context, github };
}
const WRITE_PERMISSION = {
permission: 'write',
user: { permissions: { push: true } },
};
describe('input validation', () => {
it('rejects missing and conflicting targets', async () => {
const mocks = createMocks();
await assert.rejects(
() => resolveIntegrationTestTarget(mocks),
/provide either a PR number or a branch name/,
);
await assert.rejects(
() => resolveIntegrationTestTarget({ ...mocks, prNumber: '1', branch: 'feature' }),
/not both/,
);
});
it('rejects invalid PR numbers and branch names', async () => {
const mocks = createMocks();
await assert.rejects(
() => resolveIntegrationTestTarget({ ...mocks, prNumber: '1;echo' }),
/Invalid PR number/,
);
await assert.rejects(
() => resolveIntegrationTestTarget({ ...mocks, branch: 'feature branch' }),
/Invalid branch name/,
);
});
});
describe('pull request resolution', () => {
it('pins an open PR with two fresh write-capable approvals', async () => {
const mocks = createMocks({
reviews: [
review({ id: 1, login: 'maintainer-one' }),
review({ id: 2, login: 'maintainer-two' }),
],
permissions: {
'maintainer-one': WRITE_PERMISSION,
'maintainer-two': WRITE_PERMISSION,
},
});
const result = await resolveIntegrationTestTarget({ ...mocks, prNumber: '123' });
assert.deepEqual(result, {
baseRef: BASE_SHA,
checkoutRef: HEAD_SHA,
description: 'PR #123',
});
});
it('rejects closed PRs', async () => {
const mocks = createMocks({ pullState: 'closed' });
await assert.rejects(
() => resolveIntegrationTestTarget({ ...mocks, prNumber: '123' }),
/is not open/,
);
});
it('ignores stale, self, and read-only approvals', async () => {
const mocks = createMocks({
reviews: [
review({ id: 1, login: 'stale', commitId: 'c'.repeat(40) }),
review({ id: 2, login: 'contributor' }),
review({ id: 3, login: 'reader' }),
review({ id: 4, login: 'maintainer' }),
],
permissions: {
contributor: WRITE_PERMISSION,
reader: { permission: 'read', user: { permissions: { push: false } } },
maintainer: WRITE_PERMISSION,
},
});
await assert.rejects(
() => resolveIntegrationTestTarget({ ...mocks, prNumber: '123' }),
/found 1/,
);
});
it('uses each reviewer latest decisive review and ignores later comments', async () => {
const mocks = createMocks({
reviews: [
review({ id: 1, login: 'changes-requested' }),
review({ id: 2, login: 'changes-requested', state: 'CHANGES_REQUESTED' }),
review({ id: 3, login: 'maintainer-one' }),
review({ id: 4, login: 'maintainer-one', state: 'COMMENTED' }),
review({ id: 5, login: 'maintainer-two' }),
],
permissions: {
'changes-requested': WRITE_PERMISSION,
'maintainer-one': WRITE_PERMISSION,
'maintainer-two': WRITE_PERMISSION,
},
});
const result = await resolveIntegrationTestTarget({ ...mocks, prNumber: '123' });
assert.equal(result.checkoutRef, HEAD_SHA);
});
it('does not count a dismissed approval', async () => {
const mocks = createMocks({
reviews: [
review({ id: 1, login: 'dismissed', state: 'DISMISSED' }),
review({ id: 2, login: 'maintainer' }),
],
permissions: {
dismissed: WRITE_PERMISSION,
maintainer: WRITE_PERMISSION,
},
});
await assert.rejects(
() => resolveIntegrationTestTarget({ ...mocks, prNumber: '123' }),
/found 1/,
);
});
});
describe('branch resolution', () => {
it('pins base-repository branches and their comparison base to SHAs', async () => {
const mocks = createMocks();
const result = await resolveIntegrationTestTarget({ ...mocks, branch: 'feature/test' });
assert.deepEqual(result, {
baseRef: BASE_SHA,
checkoutRef: HEAD_SHA,
description: 'branch feature/test',
});
});
});
+80 -30
View File
@@ -6,6 +6,9 @@ on:
- opened
- reopened
- ready_for_review
issue_comment:
types:
- created
workflow_dispatch:
inputs:
pr_number:
@@ -15,11 +18,12 @@ on:
permissions:
contents: read
id-token: write
issues: write
pull-requests: write
concurrency:
group: devflow-pr-review-${{ github.repository }}-${{ github.event.pull_request.number || inputs.pr_number || github.run_id }}
group: devflow-pr-review-${{ github.repository }}-${{ github.event.pull_request.number || github.event.issue.number || inputs.pr_number || github.run_id }}
cancel-in-progress: true
env:
@@ -27,10 +31,22 @@ env:
DEVFLOW_REF: main
TARGET_REPO_PATH: ${{ github.workspace }}/target-repo
DEVFLOW_PATH: ${{ github.workspace }}/devflow
MODEL_CONFIG_PATH: ${{ github.workspace }}/devflow/config.ci.yaml
jobs:
team_check:
if: >-
github.event_name != 'issue_comment' ||
(
github.event.issue.pull_request &&
github.event.comment.body == '/review' &&
(
github.event.comment.author_association == 'MEMBER' ||
github.event.comment.author_association == 'OWNER'
)
)
runs-on: ubuntu-latest
environment: github-app-auth
outputs:
is_team_member: ${{ steps.check.outputs.is_team_member }}
pr_number: ${{ steps.pr.outputs.pr_number }}
@@ -42,6 +58,7 @@ jobs:
shell: bash
env:
PR_HTML_URL: ${{ github.event.pull_request.html_url }}
PR_NUMBER_COMMENT: ${{ github.event.issue.number }}
PR_NUMBER_EVENT: ${{ github.event.pull_request.number }}
PR_NUMBER_INPUT: ${{ inputs.pr_number }}
run: |
@@ -50,6 +67,9 @@ jobs:
if [[ "${GITHUB_EVENT_NAME}" == "pull_request_target" ]]; then
pr_number="${PR_NUMBER_EVENT}"
pr_url="${PR_HTML_URL}"
elif [[ "${GITHUB_EVENT_NAME}" == "issue_comment" ]]; then
pr_number="${PR_NUMBER_COMMENT}"
pr_url="https://github.com/${GITHUB_REPOSITORY}/pull/${pr_number}"
else
pr_number="${PR_NUMBER_INPUT}"
pr_url="https://github.com/${GITHUB_REPOSITORY}/pull/${pr_number}"
@@ -64,49 +84,78 @@ jobs:
echo "pr_number=${pr_number}" >> "$GITHUB_OUTPUT"
echo "repo=${GITHUB_REPOSITORY}" >> "$GITHUB_OUTPUT"
- name: Check PR author team membership
- name: Checkout GitHub automation
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.sha || github.sha }}
sparse-checkout: |
.github/actions/github-app-token
.github/scripts/check_team_membership.js
fetch-depth: 1
persist-credentials: false
- name: Get GitHub automation token
id: github-auth
uses: ./.github/actions/github-app-token
with:
mode: ${{ vars.GH_APP_AUTH_MODE }}
azure-client-id: ${{ secrets.GH_APP_AZURE_CLIENT_ID }}
azure-tenant-id: ${{ secrets.GH_APP_AZURE_TENANT_ID }}
azure-subscription-id: ${{ secrets.GH_APP_AZURE_SUBSCRIPTION_ID }}
key-vault-name: ${{ secrets.GH_APP_KEY_VAULT_NAME }}
key-name: ${{ secrets.GH_APP_KEY_NAME }}
github-app-client-id: ${{ secrets.GH_APP_CLIENT_ID }}
github-app-installation-id: ${{ secrets.GH_APP_INSTALLATION_ID }}
repository: ${{ github.repository }}
fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
- name: Check review requester team membership
id: check
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
MEMBERSHIP_USER: ${{ github.event_name == 'issue_comment' && github.event.comment.user.login || '' }}
TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }}
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
with:
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
github-token: ${{ steps.github-auth.outputs.token }}
script: |
let author = context.payload.pull_request?.user?.login;
if (!author) {
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: Number(process.env.PR_NUMBER),
});
author = pr.user.login;
}
let isTeamMember = false;
try {
const teamMembership = await github.rest.teams.getMembershipForUserInOrg({
org: context.repo.owner,
team_slug: process.env.TEAM_NAME,
username: author,
});
isTeamMember = teamMembership.data.state === 'active';
} catch (error) {
console.log(`Team membership lookup failed for ${author}: ${error.message}`);
isTeamMember = false;
}
const checkTeamMembership = require('./.github/scripts/check_team_membership.js');
const { author, isTeamMember } = await checkTeamMembership({
github,
context,
core,
teamSlug: process.env.TEAM_NAME,
issueNumber: process.env.PR_NUMBER,
username: process.env.MEMBERSHIP_USER,
});
core.setOutput('is_team_member', isTeamMember ? 'true' : 'false');
if (isTeamMember) {
core.info(`Author ${author} is a team member; proceeding with review.`);
core.info(`User ${author} is a team member; proceeding with review.`);
} else {
core.info(`Author ${author} is not a member of ${process.env.TEAM_NAME}; skipping review.`);
core.info(`User ${author} is not a member of ${process.env.TEAM_NAME}; skipping review.`);
}
- name: React to authorized review command
if: ${{ github.event_name == 'issue_comment' && steps.check.outputs.is_team_member == 'true' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
github-token: ${{ steps.github-auth.outputs.token }}
script: |
await github.rest.reactions.createForIssueComment({
...context.repo,
comment_id: context.payload.comment.id,
content: 'eyes',
});
review:
runs-on: ubuntu-latest
needs: team_check
if: ${{ needs.team_check.outputs.is_team_member == 'true' }}
permissions:
copilot-requests: write
contents: read
issues: write
pull-requests: write
timeout-minutes: 60
# Advisory check: failures here should not block the PR. The reviewer
# posts comments as a best-effort signal; if the pipeline breaks, the
@@ -153,8 +202,8 @@ jobs:
id: review
working-directory: ${{ env.DEVFLOW_PATH }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_COPILOT_TOKEN: ${{ secrets.GH_COPILOT_TOKEN }}
DEVFLOW_TOKEN: ${{ secrets.DEVFLOW_TOKEN }}
GITHUB_TOKEN: ${{ github.token }}
SK_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
AGENT_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
PR_URL: ${{ needs.team_check.outputs.pr_url }}
@@ -162,4 +211,5 @@ jobs:
uv run python scripts/trigger_pr_review.py \
--pr-url "$PR_URL" \
--github-username "$GITHUB_ACTOR" \
--review-compare \
--no-require-comment-selection
@@ -163,6 +163,7 @@ jobs:
# Change to project directory to ensure local nuget.config is used
pushd consoleapp
dotnet add packcheck.csproj package Microsoft.Agents.AI --prerelease
dotnet add packcheck.csproj package Microsoft.Agents.AI.LocalCodeAct --prerelease
dotnet build -f ${{ matrix.targetFramework }} -c ${{ matrix.configuration }} packcheck.csproj
# Clean up
+17 -3
View File
@@ -9,16 +9,30 @@ on:
workflow_call:
inputs:
checkout-ref:
description: "Git ref to checkout (e.g., refs/pull/123/head)"
description: "Immutable commit SHA to check out"
required: true
type: string
secrets:
AZURE_CLIENT_ID:
required: true
AZURE_TENANT_ID:
required: true
AZURE_SUBSCRIPTION_ID:
required: true
AZUREAI__ENDPOINT:
required: true
OPENAI__APIKEY:
required: true
permissions:
contents: read
id-token: write
jobs:
dotnet-integration-tests:
permissions:
copilot-requests: write
contents: read
id-token: write
strategy:
fail-fast: false
matrix:
@@ -88,7 +102,7 @@ jobs:
env:
COSMOSDB_ENDPOINT: https://localhost:8081
COSMOSDB_KEY: C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ github.token }}
OpenAI__ApiKey: ${{ secrets.OPENAI__APIKEY }}
OpenAI__ChatModelId: ${{ vars.OPENAI__CHATMODELID }}
OpenAI__ChatReasoningModelId: ${{ vars.OPENAI__CHATREASONINGMODELID }}
@@ -0,0 +1,42 @@
name: GitHub automation tests
on:
pull_request:
paths:
- ".github/actions/**"
- ".github/scripts/**"
- ".github/tests/**"
- ".github/workflows/python-test-coverage.yml"
- ".github/workflows/github-automation-tests.yml"
push:
branches:
- main
paths:
- ".github/actions/**"
- ".github/scripts/**"
- ".github/tests/**"
- ".github/workflows/python-test-coverage.yml"
- ".github/workflows/github-automation-tests.yml"
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: "22"
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.11"
- name: Run JavaScript tests
run: node --test .github/tests/*.js
- name: Run Python tests
run: python .github/tests/test_python_check_coverage.py
+53 -52
View File
@@ -3,7 +3,7 @@
# Go to Actions → "Integration Tests (Manual)" → Run workflow → enter a PR number or branch name.
#
# It calls dedicated integration-only workflows (dotnet-integration-tests and python-integration-tests),
# passing a ref so they check out and test the correct code.
# passing an immutable commit SHA so they check out and test the approved code.
# Changed paths are detected here so only the relevant test suites run.
#
@@ -26,7 +26,6 @@ on:
permissions:
contents: read
pull-requests: read
id-token: write
concurrency:
group: integration-tests-manual-${{ github.event.inputs.pr-number || github.event.inputs.branch }}
@@ -38,67 +37,50 @@ jobs:
runs-on: ubuntu-latest
outputs:
checkout-ref: ${{ steps.resolve.outputs.checkout-ref }}
base-ref: ${{ steps.resolve.outputs.base-ref }}
dotnet-changes: ${{ steps.detect-changes.outputs.dotnet }}
python-changes: ${{ steps.detect-changes.outputs.python }}
steps:
- name: Resolve checkout ref
- name: Check out trusted workflow helpers
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ github.sha }}
persist-credentials: false
sparse-checkout: .github/scripts
- name: Resolve and authorize checkout ref
id: resolve
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const resolveIntegrationTestTarget = require(
'./.github/scripts/resolve_integration_test_target.js'
);
const target = await resolveIntegrationTestTarget({
github,
context,
core,
prNumber: process.env.PR_NUMBER,
branch: process.env.BRANCH,
});
core.setOutput('checkout-ref', target.checkoutRef);
core.setOutput('base-ref', target.baseRef);
core.info(`Running integration tests for ${target.description} at ${target.checkoutRef}.`);
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.inputs.pr-number }}
BRANCH: ${{ github.event.inputs.branch }}
REPO: ${{ github.repository }}
run: |
if [ -n "$PR_NUMBER" ] && [ -n "$BRANCH" ]; then
echo "::error::Please provide either a PR number or a branch name, not both."
exit 1
fi
if [ -z "$PR_NUMBER" ] && [ -z "$BRANCH" ]; then
echo "::error::Please provide either a PR number or a branch name."
exit 1
fi
if [ -n "$PR_NUMBER" ]; then
if ! echo "$PR_NUMBER" | grep -Eq '^[0-9]+$'; then
echo "::error::Invalid PR number. Only numeric values are allowed."
exit 1
fi
PR_DATA=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json state)
PR_STATE=$(echo "$PR_DATA" | jq -r '.state')
if [ "$PR_STATE" != "OPEN" ]; then
echo "::error::PR #$PR_NUMBER is not open (state: $PR_STATE)"
exit 1
fi
echo "checkout-ref=refs/pull/$PR_NUMBER/head" >> "$GITHUB_OUTPUT"
echo "Running integration tests for PR #$PR_NUMBER"
else
if ! echo "$BRANCH" | grep -Eq '^[a-zA-Z0-9_./-]+$'; then
echo "::error::Invalid branch name. Only alphanumeric characters, hyphens, underscores, dots, and slashes are allowed."
exit 1
fi
echo "checkout-ref=$BRANCH" >> "$GITHUB_OUTPUT"
echo "Running integration tests for branch $BRANCH"
fi
- name: Detect changed paths
id: detect-changes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.inputs.pr-number }}
BRANCH: ${{ github.event.inputs.branch }}
BASE_REF: ${{ steps.resolve.outputs.base-ref }}
CHECKOUT_REF: ${{ steps.resolve.outputs.checkout-ref }}
REPO: ${{ github.repository }}
run: |
if [ -n "$PR_NUMBER" ]; then
CHANGED_FILES=$(gh pr diff "$PR_NUMBER" --repo "$REPO" --name-only)
else
# For branches, compare against main using the GitHub API
CHANGED_FILES=$(gh api "repos/$REPO/compare/main...$BRANCH" --jq '.files[].filename')
fi
CHANGED_FILES=$(gh api "repos/$REPO/compare/$BASE_REF...$CHECKOUT_REF" \
--jq '.files[].filename')
DOTNET_CHANGES=false
PYTHON_CHANGES=false
@@ -113,22 +95,41 @@ jobs:
echo "dotnet=$DOTNET_CHANGES" >> "$GITHUB_OUTPUT"
echo "python=$PYTHON_CHANGES" >> "$GITHUB_OUTPUT"
echo "Detected changes dotnet: $DOTNET_CHANGES, python: $PYTHON_CHANGES"
echo "Detected changes; dotnet: $DOTNET_CHANGES, python: $PYTHON_CHANGES"
dotnet-integration-tests:
name: .NET Integration Tests
needs: resolve-ref
if: needs.resolve-ref.outputs.dotnet-changes == 'true'
permissions:
copilot-requests: write
contents: read
id-token: write
uses: ./.github/workflows/dotnet-integration-tests.yml
with:
checkout-ref: ${{ needs.resolve-ref.outputs.checkout-ref }}
secrets: inherit
secrets:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZUREAI__ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }}
OPENAI__APIKEY: ${{ secrets.OPENAI__APIKEY }}
python-integration-tests:
name: Python Integration Tests
needs: resolve-ref
if: needs.resolve-ref.outputs.python-changes == 'true'
permissions:
copilot-requests: write
contents: read
id-token: write
uses: ./.github/workflows/python-integration-tests.yml
with:
checkout-ref: ${{ needs.resolve-ref.outputs.checkout-ref }}
secrets: inherit
secrets:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
FOUNDRY_MODELS_API_KEY: ${{ secrets.FOUNDRY_MODELS_API_KEY }}
OPENAI__APIKEY: ${{ secrets.OPENAI__APIKEY }}
+28 -5
View File
@@ -29,10 +29,12 @@ env:
DEVFLOW_REF: main
TARGET_REPO_PATH: ${{ github.workspace }}/target-repo
DEVFLOW_PATH: ${{ github.workspace }}/devflow
MODEL_CONFIG_PATH: ${{ github.workspace }}/devflow/config.ci.yaml
jobs:
team_check:
runs-on: ubuntu-latest
environment: github-app-auth
if: >-
${{
github.event_name == 'workflow_dispatch'
@@ -68,10 +70,27 @@ jobs:
- name: Checkout scripts
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
sparse-checkout: .github/scripts
sparse-checkout: |
.github/actions/github-app-token
.github/scripts
fetch-depth: 1
persist-credentials: false
- name: Get GitHub automation token
id: github-auth
uses: ./.github/actions/github-app-token
with:
mode: ${{ vars.GH_APP_AUTH_MODE }}
azure-client-id: ${{ secrets.GH_APP_AZURE_CLIENT_ID }}
azure-tenant-id: ${{ secrets.GH_APP_AZURE_TENANT_ID }}
azure-subscription-id: ${{ secrets.GH_APP_AZURE_SUBSCRIPTION_ID }}
key-vault-name: ${{ secrets.GH_APP_KEY_VAULT_NAME }}
key-name: ${{ secrets.GH_APP_KEY_NAME }}
github-app-client-id: ${{ secrets.GH_APP_CLIENT_ID }}
github-app-installation-id: ${{ secrets.GH_APP_INSTALLATION_ID }}
repository: ${{ github.repository }}
fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
- name: Check issue author team membership
if: ${{ github.event_name != 'workflow_dispatch' }}
id: check
@@ -80,7 +99,7 @@ jobs:
TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }}
ISSUE_NUMBER: ${{ steps.issue.outputs.issue_number }}
with:
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
github-token: ${{ steps.github-auth.outputs.token }}
script: |
const checkTeamMembership = require('./.github/scripts/check_team_membership.js');
const { author, isTeamMember } = await checkTeamMembership({
@@ -106,6 +125,11 @@ jobs:
|| needs.team_check.outputs.is_team_member == 'false'
}}
environment: integration
permissions:
copilot-requests: write
contents: read
id-token: write
issues: write
timeout-minutes: 60
steps:
@@ -154,7 +178,7 @@ jobs:
id: spam
working-directory: ${{ env.DEVFLOW_PATH }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ github.token }}
DEVFLOW_TOKEN: ${{ secrets.DEVFLOW_TOKEN }}
SK_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
AGENT_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
@@ -179,8 +203,7 @@ jobs:
id: repro
working-directory: ${{ env.DEVFLOW_PATH }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_COPILOT_TOKEN: ${{ secrets.GH_COPILOT_TOKEN }}
GITHUB_TOKEN: ${{ github.token }}
# Not seen by the agent prompt; used only to push a paper-trail
# branch back to maf-dashboard at run end.
DEVFLOW_TOKEN: ${{ secrets.DEVFLOW_TOKEN }}
+36 -16
View File
@@ -10,12 +10,39 @@ jobs:
name: "Issue: add labels"
if: ${{ github.event.action == 'opened' || github.event.action == 'reopened' }}
runs-on: ubuntu-latest
environment: github-app-auth
permissions:
contents: read
id-token: write
issues: write
steps:
- name: Checkout GitHub automation
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
sparse-checkout: |
.github/actions/github-app-token
.github/scripts/check_team_membership.js
fetch-depth: 1
persist-credentials: false
- name: Get GitHub automation token
id: github-auth
uses: ./.github/actions/github-app-token
with:
mode: ${{ vars.GH_APP_AUTH_MODE }}
azure-client-id: ${{ secrets.GH_APP_AZURE_CLIENT_ID }}
azure-tenant-id: ${{ secrets.GH_APP_AZURE_TENANT_ID }}
azure-subscription-id: ${{ secrets.GH_APP_AZURE_SUBSCRIPTION_ID }}
key-vault-name: ${{ secrets.GH_APP_KEY_VAULT_NAME }}
key-name: ${{ secrets.GH_APP_KEY_NAME }}
github-app-client-id: ${{ secrets.GH_APP_CLIENT_ID }}
github-app-installation-id: ${{ secrets.GH_APP_INSTALLATION_ID }}
repository: ${{ github.repository }}
fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
github-token: ${{ steps.github-auth.outputs.token }}
script: |
// Get the issue body and title
const body = context.payload.issue.body
@@ -24,21 +51,14 @@ jobs:
// Define the labels array
let labels = []
// Check if the issue author is in the agentframework-developers team
let isTeamMember = false
try {
const teamMembership = await github.rest.teams.getMembershipForUserInOrg({
org: context.repo.owner,
team_slug: process.env.TEAM_NAME,
username: context.payload.issue.user.login
})
console.log("Team Membership Data:", teamMembership);
isTeamMember = teamMembership.data.state === 'active'
} catch (error) {
// User is not in the team or team doesn't exist
console.error("Error fetching team membership:", error);
isTeamMember = false
}
const checkTeamMembership = require('./.github/scripts/check_team_membership.js')
const { isTeamMember } = await checkTeamMembership({
github,
context,
core,
teamSlug: process.env.TEAM_NAME,
issueNumber: context.issue.number,
})
// Only add triage label if the author is not in the team
if (!isTeamMember) {
+26 -6
View File
@@ -13,27 +13,47 @@ on:
jobs:
add_label:
runs-on: ubuntu-latest
environment: github-app-auth
permissions:
contents: read
id-token: write
issues: write
pull-requests: write
steps:
- uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6
with:
repo-token: "${{ secrets.GH_ACTIONS_PR_WRITE }}"
- name: Checkout scripts
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
sparse-checkout: .github/scripts
ref: ${{ github.event.pull_request.base.sha }}
sparse-checkout: |
.github/actions/github-app-token
.github/scripts
fetch-depth: 1
persist-credentials: false
- name: Get GitHub automation token
id: github-auth
uses: ./.github/actions/github-app-token
with:
mode: ${{ vars.GH_APP_AUTH_MODE }}
azure-client-id: ${{ secrets.GH_APP_AZURE_CLIENT_ID }}
azure-tenant-id: ${{ secrets.GH_APP_AZURE_TENANT_ID }}
azure-subscription-id: ${{ secrets.GH_APP_AZURE_SUBSCRIPTION_ID }}
key-vault-name: ${{ secrets.GH_APP_KEY_VAULT_NAME }}
key-name: ${{ secrets.GH_APP_KEY_NAME }}
github-app-client-id: ${{ secrets.GH_APP_CLIENT_ID }}
github-app-installation-id: ${{ secrets.GH_APP_INSTALLATION_ID }}
repository: ${{ github.repository }}
fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
- uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6
with:
repo-token: ${{ steps.github-auth.outputs.token }}
- name: "PR: add breaking change label from title"
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
github-token: ${{ steps.github-auth.outputs.token }}
script: |
const { syncBreakingChangeLabelFromTitle } = require('./.github/scripts/title_prefix.js');
await syncBreakingChangeLabelFromTitle({ github, context, core });
+43 -4
View File
@@ -6,6 +6,7 @@ on:
permissions:
contents: read
id-token: write
issues: write
pull-requests: write
@@ -21,16 +22,35 @@ env:
jobs:
team_check:
runs-on: ubuntu-latest
environment: github-app-auth
outputs:
is_team_member: ${{ steps.check.outputs.is_team_member }}
steps:
- name: Checkout scripts
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
sparse-checkout: .github/scripts
ref: ${{ github.event.pull_request.base.sha }}
sparse-checkout: |
.github/actions/github-app-token
.github/scripts
fetch-depth: 1
persist-credentials: false
- name: Get GitHub automation token
id: github-auth
uses: ./.github/actions/github-app-token
with:
mode: ${{ vars.GH_APP_AUTH_MODE }}
azure-client-id: ${{ secrets.GH_APP_AZURE_CLIENT_ID }}
azure-tenant-id: ${{ secrets.GH_APP_AZURE_TENANT_ID }}
azure-subscription-id: ${{ secrets.GH_APP_AZURE_SUBSCRIPTION_ID }}
key-vault-name: ${{ secrets.GH_APP_KEY_VAULT_NAME }}
key-name: ${{ secrets.GH_APP_KEY_NAME }}
github-app-client-id: ${{ secrets.GH_APP_CLIENT_ID }}
github-app-installation-id: ${{ secrets.GH_APP_INSTALLATION_ID }}
repository: ${{ github.repository }}
fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
- name: Check PR author team membership
id: check
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
@@ -38,7 +58,7 @@ jobs:
TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }}
PR_NUMBER: ${{ github.event.pull_request.number }}
with:
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
github-token: ${{ steps.github-auth.outputs.token }}
script: |
const checkTeamMembership = require('./.github/scripts/check_team_membership.js');
const { author, isTeamMember } = await checkTeamMembership({
@@ -57,20 +77,39 @@ jobs:
limit_open_prs:
runs-on: ubuntu-latest
environment: github-app-auth
needs: team_check
if: ${{ needs.team_check.outputs.is_team_member == 'false' }}
steps:
- name: Checkout scripts
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
sparse-checkout: .github/scripts
ref: ${{ github.event.pull_request.base.sha }}
sparse-checkout: |
.github/actions/github-app-token
.github/scripts
fetch-depth: 1
persist-credentials: false
- name: Get GitHub automation token
id: github-auth
uses: ./.github/actions/github-app-token
with:
mode: ${{ vars.GH_APP_AUTH_MODE }}
azure-client-id: ${{ secrets.GH_APP_AZURE_CLIENT_ID }}
azure-tenant-id: ${{ secrets.GH_APP_AZURE_TENANT_ID }}
azure-subscription-id: ${{ secrets.GH_APP_AZURE_SUBSCRIPTION_ID }}
key-vault-name: ${{ secrets.GH_APP_KEY_VAULT_NAME }}
key-name: ${{ secrets.GH_APP_KEY_NAME }}
github-app-client-id: ${{ secrets.GH_APP_CLIENT_ID }}
github-app-installation-id: ${{ secrets.GH_APP_INSTALLATION_ID }}
repository: ${{ github.repository }}
fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
- name: Enforce open PR limit
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
github-token: ${{ steps.github-auth.outputs.token }}
script: |
const { enforcePrLimit } = require('./.github/scripts/pr_limit_moderation.js');
await enforcePrLimit({
-382
View File
@@ -1,382 +0,0 @@
#!/usr/bin/env python3
# Copyright (c) Microsoft. All rights reserved.
"""Check Python test coverage against threshold for enforced targets.
This script parses a Cobertura XML coverage report and enforces a minimum
coverage threshold on specific targets. Targets can be package names
(e.g., "packages.core.agent_framework") or individual Python file paths
(e.g., "packages/core/agent_framework/observability.py").
Non-enforced targets are reported for visibility but don't block the build.
Usage:
python python-check-coverage.py <coverage-xml-path> <threshold>
Example:
python python-check-coverage.py python-coverage.xml 85
"""
import sys
import xml.etree.ElementTree as ET
from dataclasses import dataclass
# =============================================================================
# ENFORCED TARGETS CONFIGURATION
# =============================================================================
# Add or remove entries from this set to control which targets must meet
# the coverage threshold. Only these targets will fail the build if below
# threshold. Other targets are reported for visibility only.
#
# Target values can be:
# - Package paths as they appear in the coverage report
# (e.g., "packages.azure-ai.agent_framework_azure_ai")
# - Python source file paths as they appear in the coverage report
# (e.g., "packages/core/agent_framework/observability.py")
# =============================================================================
ENFORCED_TARGETS: set[str] = {
# Packages (sorted alphabetically)
"packages.anthropic.agent_framework_anthropic",
"packages.azure-ai-search.agent_framework_azure_ai_search",
"packages.core.agent_framework",
"packages.core.agent_framework._workflows",
"packages.foundry.agent_framework_foundry",
"packages.openai.agent_framework_openai",
"packages.purview.agent_framework_purview",
# Individual files (if you want to enforce specific files instead of whole packages)
"packages/core/agent_framework/observability.py",
# Add more targets here as coverage improves
}
@dataclass
class PackageCoverage:
"""Coverage data for a single package."""
name: str
line_rate: float
branch_rate: float
lines_valid: int
lines_covered: int
branches_valid: int
branches_covered: int
@property
def line_coverage_percent(self) -> float:
"""Return line coverage as a percentage."""
return self.line_rate * 100
@property
def branch_coverage_percent(self) -> float:
"""Return branch coverage as a percentage."""
return self.branch_rate * 100
def normalize_coverage_path(path: str) -> str:
"""Normalize coverage paths for reliable matching."""
return path.replace("\\", "/").lstrip("./")
def parse_coverage_xml(
xml_path: str,
) -> tuple[dict[str, PackageCoverage], dict[str, PackageCoverage], float, float]:
"""Parse Cobertura XML and extract per-package coverage data.
Args:
xml_path: Path to the Cobertura XML coverage report.
Returns:
A tuple of (packages_dict, files_dict, overall_line_rate, overall_branch_rate).
"""
tree = ET.parse(xml_path)
root = tree.getroot()
# Get overall coverage from root element
overall_line_rate = float(root.get("line-rate", 0))
overall_branch_rate = float(root.get("branch-rate", 0))
packages: dict[str, PackageCoverage] = {}
file_stats: dict[str, dict[str, int]] = {}
for package in root.findall(".//package"):
package_path = package.get("name", "unknown")
line_rate = float(package.get("line-rate", 0))
branch_rate = float(package.get("branch-rate", 0))
# Count lines and branches from classes within this package
lines_valid = 0
lines_covered = 0
branches_valid = 0
branches_covered = 0
for class_elem in package.findall(".//class"):
file_path = normalize_coverage_path(class_elem.get("filename", ""))
if file_path and file_path not in file_stats:
file_stats[file_path] = {
"lines_valid": 0,
"lines_covered": 0,
"branches_valid": 0,
"branches_covered": 0,
}
for line in class_elem.findall(".//line"):
lines_valid += 1
if int(line.get("hits", 0)) > 0:
lines_covered += 1
if file_path:
file_stats[file_path]["lines_valid"] += 1
if int(line.get("hits", 0)) > 0:
file_stats[file_path]["lines_covered"] += 1
# Branch coverage from line elements
if line.get("branch") == "true":
condition_coverage = line.get("condition-coverage", "")
if condition_coverage:
# Parse "X% (covered/total)" format
try:
coverage_parts = (
condition_coverage.split("(")[1].rstrip(")").split("/")
)
branches_covered += int(coverage_parts[0])
branches_valid += int(coverage_parts[1])
if file_path:
file_stats[file_path]["branches_covered"] += int(
coverage_parts[0]
)
file_stats[file_path]["branches_valid"] += int(
coverage_parts[1]
)
except (IndexError, ValueError):
# Ignore malformed condition-coverage strings; treat this line as having no branch data.
pass
# Use full package path as the key (no aggregation)
packages[package_path] = PackageCoverage(
name=package_path,
line_rate=line_rate if lines_valid == 0 else lines_covered / lines_valid,
branch_rate=branch_rate
if branches_valid == 0
else branches_covered / branches_valid,
lines_valid=lines_valid,
lines_covered=lines_covered,
branches_valid=branches_valid,
branches_covered=branches_covered,
)
files: dict[str, PackageCoverage] = {}
for file_path, stats in file_stats.items():
lines_valid = stats["lines_valid"]
lines_covered = stats["lines_covered"]
branches_valid = stats["branches_valid"]
branches_covered = stats["branches_covered"]
files[file_path] = PackageCoverage(
name=file_path,
line_rate=0 if lines_valid == 0 else lines_covered / lines_valid,
branch_rate=0 if branches_valid == 0 else branches_covered / branches_valid,
lines_valid=lines_valid,
lines_covered=lines_covered,
branches_valid=branches_valid,
branches_covered=branches_covered,
)
return packages, files, overall_line_rate, overall_branch_rate
def format_coverage_value(coverage: float, threshold: float, is_enforced: bool) -> str:
"""Format a coverage value with optional pass/fail indicator.
Args:
coverage: Coverage percentage (0-100).
threshold: Minimum required coverage percentage.
is_enforced: Whether this target is enforced.
Returns:
Formatted string like "85.5%" or "85.5%" or "75.0%".
"""
formatted = f"{coverage:.1f}%"
if is_enforced:
icon = "" if coverage >= threshold else ""
formatted = f"{formatted} {icon}"
return formatted
def print_coverage_table(
packages: dict[str, PackageCoverage],
files: dict[str, PackageCoverage],
threshold: float,
overall_line_rate: float,
overall_branch_rate: float,
) -> None:
"""Print a formatted coverage summary table.
Args:
packages: Dictionary of package name to coverage data.
files: Dictionary of file path to coverage data, used for per-file enforcement.
threshold: Minimum required coverage percentage.
overall_line_rate: Overall line coverage rate (0-1).
overall_branch_rate: Overall branch coverage rate (0-1).
"""
print("\n" + "=" * 80)
print("PYTHON TEST COVERAGE REPORT")
print("=" * 80)
# Overall coverage
print(f"\nOverall Line Coverage: {overall_line_rate * 100:.1f}%")
print(f"Overall Branch Coverage: {overall_branch_rate * 100:.1f}%")
print(f"Threshold: {threshold}%")
enforced_targets = {normalize_coverage_path(t) for t in ENFORCED_TARGETS}
# Package table
print("\n" + "-" * 110)
print(f"{'Package':<80} {'Lines':<15} {'Line Cov':<15}")
print("-" * 110)
# Sort: enforced package targets first, then alphabetically
sorted_packages = sorted(
packages.values(),
key=lambda p: (p.name not in ENFORCED_TARGETS, p.name),
)
for pkg in sorted_packages:
is_enforced = normalize_coverage_path(pkg.name) in enforced_targets
enforced_marker = "[ENFORCED] " if is_enforced else ""
line_cov = format_coverage_value(
pkg.line_coverage_percent, threshold, is_enforced
)
lines_info = f"{pkg.lines_covered}/{pkg.lines_valid}"
package_label = f"{enforced_marker}{pkg.name}"
print(f"{package_label:<80} {lines_info:<15} {line_cov:<15}")
print("-" * 110)
# Enforced file/model entries (if configured)
enforced_files = [
files[target]
for target in sorted(enforced_targets)
if target in files and target.endswith(".py")
]
if enforced_files:
print("\nEnforced Files/Models")
print("-" * 110)
print(f"{'File':<80} {'Lines':<15} {'Line Cov':<15}")
print("-" * 110)
for file_cov in enforced_files:
line_cov = format_coverage_value(
file_cov.line_coverage_percent, threshold, True
)
lines_info = f"{file_cov.lines_covered}/{file_cov.lines_valid}"
print(f"[ENFORCED] {file_cov.name:<69} {lines_info:<15} {line_cov:<15}")
print("-" * 110)
def check_coverage(xml_path: str, threshold: float) -> bool:
"""Check if all enforced targets meet the coverage threshold.
Args:
xml_path: Path to the Cobertura XML coverage report.
threshold: Minimum required coverage percentage.
Returns:
True if all enforced targets pass, False otherwise.
"""
packages, files, overall_line_rate, overall_branch_rate = parse_coverage_xml(
xml_path
)
print_coverage_table(
packages, files, threshold, overall_line_rate, overall_branch_rate
)
# Check enforced targets
failed_targets: list[str] = []
missing_targets: list[str] = []
for target_name in ENFORCED_TARGETS:
normalized_target = normalize_coverage_path(target_name)
package_alias = normalized_target.replace("/", ".")
target_coverage = None
if target_name in packages:
target_coverage = packages[target_name]
elif normalized_target in files:
target_coverage = files[normalized_target]
elif package_alias in packages:
target_coverage = packages[package_alias]
if target_coverage is None:
missing_targets.append(target_name)
continue
if target_coverage.line_coverage_percent < threshold:
failed_targets.append(
f"{target_name} ({target_coverage.line_coverage_percent:.1f}%)"
)
# Report results
if missing_targets:
print(
f"\n❌ FAILED: Enforced targets not found in coverage report: {', '.join(missing_targets)}"
)
return False
if failed_targets:
print(
f"\n❌ FAILED: The following enforced targets are below {threshold}% coverage threshold:"
)
for target in failed_targets:
print(f" - {target}")
print("\nTo fix: Add more tests to improve coverage for the failing targets.")
return False
if ENFORCED_TARGETS:
found_enforced = [
target
for target in ENFORCED_TARGETS
if target in packages or normalize_coverage_path(target) in files
]
if found_enforced:
print(
f"\n✅ PASSED: All enforced targets meet the {threshold}% coverage threshold."
)
return True
def main() -> int:
"""Main entry point.
Returns:
Exit code: 0 for success, 1 for failure.
"""
if len(sys.argv) != 3:
print(f"Usage: {sys.argv[0]} <coverage-xml-path> <threshold>")
print(f"Example: {sys.argv[0]} python-coverage.xml 85")
return 1
xml_path = sys.argv[1]
try:
threshold = float(sys.argv[2])
except ValueError:
print(f"Error: Invalid threshold value: {sys.argv[2]}")
return 1
try:
success = check_coverage(xml_path, threshold)
return 0 if success else 1
except FileNotFoundError:
print(f"Error: Coverage file not found: {xml_path}")
return 1
except ET.ParseError as e:
print(f"Error: Failed to parse coverage XML: {e}")
return 1
if __name__ == "__main__":
sys.exit(main())
+31 -3
View File
@@ -13,13 +13,25 @@ on:
workflow_call:
inputs:
checkout-ref:
description: "Git ref to checkout (e.g., refs/pull/123/head)"
description: "Immutable commit SHA to check out"
required: true
type: string
secrets:
ANTHROPIC_API_KEY:
required: true
AZURE_CLIENT_ID:
required: true
AZURE_TENANT_ID:
required: true
AZURE_SUBSCRIPTION_ID:
required: true
FOUNDRY_MODELS_API_KEY:
required: false
OPENAI__APIKEY:
required: true
permissions:
contents: read
id-token: write
env:
UV_CACHE_DIR: /tmp/.uv-cache
@@ -99,6 +111,9 @@ jobs:
# Azure OpenAI integration tests
python-tests-azure-openai:
name: Python Integration Tests - Azure OpenAI
permissions:
contents: read
id-token: write
runs-on: ubuntu-latest
environment: integration
timeout-minutes: 60
@@ -224,6 +239,7 @@ jobs:
packages/hyperlight/tests
packages/ollama/tests
packages/core/tests/core/test_mcp.py
packages/hosting-mcp/tests
-m integration
-n logical --dist worksteal
--timeout=120 --session-timeout=900 --timeout_method thread
@@ -260,6 +276,9 @@ jobs:
# Azure Functions + Durable Task integration tests
python-tests-functions:
name: Python Integration Tests - Functions
permissions:
contents: read
id-token: write
runs-on: ubuntu-latest
environment: integration
timeout-minutes: 60
@@ -324,6 +343,9 @@ jobs:
# Foundry integration tests
python-tests-foundry:
name: Python Integration Tests - Foundry
permissions:
contents: read
id-token: write
runs-on: ubuntu-latest
environment: integration
timeout-minutes: 60
@@ -378,6 +400,9 @@ jobs:
# Foundry Hosting integration tests
python-tests-foundry-hosting:
name: Python Integration Tests - Foundry Hosting
permissions:
contents: read
id-token: write
runs-on: ubuntu-latest
environment: integration
timeout-minutes: 60
@@ -479,9 +504,12 @@ jobs:
name: Python Integration Tests - GitHub Copilot
runs-on: ubuntu-latest
environment: integration
permissions:
copilot-requests: write
contents: read
timeout-minutes: 60
env:
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ github.token }}
GITHUB_COPILOT_TIMEOUT: "120"
defaults:
run:
+1 -1
View File
@@ -71,7 +71,7 @@ jobs:
with:
python-version: ${{ matrix.python-version }}
os: ${{ runner.os }}
exclude-packages: ${{ matrix.python-version == '3.10' && 'agent-framework-github-copilot' || '' }}
exclude-packages: ${{ matrix.python-version == '3.10' && 'agent-framework-github-copilot agent-framework-azure-cosmos-memory' || '' }}
env:
# Configure a constant location for the uv cache
UV_CACHE_DIR: /tmp/.uv-cache
+6 -1
View File
@@ -71,6 +71,7 @@ jobs:
- 'python/packages/ollama/**'
- 'python/packages/core/agent_framework/_mcp.py'
- 'python/packages/core/tests/core/test_mcp.py'
- 'python/packages/hosting-mcp/**'
- 'python/scripts/local_mcp_streamable_http_server.py'
- '.github/actions/setup-local-mcp-server/**'
- '.github/workflows/python-merge-tests.yml'
@@ -345,6 +346,7 @@ jobs:
packages/hyperlight/tests
packages/ollama/tests
packages/core/tests/core/test_mcp.py
packages/hosting-mcp/tests
-m integration
-n logical --dist worksteal
--timeout=120 --session-timeout=900 --timeout_method thread
@@ -673,9 +675,12 @@ jobs:
needs.paths-filter.outputs.coreChanged == 'true')
runs-on: ubuntu-latest
environment: integration
permissions:
copilot-requests: write
contents: read
timeout-minutes: 60
env:
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ github.token }}
GITHUB_COPILOT_TIMEOUT: "120"
defaults:
run:
@@ -8,9 +8,6 @@ on:
env:
# Configure a constant location for the uv cache
UV_CACHE_DIR: /tmp/.uv-cache
# GitHub Copilot configuration
GITHUB_COPILOT_MODEL: claude-opus-4.6
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
permissions:
contents: read
@@ -238,6 +235,13 @@ jobs:
name: Validate 02-agents/providers/github_copilot
runs-on: ubuntu-latest
environment: integration
permissions:
copilot-requests: write
contents: read
id-token: write
env:
GITHUB_TOKEN: ${{ github.token }}
GITHUB_COPILOT_MODEL: claude-opus-4.6
defaults:
run:
working-directory: python
+5 -2
View File
@@ -6,6 +6,9 @@ on:
paths:
- "python/packages/**"
- "python/tests/unit/**"
- "python/scripts/workspace_poe_tasks.py"
- ".github/scripts/python_check_coverage.py"
- ".github/workflows/python-test-coverage.yml"
env:
# Configure a constant location for the uv cache
UV_CACHE_DIR: /tmp/.uv-cache
@@ -37,10 +40,10 @@ jobs:
env:
# Configure a constant location for the uv cache
UV_CACHE_DIR: /tmp/.uv-cache
- name: Run all tests with coverage report
- name: Run aggregate tests with coverage report
run: uv run poe test -A -C --cov-report=xml:python-coverage.xml -q --junitxml=pytest.xml
- name: Check coverage threshold
run: python ${{ github.workspace }}/.github/workflows/python-check-coverage.py python-coverage.xml ${{ env.COVERAGE_THRESHOLD }}
run: python ${{ github.workspace }}/.github/scripts/python_check_coverage.py python-coverage.xml ${{ env.COVERAGE_THRESHOLD }}
- name: Upload coverage report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
with:
python-version: ${{ matrix.python-version }}
os: ${{ runner.os }}
exclude-packages: ${{ matrix.python-version == '3.10' && 'agent-framework-github-copilot' || '' }}
exclude-packages: ${{ matrix.python-version == '3.10' && 'agent-framework-github-copilot agent-framework-azure-cosmos-memory' || '' }}
env:
# Configure a constant location for the uv cache
UV_CACHE_DIR: /tmp/.uv-cache
+18 -1
View File
@@ -26,13 +26,30 @@ jobs:
ping_stale:
name: "Ping stale issues and PRs"
runs-on: ubuntu-latest
environment: github-app-auth
permissions:
contents: read
id-token: write
issues: write
pull-requests: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Get GitHub automation token
id: github-auth
uses: ./.github/actions/github-app-token
with:
mode: ${{ vars.GH_APP_AUTH_MODE }}
azure-client-id: ${{ secrets.GH_APP_AZURE_CLIENT_ID }}
azure-tenant-id: ${{ secrets.GH_APP_AZURE_TENANT_ID }}
azure-subscription-id: ${{ secrets.GH_APP_AZURE_SUBSCRIPTION_ID }}
key-vault-name: ${{ secrets.GH_APP_KEY_VAULT_NAME }}
key-name: ${{ secrets.GH_APP_KEY_NAME }}
github-app-client-id: ${{ secrets.GH_APP_CLIENT_ID }}
github-app-installation-id: ${{ secrets.GH_APP_INSTALLATION_ID }}
repository: ${{ github.repository }}
fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.13'
@@ -43,7 +60,7 @@ jobs:
- name: Run stale issue/PR ping
run: python .github/scripts/stale_issue_pr_ping.py
env:
GITHUB_TOKEN: ${{ secrets.GH_ACTIONS_PR_WRITE }}
GITHUB_TOKEN: ${{ steps.github-auth.outputs.token }}
TEAM_SLUG: ${{ secrets.DEVELOPER_TEAM }}
DAYS_THRESHOLD: ${{ github.event.inputs.days_threshold || '4' }}
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
+1
View File
@@ -199,6 +199,7 @@ For environment variable configuration specific to each sample, refer to the REA
## Contributor Resources
- [Contributing Guide](./CONTRIBUTING.md)
- [Code of Conduct](./CODE_OF_CONDUCT.md)
- [Python Development Guide](./python/DEV_SETUP.md)
- [Design Documents](./docs/design)
- [Architectural Decision Records](./docs/decisions)
+7 -1
View File
@@ -204,7 +204,8 @@ safe to use:
transient execution.
A `SessionStore` stores `session_id -> AgentSession`, but it does not create sessions. `AgentState` resolves the agent
target and creates the session on first use:
target and creates the session on first use. Reads return independent working copies so running from one continuation
point does not mutate the stored snapshot or another simultaneous branch:
For agent targets:
@@ -227,6 +228,11 @@ await state.set_session(response_id, session)
`agent.run(...)` may update the session object (for example, with service continuation state), so the explicit store call
belongs after the run, not before it.
Response ids are immutable continuation points, so simultaneous callers can branch from one `previous_response_id` and
store their completed sessions under different new response ids. A stable `conversation_id` is a mutable head: the app
must explicitly update it after the run and provide single-writer coordination. The hosting state helper does not lock
an entire run or resolve concurrent updates to that stable key.
The session id is a partition key, not proof of identity. App or platform code must authenticate and authorize any
externally supplied key before using it.
@@ -0,0 +1,177 @@
---
status: accepted
contact: rogerbarreto
date: 2026-07-08
deciders: rogerbarreto
consulted: eavanvalkenburg
informed: []
---
# .NET hosting: OpenAI Responses protocol helpers for app-owned routing
Realizes the helper-first direction of [ADR-0027](0027-hosting-channels.md) for .NET.
## Context and Problem Statement
[ADR-0027](0027-hosting-channels.md) refocused the (Python) hosting design away from a channel
framework toward **protocol conversion helpers plus optional execution state**: Agent Framework owns
protocol-native <-> run conversion, while the application owns HTTP routing, authentication,
middleware, storage, and native SDK calls.
.NET already ships `Microsoft.Agents.AI.Hosting.OpenAI`, a route-owning server that **exposes an
`AIAgent` (or workflow) as the OpenAI Responses API** (`MapOpenAIResponses` + `IResponsesService`). It
owns the routes, an in-memory response/conversation store, streaming, and lifecycle. The question is
what, if anything, .NET must add to satisfy the ADR-0027 boundary.
## Decision Drivers
- Do not reinvent conversion logic that already exists and is battle-tested in `Hosting.OpenAI`.
- Give applications a way to own their own route/auth/middleware/storage while reusing Agent Framework
conversion (the ADR-0027 boundary).
- Keep the released public surface small.
- Stay consistent with the existing .NET hosting stack, which deliberately does **not** use the OpenAI
SDK Responses types server-side (it hand-rolled its own wire model).
## Considered Options
1. Self-contained new package that reimplements conversion using the OpenAI SDK Responses types
(mirrors the Python `agent-framework-hosting-responses` lineage).
2. New package that reuses `Hosting.OpenAI`'s internal converters (via `InternalsVisibleTo` or by
moving the conversion core out).
3. Thin public helper facade **inside** `Hosting.OpenAI` over the existing internal converters, plus
protocol-neutral execution-state holders in `Microsoft.Agents.AI.Hosting`.
### First-principles gap analysis
A capability comparison of the ADR-0027 / PR #6891 helper surface against the existing .NET stack:
| Python helper capability | .NET today | Status |
| --- | --- | --- |
| `responses_to_run` | `ResponseInput.GetInputMessages` + `InputMessage.ToChatMessage` + `OpenAIResponsesMapOptions.RunOptionsFactory` | exists, internal |
| `responses_from_run` | `AgentResponseExtensions.ToResponse` | exists, internal |
| `responses_from_streaming_run` | `AgentResponseUpdateExtensions.ToStreamingResponseAsync` + `SseJsonResult` (also renders workflow events) | exists, internal, richer |
| `responses_session_id` | continuity resolved inside `InMemoryResponsesService` | exists, internal, not standalone |
| `create_response_id` | `IdGenerator` | exists, internal |
| `AgentState` (target + store, get-or-create, callable/awaitable target) | `AgentSessionStore` (get-or-create + save + serialize + isolation) + DI container (target lifetime + async setup) | create-on-miss lives in the store; per-run instance and deferred/async target come from DI, so no separate holder is needed |
| `SessionStore` (get/set/delete) | `AgentSessionStore` + `InMemoryAgentSessionStore` | richer; `Delete` added |
| `WorkflowState` + checkpoint resume | `WorkflowCatalog`/`HostedWorkflowBuilder`; workflow events already render over Responses; `CheckpointManager` is session-keyed | partial; no per-session checkpoint cursor |
| App owns routing/auth/middleware/storage | `MapOpenAIResponses`/`IResponsesService` own routing + storage | **the one real gap** |
.NET already covers ~90% of the capability, and more richly (its streaming renderer even emits workflow
events; its session store serializes and supports per-principal isolation, neither of which Python's
in-memory `SessionStore` does). The single genuine gap is the **ownership model**: every conversion
primitive is bundled behind the route-owning server, so an application cannot own its own route and
call just the conversion.
Note on lineage: Python's Responses offering was introduced *as a channel* (PR #6580) and always used
the `openai` SDK Responses types. .NET's `Hosting.OpenAI` predates and is independent of channels and
hand-rolled its own server-side wire DTOs (the SDK's Responses types are client-shaped and awkward
server-side). So Option 1 would both reinvent a working asset and contradict the .NET codebase's own
precedent.
## Decision Outcome
Chosen option: **3. Thin public helper facade inside `Hosting.OpenAI` plus neutral state holders**,
because the only real gap is the ownership model, so the work is to *un-bundle* the existing
converters, not to rebuild them or add a package.
### Public surface
`Microsoft.Agents.AI.Hosting.OpenAI` gains a single public static facade, `OpenAIResponses`, whose
boundary is `System.Text.Json` (`JsonElement`/streamed events), matching Python's dict boundary and
keeping the hand-rolled wire DTOs internal:
- `OpenAIResponses.ToAgentRunRequest(JsonElement body)` -> messages + `AgentRunOptions?`.
- `OpenAIResponses.WriteResponse(AgentRunResponse response, string responseId, string? sessionId = null)`
-> a Responses-shaped `JsonElement`.
- `OpenAIResponses.WriteResponseStreamAsync(IAsyncEnumerable<AgentRunResponseUpdate> updates, string responseId, ...)`
-> Responses SSE `data:` frames.
- `OpenAIResponses.GetSessionId(JsonElement body)` -> `previous_response_id` or `conversation` id, or
`null`. Kept **separate** from `ToAgentRunRequest` so the trust boundary is visible: choosing to use
a request-derived key is an explicit application decision.
- `OpenAIResponses.CreateResponseId()` -> a `resp_*` id.
All helpers are side-effect-free and delegate to the existing internal converters. `MapOpenAIResponses`
public behavior is unchanged; it and the facade share one internal conversion core (an internal
`ToResponse` overload with an optional originating request is added so the facade can render without a
request object).
### Optional execution state (neutral package)
`Microsoft.Agents.AI.Hosting` gains:
- `AgentSessionStore.DeleteSessionAsync(...)` (+ `InMemoryAgentSessionStore` implementation and
isolation-decorator passthrough): the one missing store operation.
- No agent-side holder. Applications use `AgentSessionStore` directly: `GetSessionAsync(agent, id)`
already creates on miss and returns an independent session instance per call (so concurrent calls fork
the same stored state rather than sharing an instance), `SaveSessionAsync(agent, id, session)` persists
post-run (including under a newly minted id), and `DeleteSessionAsync(agent, id)` removes it. An earlier
draft added a `HostedAgentState` holder, but once create-on-miss lives in the store and the store does no
cross-call locking, the holder would only bind the `agent` argument, which is not enough to justify a
public type. Any coordination for concurrent runs against the same id is the application's concern.
(Unlike Python, whose `SessionStore` is get/set-only and whose `AgentState` therefore owns
create-on-miss, .NET's store already owns it.)
Python's `AgentState` carries two further responsibilities beyond create-on-miss: it accepts a callable
or awaitable target so the host can (1) obtain a fresh agent instance per run and (2) defer expensive or
asynchronous agent setup while keeping server construction synchronous. In .NET these two concerns are
owned by the dependency-injection container, not by a hosting type. Per-run lifetime is expressed by the
registration lifetime (`AddScoped`/`AddTransient` yields a fresh `AIAgent` per request or scope, resolved
by the framework), and deferred or asynchronous construction is expressed by an async factory registration
(for example an `async` factory delegate, `ActivatorUtilities`, or resolving the agent inside the request
after any async warm-up), so the route handler resolves an already-built agent from the container. An
`AIAgent` is also safe to invoke concurrently (per-turn state lives in `AgentSession`, not the agent), so
the "fresh instance per run" motivation does not apply to it the way it does to a workflow. This is the
deliberate asymmetry with `HostedWorkflowState` below: a `Workflow` instance is a stateful run engine that
cannot be driven by two runners at once, so the factory/`cacheWorkflow` affordance is load-bearing there
for correctness, whereas for agents the container already provides both per-run instances and async setup.
- `HostedWorkflowState`: a thin holder bundling a workflow target with a `CheckpointManager` and an
internal `sessionId -> CheckpointInfo` head cursor, exposing `RunOrResumeAsync`. .NET's checkpoint
store is already `sessionId`-keyed (unlike Python's workflow-name keying), but `CheckpointInfo` has
no ordering, so the holder remembers the head checkpoint per session to resume. On subsequent turns it
restores that checkpoint and runs the workflow forward with the new turn's input (mirroring the Python
host's restore-then-run semantics), rather than continuing a halted run with no input. When the
in-memory cursor misses (new holder / process restart) it reads the session's latest checkpoint from the
`CheckpointManager`, so a durable manager resumes across restarts. It accepts either a single workflow
instance (which cannot be run by two runners at once, so its turns are processed one at a time) or a
workflow factory (`Func<CancellationToken, ValueTask<Workflow>>`). By default the factory builds a fresh
instance per run so independent sessions run in parallel; with `cacheWorkflow: true` the factory is invoked
once lazily and its result is cached and reused (a deferred, cached target that, like the instance, cannot
run concurrent turns). A resume rehydrates an instance from the session's checkpoint in the shared store, so
per-run instances still continue the same run; concurrent turns against the same session id remain the
application's coordination responsibility.
### Scope
Responses only for v1; the facade is named so a parallel `OpenAIChatCompletions` facade can follow.
No new package, no OpenAI-SDK-typed reimplementation, no change to `MapOpenAIResponses` public
behavior.
### Security responsibilities
Consistent with ADR-0027, the application owns the trust boundary. `GetSessionId(...)` returns an
untrusted candidate key; the application must authenticate the caller and authorize/bind the id before
using it as an `AgentSessionStore` key or workflow checkpoint session id. Multi-user hosts must scope
the session store per principal (`IsolationKeyScopedAgentSessionStore`). Helpers stay side-effect-free;
persistence happens only after the run completes.
## Consequences
Positive:
- Smallest possible surface: the released addition is one facade type plus one thin workflow state
holder and one new store method (agents use `AgentSessionStore` directly, no holder).
- No duplicated conversion; the app-owned-routing path and the route-owning server share one core.
- `MapOpenAIResponses` users are unaffected.
Negative:
- The facade's `JsonElement` boundary is less strongly typed than the internal DTOs (accepted to keep
the wire model internal and mirror Python's dict boundary).
- Workflow resume relies on an in-memory head cursor by default; durable multi-replica hosts must
supply their own cursor persistence.
## More Information
- Parent ADR: [ADR-0027](0027-hosting-channels.md).
- Spec: `docs/specs/003-dotnet-hosting-protocol-helpers.md`.
@@ -0,0 +1,642 @@
---
status: accepted
contact: eavanvalkenburg
date: 2026-07-22
deciders: eavanvalkenburg, chetantoshniwal
consulted: TaoChenOSU, moonbox3, peibekwe, rogerbarreto, westey-m
informed:
---
# Feature-usage bitmask in the User-Agent
## Context and Problem Statement
We can see which Agent Framework packages are installed and that *some* framework
call happened (via the existing `agent-framework-python/{version}` User-Agent),
but we have no usage-based signal about **which features are actually exercised**
at runtime, nor which are used *together* (e.g. workflows + MCP + Foundry). How
can we collect a lightweight, privacy-respecting signal of feature usage for the
traffic we can actually read, without standing up new event pipelines?
The detailed mechanism is in [SPEC-004](../specs/004-feature-usage-telemetry.md);
the per-language bit tables are in
[feature-usage-bit-registry.md](../specs/feature-usage-bit-registry.md).
## Decision Drivers
- **Transparency** — openly documented, human-decodable, user-controllable. No
hidden or obfuscated telemetry.
- **First-party scope / no third-party leakage** — emission requires both an
explicitly approved client/pipeline family and an approved actual HTTPS origin
on every request (including redirects). Credentials or an Azure setting alone
never approve a custom gateway/origin.
- **Live signal** — read the process's observed-feature set *so far* at request
send time, rather than freezing it at client construction.
- **Low cost / few moving parts** — reuse telemetry already in the request path;
bounded fixed-width processing; as little machinery as the job needs.
- **Privacy** — encode only coarse "observed at least once" Boolean feature
state, never counts; no identifiers, arguments, prompts, payloads,
model/deployment names, endpoints, or customer-defined names.
- **Use, not presence** — package-level indexes mean a capability reached its
first meaningful activation, not that a package was installed/imported or a
DI container constructed an unused service.
- **Versioning discipline** — v1 is a point-in-time decision. Adding bits later is
easier than removing or redefining them, so the initial table should lean toward
fewer bits and avoid forcing v2 shortly after launch.
- **Allocation discipline** — each bit represents a stable framework-owned
capability with a concrete product/support question and an actual-use mark
point; implementation detail and speculative distinctions stay out.
## Considered Options
The options below are grouped by the decisions that matter: the **transport**,
the **granularity**, and the **registry sharing model**.
### Transport
#### A. User-Agent token, first-party only, per request (chosen)
Stamp a `(feat=...)` comment onto the UA, but only on approved Azure/Foundry
client pipelines, and re-evaluate it per request.
- Good, reuses telemetry already sent to approved backends we can read.
- Good, request-time stamping reflects the live mask (not frozen at construction).
- Good, first-party scoping means no fingerprint leaks to third-party providers.
- Good, two-factor destination approval (pipeline + actual origin) denies custom
`base_url` gateways and strips the token on unapproved redirect hops.
- Good, maps onto .NET's existing per-request UA pipeline policies unchanged.
- Neutral, v1 stamps only pipelines the framework creates or can configure
through supported public hooks. It does not mutate caller-owned clients or
reach into private SDK pipelines.
- Bad, no signal for traffic that never hits a first-party endpoint (accepted —
we couldn't read it anyway).
#### B. User-Agent token on all clients
- Good, simplest to wire (one static header).
- Bad, sends a deployment fingerprint to OpenAI/Anthropic/AWS/Google logs we
cannot read — privacy leak for zero benefit.
- Bad, baked into static `default_headers`, so it freezes at client construction
and reports a near-empty mask.
#### C. OpenTelemetry span/resource attribute
- Good, precise per-call usage; no UA change.
- Bad (**privacy — the main reason to hold it**), a span attribute broadcasts the
feature-combination fingerprint into the user's **general** telemetry pipeline,
which is typically exported to third-party APM vendors (Datadog, Honeycomb, …).
That re-introduces exactly the fingerprint leakage the first-party-only UA
scoping (A) was chosen to avoid — just into a different set of third parties.
- Bad (secondary), also a cardinality footgun (a growing, combinatorial value
must never become a metric dimension).
- Neutral, for the team's own goal it reaches us only if the user exports to
Azure Monitor and we query it.
- **Deferred, not rejected.** The version prefix lets us add it later **if** the
User-Agent path cannot answer a concrete query and there is an acceptable
scoped/redacted variant.
#### D. Bespoke usage events
- Good, richest detail and flexibility.
- Bad, new data flow and cost; larger privacy surface; heavy to build and review;
overkill for a coarse "which features" signal.
#### E. Install/import-time signal only (status quo-ish)
- Good, zero new runtime work.
- Bad, measures installation, not usage; cannot capture feature combinations —
does not solve the problem.
### Accumulation scope
#### S1. Process-global, monotonic mask (chosen)
A single mask per process; bits are OR-ed in as features are first used and never
cleared. The token reflects "what this process has used so far."
- **Binary interpretation:** a set bit means the feature was observed at least
once in this process before the request was sent. A bit repeated on later
requests is the same Boolean observation, not another feature use. It cannot be
summed into invocation, request, agent, user, or tenant counts.
- Good, fits our **mixed feature lifecycle**: many features are *not* bound to an
outbound service request — an agent/workflow may first run or build, a
context/history provider may first participate in a session, and a host may
start serving before the request that later emits the token. A process-wide
mask can carry those activations forward.
- Good, trivial and cheap: one OR under a lock (Python) / one atomic OR into one
of two 64-bit lanes (.NET); no per-request state plumbing.
- Good, deliberately coarse for privacy: it avoids emitting a sequence of exact
per-call feature combinations that could reconstruct a workload's behavioral
trace.
- Neutral, coarser than per-call — early requests carry fewer bits than later
ones, and the token says "this process used X", not "this call used X" or "X
was used this many times."
For example, at time 1 Agent A can use MCP and a Foundry chat client. At time 2,
Agent B in the same worker can make a normal Foundry chat call without MCP. The
time-2 request still carries the MCP bit because MCP was previously observed in
that process. It does **not** say Agent B used MCP, nor count a second MCP use.
#### S2. Per-request set, reset between calls (botocore's model — rejected)
AWS botocore scopes its `m/` feature codes to a `contextvars` set that is reset
between requests, giving exact per-call attribution (and it deliberately no-ops
when called outside a request context to avoid features bleeding across requests).
See [Prior art](#prior-art).
- Good, exact per-call attribution directly in the User-Agent.
- Bad, **assumes every feature is exercised inside a single service request**
true for botocore (an SDK natively bound to AWS service calls), but *not* for
us. Our features split into request-scoped ones (a chat call, an MCP tool
invocation) and decidedly non-request ones (workflow build/start, provider
participation, hosting startup). The latter have no service request to attach to, so a
per-request set would simply miss them.
- Bad, needs `contextvars` propagation through every async/threaded path and a
reset discipline, plus enable/disable calls around every scoped operation; the
bleed-guard botocore documents is the warning sign.
- Bad, creates a more detailed per-call behavioral trace, increasing the privacy
sensitivity and review burden compared with a coarse process-lifetime Boolean.
- Note, per-call attribution for the request-scoped subset is better served by
the deferred OTel span path (option C) than by reshaping the UA token.
### Granularity
The mechanism can support several granularities. The remaining decision before
implementation is how detailed v1 should be. The estimates below are
intentionally rough; v1 uses a fixed 128-bit bound to leave useful headroom
without making the registry unbounded.
#### F0. Package-level bits
One bit per package, set on first use of a package-owned public API, client,
provider, or tool. It is **not** set on install, import, or assembly load.
Examples that get bits:
- `agent-framework-core` when `Agent`, `AgentSession`, `Workflow`, etc. is used.
- `agent-framework-tools` when a `LocalShellTool` or `DockerShellTool` first
executes/probes its shell capability.
- `agent-framework-foundry` when a `FoundryChatClient`, `FoundryAgent`, etc.
performs its first Foundry operation.
- `agent-framework-openai` when `OpenAIChatClient`,
`OpenAIEmbeddingClient`, etc. performs its first provider operation.
- `agent-framework-azure-ai-search` when `AzureAISearchContextProvider` is used.
- `agent-framework-azure-cosmos` when `CosmosHistoryProvider` is used.
- `agent-framework-redis` when `RedisContextProvider` or `RedisHistoryProvider`
is used.
Examples that do **not** get separate bits: merely installed dependencies;
imports or DI construction with no activation; `Agent` vs `AgentSession` vs
`InMemoryHistoryProvider`; `FunctionTool` vs `MCPStdioTool` vs `LocalShellTool`
vs `DockerShellTool`; `FoundryChatClient` vs `FoundryAgent`; `OpenAIChatClient`
vs `OpenAIEmbeddingClient`.
Rough estimate: Python ~25-35 bits; .NET ~15-25 bits.
- Good, lowest specificity and simplest registry.
- Good, clearly measures usage rather than dependency inventory if bits are set
only at package-owned public API/client/provider/tool use sites.
- Bad, does not answer which major capability within a package is used.
#### F1. Package + major capability bits
Package bits plus selected major capabilities that are product-distinct and stable
across implementations.
Examples that get bits:
- `agent-framework-core` plus `Agent`.
- `AgentSession` plus `InMemoryHistoryProvider` / `FileHistoryProvider` as one
history capability.
- `Workflow` / `FunctionalWorkflow` as one workflow capability.
- `FunctionTool`; MCP transports as one MCP capability; shell tools as one shell
capability.
- Skills provider plus stable source types: file, in-memory/programmatic, and
MCP-backed skills (with .NET inline/class skill distinctions).
- Foundry chat/agent/embedding capabilities; OpenAI chat/embedding capabilities.
Examples that do **not** get separate bits: `InMemoryHistoryProvider` vs
`FileHistoryProvider`; `WorkflowBuilder`, `AgentExecutor`, `FunctionExecutor`, or
`FanOutEdgeGroup`; `MCPStdioTool` vs `MCPStreamableHTTPTool` vs
`MCPWebsocketTool`; `LocalShellTool` vs `DockerShellTool` vs
`ShellEnvironmentProvider` vs `ShellPolicy`; `OpenAIChatClient` vs
`OpenAIChatCompletionClient`; skill-source decorators such as caching, filtering,
deduplication, and aggregation.
Rough estimate: Python ~60-70 indexes; .NET ~45-55 indexes. The current candidate
registry is at 63 Python / 52 .NET assigned indexes.
- Good, likely answers the first product adoption questions while staying compact.
- Good, fits comfortably within 128 bits while leaving room for additive package
and feature growth.
- Neutral, some provider internals remain collapsed until a later additive bit is
justified.
#### F2. Public construct / concrete type bits
One bit per public construct that users intentionally instantiate or configure.
Examples that get bits:
- `Agent`, `AgentSession`, `InMemoryHistoryProvider`, `FileHistoryProvider`.
- `Workflow`, `WorkflowBuilder`, `FunctionalWorkflow`.
- `FunctionTool`, `MCPStdioTool`, `MCPStreamableHTTPTool`, `MCPWebsocketTool`.
- `LocalShellTool`, `DockerShellTool`, `ShellEnvironmentProvider`, `ShellPolicy`.
- `FoundryChatClient`, `FoundryAgent`, `OpenAIChatClient`,
`OpenAIChatCompletionClient`, `OpenAIEmbeddingClient`.
Examples that do **not** get separate bits: `Agent.run` vs
`Agent.run_streamed`; workflow edge/executor internals such as `AgentExecutor`,
`FunctionExecutor`, or `FanOutEdgeGroup`; `LocalShellTool` persistent vs
stateless mode; `ShellPolicy` allowlist vs denylist configuration; `FunctionTool`
approval mode or result parser choices.
Rough estimate: Python ~70-100 bits; .NET ~55-80 bits.
- Good, concrete and directly tied to public API use.
- Neutral, fits within 128 bits at the current estimate, but consumes much of the
deliberate growth reserve.
- Bad, adds many call sites and more fingerprint specificity for v1.
#### F3. Construct subtype / configuration bits
Split important constructs by mode, transport, storage, or workflow primitive
when that distinction matters.
Examples that get bits:
- `InMemoryHistoryProvider` and `FileHistoryProvider` separately.
- `FunctionalWorkflow`, `WorkflowBuilder`, `AgentExecutor`, `FunctionExecutor`.
- `FanOutEdgeGroup`, `FanInEdgeGroup`, `SwitchCaseEdgeGroup`.
- `LocalShellTool` persistent, `LocalShellTool` stateless, `DockerShellTool`.
- `MCPStdioTool`, `MCPStreamableHTTPTool`, `MCPWebsocketTool`;
`OpenAIChatClient` vs `OpenAIChatCompletionClient`.
Examples that do **not** get separate bits: exact session id or persisted history
file path; exact shell command, workdir, timeout, or output cap; exact MCP server
command, URL, or tool names from the server; exact workflow graph shape or edge
count; model/deployment names, prompts, tool arguments, payloads.
Rough estimate: Python ~110-150 bits; .NET ~85-125 bits.
- Good, useful where mode-level distinctions are decision-relevant.
- Bad, trades simplicity for precision, increases fingerprint specificity, and
may exhaust or exceed 128 bits in Python.
#### F4. Option / behavior flag bits
The most detailed framework-owned option: bits for specific modes and behavior
switches, still excluding customer/runtime values.
Examples that get bits:
- Agent streaming used vs non-streaming used.
- `FunctionTool` `approval_mode="always_require"` vs `"never_require"`.
- `FunctionTool` `SKIP_PARSING` / result-parser path used.
- MCP sampling configured; MCP long-running task support used.
- `LocalShellTool` `clean_env` / `confine_workdir`; `DockerShellTool` container
mode.
Examples that do **not** get separate bits: function names wrapped by
`FunctionTool`; approval rule arguments or approval decisions; MCP remote tool
names or schemas; shell command text or policy regex patterns; prompt/message
content, model names, URLs, tenant/user/session identifiers.
Rough estimate: Python 150+ bits; .NET 120+ bits.
- Good, maximum framework-owned detail.
- Bad, exceeds or nearly exhausts 128 bits and is too detailed for v1 without a
concrete decision that requires it.
### Registry sharing model
#### H. Per-language bit lists (chosen)
Each SDK owns an independent list; the decoder picks the list using the language
already present in the UA product token.
- Good, **no cross-language coordination**: each SDK numbers and evolves its
features independently; adding a Python feature never touches .NET numbering.
- Good, no null placeholders for one-SDK features, no "same bit, same meaning"
rule, no SDK-aware decode caveats.
- Good, decoding is trivial: language (from UA) + version -> list -> AND.
- Neutral, two small lists to maintain instead of one (but they were going to
diverge anyway — the packages differ).
#### I. Single shared cross-language registry
- Good, one list, one number space.
- Bad, forces synchronized numbering and null placeholders for features that
exist in only one SDK, plus SDK-aware decode rules.
- Bad, the synchronization is pure accidental complexity — **the language is
already in the User-Agent**, so sharing the number space buys nothing.
### Registry maintenance
#### J. Package-local indexes + parity/no-overlap test (chosen)
- Good, each package owns private `FeatureIndex` declarations only for its own
rows; adding an optional-provider index does not require a core release after
the marker API exists.
- Good, one repository test compares the package-local declarations with the
per-language table and rejects missing rows, wrong ids, out-of-range indexes,
and any duplicate/overlapping index.
- Good, no build step, no generator to own.
#### K. Code-generate the enums from the registry
- Bad, a generator + drift test + schema test to maintain a short list of
integer constants; likely justified only if v1 deliberately chooses the most
detailed L3/L4 granularities.
### Representation (how the mask is rendered as text)
All examples below encode the same mask — bits 0, 2, 32, 48, 56 set
(agent + workflow + sequential-orchestration + foundry.chat_client + openai, in
the Python v1 list) = decimal `72339073309605893`.
#### L. Decimal — `feat=v1.72339073309605893`
- Good, human-familiar; trivial to parse.
- Neutral, no visual alignment to four-bit groups; slightly longer than hex for
large masks. No advantage over hex.
#### M. Hex (chosen) — `feat=v1.101000100000005`
- Good, compact (≤32 chars for a 128-bit mask).
- Good, decodes with one stdlib call in every language (`int(x, 16)` /
two 64-bit lane parses in .NET); each hex character corresponds to four
consecutive bit positions.
- Good, lowercase, no `0x` prefix, no leading zeros — unambiguous and stable.
A grouped variant such as `feat=v1.101.0001.0000.0005` was also considered.
Separators make the value longer and must be removed before `int(x, 16)` can
parse it, while the ordinary hex digits already preserve fixed four-bit groups.
#### N. Binary — `feat=v1.100000001000000000000000100000000000000000000000000000101`
- Good, directly shows every zero/one position.
- Bad, grows to 128 payload characters and is difficult to scan reliably.
#### O. Bit-list — `feat=v1.0,2,32,48,56`
- Good, most directly human-readable ("which bits").
- Bad, needs delimiter handling and grows with the number of set bits; a full
128-bit list is substantially larger than every fixed-width representation.
#### P. Alphabet / base-N (e.g. Crockford base32 `feat=v1.208004000005`, base62 `feat=v1.5LJRx1i6xJ`)
- Good, shortest representation.
- Bad, needs a custom alphabet + decode table on both ends; base62 is
case-sensitive (fragile through case-normalizing intermediaries); not
directly readable. Premature optimization for a value that is already ≤32
chars in hex.
All forms are ASCII. The table shows total bytes added to the existing
User-Agent, including the leading space and `(feat=v1.)` wrapper:
| Representation | Example (5 bits) | All current Python rows (63) | All current .NET rows (52) | Full 128-bit v1 |
| --- | ---: | ---: | ---: | ---: |
| Hex | 26 | 34 | 30 | 43 |
| Grouped hex | 29 | 39 | 34 | 50 |
| Decimal | 28 | 38 | 34 | 50 |
| Binary | 68 | 100 | 86 | 139 |
| Bit-list | 23 | 189 | 156 | 412 |
| Crockford base32 | 23 | 29 | 26 | 37 |
| Base62 | 21 | 26 | 24 | 33 |
There is no defensible average before rollout, and the design does not depend on
one: a process-global mask may eventually contain every assigned row. There is
no smaller per-request bit budget because the bits are not request-scoped; the
registry allocation tenet controls how many distinctions v1 assigns. Client
processing is bounded by the fixed 128-bit width: marking performs one
lock/atomic OR, and request-time stamping reads the mask, formats at most 32 hex
characters, and replaces one User-Agent comment. It performs no registry scan,
network call, or per-feature enable/disable bookkeeping.
## Decision Outcome
Chosen: **a request-time-stamped, first-party-only User-Agent `(feat=...)` token (A),
with a 128-bit process-global monotonic accumulator (S1), per-language bit lists
(H), package-local index enums kept honest by parity and no-overlap tests (J),
rendered as lowercase hex (M).**
This is a bounded design with enough v1 headroom. A 128-bit
**process-global, monotonic** mask accumulates from universal
`mark_feature_used()` calls (so it spans build/start/participation activations
that aren't bound to any service request — the per-request set model (S2) can't);
the token is **stamped per request** only when both the client/pipeline and the
actual HTTPS origin are approved, so custom origins and cross-origin redirects
cannot inherit the fingerprint; each
SDK owns an independent bit list selected by the language already in the UA; the
mask is rendered as hex (`feat=v1.101000100000005`). The dedicated
`AGENT_FRAMEWORK_FEATURE_MASK_DISABLED` opt-out drops only the mask while
keeping the base SDK identity/version User-Agent. Python's existing
`AGENT_FRAMEWORK_USER_AGENT_DISABLED` continues to suppress its entire
contribution, including the mask; this decision does not introduce a matching
whole-User-Agent switch in .NET. OTel (C) is deferred — mainly because a
broadly-emitted span attribute would leak the fingerprint into the user's
general telemetry, against the first-party-only stance and would require
user-side OTel setup that may still not make the data available to us — but left
open behind the version prefix. Per-request scoping (S2), a shared registry (I),
codegen for the initial registry (K), and the decimal/grouped-hex/binary/bit-list/
base-N representations (L, M variant, N, O, P) are rejected as complexity or
length the problem does not require.
The remaining choice before implementation is the **v1 granularity level** among
F0-F4. This is a point-in-time decision: adding new bits later is easier than
removing or redefining them, because removals/redefinitions require a new
registry version and historical decode tables. For v1, prefer the least detailed
level that answers the known product/support questions so we do not force a v2
shortly after launch. The refreshed candidate registry uses **63 Python indexes and
52 .NET indexes**, leaving 65 and 76 positions respectively. That headroom supports
normal growth; it does not waive the registry's
[allocation tenet](../specs/feature-usage-bit-registry.md#allocation-tenet).
### Consequences
- Good, adds a bounded-cost usage signal with no new data flow and few moving
parts.
- Good, transparent (public registry, human-decodable token) and disabled by a
dedicated `AGENT_FRAMEWORK_FEATURE_MASK_DISABLED` mask-only opt-out. Python's
existing whole-User-Agent opt-out also suppresses the mask.
- Good, first-party-only + request-time stamping gives a live mask and no
third-party fingerprint leak.
- Good, 128 bits leaves useful v1 headroom; .NET remains lock-free by storing two
independently atomic 64-bit lanes; per-language lists remove all cross-language
sync; package-local enums avoid both codegen and provider→core release coupling.
- Neutral, the token's reach equals eligible framework-configured first-party
traffic; broader per-call signal (OTel) can be added later if needed.
- Neutral, every set bit is a repeated Boolean observation after first use;
request rows carrying it are not feature invocation counts.
- Neutral, v1 granularity is intentionally a separate choice; the registry should
start with fewer bits unless a more detailed bit answers a concrete question.
- Bad, each feature must add an activation mark, first-party clients need a
per-request destination-aware hook, and the registry validator must scan all
package-local index declarations.
## Prior art
SDK telemetry-in-the-User-Agent is well-established; this design is closest to
AWS's, and conventional in the rest. Summary of what comparable SDKs do:
| SDK | What's in the UA / headers | Usage-based? | Opt-out | Closest to ours? |
| --- | --- | --- | --- | --- |
| **AWS botocore** | structured UA with an `m/` token: a per-request set of **short feature codes** for features actually exercised (`WAITER``B`, `PAGINATOR``C`, retry mode, checksums, credential source, …) | **Yes** — registered at call time via `register_feature_id`, contextvar-scoped per request | `AWS_SDK_UA_APP_ID` sets app id (no opt-out for `m/`) | **Yes — direct analog** |
| **OpenAI / Anthropic** (Stainless) | sidecar `X-Stainless-*` headers: lang, package version, OS, arch, runtime, runtime version; plus per-request `x-stainless-retry-count`, `x-stainless-read-timeout` | Mostly static identity (retry/timeout are per-request) | none | No (static identity) |
| **Azure SDK** (`azure-core`) | `User-Agent: azsdk-python-{pkg}/{ver} Python/{pyver} ({platform})` | No | `AZURE_TELEMETRY_DISABLED` (tracing spans only, **not** the UA) | No |
| **Google API core** | `x-goog-api-client: gl-python/… grpc/… gax/… gapic/…` | No | none | No |
| **LangSmith** | `User-Agent: langsmith-py/{ver}`; usage lives in trace payloads | No (header) | opt-in via `LANGSMITH_TRACING_V2`/`LANGCHAIN_TRACING_V2`; `…HIDE_INPUTS/OUTPUTS` | No |
Takeaways that shaped (or validate) our choices:
- **AWS `m/` is the precedent for usage-based feature flags in a first-party
User-Agent.** It validates the core idea. Its key *difference* is the encoding:
AWS uses a **comma-separated set of 12 char short codes** (open-ended, no bit
coordination, but variable length), whereas we use a fixed-width **hex
bitmask** (compact, bounded, decode-by-AND, but needs per-language bit
allocation). We keep the bitmask for boundedness and trivial AND-decoding;
AWS's short-code set is recorded as a viable alternative if bit-position
coordination ever becomes painful (it would also drop the fixed 128-bit bound).
- **A fixed-width bitmask gives bounded token size for free.** botocore must cap
the `m/` component at 1024 bytes and truncate at delimiter boundaries (with a
fallback log) precisely *because* its short-code set is unbounded. Our 128-bit
hex is ≤32 chars by construction — no size cap, no truncation logic.
- **Scope is where we diverge most — and deliberately.** botocore collects
features into a per-request `contextvars` set that is **reset between
requests**, and no-ops outside a request context to prevent cross-request
bleed. That works because every botocore feature is exercised *inside* an AWS
service request. We are more general: some features are request-scoped (a chat
call, an MCP tool invocation) but many are **not bound to any request**
(workflow build/start, provider participation, hosting startup). So we use a
**process-global, monotonic** mask (option S1), which is the only scope that can
represent the non-request features. Our mask therefore intentionally "bleeds"
(accumulates) for the life of the process — the opposite of botocore's reset —
and that is the intended semantic, not the bug botocore guards against.
- **The mechanism is private; the wire format is the contract.** botocore marks
its whole user-agent module private and "subject to abrupt breaking changes."
Same for us: the Python/.NET helpers are internal, and only the emitted token +
the per-language registry tables are the stable, decodable contract.
- **First-party-only emission** is stricter than any of the above; the closest in
spirit is Stainless headers, which only reach the owning API. We make the
client/pipeline allowlist explicit (initially Foundry/Azure OpenAI) rather than
attempting to infer safety from arbitrary request URLs. Other Azure clients
join only after telemetry access is confirmed.
- **Opt-out naming.** `AZURE_TELEMETRY_DISABLED` is the family precedent for our
`AGENT_FRAMEWORK_*_DISABLED` names. Separately, the cross-tool `DO_NOT_TRACK`
convention (honored by e.g. HuggingFace Hub) is worth considering — see Open
Questions.
Sources: botocore [`useragent.py`](https://github.com/boto/botocore/blob/develop/botocore/useragent.py)
(`_USERAGENT_FEATURE_MAPPINGS`, `register_feature_id`, `_build_feature_metadata`);
openai-python [`_base_client.py` `platform_headers()`](https://github.com/openai/openai-python/blob/main/src/openai/_base_client.py);
anthropic-sdk-python [`_base_client.py`](https://github.com/anthropics/anthropic-sdk-python/blob/main/src/anthropic/_base_client.py);
azure-core [`_universal.py` `UserAgentPolicy`](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/core/azure-core/azure/core/pipeline/policies/_universal.py);
google-api-core [`client_info.py`](https://github.com/googleapis/python-api-core/blob/main/google/api_core/client_info.py);
langsmith-sdk [`client.py`](https://github.com/langchain-ai/langsmith-sdk/blob/main/python/langsmith/client.py) /
[`utils.py`](https://github.com/langchain-ai/langsmith-sdk/blob/main/python/langsmith/utils.py);
huggingface_hub [`constants.py`](https://github.com/huggingface/huggingface_hub/blob/main/src/huggingface_hub/constants.py).
## Registry versioning and migration (v1 → v2)
The token carries a **per-language** version (`feat=v1.<hex>`); a version bump is
independent for Python and .NET.
- **Additive growth stays on v1 — no bump.** Allocating a new feature to a
reserved/unused bit is backward-compatible: an older decoder simply sees an
unknown bit and ignores it. Normal package growth never needs a new
version.
- **A bump (v2) is required only for breaking changes:** renumbering or
re-partitioning existing bits, changing the *meaning* of an already-assigned
index, or widening beyond 128-bit. Within a version an index is **never** reused or
reassigned — that invariant is what lets old decoders stay correct.
- **The draft 64→128 change is still v1.** No v1 token or enum has shipped, so
this pre-implementation repartition establishes the initial contract rather
than migrating an existing one.
- **Mixed-version coexistence is the norm.** A fleet runs many SDK releases at
once, so `v1` and `v2` tokens appear simultaneously for a long time (old SDKs
keep emitting `v1`). The decoder keeps **every** published `(language,
version)` table and selects by the token's version; the `v1` table is retained
indefinitely for historical decode.
- **Unknown version → do not guess.** A decoder without the `vN` table must
record "unknown registry version" rather than decode against an older table —
bit meanings may differ across versions, so mis-attribution is worse than
no data.
- **Producing v2:** publish the v2 table alongside v1, update the affected
package-local `FeatureIndex` declarations and SDK version constant, and emit
`v2` from the release that ships them. Prefer staying on v1 (additive) and
reserving a clean v2 for an eventual deliberate re-partition.
## Limitations
| Limitation | Caused by (choice) | Why we accepted it |
| --- | --- | --- |
| **No signal for self-hosted or third-party-only traffic.** If a process never calls Azure/Foundry, we see nothing. | First-party-only emission (A) | We can't read third-party logs anyway, and must not leak a fingerprint into them. Reach traded for privacy. |
| **Not every first-party client is stampable.** Caller-supplied `AIProjectClient` / OpenAI clients and toolkit-owned clients may not expose a supported per-request policy hook. | Supported-hook-only emission (A) | V1 does not mutate caller-owned clients or private SDK pipelines. Those features may still appear on another eligible request from the same process-global mask. |
| **Custom origins intentionally receive no feature token.** A customer gateway may use Azure credentials or Azure-named settings but route to a non-approved origin. | Two-factor destination classification (A) | Credentials and configuration names are not proof of telemetry ownership. Unknown/custom origins and cross-origin redirects are denied by default. |
| **No OTel / per-call signal in v1.** | OTel deferred (C) — primarily on **privacy** and availability grounds | A broadly-emitted span attribute would push the fingerprint into the user's general telemetry / third-party APM vendors, undoing the first-party-only scoping. It also requires customer/user OTel setup, and even Foundry users may not export data where we can query it. Left open only if there is a compelling reason to add. |
| **Mask reflects "usage so far," not the whole session.** Early requests carry fewer bits than later ones. | Process-global accumulator + request-time stamping | Honest and still useful as a Boolean process-lifetime observation. Repeated request rows must not be summed as additional uses. Reading the mask at request time makes it *grow* rather than freeze. |
| **No per-agent / per-call attribution.** The mask is one process-wide value — "this process used X", not "this agent/call used X". | Process-global monotonic scope (S1) | A deliberate choice, not a transport limit: botocore *does* per-call attribution in the UA via a per-request `contextvars` set, but many AF activations (workflow build/start, provider participation, hosting startup) occur outside the service request that later emits the token. Per-call detail remains deferred to OTel. |
| **Shared processes intentionally carry usage across agents and tenants.** A request can include bits first set by another workload in the same worker. | Process-global monotonic scope (S1) | The token must be interpreted only as process-level "used so far," never as request/user/tenant attribution. Privacy review must explicitly accept this. |
| **Bits are binary, sticky observations — not countable events.** Once set, a bit appears on every later eligible request from that process, so raw request counts repeat the same observation and long-lived/high-traffic processes dominate. | Monotonic mask stamped at request time | The signal supports coarse observed-feature and co-occurrence questions only. It cannot provide first-use counts, unique-process counts, request attribution, or feature invocation frequency. |
| **Granularity may be too coarse or too detailed.** The chosen level may miss useful distinctions or create more specificity than needed. | v1 granularity choice (F0-F4) | This is the main remaining decision. Adding bits later is easier than removing/redefining them, so v1 should lean toward fewer bits that answer known questions. |
| **.NET snapshots span two atomic lanes.** A bit can be marked between the low/high reads, so one request may omit that just-added bit. | 128-bit width without a global lock | The mask is monotonic: the snapshot cannot invent or clear a bit, and the next request includes the addition. This matches the existing "usage so far" timing semantics. |
| **Fingerprinting risk is reduced, not eliminated.** A feature-combination mask is still a deployment signature, and it transits intermediaries (proxies/CDNs) even when first-party-scoped. | Emitting any feature-combination value | Scope + opt-out + coarse granularity mitigate it; v1 should avoid unnecessary detailed bits. |
## Open Questions (for decider discussion)
These are unresolved and should be decided before implementation:
1. **Which v1 granularity level (F0-F4)?** This is the primary remaining choice.
Adding bits later is easier than removing or redefining bits, so v1 should
choose the least detailed level that answers known questions and avoids a quick
v2.
2. **Privacy approval for the v1 User-Agent signal.** Before implementation,
confirm that a transparent, opt-out, first-party-only feature-combination
fingerprint is acceptable, including the exact client allowlist, retention,
access, and permitted product queries. This is a rollout precondition.
3. **When (if ever) to add the OTel path?** Held back mainly for **privacy** and
data availability: a span attribute broadcasts the fingerprint into the user's
general telemetry and onward to third-party APM vendors, contradicting the
first-party-only stance, and it requires user-side OTel setup that may not make
the data available to us even for Foundry users. It also carries a
metric-cardinality hazard. Revisit only if the User-Agent path cannot answer a
concrete question.
4. **Honor the cross-tool `DO_NOT_TRACK` convention?** Several ecosystems treat
`DO_NOT_TRACK=1` as a universal telemetry opt-out (HuggingFace Hub honors it;
see [Prior art](#prior-art)). Should our mask opt-out also respect
`DO_NOT_TRACK` (in addition to `AGENT_FRAMEWORK_FEATURE_MASK_DISABLED` and
Python's pre-existing whole-UA flag)? Cheap to add and
community-friendly, but it widens the opt-out surface and needs a clear
precedence rule. Recommend yes; confirm with the deciders.
### Decided
- **Dedicated opt-out flag — included.** In addition to the existing
Python `AGENT_FRAMEWORK_USER_AGENT_DISABLED` (drops the whole UA), v1 ships
`AGENT_FRAMEWORK_FEATURE_MASK_DISABLED`, which drops **only** the feature mask
while keeping the base SDK identity/version User-Agent. This lets a
privacy-conscious user withhold the usage signal without losing the
support/compat value of the SDK-version header. .NET adopts the dedicated
mask-only flag; adding a .NET whole-User-Agent switch is outside this decision.
- **Caller-owned clients are not modified.** V1 stamps only framework-created
clients or clients with a supported public policy/hook registration point. It
does not patch private pipelines; injected clients are an explicit coverage
limitation.
- **Destination approval is explicit and redirect-aware.** An eligible pipeline
still emits only to a reviewed HTTPS origin. Custom origins are default-deny,
and the token is removed on an unapproved redirect hop.
- **Telemetry does not replace transport defaults.** Framework-created OpenAI
clients use the SDK's default async HTTP client with the request hook added,
preserving redirect, timeout, connection-limit, and pooling behavior.
- **Marking uses activation, not DI construction.** Operational surfaces mark on
first real use; a constructor marks only when construction itself exercises or
registers the capability.
## More Information
- Mechanism & API: [SPEC-004](../specs/004-feature-usage-telemetry.md)
- Per-language bit tables, encoding, opt-out, governance: [feature-usage-bit-registry.md](../specs/feature-usage-bit-registry.md)
- Existing accumulator pattern: `python/packages/core/agent_framework/_telemetry.py`
- .NET emission policies: `dotnet/src/Microsoft.Agents.AI.Foundry/AgentFrameworkUserAgentPolicy.cs`,
`dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedAgentUserAgentPolicy.cs`
@@ -0,0 +1,308 @@
---
status: proposed
contact: eavanvalkenburg
date: 2026-07-24
deciders: eavanvalkenburg, chetantoshnival, taochenosu, moonbox3, giles17
---
# Python session storage and serialization
## Context and Problem Statement
Python does not have a broadly shared session-store API in
`agent-framework-core`. The alpha `agent-framework-hosting` package has a small process-local `SessionStore`, but that
type is hosting-specific, in-memory only, and unavailable to packages such as Foundry Hosting without taking a
dependency on the hosting helper package.
The alpha implementation is a prototype, not a compatibility constraint. This decision may replace its location,
names, method shape, and behavior if another design is preferable.
The existing file-backed persistence surfaces solve narrower problems:
- `FileHistoryProvider` stores conversation `Message` records, not complete `AgentSession` snapshots;
- `FileCheckpointStorage` stores workflow checkpoints; and
- the Responses provider stores protocol history, but not Agent Framework runtime state carried in
`AgentSession.state`.
`AgentSession.to_dict()` / `from_dict()` already provide a dictionary snapshot shape. Session state may contain
framework or application-defined objects, and `register_state_type` provides dynamic type restoration, but the
registration and collision behavior is not yet strong enough to serve as a durable, cold-start persistence contract.
The framework therefore needs to decide:
- where a reusable in-memory and file-backed session store belongs;
- how a complete `AgentSession` should be serialized atomically and validated;
- how custom nested state types are registered and restored after process restart; and
- how to provide the required readable JSON format while leaving room for an optional optimized binary format.
## Decision Drivers
### Session-store ownership and API
- Make session storage reusable by core, hosting, and provider packages without creating dependency cycles.
- Keep the smallest public API that supports in-memory use, durable implementations, and application-defined stores.
- Define the minimum async operations required for lookup, replacement, and deletion.
- Decide explicitly whether reads return shared instances or independent snapshots suitable for branching.
- Simpler is better
### Serialization and type restoration
- Provide readable JSON serialization as a required capability.
- Treat an optimized binary format as a nice-to-have only when the chosen JSON implementation supports it without a
separate state model or substantial additional complexity.
- Perform one typed encode and decode operation per file write/read.
- Preserve dynamic registration of nested state types by the provider modules that own them.
- Fail before persistence when an object cannot be restored after a cold start.
- Keep the existing serialized `{"type": "<id>", ...}` representation compatible.
## Decision 1: Session-store ownership and API shape
### Keep `SessionStore` in `agent-framework-hosting`
- Good: keeps the abstraction local to app-owned hosting scenarios.
- Bad: Foundry Hosting and other packages cannot reuse it without depending on the hosting helper package.
- Bad: a generic session snapshot store is not inherently or only a web-hosting concern.
- Bad: durable implementations would either be duplicated or placed in an unrelated package.
### Add an abstract store plus separate in-memory and file implementations
For example, define a `SessionStore` protocol/ABC with `InMemorySessionStore` and `FileSessionStore`.
- Good: clearly separates the contract from implementations.
- Good: implementation names state their storage behavior explicitly.
- Neutral: follows a familiar repository/adapter pattern.
- Bad: introduces an additional public type and rename for a three-method experimental API.
- Bad: callers must choose an implementation even for the default in-memory case.
- Bad: the abstraction adds little value while every implementation still needs the same method overrides.
### Move the concrete store to core and use it as the overridable base
Move `SessionStore` to `agent-framework-core`, retain its in-memory behavior, and implement `FileSessionStore` by
overriding the same async methods.
- Good: one public type is both the useful default and the extension point.
- Good: existing custom stores can continue subclassing and overriding `get` / `set` / `delete`.
- Good: core and provider packages can share the API without depending on hosting helpers.
- Good: `FileSessionStore` remains a focused subclass while the base stays free of file-system concerns.
- Bad: the class name does not explicitly say "in memory" when used without overrides.
## Decision 2: Serialization and type restoration
Once a file-backed store exists, it needs an on-disk format and a reliable way to reconstruct the complete
`AgentSession`, including nested framework and application-defined state. Serialization belongs to each durable store
implementation rather than the `SessionStore` API: the default in-memory store does not serialize, and custom stores
remain free to choose another protocol.
The alternatives below compare top-level snapshot validation, JSON encoding/decoding cost, and how each option
interacts with the dynamic custom-state registry. Binary storage is not a primary selection criterion.
### Considered options
The standard-library and optimized-JSON options are not mutually exclusive. A store can default to `json` while
accepting caller-supplied `dumps` / `loads` callables for `orjson` or another compatible implementation. This is the
pre-msgspec `FileHistoryProvider` design; those hooks remain only as a deprecated compatibility path.
### Standard library `json`
- Good: no additional dependency and familiar readable output.
- Good: accepts the existing dictionary snapshots without a schema.
- Good: can remain the fallback/default behind pluggable `dumps` / `loads`.
- Neutral: custom state restoration still requires the framework registry.
- Bad: slower encoding and decoding than optimized native implementations.
- Bad: provides no typed snapshot validation during file reads.
### Optimized drop-in JSON libraries such as `orjson`
- Good: substantially faster JSON encoding and decoding than the standard library.
- Good: can preserve the existing dictionary-oriented snapshot and custom `dumps` / `loads` shape.
- Good: can be an opt-in codec without making the optimized package a framework dependency.
- Neutral: returns bytes when encoding, which the file stores can already handle.
- Neutral: custom state restoration still requires the framework registry.
- Bad: remains an untyped top-level decode; the framework must separately validate the session snapshot shape.
- Bad: choosing one drop-in implementation as a core dependency adds a dependency without providing typed construction.
### Pydantic `model_dump` / `model_validate`
- Good: Pydantic is already a core dependency.
- Good: a typed session snapshot model can validate top-level fields and provide `model_dump_json` /
`model_validate_json` for file serialization.
- Good: validation errors include useful field paths.
- Neutral: the dynamic `state` field remains `dict[str, Any]`, so custom nested state restoration still requires the
framework registry.
- Neutral: the public `AgentSession` does not need to become a Pydantic model; an internal snapshot model can bridge it.
- Bad: benchmarked encode/decode includes model construction and dumping overhead on every operation.
- Bad: core dependency on Pydantic run the risk of us not being able to use different versions or users of the framework being unable to upgrade or having additional extra code dealing with major version bumps in Pydantic.
### msgspec typed/tagged unions only
- Good: msgspec owns validation and reconstruction end to end.
- Neutral: works well for a closed set of framework-owned `msgspec.Struct` types.
- Bad: every external type must be known when the decoder schema is constructed; dynamic registration is lost.
### msgspec codecs plus an explicit dynamic registry
- Good: one typed file encode/decode and dynamic nested custom types.
- Good: it satisfies the required readable JSON format.
- Neutral: the same typed snapshot can also support optional MessagePack as a low-cost implementation detail.
- Good: the registry can enforce stable IDs, codec completeness, and collision handling.
- Neutral: a single state-payload hook still recursively applies registry codecs.
- Bad: msgspec cannot infer dynamic types from JSON without the framework's type tags.
## Benchmark Evidence
A benchmark using a large `AgentSession` with 2,000 `Message` objects stored through
`InMemoryHistoryProvider`, nested standard dictionaries, registered custom classes, and registered Pydantic models
measured the complete `AgentSession.to_dict()` / codec / `AgentSession.from_dict()` path.
The reproducible harness is
[`python/scripts/session_serialization_benchmark.py`](../../python/scripts/session_serialization_benchmark.py):
```bash
cd python
uv run --with orjson python scripts/session_serialization_benchmark.py
```
| Codec | File size | Encode median (ms) | Decode median (ms) | Round-trip median (ms) | Disk round-trip median (ms) |
| --- | ---: | ---: | ---: | ---: | ---: |
| Standard library JSON | 1.57 MiB | 33.503 | 14.316 | 55.261 | 75.226 |
| orjson | 1.57 MiB | 25.808 | 11.754 | 39.398 | 63.319 |
| Pydantic JSON | 1.57 MiB | 28.330 | 18.344 | 53.522 | 77.096 |
| msgspec JSON | 1.57 MiB | 26.019 | 11.379 | **38.060** | 62.230 |
| msgspec MessagePack | **1.45 MiB** | **25.134** | **11.201** | 38.512 | **58.112** |
The JSON encodings produced the same 1.57 MiB file size. msgspec JSON had the best median JSON round-trip latency,
slightly ahead of orjson, while also supporting typed top-level decoding. Pydantic validation added measurable decode
and disk-round-trip overhead without eliminating the dynamic state registry.
MessagePack reduced file size to 92.2% of JSON (about 7.8% smaller) and produced the best encode, decode, and disk
round-trip medians. Its in-memory round-trip median was effectively tied with msgspec JSON. This supports offering it
as a nice-to-have, but it is not required to justify choosing msgspec for JSON.
These results are workload- and machine-dependent. The small differences between optimized JSON implementations are
not the basis for the architectural choice. The benchmark instead confirms that the typed design does not impose a
material regression for this representative payload:
- use msgspec JSON as the readable default;
- optionally offer msgspec MessagePack when storage size or disk latency matters;
- retain the explicit registry for dynamic custom state in both formats;
- do not add orjson solely for a small JSON performance difference without typed decoding; and
- do not use Pydantic as the file codec when its validation overhead does not replace the registry.
## Decision Outcome
### Decision 1: Move the concrete overridable store to core
`SessionStore` moves to `agent-framework-core` as an experimental public API. It remains a concrete in-memory store and
the default used by `AgentState` in the `hosting` package. Its async `get`, `set`, and `delete` methods remain overridable for custom storage
implementations.
`FileSessionStore` subclasses `SessionStore` and provides durable atomic file persistence. No separate
`InMemorySessionStore`, protocol, or ABC is introduced. `agent-framework-hosting` consumes the core type and no longer
owns or re-exports `SessionStore` (this will be a breaking change in the `hosting` package).
Actual `SessionStore` and `FileSessionStore` operations mark Python feature-usage index 17,
`core.session_store`, following ADR-0033's use-not-presence policy. Construction and import alone do not mark the bit.
`SessionStore` accepts opaque non-empty keys so custom backends can use their native key contracts. `FileSessionStore`
accepts opaque keys up to 128 characters and encodes values that are not portable filename stems; this supports
provider IDs such as `telegram:<bot-id>:<chat-id>` without permitting path traversal. `AgentState` remains
storage-agnostic and passes keys through unchanged; each store implementation owns backend-specific validation or
normalization. Protocol-specific hosts such as Foundry may still derive their own stable storage key before calling the
store.
Foundry Hosting exposes an experimental `FoundrySessionStore`, which is the
default `ResponsesHostServer` store when hosted; local hosting defaults to the
in-memory `SessionStore`. `FoundrySessionStore` currently subclasses
`FileSessionStore`, stores snapshots under
`/.sessions/<user-id>/<conversation-id-or-response-id>.json`, and derives the
validated user partition from
`azure.ai.agentserver.core.get_request_context()`. A Foundry session controls
hosted compute and filesystem lifetime and may host multiple users and
Responses conversations, so its ID is not used as the MAF session identifier.
Stored-conversation requests read and write one snapshot under
`conversation_id`. Response-chain requests read under `previous_response_id`
and write the updated, loaded MAF session under the current `response_id`, which
allows branching without overwriting the parent snapshot. Because Foundry does
not infer `agent_session_id` from `previous_response_id`, response-chain callers
must also reuse the prior response's hosted session ID so the request reaches
the same persistent `$HOME`; conversation objects bind a stable hosted session
automatically.
The Foundry-specific type is the host configuration seam; its implementation
may later move from files to a Foundry storage API without changing the generic
core store contract. The session file API maps `/` to the hosted `$HOME`
directory, so this API path is persisted on disk under `$HOME/.sessions`.
### Decision 2: Use msgspec codecs plus an explicit dynamic registry
Chosen option: **msgspec codecs plus an explicit dynamic registry**.
`FileSessionStore` uses a typed internal `msgspec.Struct` snapshot with reusable JSON and MessagePack encoders/decoders.
JSON is the required and default format. Because msgspec can reuse the same typed snapshot and registry hooks,
`serialization_format="msgpack"` is also exposed as an optional compact binary convenience. The complete state
dictionary is wrapped in one custom field; its encode/decode hooks recursively translate explicitly registered types
to and from the existing tagged mappings in either format.
The dependency range is `msgspec>=0.20.0,<0.22`: version 0.20.0 added Python 3.14 support, and the upper bound limits
core to the tested 0.20/0.21 minor lines.
Three dependency placements were considered:
1. Make msgspec a standard core dependency.
2. Make msgspec optional in core but standard in Foundry hosting.
3. Make msgspec optional in both packages.
Option 3 moves installation failures to application developers even though durable session persistence is required for
the primary `ResponsesHostServer` API to preserve Agent Framework state. Option 2 removes that burden from Foundry
hosting but makes core's shared `_sessions` module and public types conditionally defined or lazily imported without
removing msgspec from the default Foundry installation. Option 1 is therefore selected: msgspec is a standard core
dependency, giving both core file providers and Foundry hosting one predictable implementation path.
Core already depends on the native `pydantic-core` extension, so native-wheel availability is not a new packaging
constraint. The msgspec project is also actively tracking upcoming Python support; its merged
[`Add 3.15-dev to CI` PR](https://github.com/msgspec/msgspec/pull/1037) exercises Python 3.15 development builds. This gives confidence that they will add support for new python version quickly.
The public `AgentSession` remains a normal framework class. The msgspec Struct is an internal persistence DTO rather
than the inheritance base for runtime sessions. The Struct gives persistence one typed encode/decode operation, validates
the snapshot envelope, and carries an explicit payload version. The benchmark's small timing spread was not used to
choose the Struct.
`register_state_type` supports stable type IDs and optional codecs, rejects collisions, and provides defaults for
`to_dict` / `from_dict` classes and Pydantic models. Type IDs share one process-wide registry, so provider packages
should use stable package-qualified identifiers and register their own state types at module import time; consumers do
not need to know those implementation details. One recursive serializer is shared by `AgentSession.to_dict()` and the
durable codecs. The established implicit Pydantic registration behavior remains temporarily for compatibility, but now
emits `DeprecationWarning`. Same-process round-trips continue to work; cold-start deserialization is not guaranteed
without explicit provider registration. Unknown persisted type IDs remain raw dictionaries.
File snapshots are quarantined only when their bytes cannot be parsed as the selected JSON or MessagePack format.
Schema errors, unsupported snapshot versions, and registered state-decoder failures leave the original file in place so
an application fix, rollback, or compatible reader can recover it.
`FileHistoryProvider` also adds msgspec JSON as its default JSON Lines codec. It supports the same explicit
`serialization_format="msgpack"` choice using length-prefixed append-only MessagePack records. Its existing `dumps` /
`loads` extension points remain temporarily for JSON compatibility, emit `DeprecationWarning` when supplied, and do
not apply to MessagePack. New code uses the built-in codecs. The default JSON reader falls back to the standard library
for legacy JSON Lines containing `NaN` or infinity, and writes those non-finite values with the standard library so
existing history semantics are preserved.
## Follow-up Work
Audit the remaining file-backed stores to determine whether they benefit from the same typed msgspec treatment and
optional JSON / MessagePack formats. `FileCheckpointStorage` is the first candidate because it persists large,
structured workflow state and currently uses JSON plus custom checkpoint value encoding. Its existing
`WorkflowCheckpoint.version` field already provides a payload-shape discriminator.
Checkpoint migration should be reader-first. A compatibility release can detect the codec from the first byte, widen
the two `glob("*.json")` readers to discover future formats, and continue writing only JSON. A later release can add
opt-in MessagePack writes while retaining JSON as the default. The payload `version` should describe the checkpoint
shape rather than the codec, which is discoverable from the bytes. MessagePack should not become the default while
mixed-version fleets may share one checkpoint directory: older readers silently ignore non-JSON files and could resume
from no checkpoint instead of surfacing an incompatibility.
`MemoryContextProvider` is another candidate because its file-backed path combines `MemoryFileStore` state with
transcript files and still exposes `history_dumps` / `history_loads` passthroughs to the deprecated
`FileHistoryProvider` codec hooks.
The follow-up should measure real framework payloads before changing formats, preserve compatibility or define a clear
migration path for existing files, and consider whether each store needs readable JSON, compact binary storage, append
semantics, or atomic whole-file replacement. Other candidates include file-backed todo state, but each should be
evaluated independently rather than adopting msgspec by default solely for consistency.
+100
View File
@@ -66,6 +66,8 @@ must be aligned with the helper-first model before implementation. Old vocabular
| Package | Import surface | v1 helper-first contents |
|---|---|---|
| `agent-framework-hosting` | `agent_framework_hosting` | `AgentState`, `WorkflowState`, `SessionStore`, and run-argument `TypedDict`s. |
| `agent-framework-hosting-a2a` | `agent_framework_hosting_a2a` | A2A `Message` to run conversion and Agent Framework output to A2A `Part` conversion. |
| `agent-framework-hosting-mcp` | `agent_framework_hosting_mcp` | Agent and workflow MCP tool adapters, MCP tool arguments to run conversion, and Agent Framework output to MCP `ContentBlock` conversion. |
| `agent-framework-hosting-responses` | `agent_framework_hosting_responses` | Responses helpers: request parsing, session id extraction, response id creation, response rendering, streaming rendering. |
| `agent-framework-hosting-telegram` | `agent_framework_hosting_telegram` | Telegram Bot API helpers: update parsing, chat/session/command/media extraction, final rendering, and streaming edit rendering. |
| Future protocol packages | e.g. `agent_framework_hosting_activity_protocol` | Protocol-specific helpers such as `activity_to_run(...)`, `activity_from_run(...)`, `activity_session_id(...)`, and command/media helpers when useful. |
@@ -91,6 +93,7 @@ Examples:
- `responses_to_run(...)`, `responses_from_run(...)`, `responses_from_streaming_run(...)`,
`responses_session_id(...)`;
- `a2a_to_run(...)`, `a2a_from_run(...)`;
- `telegram_to_run(...)`, `telegram_from_run(...)`, `telegram_from_streaming_run(...)`,
`telegram_session_id(...)`, `telegram_command(...)`;
- `activity_to_run(...)`, `activity_from_run(...)`, `activity_session_id(...)`, `activity_command(...)`;
@@ -178,6 +181,9 @@ The target may be:
- `await get_target()`;
- synchronous `target` only after a target is already available/resolved.
A workflow instance permits one active run. Concurrent hosts use a factory or
builder with `cache_target=False` to resolve a fresh instance per run.
Workflow checkpointing uses Agent Framework's existing `CheckpointStorage` abstraction directly. Apps that need
per-session workflow resume should keep an app-owned cursor such as `session_id -> checkpoint_id`. When the app uses
file-backed cursor storage, the file-based checkpoint storage should share the same app storage root and should be
@@ -245,6 +251,100 @@ text deltas, and a completed event. The final completed payload is produced thro
also preserves the model id observed on streaming updates when the finalized `AgentResponse` no longer carries raw model
metadata.
## `agent-framework-hosting-a2a`
The A2A package provides only the conversion seam between the native A2A SDK
and Agent Framework:
- `a2a_to_run(message, *, stream=False) -> AgentRunArgs`
- `a2a_from_run(result) -> list[a2a.types.Part]`
`a2a_to_run(...)` accepts a native A2A `Message` and converts its text, URL,
raw-byte, and structured-data parts into one Agent Framework user message.
`a2a_from_run(...)` accepts an `AgentResponse`, `Message`, or
`AgentResponseUpdate` and converts supported text, URI, and data content into
native A2A `Part` values. This one helper is usable for both completed and
streaming runs.
The package does not provide an A2A `AgentExecutor`, application, route,
request handler, task store, event queue, `TaskUpdater`, task-state policy,
artifact-id policy, or session-key policy. Application code composes the two
helpers with those native A2A SDK constructs and may use any server framework
supported by the SDK.
## `agent-framework-hosting-mcp`
The MCP package provides only the conversion seam between native MCP SDK values
and Agent Framework:
- `MCPAgentTool(target, ...)`
- `MCPWorkflowTool(target, ...)`
- `mcp_to_run(arguments, *, argument_name="task", chat_option_arguments=()) -> AgentRunArgs`
- `mcp_from_run(result) -> list[mcp.types.ContentBlock]`
`MCPAgentTool` represents one Agent Framework agent as one native MCP tool. It
derives the default tool name and description from the agent, accepts
overrides for those values and the main text parameter, includes app-owned
additional parameter schemas, and explicitly maps selected parameter schemas
to ChatOptions. Its asynchronous `list_tools()` returns the native `Tool` list,
and `call_tool(...)` performs conversion, agent execution, and final result
conversion.
The adapter accepts either an agent or an existing `AgentState`. With a
configured `session_id_parameter`, it loads and stores the corresponding
`AgentSession`. The application remains responsible for deriving and
authorizing the session id and preventing concurrent updates to the same
session.
`MCPWorkflowTool` represents one Agent Framework workflow as one native MCP
tool. It derives the tool name and description from the workflow and derives
the input schema from the start executor's single declared input type.
Object-shaped dataclass and Pydantic inputs become top-level MCP arguments;
primitive inputs are wrapped in one configurable argument. The adapter
validates the arguments against that type, runs the workflow, and converts
terminal outputs to MCP content blocks.
Workflow instances preserve state and reject concurrent runs. Applications
that need independent calls should provide a `WorkflowState` factory with
`cache_target=False`. Checkpoint restoration, human-in-the-loop responses, and
continuation identifiers remain application-owned contracts. If a workflow
stops to request external input, the adapter raises rather than returning an
empty successful tool result.
`mcp_to_run(...)` accepts the argument mapping from a native MCP `call_tool`
handler. The application owns the tool schema and may select which required
string argument contains the user request. The application should define that
argument name once and use the same value in the native tool schema and the
`argument_name` parameter so those two sides of the contract remain aligned.
Applications may also expose selected ChatOptions fields in their native tool
schema and pass those names through `chat_option_arguments`. Only explicitly
selected names are copied to run options; the helper does not forward all MCP
arguments or own their JSON Schema validation.
MCP `tools/call` arguments are JSON-only and do not have a native multimodal
content-block union. The package does not impose a non-standard JSON
representation for multimodal tool arguments.
`mcp_from_run(...)` accepts an `AgentResponse` or `Message`. It converts text,
URI, image data, audio data, and other binary data into native MCP content
blocks.
Its output is specifically the content union accepted by `CallToolResult`.
Sampling-only values such as `ToolUseContent` belong to the separate MCP
sampling response path and are not emitted by this hosting helper.
MCP `tools/call` returns one final `CallToolResult`. Streamable HTTP can carry
multiple MCP messages and progress notifications can report operation status,
but the protocol does not define partial tool-result content chunks.
Experimental MCP tasks defer retrieval of the same final result. Therefore the
conversion helpers do not expose Agent Framework streaming updates.
The package does not provide an MCP `Server`, handler registration, transport, route,
session policy, authentication, authorization, or deployment wrapper.
Application code composes the adapters and conversion helpers with native MCP SDK constructs and
may use stdio, streamable HTTP, or another transport supported by the SDK.
## `agent-framework-hosting-telegram`
The Telegram package provides side-effect-free helpers around Telegram Bot API
@@ -0,0 +1,246 @@
---
status: accepted
contact: rogerbarreto
date: 2026-07-08
deciders: rogerbarreto
consulted: eavanvalkenburg
informed: []
---
# .NET hosting: OpenAI Responses protocol helpers and optional execution state
Implements [ADR-0032](../decisions/0032-dotnet-hosting-protocol-helpers.md), which realizes the
helper-first direction of [ADR-0027](../decisions/0027-hosting-channels.md) for .NET.
## What is the goal of this feature?
Let application developers expose an `AIAgent` or workflow over the OpenAI Responses protocol **while
owning their own ASP.NET Core route, authentication, middleware, and storage**, by calling small,
side-effect-free Agent Framework conversion helpers instead of adopting the batteries-included,
route-owning `MapOpenAIResponses` server.
Success: an application can implement a working `POST /responses` endpoint (sync + streaming) in its
own minimal-API handler using only the public helpers plus its own auth/storage, with no dependency on
`MapOpenAIResponses` or `IResponsesService`.
## What is the problem being solved?
.NET already exposes agents as the OpenAI Responses API, but only through the route-owning
`MapOpenAIResponses`/`IResponsesService`, which also owns routing, response/conversation storage,
streaming, and lifecycle. An application that wants its own routing (custom auth, middleware, status
codes, durable storage, or a different framework surface) currently has no supported way to reuse the
framework's Responses<->agent conversion. Every conversion primitive that would make this possible
already exists in `Microsoft.Agents.AI.Hosting.OpenAI` but is `internal`.
This feature un-bundles that conversion into a public, app-callable surface, and adds the minimal
execution-state helpers an app needs for session continuity and workflow checkpoint resume.
## API Changes
### `Microsoft.Agents.AI.Hosting.OpenAI` (new public static facade `OpenAIResponses`)
Boundary is `System.Text.Json`; the wire DTOs stay internal. All members are side-effect-free.
```csharp
namespace Microsoft.Agents.AI.Hosting.OpenAI;
public static class OpenAIResponses
{
// Wire -> Agent Framework run input.
public static OpenAIResponsesRunRequest ToAgentRunRequest(
JsonElement body,
OpenAIResponsesMapOptions? mapOptions = null);
// Agent Framework result -> Responses payload (no originating request required).
public static JsonElement WriteResponse(
AgentResponse response,
string responseId,
string? sessionId = null);
// Agent Framework stream -> Responses SSE `data:` frames.
public static IAsyncEnumerable<string> WriteResponseStreamAsync(
IAsyncEnumerable<AgentResponseUpdate> updates,
string responseId,
string? sessionId = null,
CancellationToken cancellationToken = default);
// Untrusted candidate continuation key: previous_response_id or conversation id (or null).
// Kept SEPARATE from ToAgentRunRequest so using a request-derived key is an explicit decision.
public static string? GetSessionId(JsonElement body);
// Mint a `resp_*` id.
public static string CreateResponseId();
}
// Result of ToAgentRunRequest.
public sealed class OpenAIResponsesRunRequest
{
public IList<ChatMessage> Messages { get; }
public AgentRunOptions? Options { get; }
}
```
`ToAgentRunRequest` honors `OpenAIResponsesMapOptions.RunOptionsFactory` exactly as the route model
does (by default no request setting is mapped onto the run; unsupported settings surface as a
`NotSupportedException`). `WriteResponse`/`WriteResponseStreamAsync` reuse the existing internal
`AgentResponseExtensions.ToResponse` / `AgentResponseUpdateExtensions.ToStreamingResponseAsync`
converters (an internal `ToResponse` overload with an optional originating request is added so the
facade can render without one). The streaming renderer's existing workflow-event support is preserved.
### `Microsoft.Agents.AI.Hosting` (execution state, protocol-neutral)
```csharp
namespace Microsoft.Agents.AI.Hosting;
public abstract class AgentSessionStore
{
// ... existing members ...
// New: the one missing store operation. Virtual (not abstract) with a default that throws
// NotSupportedException, so existing external stores (e.g. the Foundry hosting stores) keep
// compiling; the in-box Hosting stores override it. In-box overrides treat deleting a missing
// session as a no-op.
public virtual ValueTask DeleteSessionAsync(
AIAgent agent, string conversationId, CancellationToken cancellationToken = default);
}
// Thin holder: pairs a workflow target with checkpointing + a per-session head cursor.
public sealed class HostedWorkflowState
{
// Shared-instance mode: one instance cannot be run by two runners at once, so turns run one at a time.
public HostedWorkflowState(Workflow workflow, CheckpointManager? checkpointManager = null);
// Factory mode: by default a fresh instance is built per run, so independent sessions run in parallel.
// With cacheWorkflow: true the factory is invoked once lazily and the built instance is cached and reused.
public HostedWorkflowState(Func<CancellationToken, ValueTask<Workflow>> workflowFactory, CheckpointManager? checkpointManager = null, bool cacheWorkflow = false);
// First turn runs forward from the start; subsequent turns restore the session's latest
// checkpoint and run forward with the new turn's input, then record the new head checkpoint.
public ValueTask<HostedWorkflowRunResult> RunOrResumeAsync(
string sessionId, object input, CancellationToken ct = default);
}
```
For agents, the application uses `AgentSessionStore` directly: `GetSessionAsync(agent, id)` creates a
session on miss and returns an independent instance per call (so concurrent calls can fork the same
stored state — for example branching from a `previous_response_id` or managing several `conversation`
ids side by side — without one branch observing another's in-flight mutations). The store performs no
cross-call locking; an application that needs concurrent runs against the same id to be serialized owns
that coordination. `SaveSessionAsync(agent, id, session)` persists post-run, including under a newly
minted `resp_*` id when the protocol mints a new continuation id. `DeleteSessionAsync` uses the new
store method. No agent-side holder is needed: create-on-miss already lives in the store, so a
pass-through wrapper would only bind the `agent` argument.
`HostedWorkflowState` defaults to `CheckpointManager.CreateInMemory()` and an in-memory
`sessionId -> CheckpointInfo` cursor. Because the checkpoint store is already `sessionId`-keyed but
`CheckpointInfo` carries no ordering, the holder remembers the head checkpoint per session so
`RunOrResumeAsync` can resume the correct one. On subsequent turns it restores that checkpoint to
rehydrate accumulated workflow state and then runs the workflow forward with the new turn's input,
rather than continuing a halted run with no input (which would wait for input
indefinitely). For agent (chat-protocol) workflows the new input is accompanied by a `TurnToken` so the
turn is driven. When the in-memory cursor misses (a new holder or a process restart), the holder falls
back to `CheckpointManager.GetLatestCheckpointAsync(sessionId)`, so a durable `CheckpointManager` resumes
correctly across restarts (the default in-memory manager does not persist, so a restart starts fresh). A
resume that produces no events is logged as a warning (possible stale checkpoint or mismatched input).
Concurrency depends on how the holder is constructed. With a single shared workflow instance, concurrent runs
are not supported, because a workflow instance cannot be run by two runners at once; process turns one at a
time. With a workflow factory
(`Func<CancellationToken, ValueTask<Workflow>>`) it builds a fresh instance per run by default, so independent
sessions run in parallel; a resume rehydrates a fresh instance
from the session's checkpoint in the shared store, and concurrent turns against the same session id remain the
application's coordination responsibility. Passing `cacheWorkflow: true` instead builds the workflow once,
lazily on first use, and reuses it (a deferred, cached target that — like the instance — cannot run concurrent
turns). A
streaming counterpart, `RunOrResumeStreamingAsync`, yields the turn's `WorkflowEvent`s as they occur (for
example to render agent updates over the Responses SSE wire) and records the head checkpoint once the
stream is fully enumerated, keeping the blocking and streaming workflow paths in lockstep.
Because `RunOrResumeAsync`/`RunOrResumeStreamingAsync` are generic over the input type, the application
adapts the Responses input into the workflow's start-executor input type at the call site (for example
parsing a structured payload into a typed record), without coupling the holder to a specific wire type.
## Non-goals for v1
- ChatCompletions / Conversations helper surfaces (the facade is named so `OpenAIChatCompletions` can
follow).
- Changing `MapOpenAIResponses` public behavior.
- A new package or an OpenAI-SDK-typed reimplementation.
- Durable/pluggable workflow checkpoint-cursor storage (in-memory default only for v1).
## Security responsibilities (application-owned)
- Authenticate the caller before using any `GetSessionId(...)` result.
- Authorize and bind the candidate id to the authenticated principal/tenant before using it as an
`AgentSessionStore` key or a workflow checkpoint session id.
- For multi-user hosts, wrap the store with `IsolationKeyScopedAgentSessionStore` (for example via
`UseClaimsBasedSessionIsolation(...)`), so the session namespace is scoped per principal.
- Persist session/checkpoint state only after the run or stream has completed.
## E2E Code Samples
### Agent over Responses, app-owned route (non-streaming + SSE)
```csharp
var agent = /* an AIAgent */;
AgentSessionStore sessionStore = new InMemoryAgentSessionStore(); // in-memory session store
app.MapPost("/responses", async (HttpContext http, CancellationToken ct) =>
{
using var doc = await JsonDocument.ParseAsync(http.Request.Body, cancellationToken: ct);
JsonElement body = doc.RootElement;
// App owns auth + id trust decisions.
string? candidate = OpenAIResponses.GetSessionId(body);
string sessionId = Authorize(http.User, candidate) ?? OpenAIResponses.CreateResponseId();
var run = OpenAIResponses.ToAgentRunRequest(body);
var session = await sessionStore.GetSessionAsync(agent, sessionId, ct);
string responseId = OpenAIResponses.CreateResponseId();
if (body.TryGetProperty("stream", out var s) && s.GetBoolean())
{
http.Response.ContentType = "text/event-stream";
var updates = agent.RunStreamingAsync(run.Messages, session, run.Options, ct);
await foreach (var frame in OpenAIResponses.WriteResponseStreamAsync(updates, responseId, sessionId, ct))
{
await http.Response.WriteAsync(frame, ct);
await http.Response.Body.FlushAsync(ct);
}
await sessionStore.SaveSessionAsync(agent, responseId, session, ct);
return Results.Empty;
}
var result = await agent.RunAsync(run.Messages, session, run.Options, ct);
await sessionStore.SaveSessionAsync(agent, responseId, session, ct);
return Results.Json(OpenAIResponses.WriteResponse(result, responseId, sessionId));
});
```
### Workflow over Responses with checkpoint resume
Workflow checkpoint resume requires a **stable** session key across turns. `previous_response_id` changes
every turn, so it is not a valid checkpoint key; use the `conversation` id (constant for the conversation).
Because `GetSessionId(...)` prefers `previous_response_id`, a workflow route reads the conversation id
directly rather than calling `GetSessionId(...)`.
```csharp
var state = new HostedWorkflowState(workflow); // in-memory checkpoints + cursor
app.MapPost("/responses", async (HttpContext http, CancellationToken ct) =>
{
using var doc = await JsonDocument.ParseAsync(http.Request.Body, cancellationToken: ct);
JsonElement body = doc.RootElement;
// Stable, authorized checkpoint key. GetConversationId(...) reads the conversation id (string or object).
string sessionId = Authorize(http.User, GetConversationId(body))
?? OpenAIResponses.CreateResponseId();
var run = OpenAIResponses.ToAgentRunRequest(body);
// Runs forward on first call, resumes from the session's head checkpoint thereafter.
var result = await state.RunOrResumeAsync(sessionId, run.Messages, ct);
return Results.Json(OpenAIResponses.WriteResponse(result.AsAgentResponse(),
OpenAIResponses.CreateResponseId(), sessionId));
});
```
+500
View File
@@ -0,0 +1,500 @@
---
status: proposed
contact: eavanvalkenburg
date: 2026-07-22
deciders: eavanvalkenburg
consulted:
informed:
---
# Feature-usage telemetry via an accumulating bitmask
> Companion design for [ADR-0033](../decisions/0033-feature-usage-bitmask-user-agent.md).
> The per-language bit tables, encoding, opt-out, and governance live in
> [feature-usage-bit-registry.md](feature-usage-bit-registry.md). The registry
> allocates indexes; package-local `FeatureIndex` declarations implement them.
## What is the goal of this feature?
Give the Agent Framework team a lightweight signal about **which framework
features are actually exercised** at runtime (not merely installed), so we can
prioritise investment based on real usage. We emit a single small number — a
*feature mask* — on the User-Agent that already goes out with each request.
**Reach is deliberately bounded.** The mask accumulates from *all* feature usage,
but the `feat=` token is only stamped through an explicit allowlist of
**first-party Azure/Foundry client pipelines** whose User-Agent telemetry the
team can ingest (initially Foundry/Azure OpenAI). We do **not** send the token to
third-party providers (OpenAI direct, Anthropic, Bedrock, Gemini, Ollama,
Mistral), or to an Azure service merely because its hostname is first-party;
doing so would leak a deployment fingerprint into logs we cannot read (see
[Emission](#emission)).
The current candidate uses package-level bits plus selected major capabilities:
one bit per orchestration pattern (sequential / concurrent / group-chat /
magentic / handoff), **one bit per built-in context/history provider**, selected
skill source types, and separate Foundry chat/agent/memory/evals/toolbox bits
(plus embedding in Python).
See the
[registry](feature-usage-bit-registry.md). ADR-0033 still leaves final v1
granularity open. The refreshed candidate assigns 63 Python indexes and 52 .NET
indexes. V1 uses 128 bits, leaving 65 Python and 76 .NET positions for additive
growth.
Success metric: within one release after rollout, ≥80% of **eligible,
framework-created** first-party (Foundry) requests carry a **non-empty** feature
token whose mask reflects features activated **after** client construction (i.e.
the token is live, not frozen — see the request-time stamping requirement
below). This measures transport coverage, not feature invocation volume.
Secondary: ability to describe which process-lifetime feature bits are observed
together in eligible traffic (e.g. "requests observed from processes that have
used workflows"). Repeated requests carrying a bit are not additional uses.
This is done **transparently**: the bit registry is public, the emitted value is
human-decodable, and a dedicated `AGENT_FRAMEWORK_FEATURE_MASK_DISABLED`
disables the mask while preserving the base User-Agent. Python's existing
`AGENT_FRAMEWORK_USER_AGENT_DISABLED` continues to suppress its entire
User-Agent contribution, mask included.
## What is the problem being solved?
Today we only know which packages are *installed* (from package telemetry) or
that *some* Agent Framework call happened (the existing
`agent-framework-python/{version}` User-Agent). We have no usage-based signal
about feature combinations, and no way to tell that, say, a process uses
workflows + MCP + Foundry together. Collecting this through bespoke events would
add cost and new data flows; folding a tiny accumulating integer into telemetry
we already send is far cheaper and easier to reason about for privacy.
## Mechanism
### Process-global accumulator in `core`
The accumulator and its helpers live in the existing
`agent_framework/_telemetry.py` (alongside `get_user_agent()` /
`prepend_agent_framework_to_user_agent()`), so the User-Agent machinery stays in
one module. It owns a process-global 128-bit accumulator. Python's arbitrary-size
`int` stores it directly. A **dedicated**
`AGENT_FRAMEWORK_FEATURE_MASK_DISABLED` that drops **only** the feature mask
while keeping the base `agent-framework-python/{version}` User-Agent is
introduced by this design. The existing Python
`AGENT_FRAMEWORK_USER_AGENT_DISABLED` continues to drop the whole User-Agent
contribution, mask included:
```python
# agent_framework/_telemetry.py (same module as get_user_agent)
# IS_TELEMETRY_ENABLED already defined here (AGENT_FRAMEWORK_USER_AGENT_DISABLED)
FEATURE_MASK_DISABLED_ENV_VAR = "AGENT_FRAMEWORK_FEATURE_MASK_DISABLED"
REGISTRY_VERSION = 1
_feature_mask = 0
_feature_mask_lock = threading.Lock()
def _feature_mask_enabled() -> bool:
"""Mask is on unless the UA is disabled or the dedicated flag is set."""
if not IS_TELEMETRY_ENABLED:
return False
return os.environ.get(FEATURE_MASK_DISABLED_ENV_VAR, "false").lower() not in ("true", "1")
def mark_feature_used(index: int) -> None:
"""OR a feature bit into the process-global mask.
Called the first time a feature is exercised. Cheap and idempotent;
a no-op when the feature mask is disabled.
"""
global _feature_mask
if not _feature_mask_enabled():
return
if not 0 <= index < 128:
raise ValueError(f"Feature index must be in range 0..127, got {index}")
with _feature_mask_lock:
_feature_mask |= 1 << index
def get_feature_token() -> str | None:
"""Return ``v<version>.<hex_mask>`` for the accumulated mask, or None."""
if not _feature_mask_enabled() or _feature_mask == 0:
return None
return f"v{REGISTRY_VERSION}.{_feature_mask:x}"
```
- **Per package/feature, usage-based:** `mark_feature_used()` is called at the
feature's first meaningful activation, never at import/install time. For
operational clients, tools, providers, and hosts, activation is the first
public operation that exercises the capability. Construction is a valid mark
point only when construction itself performs the capability (for example,
registering/starting runtime resources), not merely because a DI container
instantiated an otherwise-unused object.
- **Process-global and monotonic — intentionally never reset.** Unlike a
per-request scheme (e.g. botocore's `contextvars` feature set that resets
between calls), our mask spans the whole process because many features are not
bound to any service request — an agent or workflow may first run, a provider
may first participate in a session, and a host may start serving independently
of the later request that emits the token. The single global
mask is the only scope that can represent them, and its monotonic "usage so
far" growth is the intended semantic, not a bleed bug. Concurrency-safe via the
module lock (Python) / two atomic 64-bit lanes in .NET.
- **Binary and non-countable.** A set bit means "this feature was observed at
least once in this process before this request." Repeating that bit on every
later eligible request does not represent additional uses and must not be
interpreted as request, invocation, agent, user, or tenant counts.
- **No scoped enable/disable bookkeeping.** Making the mask exact per operation
would add hot-path state changes, context propagation, and reset/error-path
handling. It would also produce a more detailed behavioral trace and therefore
increase privacy sensitivity. V1 deliberately keeps the coarser process-level
Boolean.
- **Token is safe by construction.** The emitted value is `v{int}.{hex}`
characters limited to `[0-9a-fv.]` — so no header-injection sanitization is
required. A 128-bit mask is at most 32 hex characters (contrast botocore,
which must sanitize and cap arbitrary component strings).
- **Private API.** `mark_feature_used`, `get_feature_token`, `apply_feature_token`
and the mask itself are internal helpers; only the emitted token and the
per-language registry tables are the stable, decodable contract.
- **No import cycles:** the accumulator lives in core, while each package owns
private index constants for its own features and calls the core marker. Core
never imports optional packages.
### Interpretation contract
At time 1, Agent A in a worker can use MCP and a Foundry chat client. At time 2,
Agent B in the same worker can make a normal Foundry chat call without MCP. The
time-2 request still carries the MCP bit because MCP was observed earlier in the
process.
That request means only "this process has used MCP." It does not mean Agent B
used MCP, that MCP was used on the time-2 request, or that two requests carrying
the bit equal two MCP uses. Without a separate stable process identifier, the
signal also cannot produce unique-process counts. Supported analysis is limited
to coarse observed-feature prevalence and feature co-occurrence, with the
request-weighting limitation called out explicitly.
### Bit constants
The registry is the allocation authority. Each package defines a private,
hand-written `FeatureIndex` IntEnum (or equivalent constants) containing only
the rows it owns. Core owns core indexes plus the accumulator; optional packages
can allocate and ship new indexes without requiring a core release after the
marker API exists.
```python
# agent_framework_foundry/_feature_usage.py
from enum import IntEnum
from agent_framework._telemetry import mark_feature_used # pyright: ignore[reportAttributeAccessIssue]
class FeatureIndex(IntEnum):
FOUNDRY_CHAT_CLIENT = 48
class RawFoundryChatClient:
async def _send_request(self) -> None:
mark_feature_used(FeatureIndex.FOUNDRY_CHAT_CLIENT)
...
```
A repository validation test reads every package-local declaration and the
matching language/version table. It fails when an index is out of range, missing
from the registry, duplicated/overlapping across packages, or mapped to the wrong
id. For reference, in v1 `FoundryChatClient` → index 48,
`FoundryAgent` → index 49, Foundry memory → index 50.
### Usage activation points
- **Clients/embeddings/evals:** first outbound operation.
- **Tools/MCP:** first connection, discovery, or invocation that exercises the
tool surface.
- **Context/history providers:** first provider hook or load/save operation, not
constructor-only registration.
- **Agents/workflows/orchestrations:** first run/build/start operation that
activates the defined runtime.
- **Hosting:** first serve/start/route activation.
- **Constructor marking:** allowed only when construction itself performs one of
those activations or acquires/registers the runtime resource.
## Emission
**One path in v1: the User-Agent `feat=` token, stamped at request time on an
explicit allowlist of first-party Azure/Foundry client pipelines only.**
Marking (`mark_feature_used`) is **universal** — every feature sets its index
regardless of provider. Only **emission** is scoped. A user who never calls a
first-party endpoint emits no token; this is the honest, intended behaviour (no
third-party leakage, no signal we couldn't read anyway).
The existing base User-Agent behavior (`agent-framework-python/{version}` plus
any dynamically detected hosting prefix) is unchanged; packages continue using
their current `default_headers`, `user_agent`, suffix, or policy mechanisms.
`get_user_agent()` stays base-only (no `feat=`). The `feat=` token is
**separate**, added **only** by eligible Azure/Foundry clients, and
**re-evaluated on each request** so it reflects the mask accumulated so far. A
helper stamps it:
This request-time read does not make the signal request-scoped. The payload
remains the process-global Boolean history described above.
```python
# agent_framework/_telemetry.py
def apply_feature_token(user_agent: str) -> str:
"""Append/refresh the live ``(feat=v<ver>.<hex>)`` comment on a UA string.
Re-reads the current mask on every call, so newly accumulated bits are
reflected immediately. Idempotent: replaces an existing ``(feat=...)``
comment rather than appending a second.
"""
token = get_feature_token() # None when disabled or mask == 0
base = _strip_feature_comment(user_agent)
return f"{base} (feat={token})" if token else base
```
Emission requires **both**:
1. an explicitly approved framework client/pipeline family; and
2. the actual request's normalized HTTPS origin matching that family's reviewed
first-party origin allowlist.
Credentials, `use_azure`, or an Azure-named setting alone do not approve a
destination. Approval depends on the **resolved origin**: customer-specific
subdomains on reviewed Azure/Foundry suffixes remain eligible even when supplied
through `base_url` / `AZURE_OPENAI_BASE_URL`, while customer gateways and unknown
OpenAI-compatible origins are denied by default. The check runs on every actual
request, including redirect hops; a cross-origin or otherwise unapproved redirect
removes `(feat=...)` before sending.
Eligible first-party clients install a **request hook** that performs this
classification and calls `apply_feature_token()`:
- **OpenAI-SDK clients created by Agent Framework**: construct the underlying
client with
`http_client=DefaultAsyncHttpxClient(event_hooks={"request": [_stamp_feat_hook]})`.
Using OpenAI's `DefaultAsyncHttpxClient` preserves the SDK's redirect,
connection-limit, and timeout defaults; a plain `httpx.AsyncClient` must not
replace them. The hook adds or removes the token based on the approved pipeline
plus actual-origin classification. Caller-supplied clients/transports are not
replaced or patched.
- **azure-core pipeline clients**: start with `AIProjectClient` paths whose
telemetry is confirmed ingestible. When Agent Framework constructs/configures
an approved pipeline, add a separate per-call `SansIOHTTPPolicy` whose
`on_request` performs the same actual-origin check and calls
`apply_feature_token()` on
`request.http_request.headers["User-Agent"]`. Do not stamp `SearchClient`,
`CosmosClient`, or another Azure client merely because it is first-party; add
it to the allowlist only after confirming the data path. This mirrors .NET's
request-time `PipelinePolicy` exactly.
This fixes the frozen-at-construction problem: the token is materialised at
**send time**, not client-init time, so it carries features activated after the
client was created. It also confines the token to first-party endpoints. Caller-owned
clients are not patched, and toolkit-owned clients without a supported public
hook are outside v1 coverage.
Encoding uses the RFC 7231 **comment** form `(feat=v1.<hex>)` (metadata, not a
product token), placed after the agent-framework product token, e.g.:
```text
foundry-hosting/agent-framework-python/1.2.3 (feat=v1.2a)
```
### OpenTelemetry — not in v1
An OTel span attribute carrying the same value was considered but **deferred —
primarily for privacy, not complexity**. Unlike the first-party-only UA token, a
span attribute broadcasts the feature-combination fingerprint into the user's
**general** telemetry pipeline, which is commonly exported to third-party APM
vendors (Datadog, Honeycomb, …) — re-introducing exactly the leakage the
first-party scoping was chosen to avoid. (It also carries a cardinality footgun:
a monotonically-growing, combinatorial value must never become a metric
dimension.) The version prefix leaves the door open to add it later **if** the
User-Agent path cannot answer a concrete query and there is an acceptable
scoped/redacted variant; v1 ships the UA path only. See
[ADR-0033 → option C](../decisions/0033-feature-usage-bitmask-user-agent.md#considered-options).
## API Changes
New **internal cross-package** surface in
`agent_framework._telemetry` (not exported from `agent_framework`):
- `mark_feature_used(index: int) -> None`
- `get_feature_token() -> str | None` — returns `v<ver>.<hex>` or `None`.
- `apply_feature_token(user_agent: str) -> str` — live, idempotent UA stamper
used by first-party request hooks.
- `FEATURE_MASK_DISABLED_ENV_VAR` constant — the dedicated mask-only opt-out env
var name (`AGENT_FRAMEWORK_FEATURE_MASK_DISABLED`).
Each package also adds a private package-local `FeatureIndex` declaration for
the rows it owns. The dedicated mask-only opt-out and Python's existing
whole-User-Agent opt-out gate the Python mask; see [Opt-out](#opt-out).
Behavioural change to existing API:
- `get_user_agent()` / `prepend_agent_framework_to_user_agent()` are
**unchanged** — they keep returning the base UA with no `feat=` token. The
token is added only by first-party request hooks via
`apply_feature_token()`.
No breaking changes: when the mask is empty or disabled, for any non-first-party
client, or for an injected client outside the supported-hook set, output is
byte-for-byte identical to today.
## Opt-out
The dedicated mask-only opt-out is shared by both SDKs. Python also retains its
pre-existing whole-User-Agent opt-out:
| Env var | SDKs | Effect |
| --- | --- | --- |
| `AGENT_FRAMEWORK_FEATURE_MASK_DISABLED` | Python and .NET | disables **only** the feature mask; the base `agent-framework-<lang>/{version}` User-Agent is still sent |
| `AGENT_FRAMEWORK_USER_AGENT_DISABLED` | Python (existing behavior) | disables the **entire** Python AF User-Agent contribution, mask included |
The flags accept `true`/`1` (case-insensitive). The dedicated flag lets a
privacy-conscious user keep contributing the SDK identity/version (useful for
support and compat triage) while withholding the feature-usage signal. The mask
is also disabled implicitly whenever Python's whole User-Agent is disabled. A
new whole-User-Agent opt-out for .NET is outside this design.
## E2E example
```python
from agent_framework import Agent
from agent_framework_foundry import FoundryChatClient
from agent_framework_openai import OpenAIChatClient
# First-party (Foundry) client: request hook stamps the live feat token.
agent = Agent(client=FoundryChatClient(...), instructions="...")
# Agent use marks bit 0; FoundryChatClient marks bit 48
await agent.run("Hello")
# Outgoing request to Foundry carries:
# User-Agent: agent-framework-python/1.2.3 (feat=v1.<mask-at-send-time>)
# Third-party client: NO feat token is added (no first-party hook).
other = Agent(client=OpenAIChatClient(...), instructions="...")
await other.run("Hi")
# Outgoing request to OpenAI carries only:
# User-Agent: agent-framework-python/1.2.3
```
Drop only the feature mask (keep the base User-Agent):
```bash
AGENT_FRAMEWORK_FEATURE_MASK_DISABLED=true python app.py
# Foundry request User-Agent: agent-framework-python/1.2.3 (no (feat=...) comment)
```
Python only: use the existing flag to drop its entire User-Agent contribution
(mask included):
```bash
AGENT_FRAMEWORK_USER_AGENT_DISABLED=true python app.py
```
## .NET mapping
- Core owns `FeatureUsage.MarkUsed(int index)` plus the core package's private
index declaration. Each optional assembly owns a private `FeatureIndex` enum
containing only its allocated rows. These are index positions `0..127`, not
`[Flags]` values; `MarkUsed` performs the shift.
- Store the 128-bit mask as **two `long` lanes** (`low` for bits 063, `high`
for 64127). Marking touches one lane with `Interlocked.Or` where available
and a small `Interlocked.CompareExchange` loop on `netstandard2.0` / `net472`.
Read each lane atomically. Since bits only move from zero to one, a concurrent
two-lane snapshot may miss a just-added bit but can never invent or clear one;
the next request includes it.
- Format without depending on `UInt128`: if `high == 0`, emit `low` as lowercase
hex; otherwise emit `high` without leading zeros followed by `low:x16`. Cast
each signed lane to `ulong` before formatting so bits 63 and 127 are preserved.
Reject indexes outside `0..127`.
- **Emission is stamped at request time and first-party-scoped**, matching
Python. The
existing `AgentFrameworkUserAgentPolicy` / `HostedAgentUserAgentPolicy`
pipeline policies already run per request — extend them to apply the same
approved-pipeline + actual-origin classifier, append/refresh the `(feat=...)`
comment only for approved destinations, and remove it on unapproved redirect
hops. Do not register it on third-party `IChatClient`s.
- Same **wire format** (`v<version>.<hex>` comment, hex encoding) and the same
dedicated mask-only opt-out (`AGENT_FRAMEWORK_FEATURE_MASK_DISABLED`). The
**mask is decoded per language**: indexes are not shared, so a decoder must
read the language from the UA product token and select that language's table
before decoding. (.NET's policy was already request-time, so there is no
Python/.NET timing asymmetry.) Adding a .NET whole-User-Agent opt-out is
outside this design.
## Keeping the bitmap in sync
[feature-usage-bit-registry.md](feature-usage-bit-registry.md) is the published
allocation contract. Package-local `FeatureIndex` declarations are the runtime
implementation. There is deliberately **no shared numbering across languages**
and **no machine-readable registry file**.
One repository validation test gathers every package-local declaration for one
language/version and parses the matching Markdown table. It asserts:
1. every declared index is within `0..127`;
2. every `(index, id)` exactly matches one registry row;
3. the union of declarations has no duplicate/overlapping indexes;
4. every non-reserved registry row is declared exactly once.
Adding an optional-package feature therefore changes that package and the
registry, not core. If a programmatic decoder is built later, export the table
to JSON then.
### Decoding
```
UA: agent-framework-python/1.2.3 (feat=v1.2a)
│ │ └ hex mask
│ └ version
└ language → pick the Python table (version 1)
```
Read language → pick the table; read `vN` → pick that version; `AND` the hex mask
against each bit. Unknown bits (from a newer SDK than the decoder's copy of the
table) are ignored.
## Implementation plan (post-approval)
1. **Privacy approval** — confirm the first-party-only feature-combination
signal, retention, access, allowed queries, and opt-out behavior before code
ships.
2. **Core accumulator** — in `agent_framework/_telemetry.py` add the 128-bit
mask, lock, `mark_feature_used(index)`, `get_feature_token`, and
`apply_feature_token`; `get_user_agent()` stays base-only.
3. **Package-local indexes + validation** — add private `FeatureIndex`
declarations to packages and a repository test for exact registry parity,
complete coverage, range, and zero overlap.
4. **First-party request-time hooks** — use OpenAI's
`DefaultAsyncHttpxClient` for framework-created clients and the separate
azure-core `SansIOHTTPPolicy`. Require approved pipeline **and** approved
actual origin on every request/redirect hop. Verify custom origins and
cross-origin redirects never carry the token.
5. **Mark feature usage** — call `mark_feature_used(FeatureIndex.X)` at the
first meaningful activation. Operational clients/providers/tools mark on
their first real operation; build/start points mark compositional features.
Constructor-only marking requires construction itself to exercise the
capability.
6. **.NET parity** — package-local index enums plus the two atomic 64-bit lanes
with `Interlocked.Or` / compare-exchange fallback; extend existing request-time
Foundry UA policies through the shared destination classifier and formatter.
7. **Docs & tests** — update package `AGENTS.md`/skills; tests for **both**
Python opt-out paths (dedicated mask-only and existing whole-UA), the
dedicated .NET mask-only opt-out, first-party scoping, and the live
(non-frozen) UA.
## Limitations & open questions
The decision-level limitations and unresolved trade-offs — reach, per-process
(not per-call) attribution, v1 granularity, fingerprinting residue, and the OTel
question — are owned by the ADR (the dedicated mask-only opt-out is now decided
and included). See
**[ADR-0033 → Limitations](../decisions/0033-feature-usage-bitmask-user-agent.md#limitations)**
and **[Open Questions](../decisions/0033-feature-usage-bitmask-user-agent.md#open-questions-for-decider-discussion)**.
This spec is the implementation reference; it does not re-litigate those choices.
Implementation-only note:
- **Per-request hook overhead is negligible** (a flag check, one Python integer
snapshot or two atomic .NET lane reads, and a string concat per first-party
request), but benchmark the hot path once if a high-QPS Foundry scenario is in
scope.
@@ -0,0 +1,549 @@
---
status: proposed
contact: eavanvalkenburg
date: 2026-07-27
deciders: eavanvalkenburg
---
# Python function-calling loop contract and validation matrix
## Scope
This specification defines the required behavior and validation coverage for the Python function-calling loop.
It covers:
- normal local function execution;
- streaming and non-streaming response aggregation;
- tool approval request and resume;
- approved, rejected, mixed, and replayed approval rounds;
- reasoning content and opaque reasoning signatures bound to function calls;
- history persistence and service-side continuation;
- error, user-input, middleware-termination, and loop-limit paths;
- provider and transport serialization of function calls and results.
The primary implementation is in `python/packages/core/agent_framework/_tools.py`. History replay behavior in
`python/packages/core/agent_framework/_sessions.py`, provider serializers, hosting packages, and UI transports are
part of the same contract when they carry function-call loop content.
## Change sensitivity
This code is high risk. Small changes can produce duplicate side effects, orphaned calls or results, invalid
provider histories, invisible streaming results, stale approval authority, or loops that never terminate.
Dropping reasoning content that a service binds to a tool call can also make an otherwise balanced call/result
transcript invalid.
Any change to the function-calling loop or its approval/history/serialization paths must:
1. identify every affected row in the scenario matrix below;
2. add or update the corresponding regression tests;
3. validate streaming updates, streaming finalization, and non-streaming output where applicable;
4. validate both model-bound history and caller-visible responses;
5. run the full core package tests plus every affected provider or transport package;
6. run source typing, test typing, and syntax checks for every affected package;
7. receive extra review focused on call/result pairing, exactly-once execution, and history replay.
A passing narrow regression test is not sufficient evidence for changes in this area.
### Contribution ownership
Issues involving this code must not be picked up by external contributors without first checking with the Agent
Framework core team. The core team must confirm the intended behavior, affected scenario-matrix rows, ownership
across core/providers/transports, and the required validation scope before implementation starts.
## Flow diagrams and code map
### Main function-calling flow
The main control flow deliberately has separate streaming and non-streaming methods. They share policy helpers, but
their output mechanics differ: one returns an aggregated `ChatResponse`; the other yields `ChatResponseUpdate`
items and is finalized by `ResponseStream`.
The diagrams use only the generic distinction between **local tools**, which Agent Framework executes, and
**hosted-service tools**, whose calls and approval decisions are owned by a remote service. Provider-specific wire
formats and regression tests appear later in the scenario matrix.
```mermaid
flowchart TD
Entry["FunctionInvocationLayer.get_response(...)"]
Setup["Prepare middleware, options, session, budget state,<br/>and execute_function_calls partial"]
Enabled{"Function invocation enabled?"}
Direct["Delegate directly to super().get_response(...)"]
Mode{"stream?"}
NonStream["_get_response_with_function_invocation(...)"]
Stream["_stream_response_with_function_invocation(...)"]
Resolve["_resolve_approval_responses(...)<br/>runs once before the model-iteration loop"]
ApprovalAction{"approval action"}
Immediate["Return/yield terminal result or user-input request<br/>without another model call"]
ApprovalPolicy["Record approval executions;<br/>apply stop/function-call-limit policy"]
Model["Call super_get_response(...)<br/>response may contain reasoning + function_call"]
Process["_process_model_function_calls(...)"]
FunctionAction{"function-processing action"}
Execute["_execute_function_calls(...)"]
Try["_try_execute_function_calls(...)"]
Single["_execute_single_function_call(...)"]
Handle["_handle_function_call_results(...)"]
PostCallPolicy["Record executions; apply error/function-call-limit policy;<br/>reset required tool choice"]
Advance["_prepare_messages_for_next_iteration(...)"]
More{"iteration budget remains?"}
Final["Final model call with tool_choice = none<br/>and deterministic fallback if needed"]
Output["Return ChatResponse or complete ResponseStream"]
Entry --> Setup --> Enabled
Enabled -- no --> Direct
Enabled -- yes --> Mode
Mode -- no --> NonStream
Mode -- yes --> Stream
NonStream --> Resolve
Stream --> Resolve
Resolve --> ApprovalAction
ApprovalAction -- return --> Immediate --> Output
ApprovalAction -- stop --> ApprovalPolicy
ApprovalAction -- continue --> ApprovalPolicy
ApprovalPolicy --> More
Model --> Process
Process --> Execute --> Try --> Single --> Handle --> FunctionAction
FunctionAction -- return --> Output
FunctionAction -- stop --> PostCallPolicy
FunctionAction -- continue --> PostCallPolicy
PostCallPolicy --> Advance
Advance --> More
More -- yes --> Model
More -- no --> Final --> Output
```
Code-reading landmarks:
- `get_response(...)` owns setup and selects the response mode.
- `_get_response_with_function_invocation(...)` owns non-streaming aggregation.
- `_stream_response_with_function_invocation(...)` owns streamed emission/finalization.
- `_resolve_approval_responses(...)` handles only inbound approval decisions.
- `_process_model_function_calls(...)` handles only calls from a completed model response.
- `_try_execute_function_calls(...)` decides approval/declaration/execution behavior for a batch.
- `_replace_approval_contents_with_results(...)` is the occurrence-aware approval transcript normalizer.
### Approval pause and resume
```mermaid
sequenceDiagram
participant Caller
participant History as HistoryProvider
participant Layer as FunctionInvocationLayer
participant Tool
participant Model
Caller->>Layer: Initial user request
Layer->>Model: Messages + tools
Model-->>Layer: reasoning content + function_call
Layer->>Layer: Tool requires approval
Layer-->>Caller: function_call + function_approval_request
Caller->>Layer: function_approval_response
Layer->>Layer: Copy caller-owned messages
Layer->>Layer: _resolve_approval_responses(...)
alt approved
Layer->>Tool: Execute exactly once
Tool-->>Layer: result or exception
Layer->>Layer: Create terminal function_result
else rejected
Layer->>Layer: Create synthetic rejection function_result
end
Layer-->>Caller: Terminal result message/update
alt tool requests more user input
Layer-->>Caller: User-input request with assistant role
else middleware terminates
Layer-->>Caller: Termination result
else error limit reached
Layer->>Model: Normalized reasoning/call/result history, tools disabled
Model-->>Layer: Final assistant response
Layer-->>Caller: Final assistant response
else continue normally
Layer->>Model: Normalized reasoning/call/result history
Model-->>Layer: Final assistant response or another function_call
Layer-->>Caller: Final assistant response / continued loop
end
Layer-->>History: Persist caller input + returned response
Note over History: Later model replay filters approval request/response wrappers
```
The terminal result is caller-visible in both modes. The private normalized message copy is model-visible. The
original caller input and earlier response remain unchanged.
### Reasoning-bound function-call groups
Some hosted services bind reasoning content or an opaque reasoning signature to the function call that follows it.
For those services, reasoning is not optional decoration; it is part of the provider-valid function-call group.
```mermaid
flowchart TD
Response["Assistant response:<br/>reasoning content + function_call"]
Group["One logical reasoning/function-call group"]
Owner{"local or hosted-service tool?"}
Local["Local execution"]
Hosted["Hosted service owns tool execution/state"]
Result["Terminal function_result or hosted result"]
Continuation{"continuation mode"}
Stateless["Stateless or framework-history replay"]
Replayable{"reasoning payload/signature<br/>is replayable?"}
Replay["Replay reasoning + call + result atomically"]
Reject["Fail before the service call;<br/>do not send a lossy transcript"]
Service["Hosted-service continuation"]
Reference["Reference service-stored reasoning/call;<br/>send only the new result or approval decision"]
Compact{"compaction needed?"}
Atomic["Keep or exclude the complete<br/>reasoning/call/result group"]
Caller["Caller-visible response retains reasoning<br/>with the function-call turn"]
Response --> Group --> Owner
Group --> Caller
Owner -- local --> Local --> Result
Owner -- hosted service --> Hosted --> Result
Result --> Compact
Compact -- yes --> Atomic --> Continuation
Compact -- no --> Continuation
Continuation -- stateless / local history --> Stateless --> Replayable
Replayable -- yes --> Replay
Replayable -- no --> Reject
Continuation -- service-managed --> Service --> Reference
```
The generic contract is:
- reasoning content remains ordered immediately before or alongside the function call it explains;
- a terminal result does not replace or discard the reasoning/call portion of the active group;
- stateless replay includes the service-required reasoning payload or opaque signature;
- if required reasoning cannot be reconstructed, the adapter fails before sending invalid or lossy history;
- service-managed continuation may rely on the hosted service's stored reasoning/call items and send only new
outputs or approval decisions;
- compaction keeps or removes the entire reasoning/call/result group atomically.
In the code, core response aggregation preserves reasoning `Content` items, compaction annotations bind reasoning to
the tool-call group, and provider adapters serialize or reconstruct the provider-specific reasoning representation.
### Approval correlation, replay, and reused ids
`call_id` is not globally unique forever. The normalizer therefore tracks open logical occurrences in transcript
order instead of keeping one global result per id.
```mermaid
flowchart TD
Scan["Scan normalized messages in order"]
Kind{"content type"}
Call["function_call:<br/>open a call occurrence"]
Request["function_approval_request"]
Bind{"unbound call occurrence<br/>with same call_id?"}
BindExisting["Bind request id to existing occurrence<br/>and remove wrapper"]
Duplicate{"same request identity<br/>already restored?"}
DropDuplicate["Remove replayed duplicate wrapper"]
Restore["Restore embedded function_call<br/>as a new occurrence"]
Placeholder["function_result with APPROVAL_PENDING:<br/>attach placeholder to open occurrence"]
Completed["terminal function_result:<br/>close earliest open occurrence"]
Response["function_approval_response"]
Pending{"response still pending?"}
RemoveOld["Remove already-resolved historical response"]
Decision{"approved?"}
Approved["Pop next execution result for this call_id"]
Rejected["Create synthetic rejection result"]
HasPlaceholder{"occurrence has placeholder?"}
Replace["Replace placeholder and remove response wrapper"]
ReplaceResponse["Replace response wrapper with terminal content"]
Close["Close occurrence; append terminal content<br/>to resumed response"]
Next["Continue scan"]
Scan --> Kind
Kind -- function_call --> Call --> Next
Kind -- approval request --> Request --> Bind
Bind -- yes --> BindExisting --> Next
Bind -- no --> Duplicate
Duplicate -- yes --> DropDuplicate --> Next
Duplicate -- no --> Restore --> Next
Kind -- pending placeholder --> Placeholder --> Next
Kind -- terminal result --> Completed --> Next
Kind -- approval response --> Response --> Pending
Pending -- no --> RemoveOld --> Next
Pending -- yes --> Decision
Decision -- yes --> Approved --> HasPlaceholder
Decision -- no --> Rejected --> HasPlaceholder
HasPlaceholder -- yes --> Replace --> Close --> Next
HasPlaceholder -- no --> ReplaceResponse --> Close --> Next
Next --> Kind
```
This flow corresponds to `_ApprovalCallOccurrence`, `_collect_approval_responses(...)`, and
`_replace_approval_contents_with_results(...)`.
### History and service-side continuation
```mermaid
flowchart LR
Store["History backing store<br/>(may retain approval wrappers for audit)"]
Load{"HistoryProvider.load_messages?"}
Filter["_filter_approval_control_messages(...)"]
Context["SessionContext model history:<br/>function_call + terminal function_result"]
Current["Current caller input:<br/>new function_approval_response"]
Layer["FunctionInvocationLayer private copy"]
Local{"local or hosted-service approval?"}
LocalResult["Execute locally and normalize to function_result"]
Hosted["Hosted-service adapter"]
StoredRequest["Prior service-issued approval request"]
NewResponse["Current hosted approval decision"]
Skip["Do not replay the stored request inline"]
Send["Send the approval decision exactly once"]
Later["Later turn"]
Manual["Manual-history caller"]
Store --> Load
Load -- yes --> Filter --> Context --> Layer
Load -- no --> Layer
Current --> Layer
Layer --> Local
Local -- local --> LocalResult --> Later
Local -- hosted service --> Hosted
StoredRequest --> Hosted --> Skip
NewResponse --> Hosted --> Send --> Later
Later --> Store
Manual -. owns equivalent filtering .-> Layer
```
When `load_messages=False`, no history is replayed and the history filter is intentionally not invoked. Callers
that manually replay messages own the equivalent rule: do not resend an approval response after its terminal result.
## Normative contract
### Function calls and results
- Every actionable local `function_call` produces exactly one terminal `function_result`, unless execution pauses
for a new user-input request.
- Parallel calls retain model order in the returned transcript.
- Reused `call_id` values are correlated by logical occurrence, not one global value per id.
- A completed function call/result pair is inert on later turns.
- Informational-only and declaration-only calls are not executed as local tools.
### Reasoning-bound calls
- Reasoning content or opaque reasoning metadata that a service binds to a function call is part of the same logical
group as that call and its terminal result.
- Active function loops preserve the reasoning content, function call, function result, and final assistant output
in caller-visible responses.
- Framework-managed/stateless replay includes the service-required reasoning representation before the paired call.
- Service-managed continuation may omit inline reasoning/call items only when the hosted service already owns them.
- Missing non-reconstructable reasoning fails explicitly before a provider request instead of silently dropping the
content.
- Compaction preserves or excludes the complete reasoning/call/result group atomically.
### Approval request and resume
- A tool that requires approval does not execute before an approved response.
- An approved tool executes exactly once.
- A rejected tool executes zero times and produces one synthetic rejection `function_result` using the original
function `call_id`.
- The resumed response contains the newly resolved approved and rejected terminal results before any final assistant
message.
- Streaming yields the same logical result content and ordering as non-streaming output and
`ResponseStream.get_final_response()`.
- The function invocation layer normalizes a private copy of caller messages. It must not mutate the caller's
approval `Message`, approval `Content`, or an earlier returned response.
- Approval-time `UserInputRequiredException` and `MiddlewareTermination` return immediately without another model
call.
### Approval control content
- `function_approval_request` and `function_approval_response` are control-plane contents, not durable model
transcript items.
- A current hosted approval response must be sent once on the immediate resume request.
- A server-issued approval request must not be replayed inline during service-side continuation.
- History providers may retain approval control contents in their backing store for audit, but base history replay
filters them before later model calls.
- Callers that manually own and replay message history without a loading `HistoryProvider` must likewise omit a
previously submitted approval response from later continuation requests.
### History and continuation
- Model-bound history contains one function call/result pair per completed logical occurrence.
- Append-only history must not replay stale approval request/response wrappers to the model.
- Framework-managed and service-managed continuation must preserve the same logical call/result transcript.
- A terminal result consumes the corresponding approval authority in explicit stateless replay.
## Scenario-to-test matrix
### Normal function invocation
| Scenario | Required invariant | Primary regression test |
|---|---|---|
| Single non-streaming call | Call, result, and final assistant message are returned in order. | `packages/core/tests/core/test_function_invocation_logic.py::test_base_client_with_function_calling` |
| String input | Flexible string input follows the same loop behavior. | `test_base_client_with_function_calling_string_input` |
| Multiple sequential rounds | Each round retains one call/result pair. | `test_base_client_with_function_calling_resets` |
| Streaming call | Call chunks, one result update, and final text are emitted in order. | `test_base_client_with_streaming_function_calling` |
| Reasoning-bound call | Finalized output retains reasoning, function call, function result, and final text. | `test_streaming_function_calling_response_includes_reasoning_and_tool_results` |
| Calls across response messages | Every actionable call is executed once. | `test_base_client_executes_function_calls_across_multiple_response_messages` |
| Parallel calls | Results retain the corresponding call ids and execution count. | `test_max_function_calls_limits_parallel_invocations`, `test_streaming_multiple_function_calls_parallel_execution` |
| Informational-only call | The call is returned but not executed or approved. | `test_informational_only_function_call_is_not_invoked`, `test_informational_only_function_call_does_not_request_approval`, `test_streaming_informational_only_function_call_is_not_invoked` |
| Declaration-only call | The call is surfaced as user input and is not executed; streaming arguments appear once while finalized request metadata remains available. | `test_declaration_only_tool`, `test_streaming_declaration_only_tool_preserves_metadata_without_duplicate_arguments` |
| Function invocation disabled | The client bypasses the invocation loop without losing invocation kwargs. | `test_function_invocation_config_enabled_false`, `test_function_invocation_config_enabled_false_preserves_invocation_kwargs`, `test_streaming_function_invocation_config_enabled_false` |
| Runtime tool changes | Added tools become available on the next iteration and retain approval behavior. | `test_add_tools_available_next_iteration`, `test_add_tools_with_approval_required_tool` |
### Approval pause and resume
| Scenario | Required invariant | Primary regression test |
|---|---|---|
| Initial approval request | Assistant response contains the original call and approval request; tool does not execute. | `test_approval_requests_in_assistant_message`, `test_streaming_approval_request_generated`, `test_streaming_approval_requests_in_assistant_message` |
| Approved non-streaming resume | Result precedes final text; tool executes once; inputs remain unchanged. | `packages/core/tests/core/test_harness_tool_approval.py::test_approval_resume_returns_result_without_mutating_inputs[non-streaming-approved]` |
| Rejected non-streaming resume | Rejection result precedes final text; tool executes zero times; inputs remain unchanged. | `test_approval_resume_returns_result_without_mutating_inputs[non-streaming-rejected]` |
| Approved streaming resume | Result update precedes final text and final response matches non-streaming shape. | `test_approval_resume_returns_result_without_mutating_inputs[streaming-approved]`, `test_streaming_approval_resume_yields_terminal_result_before_model_text[approved]` |
| Rejected streaming resume | Rejection result update precedes final text and tool executes zero times. | `test_approval_resume_returns_result_without_mutating_inputs[streaming-rejected]`, `test_streaming_approval_resume_yields_terminal_result_before_model_text[rejected]` |
| Mixed approved/rejected batch | Every call gets one correctly correlated terminal result. | `packages/core/tests/core/test_function_invocation_logic.py::test_rejected_approval` |
| Persisted approval replay | Resume executes with the prior call available. | `test_persisted_approval_messages_replay_correctly` |
| Hosted approval pass-through | Hosted requests/responses are not processed as local calls. | `test_hosted_tool_approval_response`, `test_hosted_mcp_approval_response_passthrough`, `test_mixed_local_and_hosted_approval_flow` |
| Approval-time user input | Every user-input request from one approved execution returns in order with assistant role and no extra model call; the execution consumes one call-budget unit. | `packages/core/tests/core/test_harness_tool_approval.py::test_approval_resume_returns_all_user_input_requests_without_another_model_call`, `packages/core/tests/core/test_function_invocation_logic.py::test_approval_resume_user_input_counts_toward_function_call_budget` |
| Mixed terminal result and follow-up input | Completed siblings remain tool-role while only follow-up input requests use assistant-role messages/updates. | `packages/core/tests/core/test_function_invocation_logic.py::test_approval_resume_separates_terminal_results_from_follow_up_requests`, `packages/openai/tests/openai/test_openai_chat_completion_client.py::test_mixed_approval_resume_roles_serialize_function_result_as_tool` |
| Approval-time middleware termination | Terminal result returns with no extra model call in either response mode. | `packages/core/tests/core/test_function_invocation_logic.py::test_approval_resume_honors_middleware_termination` |
| Approval re-entry after iteration budget | Pending approved calls resolve once even when prior model calls consumed `max_iterations`. | `packages/core/tests/core/test_harness_tool_approval.py::test_auto_approval_resolves_after_iteration_budget_is_exhausted` |
| Approval resume with reasoning | Model-bound resume history retains reasoning before the call and terminal result in both modes. | `packages/core/tests/core/test_harness_tool_approval.py::test_approval_resume_replays_reasoning_with_function_call_group` |
### Approval correlation and replay
| Scenario | Required invariant | Primary regression test |
|---|---|---|
| Result matching without placeholders | Results match calls by id even when the result list is reordered. | `test_replace_approval_contents_with_results_uses_result_call_ids_without_placeholders` |
| Reused id after completion | A later round with the same id creates a second valid pair. | `test_replace_approval_contents_with_results_allows_reused_call_id_after_completion` |
| Replayed approval wrapper | A duplicated wrapper does not restore another function call. | `test_replace_approval_contents_with_results_deduplicates_replayed_approval_request` |
| Historical resolved response plus new round | The old response is removed from normalized input and is not converted into a rejection result. | `test_replace_approval_contents_with_results_ignores_already_resolved_response` |
| Multiple reused-id rounds | Approved and rejected rounds retain separate call/result occurrences. | `test_replace_approval_contents_with_results_correlates_reused_call_id_occurrences` |
| Multi-content result with reused id | Every content produced by one execution stays with that approval occurrence and cannot bleed into the next reused-id round. | `test_replace_approval_contents_with_results_keeps_multi_content_group_with_reused_call_id` |
| Follow-up request closes one occurrence | A user-input follow-up consumes only the preceding approval authority and leaves a later reused-id response pending. | `test_collect_approval_responses_consumes_matching_follow_up_request_occurrence` |
| Reused-id placeholders | Placeholder results consume approved results by occurrence. | `test_replace_approval_contents_with_results_correlates_reused_call_id_placeholders` |
| Rejected placeholder | Rejection replaces the pending placeholder instead of adding a second result. | `test_replace_approval_contents_with_results_replaces_rejected_placeholder` |
| Results reordered with placeholders | Results still match the correct call ids. | `test_replace_approval_contents_with_results_uses_result_call_ids_for_placeholders` |
| Missing result call id | A malformed result does not steal another approval's result. | `test_replace_approval_contents_with_results_skips_results_without_call_id` |
| Empty approval message cleanup | Fully consumed approval messages are removed from normalized model input. | `test_replace_approval_contents_with_results_prunes_emptied_messages` |
| Later stateless turn | A prior terminal approval response cannot execute again. | `test_resolved_approval_response_is_inert_on_later_stateless_turn` |
| Pending history turn | An unresolved approval batch is omitted atomically from unrelated model input while a later decision can still resume it once. | `packages/core/tests/core/test_harness_tool_approval.py::test_pending_approval_from_file_history_stays_resumable_without_model_orphan` |
| Duplicate function-call prevention | Approval normalization does not create a second call for one round. | `test_no_duplicate_function_calls_after_approval_processing` |
| Rejection call id | Rejection result uses the function call id, not only the approval id. | `test_rejection_result_uses_function_call_id` |
### Mixed batches and approval middleware
| Scenario | Required invariant | Primary regression test |
|---|---|---|
| Safe and approval-required calls in one batch | Hidden safe calls replay only with the matching visible approval. | `packages/core/tests/core/test_harness_tool_approval.py::test_mixed_batch_hides_already_approved_request_until_approval_replay` |
| Restored approval state | Serialized `ToolApprovalState` restores mixed-batch behavior. | `test_mixed_batch_accepts_restored_tool_approval_state` |
| Unrelated turn before approval | Hidden calls do not execute on an unrelated turn. | `test_hidden_mixed_batch_requests_do_not_replay_on_unrelated_turn` |
| Multiple abandoned batches | Hidden calls replay only for the matching batch. | `test_hidden_mixed_batch_requests_replay_only_for_matching_visible_approval` |
| Queued approvals | One unresolved approval is surfaced per run without premature execution. | `test_tool_approval_middleware_queues_multiple_approval_requests`, `test_tool_approval_middleware_queues_streamed_approval_requests` |
| Middleware state plus hidden core state | State saves do not discard hidden mixed-batch calls. | `test_tool_approval_middleware_preserves_hidden_mixed_batch_requests` |
| Auto-approval callback | Callback receives the original function call and executes the approved set once. | `test_tool_approval_middleware_auto_approval_rule_receives_function_call` |
| Shared call budget | Auto-approved re-entry does not reset `max_function_calls`, and every executed approval group counts even when it pauses for input. | `test_tool_approval_middleware_auto_approved_loops_share_function_call_budget`, `test_approval_resume_user_input_counts_toward_function_call_budget` |
| Standing tool rule | Tool-level approval applies only to later matching tools. | `test_tool_approval_middleware_always_approve_tool_rule` |
| Hosted server boundary | Standing approval does not cross `server_label`. | `test_tool_approval_middleware_standing_rules_include_hosted_server_boundary` |
| Argument-scoped rule | Exact arguments are required; empty arguments are not tool-wide. | `test_tool_approval_middleware_always_approve_tool_with_arguments_rule`, `test_tool_approval_middleware_empty_arguments_rule_is_not_tool_wide` |
| Provider-injected approval tool | A tool added during `before_run` defers to in-run resolution, executes once, and emits one result. | `packages/ag-ui/tests/ag_ui/test_endpoint.py::test_endpoint_agent_approval_deferred_provider_tool_executes` |
### Errors, control flow, and limits
| Scenario | Required invariant | Primary regression test |
|---|---|---|
| Rejected execution | Rejection is a normal terminal result, not an exception to the caller. | `test_unapproved_tool_execution_raises_exception` |
| Approved tool exception | Generic and detailed error modes preserve one result and one execution. | `test_approved_function_call_with_error_without_detailed_errors`, `test_approved_function_call_with_error_with_detailed_errors` |
| Approved validation error | Validation failure returns one result without invoking the function body. | `test_approved_function_call_with_validation_error` |
| Approved success | Successful approved execution returns one result. | `test_approved_function_call_successful_execution` |
| Consecutive error cap | Error threshold stops repeated failures, submits collected results, and makes only the required final no-tool model call. | `test_function_invocation_config_max_consecutive_errors`, `test_streaming_function_invocation_config_max_consecutive_errors`, `test_approval_resume_error_limit_forces_final_no_tool_response` |
| Unknown call handling | Configured false returns an error result; configured true raises. | `test_function_invocation_config_terminate_on_unknown_calls_false`, `test_function_invocation_config_terminate_on_unknown_calls_true`, streaming equivalents |
| Middleware termination | Normal non-approval loop stops without a second model call. | `test_terminate_loop_single_function_call`, `test_terminate_loop_multiple_function_calls_one_terminates`, `test_terminate_loop_streaming_single_function_call` |
| Maximum iterations | No orphan calls; a final no-tool response or deterministic fallback is returned. | `test_max_iterations_limit`, `test_max_iterations_no_orphaned_function_calls`, `test_max_iterations_makes_final_toolchoice_none_call`, `test_max_iterations_blank_final_fallback_synthesizes_message`, streaming equivalents |
| Maximum function calls | Parallel overshoot is bounded after the batch; every executed result group counts even without a `function_result`; blank final responses get fallback content. | `test_max_function_calls_limits_parallel_invocations`, `test_max_function_calls_single_calls_per_iteration`, `test_user_input_request_multiple_contents_propagate`, `test_approval_resume_user_input_counts_toward_function_call_budget`, `test_max_function_calls_blank_final_fallback_synthesizes_message`, streaming equivalent |
| Provider tool content after an active limit | Locally actionable calls and local approval requests returned despite `tool_choice="none"` are removed in both response modes. Provider-executed informational call/result pairs, hosted approval requests, and metadata-only streaming updates remain visible; fallback text never replaces retained transcript content. | `test_function_invocation_limit_drops_unexecutable_tool_content`, `test_streaming_function_invocation_limit_drops_unexecutable_tool_content`, `test_streaming_function_invocation_limit_preserves_metadata_after_tool_content_is_dropped`, `test_function_invocation_limit_preserves_provider_executed_tool_pair`, `test_streaming_function_invocation_limit_preserves_provider_executed_tool_pair`, `test_function_invocation_limit_appends_fallback_after_provider_executed_tool_pair`, `test_streaming_function_invocation_limit_appends_fallback_after_provider_executed_tool_pair`, `test_function_invocation_limit_preserves_hosted_approval_request`, `test_streaming_function_invocation_limit_preserves_hosted_approval_request` |
| Conversation continuation | Conversation id updates between iterations and is cleared on stop where required. | `test_conversation_id_updated_in_options_between_tool_iterations`, `test_function_invocation_stop_clears_conversation_id_non_stream`, `test_streaming_function_invocation_stop_clears_conversation_id` |
### History and provider serialization
| Scenario | Required invariant | Primary regression test |
|---|---|---|
| Append-only history replay | Resolved approval wrappers do not reach a later model call; one call/result pair remains. | `packages/core/tests/core/test_harness_tool_approval.py::test_approval_resume_filters_resolved_control_items_from_file_history` |
| Pending placeholder history | An approval response remains replayable while its only result is `[APPROVAL_PENDING]`. | `packages/core/tests/core/test_sessions.py::test_filter_approval_controls_keeps_response_for_pending_placeholder` |
| Pending hosted history replay | Stateless hosted approval requests remain replayable until a response is recorded, then both controls become inert. | `packages/openai/tests/openai/test_openai_chat_client.py::test_stateless_history_preserves_pending_hosted_approval_request_until_response` |
| Non-history provider plus session | Local history is still auto-injected for approval resume. | `packages/core/tests/core/test_agents.py::test_non_history_context_provider_still_injects_inmemory` |
| Hosted per-service-call persistence | A host-managed transcript remains available throughout a local function-call loop without being persisted into the framework session and replayed on the next hosted request. | `packages/foundry_hosting/tests/test_responses.py::TestAgentSessionPersistence::test_per_service_call_persistence_preserves_function_loop_history` |
| Service-side approval decision | Stored request is skipped; current approved or rejected response is sent. | `packages/openai/tests/openai/test_openai_chat_client.py::test_prepare_messages_strips_approval_request_but_keeps_response_under_storage` |
| OpenAI approval serialization | Approval id and decision serialize to `mcp_approval_response`. | `test_prepare_message_for_openai_with_function_approval_response`, `test_prepare_content_for_opentool_approval_response`, `test_function_approval_response_with_mcp_tool_call` |
| OpenAI end-to-end hosted approval | Hosted request parses, response sends, and continuation completes. | `test_end_to_end_mcp_approval_flow` |
| Stored function call/result | Service-side storage drops server-issued calls but keeps new outputs. | `test_prepare_options_with_conversation_id_strips_server_issued_items`, `test_prepare_messages_for_openai_full_conversation_with_reasoning` |
| Stateless reasoning replay | Replay reconstructs reasoning, call, and result together; missing required reasoning fails before the request. | `test_tool_loop_store_false_replays_encrypted_reasoning_group`, `test_stateless_request_rejects_non_replayable_reasoning_bound_mcp_output`, `test_prepare_messages_for_openai_full_conversation_with_reasoning` |
| Opaque reasoning signature replay | Provider-specific opaque reasoning metadata is captured and restored on reconstructed calls. | `packages/gemini/tests/test_gemini_client.py::test_function_call_part_captures_thought_signature_as_reasoning_content`, `test_reconstructed_function_call_replays_thought_signature_from_reasoning_content` |
| Chat Completions approval wrappers | Framework approval wrappers are not sent as chat messages. | `packages/openai/tests/openai/test_openai_chat_completion_client.py` approval serialization tests |
| AG-UI approval result event | Approved result emits once with content and persists in snapshot. | `packages/ag-ui/tests/ag_ui/test_approval_result_event.py::test_approval_resume_emits_tool_call_result`, `test_approval_resume_result_has_content`, `test_approval_resume_snapshot_replaces_approval_payload_with_tool_result`, `test_approval_resume_zero_updates_emits_tool_result` |
| AG-UI rejection/mixed decision | Transport emits only the events defined for approved and rejected calls without duplicates. | `test_rejection_does_not_emit_tool_call_result`, `test_mixed_approve_reject_emits_only_approved_tool_result`, `test_resolve_approval_responses_returns_only_approved` |
| AG-UI approval-time follow-up | The full grouped user-input pause remains in message history and emits no synthetic `TOOL_CALL_RESULT`. | `test_resolve_approval_responses_preserves_follow_up_user_input_group` |
| AG-UI approval execution failure | A grouped executor failure becomes one deterministic terminal error result for the approved call. | `test_resolve_approval_responses_returns_failure_when_grouped_execution_raises` |
| AG-UI no-approval path | Ordinary tool results do not gain an extra approval result event. | `test_no_approval_no_extra_tool_result` |
| AG-UI `confirm_changes` snapshot | An accepted synthetic confirmation is replaced only when its original function call has a real result; rejection is cleaned explicitly, and missing accepted results remain inert. | `packages/ag-ui/tests/ag_ui/test_confirm_changes_snapshot.py` |
| AG-UI malformed `confirm_changes` metadata | Non-list tool-call metadata and malformed argument JSON are ignored without guessing a target call. | `test_confirm_changes_target_ignores_non_list_tool_calls`, `test_confirm_changes_target_rejects_malformed_arguments_json` |
| Compaction pair integrity | Adjacent and non-adjacent pairs, including assistant-embedded results and completed reused-id occurrences, remain atomic without pairing ambiguous or out-of-order ids. | `packages/core/tests/core/test_compaction.py::test_group_annotations_keep_tool_call_and_tool_result_atomic`, `test_group_annotations_include_reasoning_in_tool_call_group`, `test_group_annotations_pair_nonadjacent_function_result_by_call_id`, `test_group_annotations_pair_multiple_nonadjacent_results_with_declaration`, `test_group_annotations_pair_completed_reused_call_id_occurrences`, `test_group_annotations_close_assistant_embedded_result_before_reused_call_id`, `test_sliding_window_does_not_retain_orphan_result_after_assistant_embedded_result`, `test_sliding_window_keeps_reused_call_id_occurrences_atomic`, `test_group_annotations_do_not_pair_ambiguous_duplicate_call_ids` |
## Required coverage gaps
These scenarios are required but are not fully covered by merged tests on `main`:
| Gap | Tracking |
|---|---|
| Service-owned `previous_response_id` continuation cannot execute a terminal approval again on a later turn. | #6851 |
Do not mark these rows covered by nearby tests; each needs a dedicated regression at the owning layer.
## Minimum validation commands
Run from `python/` for any core function-loop change:
```bash
uv run poe test -P core
uv run poe syntax -P core
uv run poe pyright -P core
uv run poe test-typing -P core
```
Also run every affected package. Common approval-loop changes require:
```bash
uv run poe test -P openai
uv run poe syntax -P openai
uv run poe pyright -P openai
uv run poe test-typing -P openai
uv run poe test -P ag-ui
uv run --directory packages/foundry_hosting poe test
```
Run focused regression files first while iterating, but do not substitute them for the full package commands above.
## Review checklist
Before accepting an update, reviewers must confirm:
- the changed behavior is represented in this specification;
- the matrix names a regression test for every affected scenario;
- approved tools cannot execute twice;
- rejected tools cannot execute;
- no call or result becomes orphaned or duplicated;
- call/result matching does not assume `call_id` is globally unique forever;
- reasoning content or opaque signatures remain in the same logical group as the paired call/result, or replay fails
explicitly before sending a lossy provider request;
- caller messages and previous responses remain immutable;
- streaming updates and final response agree with non-streaming output;
- history replay does not reintroduce approval authority;
- full package, syntax, source typing, and test typing checks were run.
## Related issues
- #7241 — approval-resolution result streaming
- #7267 / #7271 and #7304 — replayed calls and reused ids
- #7043 — provider-injected approval execution
- #6828 — AG-UI `confirm_changes` snapshot correlation
- #7212 — non-adjacent and reused-id compaction integrity
- #7125 — service-side approval response serialization
- #7045 — post-limit tool-content transcript integrity
- #6973 — declaration-only streaming metadata and argument integrity
- #6851 — duplicate side effects after approval continuation
- #7383 — bind approval responses to framework-issued requests after this foundation merges
- #6963 / #7095 — opaque reasoning-signature replay
- #6074 / #7233 — reasoning-paired tool-call replay
- #6450 / #6794 — provider message and tool-result serialization
+283
View File
@@ -0,0 +1,283 @@
# Feature-usage bit registry (per-language)
> **Status:** draft, accompanies [ADR-0033](../decisions/0033-feature-usage-bitmask-user-agent.md)
> and [SPEC-004](004-feature-usage-telemetry.md).
> **Version:** `1` per language · **Width:** 128-bit
This document is the proposed human-readable registry for the feature-usage
mask. Until ADR-0033 is accepted and the index declarations ship, these tables
are a **candidate mapping**, not a stable wire contract. The table is the
allocation authority and published decoder contract; package-local private
`FeatureIndex` declarations implement the rows they own. There is no generated
artifact.
This telemetry is intentionally **transparent**: this registry is public, the
emitted value is human-decodable, and a dedicated env var disables the mask
without removing the base User-Agent. Python's existing whole-User-Agent opt-out
also suppresses its mask; see [Opt-out](#opt-out).
## What is collected
A single 128-bit integer (the *feature mask*) describing **which Agent Framework
features were exercised** in a process — not which packages are installed. The
candidate below uses package-level bits plus selected major capabilities: core
agent/workflow/MCP features, stable skill source types, each orchestration
pattern, each individual built-in context/history provider, and distinct Foundry
surfaces. ADR-0033 still leaves the final v1 granularity open. A feature sets its
index at first meaningful activation; the SDK shifts that index, ORs the mask,
and emits the value.
No identifiers, arguments, prompts, payloads, or user data are encoded — only the
coarse Boolean \"this feature was observed at least once in this process\" per
registered bit. A repeated bit on later requests is the same observation, not
another use and not a count.
## Allocation tenet
**An index represents a stable, framework-owned capability whose adoption answers a
concrete product or support question.** It has a clear actual-use mark point in a
public entry path, and the privacy review covers the resulting distinction.
Keep imports, installation state, aliases, wrappers, internal helpers, and
implementation decorators such as caching/filtering/deduplication within their
own capability bit. Customer/runtime values — names, prompts, arguments, URLs,
identifiers, configuration choices — never become bits. A proposed distinction
without a concrete query and named decision owner waits.
Operational clients, tools, providers, and hosts mark on their first real public
operation/participation. Constructor marking is reserved for cases where
construction itself activates or registers the capability; DI instantiation
alone is not usage.
Ids use the package/integration name for a package-level signal and add a
capability suffix only when the row tracks a narrower surface. They describe the
registered feature, not an inheritance hierarchy: for example, Python
`hosting` is the base `agent-framework-hosting` package, while `hosting.a2a` is
the separate hosting-A2A integration.
## Per-language, not shared
The two tables below are **independent**. Feature indexes are **not** shared across
languages — Python bit 13 and .NET bit 13 do not mean the same thing. This is
deliberate: the User-Agent product token already names the language
(`agent-framework-python` vs `agent-framework-dotnet`), so a decoder selects the
right table from the UA and decodes against it. Each SDK numbers and evolves its
features independently — no cross-language synchronization, no null placeholders,
no \"same bit, same meaning\" rule.
## Encoding
- **Width:** 128-bit unsigned integer per language.
- **Versioning:** the emission carries the version so a decoder knows the bit
mapping in effect (version is per language).
- **User-Agent:** the mask is an RFC 7231 **comment** (metadata, not a product
token), placed after the agent-framework product token:
```text
agent-framework-python/1.2.3 (feat=v1.<hex_mask>)
```
where `<hex_mask>` is lowercase hex, no leading zeros, no `0x` prefix. Example
for bits 0, 1, 5 set (`0b100011 = 0x23`):
```text
agent-framework-python/1.2.3 (feat=v1.23)
```
- **Decoding:** read the **language** from the product token, pick that table;
read `vN`, pick that version; test `mask & (1 << index)` for each row. Unknown indexes
(newer SDK than the decoder's copy) are ignored.
## Emission scope (where the mask is sent)
- **Marking is universal:** every feature sets its index at first meaningful
activation, regardless of provider.
- **User-Agent `(feat=...)` comment — approved first-party clients only,
stamped at request time.** Added only when both the **Azure / Foundry**
client/pipeline family and the actual HTTPS origin are approved, re-evaluated
on every request and redirect hop. Custom origins are default-deny and an
unapproved redirect removes the token. It is
**never** sent to third-party providers — a feature fingerprint must not leak
into logs we cannot read. See [SPEC-004](004-feature-usage-telemetry.md#emission).
- **OpenTelemetry: not in v1.** Deferred primarily for privacy (a span attribute
would broadcast the fingerprint into the user's general telemetry / third-party
APM vendors). Left open behind the version prefix; see
[ADR-0033](../decisions/0033-feature-usage-bitmask-user-agent.md#considered-options).
## Index table — Python (`agent-framework-python`, version 1)
Layout: core features 031, orchestration patterns 3247, and
provider/integration packages from 48.
The provider/integration block is intentionally **not** partitioned by vendor
ownership. Some packages span first- and third-party services, ownership can
change, and protocols/storage integrations do not fit a stable first/third-party
taxonomy. Index ranges are allocation space, not privacy or emission policy;
the explicit destination allowlist independently ensures that the mask is sent
only to approved first-party endpoints.
| Index | Id | Feature | Activated at (representative) |
| --- | --- | --- | --- |
| 0 | `core.agent` | Agent | `agent_framework.Agent` |
| 1 | `core.harness_agent` | Harness agent | `agent_framework.create_harness_agent` |
| 2 | `core.workflow` | Workflow engine (custom graphs) | `agent_framework.WorkflowBuilder` |
| 3 | `core.mcp` | MCP tool (any transport) | `agent_framework.MCPStdioTool` |
| 4 | `core.tool_approval` | Tool-approval harness | `agent_framework.ToolApprovalMiddleware` |
| 5 | `core.memory_provider` | Memory context provider | `agent_framework.MemoryContextProvider` |
| 6 | `core.skills_provider` | Skills provider | `agent_framework.SkillsProvider` |
| 7 | `core.file_access_provider` | File-access provider | `agent_framework.FileAccessProvider` |
| 8 | `core.compaction_provider` | Context compaction provider | `agent_framework.CompactionProvider` |
| 9 | `core.todo_provider` | Todo provider | `agent_framework.TodoProvider` |
| 10 | `core.agent_mode_provider` | Agent-mode provider | `agent_framework.AgentModeProvider` |
| 11 | `core.background_agents_provider` | Background-agents provider | `agent_framework.BackgroundAgentsProvider` |
| 12 | `core.in_memory_history_provider` | In-memory history provider | `agent_framework.InMemoryHistoryProvider` |
| 13 | `core.file_history_provider` | File history provider | `agent_framework.FileHistoryProvider` |
| 14 | `core.file_skills_source` | File-backed skills | `agent_framework.FileSkillsSource` |
| 15 | `core.in_memory_skills_source` | In-memory / programmatic skills | `agent_framework.InMemorySkillsSource` |
| 16 | `core.mcp_skills_source` | MCP-backed skills | `agent_framework.MCPSkillsSource` |
| 17 | `core.session_store` | Agent session store | `agent_framework.SessionStore` / `FileSessionStore` |
| 1831 | _reserved_ | core growth | — |
| 32 | `orchestration.sequential` | Sequential orchestration | `agent_framework_orchestrations.SequentialBuilder` |
| 33 | `orchestration.concurrent` | Concurrent orchestration | `agent_framework_orchestrations.ConcurrentBuilder` |
| 34 | `orchestration.group_chat` | Group-chat orchestration | `agent_framework_orchestrations.GroupChatBuilder` |
| 35 | `orchestration.magentic` | Magentic orchestration | `agent_framework_orchestrations.MagenticBuilder` |
| 36 | `orchestration.handoff` | Handoff orchestration | `agent_framework_orchestrations.HandoffBuilder` |
| 3747 | _reserved_ | orchestration growth | — |
| 48 | `foundry.chat_client` | Foundry chat client | `agent_framework_foundry.RawFoundryChatClient` |
| 49 | `foundry.agent` | Foundry agent | `agent_framework_foundry.FoundryAgent` |
| 50 | `foundry.memory` | Foundry memory provider | `agent_framework_foundry.FoundryMemoryProvider` |
| 51 | `foundry.embedding` | Foundry embedding client | `agent_framework_foundry.RawFoundryEmbeddingClient` |
| 52 | `foundry.evals` | Foundry evaluations | `agent_framework_foundry.FoundryEvals` |
| 53 | `foundry.toolbox` | Foundry Toolbox MCP tool | `agent_framework_foundry_hosting.FoundryToolbox` |
| 54 | `foundry_local` | Foundry Local client | `agent_framework_foundry_local.FoundryLocalClient` |
| 55 | `foundry_hosting` | Foundry hosting layer | `agent_framework_foundry_hosting.ResponsesHostServer` / `InvocationsHostServer` |
| 56 | `openai` | OpenAI clients | `agent_framework_openai` |
| 57 | `anthropic` | Anthropic clients | `agent_framework_anthropic` |
| 58 | `bedrock` | AWS Bedrock clients | `agent_framework_bedrock` |
| 59 | `gemini` | Gemini chat client | `agent_framework_gemini` |
| 60 | `mistral` | Mistral embedding client | `agent_framework_mistral` |
| 61 | `ollama` | Ollama clients | `agent_framework_ollama` |
| 62 | `claude` | Claude Agent SDK agent | `agent_framework_claude` |
| 63 | `copilotstudio` | Copilot Studio agent | `agent_framework_copilotstudio` |
| 64 | `github_copilot` | GitHub Copilot agent | `agent_framework_github_copilot` |
| 65 | `azure_ai_search` | Azure AI Search context provider | `agent_framework_azure_ai_search` |
| 66 | `azure_cosmos` | Azure Cosmos history / checkpoint store | `agent_framework_azure_cosmos` |
| 67 | `azure_contentunderstanding` | Azure Content Understanding context provider | `agent_framework_azure_contentunderstanding.ContentUnderstandingContextProvider` |
| 68 | `redis` | Redis context / history provider | `agent_framework_redis` |
| 69 | `mem0` | Mem0 memory provider | `agent_framework_mem0.Mem0ContextProvider` |
| 70 | `purview` | Purview client | `agent_framework_purview.PurviewClient` |
| 71 | `a2a` | A2A agent / executor | `agent_framework_a2a.A2AAgent` / `A2AExecutor` |
| 72 | `ag_ui` | AG-UI chat client / agent | `agent_framework_ag_ui` |
| 73 | `chatkit` | ChatKit integration | `agent_framework_chatkit` |
| 74 | `devui` | DevUI served | `agent_framework_devui.serve` |
| 75 | `declarative.agent` | Declarative agent definitions | `agent_framework_declarative.AgentFactory` |
| 76 | `declarative.workflow` | Declarative workflow definitions | `agent_framework_declarative.WorkflowFactory` |
| 77 | `durabletask` | Durable task runtime | `agent_framework_durabletask` |
| 78 | `azurefunctions` | Azure Functions agent host | `agent_framework_azurefunctions` |
| 79 | `tools.shell` | Shell tools | `agent_framework_tools.shell.LocalShellTool` / `DockerShellTool` |
| 80 | `monty` | Monty CodeAct provider | `agent_framework_monty.MontyCodeActProvider` |
| 81 | `hyperlight` | Hyperlight CodeAct provider | `agent_framework_hyperlight.HyperlightCodeActProvider` |
| 82 | `azure_cosmos_memory` | Azure Cosmos DB semantic-memory provider | `agent_framework_azure_cosmos_memory.CosmosMemoryContextProvider` |
| 83 | `hosting` | App-owned agent/workflow hosting state | `agent_framework_hosting.AgentState` / `WorkflowState` |
| 84 | `hosting.a2a` | A2A hosting converters | `agent_framework_hosting_a2a.a2a_to_run` / `a2a_from_run` |
| 85 | `hosting.mcp` | MCP hosting adapters | `agent_framework_hosting_mcp.AgentMCPTool` / `WorkflowMCPTool` |
| 86 | `hosting.responses` | OpenAI Responses hosting converters | `agent_framework_hosting_responses.responses_to_run` |
| 87 | `hosting.telegram` | Telegram hosting converters | `agent_framework_hosting_telegram.telegram_to_run` |
| 88 | `lab` | Experimental Agent Framework Lab features | `agent_framework.lab` feature entry points |
| 89127 | _reserved_ | future packages | — |
## Index table — .NET (`agent-framework-dotnet`, version 1)
| Index | Id | Feature | Activated at (representative) |
| --- | --- | --- | --- |
| 0 | `core.agent` | Agent | `Microsoft.Agents.AI.ChatClientAgent` |
| 1 | `core.harness_agent` | Harness agent | `Microsoft.Agents.AI.HarnessAgent` |
| 2 | `core.workflow` | Workflow engine (custom graphs) | `Microsoft.Agents.AI.Workflows.WorkflowBuilder` |
| 3 | `core.tool_approval` | Tool-approval agent | `Microsoft.Agents.AI.ToolApprovalAgent` |
| 4 | `core.chat_history_memory_provider` | Chat-history memory provider | `Microsoft.Agents.AI.ChatHistoryMemoryProvider` |
| 5 | `core.file_memory_provider` | File memory provider | `Microsoft.Agents.AI.FileMemoryProvider` |
| 6 | `core.text_search_provider` | Text-search provider | `Microsoft.Agents.AI.TextSearchProvider` |
| 7 | `core.file_access_provider` | File-access provider | `Microsoft.Agents.AI.FileAccessProvider` |
| 8 | `core.skills_provider` | Skills provider | `Microsoft.Agents.AI.AgentSkillsProviderBuilder` |
| 9 | `core.compaction_provider` | Context compaction provider | `Microsoft.Agents.AI.Compaction.CompactionProvider` |
| 10 | `core.todo_provider` | Todo provider | `Microsoft.Agents.AI.TodoProvider` |
| 11 | `core.agent_mode_provider` | Agent-mode provider | `Microsoft.Agents.AI.AgentModeProvider` |
| 12 | `core.background_agents_provider` | Background-agents provider | `Microsoft.Agents.AI.BackgroundAgentsProvider` |
| 13 | `core.in_memory_history_provider` | In-memory history provider | `Microsoft.Agents.AI.InMemoryChatHistoryProvider` |
| 14 | `core.mcp` | MCP tasks / skills integration | `Microsoft.Agents.AI.Mcp.McpClientTaskExtensions` |
| 15 | `core.file_skills_source` | File-backed skills | `Microsoft.Agents.AI.AgentFileSkillsSource` |
| 16 | `core.in_memory_skills_source` | In-memory skills | `Microsoft.Agents.AI.AgentInMemorySkillsSource` |
| 17 | `core.inline_skill` | Inline programmatic skill | `Microsoft.Agents.AI.AgentInlineSkill` |
| 18 | `core.class_skill` | Class-based programmatic skill | `Microsoft.Agents.AI.AgentClassSkill` |
| 19 | `core.mcp_skills_source` | MCP-backed skills | `Microsoft.Agents.AI.AgentSkillsProviderBuilderMcpExtensions.UseMcpSkills` |
| 2031 | _reserved_ | core growth | — |
| 32 | `orchestration.sequential` | Sequential orchestration | `Microsoft.Agents.AI.Workflows.SequentialWorkflowBuilder` |
| 33 | `orchestration.concurrent` | Concurrent orchestration | `Microsoft.Agents.AI.Workflows.ConcurrentWorkflowBuilder` |
| 34 | `orchestration.group_chat` | Group-chat orchestration | `Microsoft.Agents.AI.Workflows.GroupChatWorkflowBuilder` |
| 35 | `orchestration.magentic` | Magentic orchestration | `Microsoft.Agents.AI.Workflows.MagenticWorkflowBuilder` |
| 36 | `orchestration.handoff` | Handoff orchestration | `Microsoft.Agents.AI.Workflows.HandoffWorkflowBuilder` |
| 3747 | _reserved_ | orchestration growth | — |
| 48 | `foundry.chat_client` | Foundry chat client | `Microsoft.Agents.AI.Foundry.FoundryChatClient` |
| 49 | `foundry.agent` | Foundry agent | `Microsoft.Agents.AI.Foundry.FoundryAgent` |
| 50 | `foundry.memory` | Foundry memory provider | `Microsoft.Agents.AI.Foundry.FoundryMemoryProvider` |
| 51 | `foundry.evals` | Foundry evaluations | `Microsoft.Agents.AI.Foundry.FoundryEvals` |
| 52 | `foundry.toolbox` | Foundry Toolbox MCP tool | `Microsoft.Agents.AI.Foundry.HostedMcpToolboxAITool` |
| 53 | `foundry_hosting` | Foundry hosting layer | `Microsoft.Agents.AI.Foundry.Hosting.FoundryHostingExtensions.AddFoundryResponses` |
| 54 | `openai` | OpenAI integration | `Microsoft.Agents.AI.OpenAI` |
| 55 | `anthropic` | Anthropic integration | `Microsoft.Agents.AI.Anthropic` |
| 56 | `copilotstudio` | Copilot Studio agent | `Microsoft.Agents.AI.CopilotStudio.CopilotStudioAgent` |
| 57 | `github_copilot` | GitHub Copilot agent | `Microsoft.Agents.AI.GitHub.Copilot.GitHubCopilotAgent` |
| 58 | `azure_cosmos` | Cosmos history / checkpoint store | `Microsoft.Agents.AI.CosmosChatHistoryProvider` |
| 59 | `valkey` | Valkey chat-history provider | `Microsoft.Agents.AI.Valkey.ValkeyChatHistoryProvider` |
| 60 | `mem0` | Mem0 memory provider | `Microsoft.Agents.AI.Mem0.Mem0Provider` |
| 61 | `purview` | Purview integration | `Microsoft.Agents.AI.Purview` |
| 62 | `a2a` | A2A agent | `Microsoft.Agents.AI.A2A.A2AAgent` |
| 63 | `hosting.ag_ui` | AG-UI hosting endpoint | `Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.AGUIEndpointRouteBuilderExtensions.MapAGUIServer` |
| 64 | `devui` | DevUI served | `Microsoft.Agents.AI.DevUI` |
| 65 | `declarative.agent` | Declarative agent definitions | `Microsoft.Agents.AI.PromptAgentFactory.CreateAsync` |
| 66 | `declarative.workflow` | Declarative workflow definitions | `Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowBuilder.Build` |
| 67 | `durabletask` | Durable task runtime | `Microsoft.Agents.AI.DurableTask` |
| 68 | `azurefunctions` | Azure Functions agent host | `Microsoft.Agents.AI.Hosting.AzureFunctions` |
| 69 | `tools.shell` | Shell tools | `Microsoft.Agents.AI.Tools.Shell.ShellExecutor` |
| 70 | `hyperlight` | Hyperlight CodeAct provider | `Microsoft.Agents.AI.Hyperlight.HyperlightCodeActProvider` |
| 71 | `hosting.agent` | Hosted AF agent wrapper | `Microsoft.Agents.AI.Hosting.AIHostAgent` |
| 72 | `local_codeact` | Local Python CodeAct provider | `Microsoft.Agents.AI.LocalCodeAct.LocalCodeActProvider` |
| 73 | `hosting.a2a` | A2A hosting endpoints | `Microsoft.AspNetCore.Builder.A2AEndpointRouteBuilderExtensions.MapA2AJsonRpc` |
| 74 | `hosting.openai` | OpenAI-compatible hosting endpoints | `Microsoft.AspNetCore.Builder.MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExtensions.MapOpenAIResponses` |
| 75127 | _reserved_ | future packages | — |
## Opt-out
The dedicated mask-only environment variable is shared by both SDKs:
- `AGENT_FRAMEWORK_FEATURE_MASK_DISABLED=true|1` — drops **only** the feature
mask; the base `agent-framework-<lang>/{version}` User-Agent is still sent.
The dedicated flag lets a privacy-conscious user keep contributing SDK
identity/version (useful for support and compatibility triage) while withholding
the feature-usage signal. Python's existing
`AGENT_FRAMEWORK_USER_AGENT_DISABLED=true|1` also suppresses its entire Agent
Framework User-Agent contribution, mask included. Adding a matching .NET
whole-User-Agent opt-out is outside this design.
## Governance
1. One index per package/feature, **numbered independently per language**, in the
table for that language. New indexes are added by editing this file in a reviewed
PR; indexes are never reused within a `(language, version)`.
2. Each package owns a private `FeatureIndex` declaration containing only its
rows. Core owns the accumulator API and core indexes, but never imports
optional packages. Adding a new optional-package index therefore does not
require a core release once the marker API exists.
3. Adding a feature: apply the [allocation tenet](#allocation-tenet), name the
concrete query/decision owner, add the package-local index and table row, and mark the
stable public entry point where actual use begins.
4. Widening beyond 128-bit or re-partitioning bumps that language's version; old
decoders keep working because the version prefix disambiguates the mapping.
5. A repository validation test gathers all package-local declarations for each
`(language, version)` and asserts exact table parity, complete non-reserved
coverage, `0..127` range, and **no duplicate/overlapping indexes**.
> **No machine-readable registry file ships today.** Nothing consumes one at
> runtime (packages own private declarations). If/when a programmatic decoder is built, this
> table is the contract to export to JSON for it then.
+10 -9
View File
@@ -60,7 +60,7 @@
<PackageVersion Include="AGUI.Client" Version="0.0.3" />
<PackageVersion Include="AGUI.Server" Version="0.0.3" />
<PackageVersion Include="System.Text.Json" Version="10.0.9" />
<PackageVersion Include="System.Threading.Channels" Version="10.0.8" />
<PackageVersion Include="System.Threading.Channels" Version="10.0.9" />
<PackageVersion Include="System.Threading.Tasks.Extensions" Version="4.6.3" />
<PackageVersion Include="System.Net.Security" Version="4.3.2" />
<!-- OpenTelemetry -->
@@ -80,11 +80,11 @@
<PackageVersion Include="Microsoft.OpenApi" Version="2.7.5" /> <!-- Pin patched OpenAPI.NET to remediate GHSA-v5pm-xwqc-g5wc -->
<PackageVersion Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.0.0" />
<!-- Microsoft.Extensions.* -->
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.6.0" />
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="10.6.0" />
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation" Version="10.6.0" />
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation.Quality" Version="10.6.0" />
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation.Safety" Version="10.6.0" />
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.7.0" />
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="10.7.0" />
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation" Version="10.7.0" />
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation.Quality" Version="10.7.0" />
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation.Safety" Version="10.7.0" />
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="10.6.0" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Compliance.Abstractions" Version="10.5.0" />
@@ -102,10 +102,10 @@
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.VectorData.Abstractions" Version="9.7.0" />
<PackageVersion Include="Microsoft.Extensions.VectorData.Abstractions" Version="10.7.0" />
<!-- Vector Stores -->
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.InMemory" Version="1.67.0-preview" />
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.Qdrant" Version="1.67.0-preview" />
<PackageVersion Include="CommunityToolkit.VectorData.InMemory" Version="1.0.0" />
<PackageVersion Include="CommunityToolkit.VectorData.Qdrant" Version="1.0.0" />
<!-- Agent SDKs -->
<PackageVersion Include="GitHub.Copilot.SDK" Version="1.0.5" />
<PackageVersion Include="Microsoft.Agents.CopilotStudio.Client" Version="1.3.171-beta" />
@@ -122,6 +122,7 @@
<PackageVersion Include="Hyperlight.HyperlightSandbox.Api" Version="0.4.0" />
<PackageVersion Include="Hyperlight.HyperlightSandbox.Guest.Python" Version="0.4.0" />
<!-- Inference SDKs -->
<PackageVersion Include="Dapr.AI.Microsoft.Extensions" Version="1.18.4" />
<PackageVersion Include="Microsoft.ML.OnnxRuntimeGenAI" Version="0.10.0" />
<PackageVersion Include="Microsoft.ML.Tokenizers" Version="2.0.0" />
<PackageVersion Include="OllamaSharp" Version="5.4.8" />
+25 -2
View File
@@ -29,7 +29,9 @@
<Project Path="samples/02-agents/AgentProviders/azure/Agent_With_AzureOpenAIChatCompletion/Agent_With_AzureOpenAIChatCompletion.csproj" />
<Project Path="samples/02-agents/AgentProviders/azure/Agent_With_AzureOpenAIResponses/Agent_With_AzureOpenAIResponses.csproj" />
<Project Path="samples/02-agents/AgentProviders/custom/Agent_With_CustomImplementation/Agent_With_CustomImplementation.csproj" />
<Project Path="samples/02-agents/AgentProviders/dapr/Agent_With_Dapr/Agent_With_Dapr.csproj" />
<Project Path="samples/02-agents/AgentProviders/github-copilot/Agent_With_GitHubCopilot/Agent_With_GitHubCopilot.csproj" />
<Project Path="samples/02-agents/AgentProviders/github-copilot/Agent_With_GitHubCopilot_BYOK/Agent_With_GitHubCopilot_BYOK.csproj" />
<Project Path="samples/02-agents/AgentProviders/google-gemini/Agent_With_GoogleGemini/Agent_With_GoogleGemini.csproj" />
<Project Path="samples/02-agents/AgentProviders/ollama/Agent_With_Ollama/Agent_With_Ollama.csproj" />
<Project Path="samples/02-agents/AgentProviders/onnx/Agent_With_ONNX/Agent_With_ONNX.csproj" />
@@ -65,6 +67,8 @@
<Project Path="samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Agent_Step19_InFunctionLoopCheckpointing.csproj" />
<Project Path="samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/Agent_Step20_DynamicFunctionTools.csproj" />
<Project Path="samples/02-agents/Agents/Agent_Step21_ShellWithEnvironment/Agent_Step21_ShellWithEnvironment.csproj" />
<Project Path="samples/02-agents/Agents/Agent_Step22_AgentMode/Agent_Step22_AgentMode.csproj" />
<Project Path="samples/02-agents/Agents/Agent_Step23_TodoList/Agent_Step23_TodoList.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/DeclarativeAgents/">
<Project Path="samples/02-agents/DeclarativeAgents/ChatClient/DeclarativeChatClientAgents.csproj" />
@@ -200,6 +204,8 @@
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock.csproj" />
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/AgentWithMemory_Step04_MemoryUsingFoundry.csproj" />
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step05_BoundedChatHistory/AgentWithMemory_Step05_BoundedChatHistory.csproj" />
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory.csproj" />
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step07_FileMemoryProvider/AgentWithMemory_Step07_FileMemoryProvider.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/AgentProviders/openai/">
<File Path="samples/02-agents/AgentProviders/openai/README.md" />
@@ -313,7 +319,19 @@
<Project Path="samples/03-workflows/Evaluation/Evaluation_WorkflowEval/Evaluation_WorkflowEval.csproj" />
<Project Path="samples/03-workflows/Evaluation/Evaluation_WorkflowExpectedOutputs/Evaluation_WorkflowExpectedOutputs.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/">
<Folder Name="/Samples/04-hosting/" />
<Folder Name="/Samples/04-hosting/af-hosting/">
<File Path="samples/04-hosting/af-hosting/README.md" />
</Folder>
<Folder Name="/Samples/04-hosting/af-hosting/local_responses/">
<File Path="samples/04-hosting/af-hosting/local_responses/README.md" />
<Project Path="samples/04-hosting/af-hosting/local_responses/Server/Server.csproj" />
<Project Path="samples/04-hosting/af-hosting/local_responses/Client/Client.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/af-hosting/local_responses_workflow/">
<File Path="samples/04-hosting/af-hosting/local_responses_workflow/README.md" />
<Project Path="samples/04-hosting/af-hosting/local_responses_workflow/Server/Server.csproj" />
<Project Path="samples/04-hosting/af-hosting/local_responses_workflow/Client/Client.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/" />
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/invocations/" />
@@ -327,6 +345,9 @@
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/HostedChatClientAgent.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent-Dockerfile/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent-Dockerfile/HostedChatClientAgentDocker.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/HostedFoundryAgent.csproj" />
</Folder>
@@ -639,7 +660,9 @@
<Project Path="src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj" />
<Project Path="src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj" />
</Folder>
<Folder Name="/Tests/" />
<Folder Name="/Tests/">
<Project Path="tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests.csproj" />
</Folder>
<Folder Name="/Tests/IntegrationTests/">
<Project Path="tests/AgentConformance.IntegrationTests/AgentConformance.IntegrationTests.csproj" />
<Project Path="tests/AnthropicChatCompletion.IntegrationTests/AnthropicChatCompletion.IntegrationTests.csproj" />
+1
View File
@@ -23,6 +23,7 @@
"src\\Microsoft.Agents.AI.Hosting.AzureFunctions\\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj",
"src\\Microsoft.Agents.AI.Hosting.OpenAI\\Microsoft.Agents.AI.Hosting.OpenAI.csproj",
"src\\Microsoft.Agents.AI.Hosting\\Microsoft.Agents.AI.Hosting.csproj",
"src\\Microsoft.Agents.AI.LocalCodeAct\\Microsoft.Agents.AI.LocalCodeAct.csproj",
"src\\Microsoft.Agents.AI.Mcp\\Microsoft.Agents.AI.Mcp.csproj",
"src\\Microsoft.Agents.AI.Mem0\\Microsoft.Agents.AI.Mem0.csproj",
"src\\Microsoft.Agents.AI.OpenAI\\Microsoft.Agents.AI.OpenAI.csproj",
+118
View File
@@ -329,6 +329,80 @@ internal static class AgentsSamples
],
},
new SampleDefinition
{
Name = "Agent_Step20_DynamicFunctionTools",
ProjectPath = "samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools",
RequiredEnvironmentVariables = ["FOUNDRY_PROJECT_ENDPOINT"],
OptionalEnvironmentVariables = ["FOUNDRY_MODEL"],
MustContain =
[
"=== Dynamic Function Tools Sample ===",
"=== Non-Streaming Mode ===",
"=== Streaming Mode ===",
"[User]",
"[Agent]",
],
ExpectedOutputDescription =
[
"The output should show the agent starting with only a RequestTools function and dynamically loading additional tools (weather, time, temperature) as needed.",
"The output should contain weather information for Seattle and London, the current time in New York, and a Fahrenheit-to-Celsius temperature conversion.",
"The output should demonstrate both non-streaming and streaming modes.",
"The output should not contain error messages or stack traces.",
],
},
new SampleDefinition
{
Name = "Agent_Step21_ShellWithEnvironment",
ProjectPath = "samples/02-agents/Agents/Agent_Step21_ShellWithEnvironment",
RequiredEnvironmentVariables = ["FOUNDRY_PROJECT_ENDPOINT"],
OptionalEnvironmentVariables = ["FOUNDRY_MODEL"],
MustContain =
[
"### Stateless mode",
"### Persistent mode",
"--- Captured environment snapshot ---",
],
ExpectedOutputDescription =
[
"The output should show an agent using a shell tool to print the current working directory.",
"The output should demonstrate that in stateless mode side effects (such as changing directory) do not carry between calls, while in persistent mode the working directory and an environment variable (DEMO_TOKEN set to 'hello-world') carry across calls.",
"The output should include a captured environment snapshot describing the OS, shell, and working directory.",
"The output should not contain error messages or stack traces.",
],
},
new SampleDefinition
{
Name = "Agent_Step22_AgentMode",
ProjectPath = "samples/02-agents/Agents/Agent_Step22_AgentMode",
RequiredEnvironmentVariables = ["FOUNDRY_PROJECT_ENDPOINT"],
OptionalEnvironmentVariables = ["FOUNDRY_MODEL"],
SkipReason = "Interactive sample that reads console input in a loop and does not exit on its own.",
},
new SampleDefinition
{
Name = "Agent_Step23_TodoList",
ProjectPath = "samples/02-agents/Agents/Agent_Step23_TodoList",
RequiredEnvironmentVariables = ["FOUNDRY_PROJECT_ENDPOINT"],
OptionalEnvironmentVariables = ["FOUNDRY_MODEL"],
MustContain =
[
"User:",
"Agent:",
"--- Current todo list ---",
],
ExpectedOutputDescription =
[
"The output should show an agent planning a team offsite by breaking the work into a todo list.",
"The output should show the todo list being updated as progress is reported (for example marking items complete after the venue is booked and invites are sent) and adjusted when the plan changes to skip catering and add a group hike.",
"The current todo list should be printed after each turn, showing item status.",
"The output should not contain error messages or stack traces.",
],
},
// ── AgentSkills ─────────────────────────────────────────────────────
new SampleDefinition
@@ -427,6 +501,37 @@ internal static class AgentsSamples
],
},
new SampleDefinition
{
Name = "AgentWithMemory_Step06_MemoryUsingAgentMemory",
ProjectPath = "samples/02-agents/AgentWithMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory",
RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"],
OptionalEnvironmentVariables = ["AZURE_OPENAI_API_KEY", "FOUNDRY_MODEL", "FOUNDRY_EMBEDDING_MODEL", "NEO4J_URI", "NEO4J_USER", "NEO4J_PASSWORD"],
SkipReason = "Requires a running Neo4j instance; standalone sample outside the repo's CPM build.",
},
new SampleDefinition
{
Name = "AgentWithMemory_Step07_FileMemoryProvider",
ProjectPath = "samples/02-agents/AgentWithMemory/AgentWithMemory_Step07_FileMemoryProvider",
RequiredEnvironmentVariables = ["FOUNDRY_PROJECT_ENDPOINT"],
OptionalEnvironmentVariables = ["FOUNDRY_MODEL"],
MustContain =
[
"Memory files will be written to:",
"=== First conversation ===",
"=== Memory files on disk ===",
"=== Second conversation (new session) ===",
],
ExpectedOutputDescription =
[
"The output should acknowledge that the user is vegetarian and travels with a dog, indicating the agent stored these preferences.",
"The memory files section should list at least one memory file written by the agent, such as a file about the user's preferences.",
"The second conversation should recommend a hotel and a restaurant in Paris that are consistent with the remembered preferences, for example a pet-friendly hotel and a restaurant with vegetarian options, even though it is a new session.",
"The output should not contain error messages or stack traces.",
],
},
// ── AgentWithRAG ────────────────────────────────────────────────────
new SampleDefinition
@@ -753,6 +858,19 @@ internal static class AgentsSamples
],
},
new SampleDefinition
{
Name = "Agent_With_GitHubCopilot_BYOK",
ProjectPath = "samples/02-agents/AgentProviders/github-copilot/Agent_With_GitHubCopilot_BYOK",
RequiredEnvironmentVariables = ["BYOK_BASE_URL", "BYOK_API_KEY"],
OptionalEnvironmentVariables = ["BYOK_PROVIDER_TYPE", "BYOK_MODEL_ID"],
ExpectedOutputDescription =
[
"The output should contain a user prompt and a response about the benefits of BYOK.",
"The output should not contain error messages or stack traces.",
],
},
new SampleDefinition
{
Name = "Agent_With_GoogleGemini",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"sdk": {
"version": "10.0.301",
"version": "10.0.302",
"rollForward": "minor",
"allowPrerelease": false
},
+3 -3
View File
@@ -1,14 +1,14 @@
<Project>
<PropertyGroup>
<!-- Central version prefix - applies to all nuget packages. -->
<VersionPrefix>1.13.0</VersionPrefix>
<VersionPrefix>1.16.0</VersionPrefix>
<RCNumber>1</RCNumber>
<DateSuffix>260703</DateSuffix>
<DateSuffix>260730</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.13.0</GitTag>
<GitTag>1.16.0</GitTag>
<Configurations>Debug;Release;Publish</Configurations>
<IsPackable>true</IsPackable>
@@ -44,6 +44,12 @@ See the README.md for each sample for the prerequisites for that sample.
| --- | --- |
| [Custom Implementation](./custom/Agent_With_CustomImplementation/) | Create an AIAgent with a custom implementation |
### [Dapr](./dapr/)
| Sample | Description |
| --- | --- |
| [Agent with Dapr](./dapr/Agent_With_Dapr/) | Create an AIAgent using Dapr's Conversation building block as the inference backend |
### [Foundry](./foundry/)
See [foundry/README.md](./foundry/README.md) for the full list of Foundry agent samples,
@@ -54,6 +60,7 @@ covering basics, function tools, structured output, middleware, MCP, code interp
| Sample | Description |
| --- | --- |
| [GitHub Copilot](./github-copilot/Agent_With_GitHubCopilot/) | Create an AIAgent using GitHub Copilot SDK |
| [GitHub Copilot BYOK](./github-copilot/Agent_With_GitHubCopilot_BYOK/) | Route GitHub Copilot agent requests through your own endpoint (Bring Your Own Key) |
### [Google Gemini](./google-gemini/)
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
<NoWarn>$(NoWarn);DAPR_CONVERSATION</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dapr.AI.Microsoft.Extensions" />
<!--
Dapr.AI.Microsoft.Extensions depends on Microsoft.Extensions.* 10.0.8, which is higher than the
versions pinned centrally in Directory.Packages.props. Central transitive pinning is disabled above
for this sample and these two direct references are overridden to that minimum. Remove the overrides
(and the CentralPackageTransitivePinningEnabled setting) once the central versions are >= 10.0.8.
-->
<PackageReference Include="Microsoft.Extensions.DependencyInjection" VersionOverride="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Hosting" VersionOverride="10.0.8" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,11 @@
apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
name: ollama
spec:
type: conversation.ollama
metadata:
- name: model
value: llama3.2
- name: cacheTTL
value: 10m
@@ -0,0 +1,36 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample shows how to create and use a simple AI agent with Dapr as the backend.
// Dapr's Conversation building block is used here to route inference to Ollama.
using Dapr.AI.Conversation.Extensions;
using Dapr.AI.Microsoft.Extensions;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
// The Dapr sidecar's gRPC endpoint. This must match the --dapr-grpc-port used when starting
// the sidecar (see this sample's README). Override it with the DAPR_GRPC_ENDPOINT environment
// variable if you run the sidecar on a different port.
var daprGrpcEndpoint = Environment.GetEnvironmentVariable("DAPR_GRPC_ENDPOINT") ?? "http://localhost:3501";
// Register the Dapr Conversation client with dependency injection.
var app = Host.CreateDefaultBuilder()
.ConfigureServices(services =>
{
// Configure the gRPC endpoint for the Dapr sidecar.
services.AddDaprConversationClient((_, builder) => builder.UseGrpcEndpoint(daprGrpcEndpoint));
// Provide the name of the Conversation component loaded in the sidecar to use.
services.AddDaprChatClient(opt => opt.ConversationComponentName = "ollama");
}).Build();
// Get an instance of the Dapr chat client from the dependency injection container.
using var scope = app.Services.CreateScope();
var daprChatClient = scope.ServiceProvider.GetRequiredService<IChatClient>();
// Use this chat client to construct an AIAgent.
AIAgent agent = daprChatClient.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."));
@@ -0,0 +1,37 @@
# Prerequisites
Before you begin, ensure you have the following prerequisites:
- .NET 10 SDK or later
- Docker installed and running on your machine
- Ollama installed
- Dapr CLI installed ([instructions](https://docs.dapr.io/getting-started/install-dapr-cli/))
You'll need to download a model from [Ollama's library](https://ollama.com/library) to get started. Open
a terminal and run the following, replacing `<model_name>` with the name of the model you want to use from
Ollama's library (e.g., `llama3.2`).
```powershell
ollama run <model_name>
```
Once it has downloaded and started running, update the component bundled with this example
in `./Components/conversation-ollama.yaml` to reflect the name of the model you just installed, modifying the value of
the `model` metadata property, then save your changes and close the file.
Next, start your Dapr sidecar and tell it where it can look for your components. If launching from this project's directory,
run the following; otherwise, replace `./Components` with the path to your components directory.
```powershell
dapr run --app-id agents --resources-path ./Components --dapr-grpc-port 3501
```
The sample connects to the sidecar at `http://localhost:3501` by default. If you start the sidecar on a
different gRPC port, set the `DAPR_GRPC_ENDPOINT` environment variable to match before running the sample.
Because the Dapr sidecar needs to continue running while your application is running, please open another terminal
window and run the following command from this project's directory to start the demo.
```powershell
dotnet run
```
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<NoWarn>$(NoWarn);GHCP001</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="GitHub.Copilot.SDK" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.GitHub.Copilot\Microsoft.Agents.AI.GitHub.Copilot.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,52 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample shows how to configure a GitHub Copilot agent with BYOK (Bring Your Own Key),
// routing requests through your own endpoint (OpenAI, Azure OpenAI, Anthropic, or an
// OpenAI-compatible service such as vLLM/LiteLLM/Ollama) instead of the GitHub Copilot backend.
//
// SECURITY NOTE: BYOK uses static credentials (no automatic token refresh) and usage is tracked
// by your provider rather than GitHub. Keep API keys out of source control; load them from
// environment variables or a secret store, as shown here.
using GitHub.Copilot;
using Microsoft.Agents.AI;
string providerType = Environment.GetEnvironmentVariable("BYOK_PROVIDER_TYPE") ?? "openai";
string baseUrl = Environment.GetEnvironmentVariable("BYOK_BASE_URL")
?? throw new InvalidOperationException("The BYOK_BASE_URL environment variable is not set.");
string apiKey = Environment.GetEnvironmentVariable("BYOK_API_KEY")
?? throw new InvalidOperationException("The BYOK_API_KEY environment variable is not set.");
string modelId = Environment.GetEnvironmentVariable("BYOK_MODEL_ID") ?? "gpt-4o";
// Create and start a Copilot client
await using CopilotClient copilotClient = new();
await copilotClient.StartAsync();
// Provider routes the session through a custom endpoint instead of the GitHub Copilot backend.
// Type is "openai", "azure", or "anthropic". WireApi "completions" is the broadly compatible
// choice; use "responses" for providers that support the OpenAI Responses API. BYOK also
// requires Model to be set at the session level.
SessionConfig sessionConfig = new()
{
Model = modelId,
Provider = new ProviderConfig
{
Type = providerType,
WireApi = "completions",
BaseUrl = baseUrl,
ApiKey = apiKey,
ModelId = modelId,
},
};
AIAgent agent = copilotClient.AsAIAgent(sessionConfig, ownsClient: true);
string prompt = "What are the benefits of using your own API keys with an agent framework?";
Console.WriteLine($"User: {prompt}\n");
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(prompt))
{
Console.Write(update);
}
Console.WriteLine();
@@ -0,0 +1,76 @@
# About BYOK (Bring Your Own Key)
BYOK lets you route model requests through your own API keys and infrastructure instead of the
GitHub Copilot backend — useful for enterprise deployments, custom hosting, or direct billing
arrangements. See [GitHub's BYOK documentation](https://docs.github.com/en/copilot/how-tos/copilot-sdk/auth/byok)
for the full list of supported providers and configuration options.
# Prerequisites
Before you begin, ensure you have the following prerequisites:
- .NET 10 SDK or later
- GitHub Copilot CLI installed and available in your PATH (or provide a custom path)
- An OpenAI, Azure OpenAI, Anthropic, or OpenAI-compatible endpoint and API key (e.g. vLLM,
LiteLLM, or Ollama)
## Setting up GitHub Copilot CLI
To use this sample, you need to have the GitHub Copilot CLI installed. You can install it by
following the instructions at:
https://github.com/github/copilot-sdk
## Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `BYOK_PROVIDER_TYPE` | Provider type (`openai`, `azure`, `anthropic`) | `openai` |
| `BYOK_BASE_URL` | Base URL of your provider endpoint | *(required)* |
| `BYOK_API_KEY` | API key for that endpoint | *(required)* |
| `BYOK_MODEL_ID` | Model name to request (e.g. "gpt-4o") | `gpt-4o` |
## Running the Sample
```powershell
dotnet run
```
The sample will:
1. Create a GitHub Copilot client with default options
2. Configure a session with a `Provider` (BYOK) pointing at your own endpoint instead of the
default GitHub Copilot backend
3. Send a message to the agent
4. Stream the response
## Advanced Usage
```csharp
using GitHub.Copilot;
using Microsoft.Agents.AI;
await using CopilotClient copilotClient = new();
await copilotClient.StartAsync();
SessionConfig sessionConfig = new()
{
// BYOK requires Model to also be set at the session level.
Model = "gpt-4o",
Provider = new ProviderConfig
{
Type = "azure", // or "openai", "anthropic"
WireApi = "completions", // or "responses"
BaseUrl = "https://api.example.com/v1",
ApiKey = "your-api-key",
ModelId = "your-model-id", // "deployment-name"
},
};
AIAgent agent = copilotClient.AsAIAgent(sessionConfig, ownsClient: true);
AgentResponse response = await agent.RunAsync("Hello!");
Console.WriteLine(response);
```
> **Note:** BYOK uses static credentials only — dynamic token refresh is not automatic, and
> model availability depends entirely on your provider's offerings. Usage is tracked through
> your provider rather than GitHub.
@@ -10,7 +10,7 @@
<ItemGroup>
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.SemanticKernel.Connectors.InMemory" />
<PackageReference Include="CommunityToolkit.VectorData.InMemory" />
</ItemGroup>
<ItemGroup>
@@ -5,10 +5,10 @@
using Azure.AI.Projects;
using Azure.Identity;
using CommunityToolkit.VectorData.InMemory;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.VectorData;
using Microsoft.SemanticKernel.Connectors.InMemory;
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-mini";
@@ -10,7 +10,7 @@
<ItemGroup>
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.SemanticKernel.Connectors.InMemory" />
<PackageReference Include="CommunityToolkit.VectorData.InMemory" />
</ItemGroup>
<ItemGroup>
@@ -7,10 +7,10 @@
using Azure.AI.Projects;
using Azure.Identity;
using CommunityToolkit.VectorData.InMemory;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.VectorData;
using Microsoft.SemanticKernel.Connectors.InMemory;
using SampleApp;
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
@@ -0,0 +1,78 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
This project is part of the repo's solution and targets .NET 10 like the rest of the repo, but it
intentionally opts out of Central Package Management and source-referencing Microsoft.Agents.AI:
it consumes the *published* AgentMemory NuGet packages (which target Microsoft.Agents.AI 1.9.0)
instead. Run it with `dotnet run` from this folder.
ManagePackageVersionsCentrally is off, but dotnet/Directory.Packages.props still unconditionally
merges its repo-wide analyzer PackageReference items (no Version, resolved via CPM) into every
project that imports it — including this one. With CPM off here those versions can't resolve
(NU1015), so each is removed and re-added with an explicit version below (matching
AgentWithRAG_Step05_Neo4jGraphRAG, which hits the same issue). xunit.analyzers/Moq.Analyzers are
dropped rather than re-added since this project has no test code.
-->
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
<RootNamespace>AgentMemoryShoppingAssistant</RootNamespace>
<!-- OPENAI001: the OpenAIClient(AuthenticationPolicy, options) ctor used for keyless Azure auth is
marked experimental in the OpenAI SDK (the MAF Foundry samples use the same pattern). -->
<NoWarn>$(NoWarn);OPENAI001</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Remove="Microsoft.CodeAnalysis.NetAnalyzers" />
<PackageReference Remove="Microsoft.VisualStudio.Threading.Analyzers" />
<PackageReference Remove="xunit.analyzers" />
<PackageReference Remove="Moq.Analyzers" />
<PackageReference Remove="Roslynator.Analyzers" />
<PackageReference Remove="Roslynator.CodeAnalysis.Analyzers" />
<PackageReference Remove="Roslynator.Formatting.Analyzers" />
</ItemGroup>
<ItemGroup>
<!-- AgentMemory (published) — an unofficial .NET port of the Neo4j Labs agent-memory library + its
Microsoft Agent Framework adapter. -->
<PackageReference Include="AgentMemory" Version="1.2.0" />
<PackageReference Include="AgentMemory.AgentFramework" Version="1.2.0" />
<!-- Microsoft Agent Framework (matches AgentMemory's target) + the OpenAI/Foundry chat & embedding clients. -->
<PackageReference Include="Microsoft.Agents.AI" Version="1.9.0" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.5.1" />
<PackageReference Include="Azure.Identity" Version="1.21.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.17" />
<!-- Transitive dependency of Microsoft.Agents.AI; pinned explicitly (CPM is off here) because the
version it would otherwise resolve to, 1.12.0, has a known moderate severity vulnerability
(GHSA-g94r-2vxg-569j) that fails the repo's NuGet audit (NU1902 as error). Matches the version
pinned in dotnet/Directory.Packages.props. -->
<PackageReference Include="OpenTelemetry.Api" Version="1.15.3" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="10.0.100">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Roslynator.Analyzers" Version="4.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Roslynator.CodeAnalysis.Analyzers" Version="4.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Roslynator.Formatting.Analyzers" Version="4.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
@@ -0,0 +1,195 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ComponentModel;
using System.Text;
using AgentMemory.Neo4j.Infrastructure;
using Microsoft.Extensions.AI;
using Neo4j.Driver;
namespace AgentMemoryShoppingAssistant;
/// <summary>
/// A small retail product graph plus the shopping tools that query it — the .NET counterpart of the
/// Python retail-assistant's <c>get_product_tools</c>. Products live in Neo4j as <c>:Product</c> nodes
/// linked to <c>:ProductCategory</c> / <c>:ProductBrand</c> nodes, so recommendations and "related
/// products" come from graph traversals. Cypher runs through the public <see cref="INeo4jTransactionRunner"/>
/// seam. Exposed as <see cref="AIFunction"/>s so a real chat model can call them during a run — the same
/// way <c>Neo4jMemoryContextProvider</c> surfaces the memory tools through <c>AIContext.Tools</c> when
/// <c>ExposeMemoryToolsFromContextProvider</c> is enabled.
/// </summary>
public sealed class ProductCatalog(INeo4jTransactionRunner runner)
{
private readonly INeo4jTransactionRunner _runner = runner;
private static readonly (string Name, string Category, string Brand, double Price, bool InStock, int Inventory, string Description, int Popularity)[] s_seed =
[
("Nike Air Zoom Pegasus 40", "shoes", "Nike", 130, true, 40, "Everyday running shoe with responsive cushioning.", 95),
("Nike Revolution 7", "shoes", "Nike", 70, true, 60, "Lightweight, budget-friendly running shoe.", 80),
("Adidas Ultraboost Light", "shoes", "Adidas", 190, true, 25, "Premium running shoe with Boost cushioning.", 90),
("Asics Gel-Kayano 31", "shoes", "Asics", 165, false, 0, "Stability running shoe for overpronation.", 70),
("Sony WH-1000XM5", "electronics", "Sony", 350, true, 18, "Industry-leading noise-cancelling headphones.", 92),
("Bose QuietComfort Ultra", "electronics", "Bose", 330, true, 12, "Premium noise-cancelling over-ear headphones.", 85),
("Apple AirPods Pro 2", "electronics", "Apple", 250, true, 50, "Wireless earbuds with active noise cancellation.", 88),
("Garmin Forerunner 265", "electronics", "Garmin", 450, true, 9, "GPS running watch with training metrics.", 78),
("Nike Dri-FIT Running Tee", "apparel", "Nike", 35, true, 120, "Breathable, moisture-wicking running shirt.", 65),
("Adidas Own the Run Jacket","apparel", "Adidas", 80, true, 33, "Lightweight, water-repellent running jacket.", 60),
];
/// <summary>Seeds the sample product graph (idempotent — safe to run every start).</summary>
public Task SeedAsync(CancellationToken ct = default) => this._runner.WriteAsync(async r =>
{
await r.RunAsync(
"""
UNWIND $products AS row
MERGE (p:Product {name: row.name})
SET p.category = row.category, p.brand = row.brand, p.price = row.price,
p.in_stock = row.in_stock, p.inventory = row.inventory,
p.description = row.description, p.popularity = row.popularity
MERGE (c:ProductCategory {name: row.category})
MERGE (b:ProductBrand {name: row.brand})
MERGE (p)-[:IN_CATEGORY]->(c)
MERGE (p)-[:MADE_BY]->(b)
""",
new
{
products = s_seed.Select(p => (object)new Dictionary<string, object>
{
["name"] = p.Name, ["category"] = p.Category, ["brand"] = p.Brand, ["price"] = p.Price,
["in_stock"] = p.InStock, ["inventory"] = p.Inventory, ["description"] = p.Description,
["popularity"] = p.Popularity,
}).ToList(),
});
}, ct);
// ── Tools (also usable directly in the scripted demo) ────────────────────────────────────────
[Description("Search the product catalog for items matching a query, with optional category, brand, and max-price filters.")]
public Task<string> SearchProductsAsync(
[Description("What the customer is looking for, e.g. 'running shoes'.")] string query,
[Description("Optional category filter: shoes, electronics, apparel.")] string? category = null,
[Description("Optional brand filter, e.g. 'Nike'.")] string? brand = null,
[Description("Optional maximum price.")] double? maxPrice = null,
CancellationToken ct = default) => this._runner.ReadAsync(async r =>
{
const string Cypher =
"""
MATCH (p:Product)
WHERE ANY(w IN split(toLower($query), ' ') WHERE
toLower(p.name) CONTAINS w OR toLower(p.description) CONTAINS w OR toLower(p.category) CONTAINS w)
AND ($category IS NULL OR p.category = $category)
AND ($brand IS NULL OR p.brand = $brand)
AND ($maxPrice IS NULL OR p.price <= $maxPrice)
RETURN p.name AS name, p.brand AS brand, p.category AS category,
p.price AS price, p.in_stock AS inStock
ORDER BY p.popularity DESC
LIMIT 10
""";
var cursor = await r.RunAsync(Cypher, new { query, category, brand, maxPrice });
return Render("Matches", await cursor.ToListAsync());
}, ct);
[Description("Get personalized product recommendations, optionally biased toward a preferred brand and/or category.")]
public Task<string> GetRecommendationsAsync(
[Description("The customer's preferred brand (from their saved preferences), if known.")] string? preferredBrand = null,
[Description("Optional category to recommend within.")] string? category = null,
[Description("How many recommendations to return.")] int limit = 5,
CancellationToken ct = default) => this._runner.ReadAsync(async r =>
{
const string Cypher =
"""
MATCH (p:Product)
WHERE p.in_stock = true
AND ($category IS NULL OR p.category = $category)
WITH p, (CASE WHEN $preferredBrand IS NOT NULL AND p.brand = $preferredBrand THEN 1 ELSE 0 END) AS onBrand
RETURN p.name AS name, p.brand AS brand, p.category AS category, p.price AS price, p.in_stock AS inStock
ORDER BY onBrand DESC, p.popularity DESC
LIMIT $limit
""";
var cursor = await r.RunAsync(Cypher, new { preferredBrand, category, limit });
var header = preferredBrand is null ? "Recommended for you" : $"Recommended for you (favoring {preferredBrand})";
return Render(header, await cursor.ToListAsync());
}, ct);
[Description("Find products related to a given product — same category or same brand — via graph traversal.")]
public Task<string> GetRelatedProductsAsync(
[Description("The exact product name to find related items for.")] string productName,
CancellationToken ct = default) => this._runner.ReadAsync(async r =>
{
const string Cypher =
"""
MATCH (p:Product {name: $productName})
CALL (p) {
MATCH (p)-[:IN_CATEGORY]->(c)<-[:IN_CATEGORY]-(rel:Product) WHERE rel <> p
RETURN rel, 'same category' AS reason
UNION
MATCH (p)-[:MADE_BY]->(b)<-[:MADE_BY]-(rel:Product) WHERE rel <> p
RETURN rel, 'same brand' AS reason
}
WITH rel, collect(DISTINCT reason) AS reasons
RETURN rel.name AS name, rel.brand AS brand, rel.category AS category,
rel.price AS price, rel.in_stock AS inStock, rel.popularity AS popularity,
reduce(s = '', x IN reasons | CASE WHEN s = '' THEN x ELSE s + ', ' + x END) AS reason
ORDER BY popularity DESC
LIMIT 5
""";
var cursor = await r.RunAsync(Cypher, new { productName });
return Render($"Related to {productName}", await cursor.ToListAsync());
}, ct);
[Description("Check whether a product is in stock and how many units are available.")]
public Task<string> CheckInventoryAsync(
[Description("The exact product name to check.")] string productName,
CancellationToken ct = default) => this._runner.ReadAsync(async r =>
{
var cursor = await r.RunAsync(
"MATCH (p:Product {name: $productName}) RETURN p.name AS name, p.in_stock AS inStock, p.inventory AS inventory",
new { productName });
var rows = await cursor.ToListAsync();
if (rows.Count == 0)
{
return $"'{productName}' was not found in the catalog.";
}
var rec = rows[0];
var inStock = rec["inStock"].As<bool>();
return inStock
? $"{rec["name"].As<string>()}: In stock ({rec["inventory"].As<long>()} available)."
: $"{rec["name"].As<string>()}: Out of stock.";
}, ct);
/// <summary>The retail tools as MAF/MEAI <see cref="AIFunction"/>s (attach to the agent's ChatOptions.Tools).</summary>
public IReadOnlyList<AIFunction> CreateAIFunctions() =>
[
AIFunctionFactory.Create(this.SearchProductsAsync, "search_products",
"Search the product catalog with optional category/brand/price filters."),
AIFunctionFactory.Create(this.GetRecommendationsAsync, "get_recommendations",
"Get personalized recommendations, optionally favoring a preferred brand/category."),
AIFunctionFactory.Create(this.GetRelatedProductsAsync, "get_related_products",
"Find products related to a given product via the graph."),
AIFunctionFactory.Create(this.CheckInventoryAsync, "check_inventory",
"Check stock/availability for a product."),
];
private static string Render(string header, List<IRecord> rows)
{
if (rows.Count == 0)
{
return $"{header}: (no matches)";
}
var sb = new StringBuilder().Append(header).Append(':').AppendLine();
foreach (var rec in rows)
{
var stock = rec["inStock"].As<bool>() ? "in stock" : "out of stock";
var reason = rec.Keys.Contains("reason") ? $" [{rec["reason"].As<string>()}]" : string.Empty;
sb.Append(" • ")
.Append(rec["name"].As<string>())
.Append(" — ").Append(rec["brand"].As<string>())
.Append(", ").Append(rec["category"].As<string>())
.Append(", $").Append(rec["price"].As<double>().ToString("0"))
.Append(", ").Append(stock).Append(reason)
.AppendLine();
}
return sb.ToString().TrimEnd();
}
}
@@ -0,0 +1,156 @@
// Copyright (c) Microsoft. All rights reserved.
// Agent Memory — Shopping Assistant (Microsoft Agent Framework, .NET)
//
// A .NET port of the Neo4j Labs "agent-memory" retail-assistant example
// (https://github.com/neo4j-labs/agent-memory/tree/main/examples/microsoft_agent_retail_assistant,
// referenced from https://learn.microsoft.com/en-us/agent-framework/integrations/neo4j-memory).
//
// A shopping assistant that LEARNS a customer's preferences and RECOMMENDS products via graph
// traversal, backed by DURABLE memory in Neo4j. It uses the AgentMemory library — a .NET port of the
// Python memory provider, not an officially recognized Neo4j integration — and its Microsoft Agent
// Framework adapter:
// • Neo4jMemoryContextProvider (an AIContextProvider) — recalls memory before each run, persists
// after, and (via ExposeMemoryToolsFromContextProvider) surfaces the memory tools (search/remember/
// recall) itself through AIContext.Tools
// • ProductCatalog.CreateAIFunctions() — retail tools over a Neo4j :Product graph
//
// Configuration (environment variables, matching the other Foundry samples):
// AZURE_OPENAI_ENDPOINT (required) — your Azure OpenAI / Foundry endpoint
// AZURE_OPENAI_API_KEY (optional) — API key; if unset, DefaultAzureCredential (az login) is used
// FOUNDRY_MODEL (default: gpt-4o-mini) — chat model deployment
// FOUNDRY_EMBEDDING_MODEL (default: text-embedding-3-small) — embedding model deployment (1536 dims)
// NEO4J_URI (default: bolt://localhost:7687)
// NEO4J_USER (default: neo4j)
// NEO4J_PASSWORD (default: password)
using System.ClientModel;
using System.ClientModel.Primitives;
using AgentMemory.Abstractions.Services;
using AgentMemory.AgentFramework;
using AgentMemory.Core;
using AgentMemory.Core.Stubs;
using AgentMemory.Neo4j.Infrastructure;
using AgentMemoryShoppingAssistant;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using OpenAI;
// ── Model + credentials (Azure OpenAI / Foundry, via env vars) ───────────────────────────────────
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")
?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var apiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_API_KEY");
var chatModel = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-4o-mini";
var embeddingModel = Environment.GetEnvironmentVariable("FOUNDRY_EMBEDDING_MODEL") ?? "text-embedding-3-small";
var clientOptions = new OpenAIClientOptions { Endpoint = new Uri(endpoint) };
// API key if provided, otherwise Azure credential (dev: `az login`).
// 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.
OpenAIClient openAI = string.IsNullOrWhiteSpace(apiKey)
? new OpenAIClient(new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"), clientOptions)
: new OpenAIClient(new ApiKeyCredential(apiKey), clientOptions);
IChatClient chatClient = openAI.GetChatClient(chatModel).AsIChatClient();
IEmbeddingGenerator<string, Embedding<float>> embeddingGenerator =
openAI.GetEmbeddingClient(embeddingModel).AsIEmbeddingGenerator();
// ── AgentMemory (Neo4j) DI ───────────────────────────────────────────────────────────────────────
var builder = Host.CreateApplicationBuilder(args);
builder.Logging.SetMinimumLevel(LogLevel.Warning);
builder.Services.AddNeo4jAgentMemory(options =>
{
options.Uri = Environment.GetEnvironmentVariable("NEO4J_URI") ?? "bolt://localhost:7687";
options.Username = Environment.GetEnvironmentVariable("NEO4J_USER") ?? "neo4j";
options.Password = Environment.GetEnvironmentVariable("NEO4J_PASSWORD") ?? "password";
});
builder.Services.AddAgentMemoryCore(_ => { });
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddSingleton<IIdGenerator, GuidIdGenerator>();
builder.Services.TryAddSingleton(chatClient);
builder.Services.TryAddSingleton(embeddingGenerator);
builder.Services.AddAgentMemoryFramework(options =>
{
options.AutoExtractOnPersist = true;
options.ContextFormat.IncludeEntities = true;
options.ContextFormat.IncludeFacts = true;
options.ContextFormat.IncludePreferences = true;
options.ExposeMemoryToolsFromContextProvider = true;
});
var host = builder.Build();
await using var hostDisposal = (IAsyncDisposable)host;
await using var scope = host.Services.CreateAsyncScope();
var sp = scope.ServiceProvider;
// ── Setup: schema + sample product graph ─────────────────────────────────────────────────────────
var catalog = new ProductCatalog(sp.GetRequiredService<INeo4jTransactionRunner>());
await sp.GetRequiredService<ISchemaBootstrapper>().BootstrapAsync();
await catalog.SeedAsync();
Console.WriteLine("Neo4j schema ready; sample products loaded.\n");
// ── The shopping assistant: context provider (recall + memory tools) + product tools ─────────────
var memoryProvider = sp.GetRequiredService<Neo4jMemoryContextProvider>();
var productTools = catalog.CreateAIFunctions();
// WithMemoryOwnerScoping(sp) scopes the whole invocation (recall, tool calls, persistence) to the
// owner set via WithMemoryIdentity below — no manual BeginOwnerScope wrapping needed per turn.
AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions
{
Name = "ShoppingAssistant",
ChatOptions = new ChatOptions
{
ModelId = chatModel,
Instructions =
"You are a helpful shopping assistant for an online store. Learn and remember the customer's "
+ "preferences (brands, budget, categories) using the memory tools, and recommend products that "
+ "fit using the product tools. Explain why each recommendation matches, and suggest alternatives "
+ "when something is out of stock.",
// memoryProvider appends the six memory tools (search_memory, remember_fact, ...) to this list
// on every model call via AIContext.Tools — see ExposeMemoryToolsFromContextProvider above.
Tools = [.. productTools],
},
AIContextProviders = [memoryProvider],
}).WithMemoryOwnerScoping(sp);
const string Shopper = "shopper-amelia";
// ── Session A — the customer shops; the model calls the tools and remembers preferences ──────────
Console.WriteLine(">> Session A\n");
var sessionA = (await agent.CreateSessionAsync())
.WithMemoryIdentity(userId: Shopper, sessionId: "cart-a", applicationId: "retail-demo");
foreach (var turn in new[]
{
"Hi! I'm looking for running shoes. I love Nike and want to stay under $150.",
"Nice — what would you recommend for me, and is anything I might like out of stock?",
})
{
await SayAsync(agent, sessionA, turn);
}
// ── Session B — a NEW session for the same shopper still recalls her preferences ─────────────────
Console.WriteLine(">> Session B — a brand-new session; memory is durable\n");
var sessionB = (await agent.CreateSessionAsync())
.WithMemoryIdentity(userId: Shopper, sessionId: "cart-b", applicationId: "retail-demo");
await SayAsync(agent, sessionB, "I'm back — remind me what I like and suggest something new.");
Console.WriteLine("=== Done. Preferences + messages persist in Neo4j across sessions. ===");
// One conversational turn. Owner scoping (recall, tool calls, and persistence) is guaranteed
// automatically by the WithMemoryOwnerScoping-wrapped agent — no manual BeginOwnerScope needed here.
static async Task SayAsync(AIAgent agent, AgentSession session, string message)
{
Console.WriteLine($"USER : {message}");
var response = await agent.RunAsync(message, session);
Console.WriteLine($"ASSISTANT : {response.Text}\n");
}
@@ -0,0 +1,75 @@
# Agent with Memory Using AgentMemory — Shopping Assistant
A **.NET port of the Neo4j Labs "agent-memory" retail assistant** example
([`microsoft_agent_retail_assistant`](https://github.com/neo4j-labs/agent-memory/tree/main/examples/microsoft_agent_retail_assistant),
referenced from the [Learn integration page](https://learn.microsoft.com/en-us/agent-framework/integrations/neo4j-memory)).
A shopping assistant that **learns a customer's preferences** and **recommends products via graph
traversal**, backed by durable memory in Neo4j.
It uses the [`AgentMemory`](https://www.nuget.org/packages/AgentMemory) library — a .NET port of the
(Python-only) Neo4j Labs memory provider, **not an officially recognized Neo4j integration** — through
its Microsoft Agent Framework adapter.
## Features Demonstrated
- **`Neo4jMemoryContextProvider`** (an `AIContextProvider`) — recalls relevant memory before each run,
persists new memory after (the same bidirectional pattern as the official provider), and — via
`ExposeMemoryToolsFromContextProvider = true` — surfaces the memory tools (search / remember / recall)
itself through `AIContext.Tools`.
- **`ProductCatalog.CreateAIFunctions()`** — retail tools over a Neo4j `:Product` graph (search /
recommend / related / inventory).
- Preference learning that persists across a brand-new `AgentSession` for the same shopper.
- Graph-based product recommendations and "related products" via traversal.
## Prerequisites
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
- A **Neo4j 5.x** instance (the sample bootstraps the schema and seeds sample products)
- An **Azure OpenAI / Foundry** deployment (a chat model + an embedding model)
## Configuration
Set the following environment variables:
| Variable | Required | Default | Purpose |
|---|---|---|---|
| `AZURE_OPENAI_ENDPOINT` | ✅ | — | Azure OpenAI / Foundry endpoint |
| `AZURE_OPENAI_API_KEY` | — | — | API key; if unset, `DefaultAzureCredential` (`az login`) is used |
| `FOUNDRY_MODEL` | — | `gpt-4o-mini` | chat model deployment |
| `FOUNDRY_EMBEDDING_MODEL` | — | `text-embedding-3-small` | embedding model deployment (1536 dims) |
| `NEO4J_URI` | — | `bolt://localhost:7687` | Neo4j bolt URI |
| `NEO4J_USER` | — | `neo4j` | Neo4j user |
| `NEO4J_PASSWORD` | — | `password` | Neo4j password |
> Ensure the embedding model's dimensions match the Neo4j vector-index dimensions AgentMemory bootstraps
> (default 1536, which matches `text-embedding-3-small`).
## Run the Sample
```bash
docker run -d --name neo4j -p 7474:7474 -p 7687:7687 -e NEO4J_AUTH=neo4j/password neo4j:5.26
export AZURE_OPENAI_ENDPOINT="https://<your-resource>.openai.azure.com"
export AZURE_OPENAI_API_KEY="<your-key>" # or omit and `az login`
export FOUNDRY_MODEL="gpt-4o-mini"
dotnet run
```
## Expected Output
1. The sample bootstraps the Neo4j schema and seeds a small product graph (`:Product`,
`:ProductCategory`, `:ProductBrand` nodes).
2. **Session A** — the shopper says she wants running shoes, loves Nike, and has a $150 budget; the
agent calls the memory tools to remember this and the product tools to recommend matching items.
3. **Session B** — a brand-new session for the same shopper (`shopper-amelia`) still recalls her
preferences and can suggest something new, because memory persists in Neo4j across sessions.
## Note on packaging
This sample is part of the repo's solution and targets .NET 10 like every other sample, but it
deliberately opts out of **Central Package Management** and does **not** reference `Microsoft.Agents.AI`
via the repo's in-source project — it consumes the **published** `AgentMemory` NuGet packages instead
(which target `Microsoft.Agents.AI` 1.9.0). A version that references the repo's current
`Microsoft.Agents.AI` source would require AgentMemory to be rebuilt against that version first.
@@ -0,0 +1,19 @@
<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" />
</ItemGroup>
</Project>
@@ -0,0 +1,98 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample shows how to give an agent file-based memory using the FileMemoryProvider.
// The FileMemoryProvider exposes a set of tools to the agent (write, read, delete, list, grep and replace)
// that allow it to store memories as individual files in an AgentFileStore.
// Because the files are stored outside of the conversation, the agent can recall them
// in later conversations, even after the original chat history is gone.
//
// The sample also shows how to control the folder that memory files are written to,
// by supplying a state initializer callback that sets the working folder for each session.
#pragma warning disable MAAI001 // AgentFileStore and its implementations are experimental.
using Azure.AI.Projects;
using Azure.Identity;
using Microsoft.Agents.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-mini";
// The id of the user that we are storing memories for.
// It is used below to give each user their own memory folder.
const string UserId = "UID1";
// Create the file store that the FileMemoryProvider will use to persist memory files.
// Here we use a file system backed store rooted at a local folder called "agent-memory",
// but any AgentFileStore implementation can be used, e.g. InMemoryAgentFileStore or a custom
// implementation backed by blob storage.
var memoryRoot = Path.Combine(AppContext.BaseDirectory, "agent-memory");
var fileStore = new FileSystemAgentFileStore(memoryRoot);
// The working folder that memories for this user will be written to, relative to the store root.
// The folder you choose determines the scope and lifetime of the memories:
// - A stable folder, like the per-user one below, gives you durable memories that are shared by
// every session for that user. That is what allows the second conversation further down to
// recall what the user said in the first.
// - A unique folder per session gives you memories that are isolated to a single session, e.g.
// generate one in the state initializer callback below:
// _ => new FileMemoryState { WorkingFolder = Guid.NewGuid().ToString() }
var workingFolder = $"users/{UserId}";
Console.WriteLine($"Memory files will be written to: {Path.Combine(memoryRoot, workingFolder)}");
Console.WriteLine();
// Create the file memory provider.
// The second parameter is a state initializer callback that is invoked whenever the provider
// cannot find existing state in a session, i.e. typically the first time it is used with a new session.
// It allows us to configure the folder that memory files for that session are written to.
// If no callback is supplied, the working folder defaults to the root of the store,
// which means all sessions share a single, flat set of memory files.
using var fileMemoryProvider = new FileMemoryProvider(
fileStore,
_ => new FileMemoryState { WorkingFolder = workingFolder });
// Create the agent and attach the FileMemoryProvider so that the agent gets the file memory tools.
AIAgent agent = 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())
.AsAIAgent(new ChatClientAgentOptions
{
ChatOptions = new()
{
ModelId = deploymentName,
Instructions = "You are a helpful travel assistant. Remember what the user tells you about themselves so that you can give better recommendations later."
},
Name = "TravelAssistant",
AIContextProviders = [fileMemoryProvider],
});
// First conversation: tell the agent something worth remembering.
// The agent should use the file_memory_write tool to store it as a file in the working folder.
AgentSession firstSession = await agent.CreateSessionAsync();
Console.WriteLine("=== First conversation ===");
Console.WriteLine(await agent.RunAsync(
"I'm vegetarian and I always travel with my dog. Please remember this for future trips.",
firstSession));
Console.WriteLine();
// Show the memory files that the agent created on disk.
Console.WriteLine("=== Memory files on disk ===");
foreach (var file in Directory.EnumerateFiles(Path.Combine(memoryRoot, workingFolder)))
{
Console.WriteLine(Path.GetFileName(file));
}
Console.WriteLine();
// Second conversation: a brand new session with no chat history from the first conversation.
// The provider surfaces the memory index to the agent, and the agent can read the memory files
// using the file_memory_read tool, so it can still recall the user's preferences.
AgentSession secondSession = await agent.CreateSessionAsync();
Console.WriteLine("=== Second conversation (new session) ===");
Console.WriteLine(await agent.RunAsync(
"Suggest a hotel and a restaurant for my trip to Paris next week.",
secondSession));
@@ -0,0 +1,68 @@
# File Based Memory with FileMemoryProvider
This sample demonstrates how to give an agent file-based memory using the `FileMemoryProvider`.
The `FileMemoryProvider` is an `AIContextProvider` that exposes a set of memory tools to the agent, allowing the agent to decide what to remember and when to recall it. Each memory is stored as an individual file in an `AgentFileStore`, so memories survive beyond the lifetime of a single conversation.
## Concepts
- **`FileMemoryProvider`**: An `AIContextProvider` that adds the following tools to the agent:
| Tool | Description |
|---|---|
| `file_memory_write` | Write a memory file with a name, content and optional description. |
| `file_memory_read` | Read the content of a memory file by name. |
| `file_memory_delete` | Delete a memory file by name. |
| `file_memory_ls` | List all memory files with their descriptions. |
| `file_memory_grep` | Search memory file contents using a regular expression. |
| `file_memory_replace` | Replace occurrences of a substring within a memory file. |
| `file_memory_replace_lines` | Replace whole lines within a memory file. |
The provider also maintains a `memories.md` index file, which it injects into the conversation so the agent knows which memories are available without having to list them first.
- **`AgentFileStore`**: The pluggable storage abstraction used by the provider. This sample uses `FileSystemAgentFileStore` to store memories on the local disk, but `InMemoryAgentFileStore` or a custom implementation (e.g. backed by blob storage) can be used instead.
- **`FileMemoryState`**: The per-session state of the provider. Its `WorkingFolder` property determines the folder, relative to the store root, that memory files are written to.
## Configuring the memory folder
By default, all sessions share the root folder of the store, which means every session reads and writes the same flat set of memory files.
To scope memories, e.g. per user, per tenant or per session, pass a state initializer callback to the `FileMemoryProvider` constructor. The callback receives the `AgentSession` and is invoked whenever the provider cannot find existing state in that session, i.e. typically the first time the provider is used with a new session:
```csharp
using var fileMemoryProvider = new FileMemoryProvider(
fileStore,
session => new FileMemoryState { WorkingFolder = $"users/{userId}" });
```
In this sample, memories are written to `agent-memory/users/UID1` under the application's base directory. Because the folder is derived from a fixed user id rather than the session, a new session for the same user picks up the memories written by earlier sessions.
## Prerequisites
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
- A Microsoft Foundry project with a chat model deployment
- Run `az login` to authenticate with `DefaultAzureCredential`
## Configuration
Set the following environment variables:
| Variable | Description | Default |
|---|---|---|
| `FOUNDRY_PROJECT_ENDPOINT` | Your Foundry project endpoint | *(required)* |
| `FOUNDRY_MODEL` | Chat model deployment name | `gpt-5.4-mini` |
## Running the Sample
```bash
dotnet run
```
## How it Works
1. A `FileSystemAgentFileStore` is created, rooted at a local `agent-memory` folder.
2. A `FileMemoryProvider` is created over that store, with a state initializer that puts the memories for the current user in their own working folder.
3. The provider is attached to the agent via `ChatClientAgentOptions.AIContextProviders`, which gives the agent the `file_memory_*` tools and instructions for using them.
4. In the first conversation, the user shares some preferences and the agent calls `file_memory_write` to store them as a file in the working folder. The sample then lists the files that were created on disk.
5. In the second conversation, a brand new session is created with no chat history from the first conversation. The provider injects the memory index into the conversation, and the agent calls `file_memory_read` to recall the stored preferences when making its recommendations.
@@ -9,6 +9,8 @@ These samples show how to create an agent with the Agent Framework that uses Mem
|[Custom Memory Implementation](../../01-get-started/04_memory/)|This sample demonstrates how to create a custom memory component and attach it to an agent.|
|[Memory with Microsoft Foundry](./AgentWithMemory_Step04_MemoryUsingFoundry/)|This sample demonstrates how to create and run an agent that uses Microsoft Foundry's managed memory service to extract and retrieve individual memories.|
|[Bounded Chat History with Overflow](./AgentWithMemory_Step05_BoundedChatHistory/)|This sample demonstrates how to create a bounded chat history provider that overflows older messages to a vector store and recalls them as memories.|
|[Memory Using AgentMemory](./AgentWithMemory_Step06_MemoryUsingAgentMemory/)|This sample demonstrates a retail shopping assistant built with [`AgentMemory`](https://www.nuget.org/packages/AgentMemory), an unofficial .NET port of the Neo4j Labs graph-memory provider, to learn customer preferences and recommend products via graph traversal.|
|[File Based Memory](./AgentWithMemory_Step07_FileMemoryProvider/)|This sample demonstrates how to use the `FileMemoryProvider` to give an agent tools for storing and recalling memories as files, and how to configure the folder that those memory files are written to.|
> **See also**: [Memory Search with Foundry Agents](../AgentProviders/foundry/Agent_Step22_MemorySearch/) - demonstrates using the built-in Memory Search tool with Microsoft Foundry agents.
@@ -10,7 +10,7 @@
<ItemGroup>
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.SemanticKernel.Connectors.InMemory" />
<PackageReference Include="CommunityToolkit.VectorData.InMemory" />
</ItemGroup>
<ItemGroup>
@@ -7,11 +7,11 @@
using Azure.AI.Projects;
using Azure.Identity;
using CommunityToolkit.VectorData.InMemory;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Samples;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.VectorData;
using Microsoft.SemanticKernel.Connectors.InMemory;
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-mini";
@@ -10,7 +10,7 @@
<ItemGroup>
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.SemanticKernel.Connectors.Qdrant" />
<PackageReference Include="CommunityToolkit.VectorData.Qdrant" />
</ItemGroup>
<ItemGroup>
@@ -6,10 +6,10 @@
using Azure.AI.Projects;
using Azure.Identity;
using CommunityToolkit.VectorData.Qdrant;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.VectorData;
using Microsoft.SemanticKernel.Connectors.Qdrant;
using Qdrant.Client;
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
@@ -134,6 +134,6 @@ internal sealed class DocumentationChunk
public string SourceName { get; set; } = string.Empty;
[VectorStoreData]
public string Text { get; set; } = string.Empty;
[VectorStoreVector(Dimensions: 3072)]
[VectorStoreVector(dimensions: 3072)]
public string Embedding => this.Text;
}
@@ -10,7 +10,7 @@
<ItemGroup>
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.SemanticKernel.Connectors.InMemory" />
<PackageReference Include="CommunityToolkit.VectorData.InMemory" />
</ItemGroup>
<ItemGroup>
@@ -12,10 +12,10 @@ using System.Text.Json;
using Azure.AI.Extensions.OpenAI;
using Azure.AI.Projects;
using Azure.Identity;
using CommunityToolkit.VectorData.InMemory;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.VectorData;
using Microsoft.SemanticKernel.Connectors.InMemory;
using SampleApp;
using ChatMessage = Microsoft.Extensions.AI.ChatMessage;
@@ -0,0 +1,16 @@
<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" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,153 @@
// Copyright (c) Microsoft. All rights reserved.
// Agent Mode — Switch an agent's operating mode at runtime with AgentModeProvider
//
// This sample shows how to use the AgentModeProvider, an AIContextProvider that tracks the
// agent's current operating "mode" in the session state and exposes tools (mode_get / mode_set)
// so the agent can query and switch modes as its work progresses. The mode is folded into the
// instructions sent to the model on every turn, so different modes can drive different behavior.
//
// The sample demonstrates two things:
// 1. The built-in default modes ("plan" and "execute") that ship with the provider.
// 2. How to customize the available modes via AgentModeProviderOptions.
//
// It runs a simple interactive loop. In addition to chatting with the agent, you can switch the
// agent's mode yourself using a slash command:
// /mode — show the current mode
// /mode <name> — switch to the named mode
// /help — list the available commands and modes
// /exit — quit
//
// When you switch modes with /mode, the provider injects a notification on the next turn so the
// agent clearly sees the change and adjusts its behavior accordingly.
using Azure.AI.Projects;
using Azure.Identity;
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 model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
// Set AGENT_MODE_USE_CUSTOM=true to run the sample with the custom modes defined below instead of
// the provider's built-in "plan" / "execute" defaults.
bool useCustomModes = string.Equals(Environment.GetEnvironmentVariable("AGENT_MODE_USE_CUSTOM"), "true", StringComparison.OrdinalIgnoreCase);
// <create_mode_provider>
AgentModeProvider modeProvider;
string[] availableModes;
if (useCustomModes)
{
// Customize the set of modes by supplying AgentModeProviderOptions. Each mode has a name and a
// block of instructions describing how the agent should behave while operating in that mode.
// DefaultMode selects the mode new sessions start in (defaults to the first mode when omitted).
modeProvider = new AgentModeProvider(new AgentModeProviderOptions
{
DefaultMode = "concise",
Modes =
[
new AgentModeProviderOptions.AgentMode(
"concise",
"Answer in a single short sentence. Do not elaborate unless the user explicitly asks for more detail."),
new AgentModeProviderOptions.AgentMode(
"detailed",
"Answer thoroughly. Explain your reasoning, provide examples, and cover relevant edge cases."),
],
});
availableModes = ["concise", "detailed"];
}
else
{
// Use the provider's built-in modes: "plan" (interactive planning) and "execute" (autonomous
// execution). No options are required.
modeProvider = new AgentModeProvider();
availableModes = ["plan", "execute"];
}
// </create_mode_provider>
// Create the agent and attach the mode provider as an AIContextProvider.
// 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(new ChatClientAgentOptions
{
Name = "ModeAwareAssistant",
ChatOptions = new ChatOptions
{
ModelId = model,
Instructions = "You are a helpful assistant. Follow the process and behavior required by your current operating mode.",
},
AIContextProviders = [modeProvider],
});
using var providerToDispose = modeProvider;
AgentSession session = await agent.CreateSessionAsync();
Console.WriteLine("Agent Mode sample. Type a message to chat, or use a slash command.");
Console.WriteLine($"Available modes: {string.Join(", ", availableModes)}");
Console.WriteLine($"Current mode: {await modeProvider.GetModeAsync(session)}");
PrintHelp(availableModes);
Console.WriteLine();
while (true)
{
Console.Write("> ");
string? input = Console.ReadLine()?.Trim();
// Treat empty input or end-of-stream (Ctrl+D / Ctrl+Z) as a request to exit.
if (string.IsNullOrWhiteSpace(input) || input.Equals("/exit", StringComparison.OrdinalIgnoreCase))
{
break;
}
if (input.Equals("/help", StringComparison.OrdinalIgnoreCase))
{
PrintHelp(availableModes);
continue;
}
// Handle the /mode slash command: "/mode" shows the current mode, "/mode <name>" switches to it.
if (input.Equals("/mode", StringComparison.OrdinalIgnoreCase) || input.StartsWith("/mode ", StringComparison.OrdinalIgnoreCase))
{
string[] parts = input.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length < 2)
{
Console.WriteLine($"Current mode: {await modeProvider.GetModeAsync(session)}");
continue;
}
try
{
await modeProvider.SetModeAsync(session, parts[1]);
Console.WriteLine($"Switched to \"{parts[1]}\" mode.");
}
catch (ArgumentException ex)
{
// SetModeAsync throws when the requested mode is not one of the configured modes.
Console.WriteLine(ex.Message);
}
continue;
}
// Anything else is a message for the agent. The mode provider injects the current mode (and any
// pending mode-change notification) into the context for this turn.
Console.WriteLine(await agent.RunAsync(input, session));
// Print the mode after the turn: the agent may have switched it itself via the mode_set tool as
// its work progressed, so this reflects any change the agent made during the turn.
Console.WriteLine($"Current mode: {await modeProvider.GetModeAsync(session)}");
}
static void PrintHelp(string[] availableModes)
{
Console.WriteLine("Commands:");
Console.WriteLine(" /mode Show the current mode");
Console.WriteLine($" /mode <name> Switch mode ({string.Join(" | ", availableModes)})");
Console.WriteLine(" /help Show this help");
Console.WriteLine(" /exit Quit");
}
@@ -0,0 +1,62 @@
# Agent Mode
This sample demonstrates how to use the `AgentModeProvider` to track and switch an agent's
operating **mode** at runtime, and drive different agent behavior depending on the active mode.
The `AgentModeProvider` is an `AIContextProvider` that stores the current mode in the session
state and injects it into the instructions sent to the model on every turn. It also exposes
`mode_get` and `mode_set` tools so the agent can query and switch modes on its own as its work
progresses.
## What it demonstrates
- Attaching an `AgentModeProvider` to an agent via `ChatClientAgentOptions.AIContextProviders`.
- The provider's **built-in** modes: `plan` (interactive planning) and `execute` (autonomous execution).
- **Customizing** the available modes with `AgentModeProviderOptions` (set the
`AGENT_MODE_USE_CUSTOM` environment variable to `true` to switch to a simple `concise` /
`detailed` mode set).
- Reading and changing the mode from application code with `GetModeAsync` / `SetModeAsync`.
- A simple interactive input loop that lets the user switch mode with a slash command. When the
mode changes this way, the provider injects a notification on the next turn so the agent adjusts
its behavior.
## Commands
| Command | Description |
|---|---|
| `/mode` | Show the current mode |
| `/mode <name>` | Switch to the named mode |
| `/help` | List the available commands and modes |
| `/exit` | Quit (an empty line also exits) |
Any other input is sent to the agent as a message.
## Prerequisites
- .NET 10 SDK or later
- Microsoft Foundry project endpoint and model configured
- Azure CLI installed and authenticated (run `az login`)
- User has the required role to invoke models in the Foundry project
## Running the sample
Set the required environment variables:
```powershell
$env:FOUNDRY_PROJECT_ENDPOINT="https://your-project-endpoint"
$env:FOUNDRY_MODEL="gpt-5.4-mini" # Optional, defaults to gpt-5.4-mini
```
Run the sample:
```powershell
dotnet run
```
To try the custom modes instead of the built-in `plan` / `execute` modes, set the
`AGENT_MODE_USE_CUSTOM` environment variable to `true` and re-run:
```powershell
$env:AGENT_MODE_USE_CUSTOM="true"
dotnet run
```
@@ -0,0 +1,16 @@
<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" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,83 @@
// Copyright (c) Microsoft. All rights reserved.
// Todo List — Track work items across turns with TodoProvider
//
// This sample shows how to use the TodoProvider, an AIContextProvider that gives an agent a set of
// tools for managing a todo list (todos_add, todos_complete, todos_remove, todos_get_remaining,
// todos_get_all) along with instructions on how to use them. The todo list is stored in the
// session state and persists across turns, so the agent can plan multi-step work, track progress,
// and adjust the list as the conversation evolves.
//
// This is a scripted, non-interactive walkthrough: it sends a sequence of messages to the agent
// and, after each turn, prints the agent's reply followed by the current todo list (read directly
// from the provider via GetAllTodosAsync). This lets you watch the todo state evolve as the agent
// adds, completes, and removes items.
using Azure.AI.Projects;
using Azure.Identity;
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 model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
// <create_todo_provider>
// Create the TodoProvider and attach it to the agent as an AIContextProvider. The provider
// contributes the todo-management tools and instructions to every agent invocation.
using var todoProvider = new TodoProvider();
// 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(new ChatClientAgentOptions
{
Name = "PlanningAssistant",
ChatOptions = new ChatOptions
{
ModelId = model,
Instructions = "You are a helpful planning assistant. Use your todo list to plan and track multi-step work.",
},
AIContextProviders = [todoProvider],
});
// </create_todo_provider>
AgentSession session = await agent.CreateSessionAsync();
// A scripted set of turns that exercises the provider end-to-end: the agent should add todos for a
// multi-step request, mark items complete as progress is reported, and adjust the list on a change
// of plan.
string[] userMessages =
[
"I'm organizing a small team offsite. Can you help me plan it? Break the work into a todo list.",
"I've booked the venue and sent out the invites. Please update the list.",
"Actually, let's skip catering and instead plan a group hike. Update the plan accordingly.",
];
foreach (string userMessage in userMessages)
{
Console.WriteLine($"User: {userMessage}");
Console.WriteLine($"Agent: {await agent.RunAsync(userMessage, session)}");
// Read the current todo list straight from the provider and print it so the state is visible.
await PrintTodoListAsync(todoProvider, session);
Console.WriteLine();
}
static async Task PrintTodoListAsync(TodoProvider todoProvider, AgentSession session)
{
IReadOnlyList<TodoItem> todos = await todoProvider.GetAllTodosAsync(session);
Console.WriteLine("--- Current todo list ---");
if (todos.Count == 0)
{
Console.WriteLine(" (empty)");
return;
}
foreach (TodoItem todo in todos)
{
string status = todo.IsComplete ? "x" : " ";
Console.WriteLine($" [{status}] {todo.Id}. {todo.Title}");
}
}
@@ -0,0 +1,47 @@
# Todo List
This sample demonstrates how to use the `TodoProvider` to let an agent plan and track multi-step
work using a todo list that persists across turns within a session.
The `TodoProvider` is an `AIContextProvider` that contributes todo-management tools and instructions
to the agent, and stores the todo list in the session state. The provider exposes the following
tools to the agent:
- `todos_add` — add one or more todo items (title + optional description).
- `todos_complete` — mark one or more items complete, with a reason.
- `todos_remove` — remove one or more items by ID.
- `todos_get_remaining` — retrieve the incomplete items.
- `todos_get_all` — retrieve all items (complete and incomplete).
## What it demonstrates
- Attaching a `TodoProvider` to an agent via `ChatClientAgentOptions.AIContextProviders`.
- The agent breaking a complex request into trackable todo items, marking items complete as
progress is reported, and adjusting the list when the plan changes.
- Reading the todo list from application code with `TodoProvider.GetAllTodosAsync`.
This is a **scripted, non-interactive** walkthrough: it sends a fixed sequence of messages and,
after each turn, prints the agent's reply followed by the current todo list so you can watch the
state evolve.
## Prerequisites
- .NET 10 SDK or later
- Microsoft Foundry project endpoint and model configured
- Azure CLI installed and authenticated (run `az login`)
- User has the required role to invoke models in the Foundry project
## Running the sample
Set the required environment variables:
```powershell
$env:FOUNDRY_PROJECT_ENDPOINT="https://your-project-endpoint"
$env:FOUNDRY_MODEL="gpt-5.4-mini" # Optional, defaults to gpt-5.4-mini
```
Run the sample:
```powershell
dotnet run
```
@@ -47,6 +47,9 @@ Before you begin, ensure you have the following prerequisites:
|[Using compaction pipeline with an agent](./Agent_Step18_CompactionPipeline/)|This sample demonstrates how to use a compaction pipeline to efficiently limit the size of the conversation history for an agent.|
|[In-function-loop checkpointing](./Agent_Step19_InFunctionLoopCheckpointing/)|This sample demonstrates how to persist chat history after each service call during a tool-calling loop, enabling crash recovery and mid-run observability.|
|[Dynamic function tools](./Agent_Step20_DynamicFunctionTools/)|This sample demonstrates how to dynamically expand the set of function tools available to an agent during a function-calling loop using the ambient FunctionInvocationContext.|
|[Shell tool with environment-aware system prompt](./Agent_Step21_ShellWithEnvironment/)|This sample demonstrates how to use the shell tool together with the ShellEnvironmentProvider to run commands in stateless and persistent modes, injecting environment-aware instructions so the agent emits commands in the right shell idiom.|
|[Switching agent operating mode](./Agent_Step22_AgentMode/)|This sample demonstrates how to use the AgentModeProvider to track and switch an agent's operating mode at runtime, including the built-in plan/execute modes and custom modes, with a simple input loop that switches mode using a slash command.|
|[Tracking work with a todo list](./Agent_Step23_TodoList/)|This sample demonstrates how to use the TodoProvider to let an agent plan and track multi-step work using a todo list that persists across turns, printing the evolving todo list after each turn.|
## Running the samples from the console
@@ -133,7 +133,7 @@ AIAgent researchAgent = ResearchAgent.Create(chatClient);
// A sandboxed shell, confined to the trade-confirmation vault. ConfineWorkingDirectory re-anchors
// every command to the vault, and the deny-list policy pre-filters obviously destructive commands.
// (Patterns are a UX guardrail, not a security boundary — for hard isolation use DockerShellExecutor.)
await using var shell = new LocalShellExecutor(new LocalShellExecutorOptions
await using var shellExecutor = new LocalShellExecutor(new LocalShellExecutorOptions
{
WorkingDirectory = vaultDir,
ConfineWorkingDirectory = true,
@@ -160,7 +160,9 @@ using var codeAct = new HyperlightCodeActProvider(HyperlightCodeActProviderOptio
// Turn the chat client into a HarnessAgent. On top of Post 2's file access and approvals we add the
// four "scaling" capabilities: skills (our own provider), background agents, a confined shell, and
// CodeAct.
List<AIContextProvider> contextProviders = [skillsProvider, codeAct];
// The shell is wired up in two parts: the ShellEnvironmentProvider injects OS/shell/CWD info into the
// system prompt, and the shell tool is registered below in ChatOptions.
List<AIContextProvider> contextProviders = [skillsProvider, codeAct, new ShellEnvironmentProvider(shellExecutor)];
AIAgent agent = chatClient.AsHarnessAgent(new HarnessAgentOptions
{
@@ -170,8 +172,6 @@ AIAgent agent = chatClient.AsHarnessAgent(new HarnessAgentOptions
DisableAgentSkillsProvider = true,
// Fan-out research is delegated to this background agent.
BackgroundAgents = [researchAgent],
// The confined shell, exposed as the approval-gated run_shell tool.
ShellExecutor = shell,
// Keep reading the portfolio frictionless while writes, trades, and shell commands still prompt.
ToolApprovalAgentOptions = new ToolApprovalAgentOptions
{
@@ -179,7 +179,7 @@ AIAgent agent = chatClient.AsHarnessAgent(new HarnessAgentOptions
},
// Start in "execute" mode for quick lookups and actions; switch any time with /mode plan.
AgentModeProviderOptions = new AgentModeProviderOptions { DefaultMode = "execute" },
// Our skills provider plus CodeAct.
// Our skills provider, CodeAct, and the shell environment provider.
AIContextProviders = contextProviders,
ChatOptions = new ChatOptions
{
@@ -188,6 +188,8 @@ AIAgent agent = chatClient.AsHarnessAgent(new HarnessAgentOptions
[
StockTools.CreateGetStockPriceTool(),
TradingTools.CreatePlaceTradeTool(),
// The confined shell, exposed as the approval-gated run_shell tool.
shellExecutor.AsAIFunction(requireApproval: true),
],
Reasoning = new() { Effort = ReasoningEffort.Medium },
},
@@ -81,7 +81,12 @@ public static class Program
}
Console.WriteLine($"Number of checkpoints created: {checkpoints.Count}");
// Rehydrate a new workflow instance from a saved checkpoint and continue execution
// <rehydrate_workflow>
// A rehydrated workflow must preserve the topology and executor identities of the workflow that
// created the checkpoint. This executor-only workflow rebuilds identically because its executors
// use fixed ids. Agent-based workflows must recreate each local agent with the same
// ChatClientAgentOptions.Id (and, if set, the same Name), otherwise the executor ids no longer
// match the checkpoint and resume fails.
var newWorkflow = WorkflowFactory.BuildWorkflow();
const int CheckpointIndex = 5;
Console.WriteLine($"\n\nHydrating a new workflow instance from the {CheckpointIndex + 1}th checkpoint.");
@@ -89,6 +94,7 @@ public static class Program
await using StreamingRun newCheckpointedRun =
await InProcessExecution.ResumeStreamingAsync(newWorkflow, savedCheckpoint, checkpointManager);
// </rehydrate_workflow>
await foreach (WorkflowEvent evt in newCheckpointedRun.WatchStreamAsync())
{
@@ -10,50 +10,81 @@ using Microsoft.Extensions.AI;
/// <param name="chatClient">The <see cref="IChatClient"/> to use as the agent backend.</param>
internal sealed class AgentRegistry(IChatClient chatClient)
{
// <stable_agent_identity>
// Give each agent a stable, unique Id so its workflow executor identity stays the same when the
// workflow is reconstructed (for example per request or dependency-injection scope), which keeps
// checkpoints resumable. If an agent also has a Name, keep that stable too, since the executor
// identity includes it. Use a fixed logical role here, not a conversation, request, or user id.
internal const string IntakeAgentName = "Assistant";
public AIAgent IntakeAgent { get; } = chatClient.AsAIAgent(
instructions:
"""
public AIAgent IntakeAgent { get; } = chatClient.AsAIAgent(new ChatClientAgentOptions
{
Id = "intake-agent",
Name = IntakeAgentName,
ChatOptions = new()
{
Instructions =
"""
You receive a user request and are responsible for routing to the correct initial expert agent.
""",
IntakeAgentName
);
},
});
// </stable_agent_identity>
internal const string LiquidityAnalysisAgentName = "Liquidity Analysis";
public AIAgent LiquidityAnalysisAgent { get; } = chatClient.AsAIAgent(
instructions:
"""
public AIAgent LiquidityAnalysisAgent { get; } = chatClient.AsAIAgent(new ChatClientAgentOptions
{
Id = "liquidity-analysis-agent",
Name = LiquidityAnalysisAgentName,
ChatOptions = new()
{
Instructions =
"""
You are responsible for Liquidity Analysis.
""",
LiquidityAnalysisAgentName
);
},
});
internal const string TaxAnalysisAgentName = "Tax Analysis";
public AIAgent TaxAnalysisAgent { get; } = chatClient.AsAIAgent(
instructions:
"""
You are responsible for Tax Analysis.
public AIAgent TaxAnalysisAgent { get; } = chatClient.AsAIAgent(new ChatClientAgentOptions
{
Id = "tax-analysis-agent",
Name = TaxAnalysisAgentName,
ChatOptions = new()
{
Instructions =
"""
You are responsible for Tax Analysis.
""",
TaxAnalysisAgentName
);
},
});
internal const string ForeignExchangeAgentName = "Foreign Exchange Analysis";
public AIAgent ForeignExchangeAgent { get; } = chatClient.AsAIAgent(
instructions:
"""
You are responsible for Foreign Exchange Analysis.
public AIAgent ForeignExchangeAgent { get; } = chatClient.AsAIAgent(new ChatClientAgentOptions
{
Id = "foreign-exchange-agent",
Name = ForeignExchangeAgentName,
ChatOptions = new()
{
Instructions =
"""
You are responsible for Foreign Exchange Analysis.
""",
ForeignExchangeAgentName
);
},
});
internal const string EquityAgentName = "Equity Analysis";
public AIAgent EquityAgent { get; } = chatClient.AsAIAgent(
instructions:
"""
You are responsible for Equity Analysis.
public AIAgent EquityAgent { get; } = chatClient.AsAIAgent(new ChatClientAgentOptions
{
Id = "equity-analysis-agent",
Name = EquityAgentName,
ChatOptions = new()
{
Instructions =
"""
You are responsible for Equity Analysis.
""",
EquityAgentName
);
},
});
public IEnumerable<AIAgent> Experts => [this.LiquidityAnalysisAgent, this.TaxAnalysisAgent, this.ForeignExchangeAgent, this.EquityAgent];
@@ -6,7 +6,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<NoWarn>$(NoWarn);MAAIW001;OPENAI001</NoWarn>
<NoWarn>$(NoWarn);MAAIW001;OPENAI001;MAAI001</NoWarn>
</PropertyGroup>
<ItemGroup>
@@ -65,6 +65,17 @@ public static class Program
.AddParticipants([researcherAgent, coderAgent])
.WithName("Magentic Orchestration Workflow")
.WithDescription("Coordinates a researcher and coder to solve a complex analytical task.")
// By default the manager's internally generated messages (task ledger, progress ledger, final answer)
// use the built-in English prompts. To have them written in another language, pin a concrete language:
// .WithResponseLanguage("French")
// For full control you can also override any of the internal prompt templates (placeholders such as
// {task}, {team}, and - for the progress ledger - {schema} are substituted by the framework):
// .WithPromptOverrides(new MagenticPromptOverrides
// {
// FinalAnswerPrompt = "Rédige la réponse finale à la demande suivante en français :\n{task}",
// })
// The built-in English templates you can copy and translate are published on MagenticDefaultPrompts
// (e.g. MagenticDefaultPrompts.ProgressLedgerPrompt) - use them as a starting point for your overrides.
.RequirePlanSignoff(false)
.WithMaxRounds(10)
.WithMaxStalls(3)
@@ -205,3 +205,17 @@ For end-to-end hosted agent deployment guidance, see the [official deployment gu
## 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 `HostedAzureSearchRag.csproj` for the `PackageReference` alternative.
## Troubleshooting
**`azd ai agent invoke` fails with `404 not_found: Conversation '<id>' not found`**
`azd` saves the session and conversation per agent and reuses them on the next invoke. Once the
agent is redeployed, deleted, or restarted, that saved conversation no longer exists on the server,
so every following invoke fails even though the agent itself is healthy. Start a fresh one:
```
azd ai agent invoke --new-conversation "Hello!"
```
Add `--new-session` as well if the failure persists.
@@ -0,0 +1,20 @@
# Keeps local-only files out of the image build context. Without this, `COPY . .` in the Dockerfile
# would copy the local .env into a build layer, so local credentials would ship inside the image.
.env
.env.*
.azure/
.git/
# Build output: the image builds from source, so shipping host binaries only bloats the context and
# risks copying binaries built for a different platform into the container.
bin/
obj/
*.user
*.suo
.vs/
# Agent session state written during local runs.
.checkpoints/
# Note: local-feed/ and nuget.config are deliberately NOT excluded. When present (contributor mode)
# the `dotnet restore` inside the image build resolves the Agent Framework from them.
@@ -0,0 +1,17 @@
# Foundry project endpoint (shape: https://<host>/api/projects/<project>)
FOUNDRY_PROJECT_ENDPOINT=<your-azure-ai-project-endpoint>
# Model deployment name in your Foundry project.
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
# Local development only. Bind the app to the port Foundry probes for readiness, which is the
# port the Using-Samples REPLs expect. The Dockerfile sets this for the container; a plain
# `dotnet run` on the host does not go through the Dockerfile, so set it here too.
ASPNETCORE_URLS=http://+:8088
# Local development only. Restrict DefaultAzureCredential to developer credentials
# (Azure CLI, Visual Studio, azd) and skip the Managed Identity probe. Without this,
# on a machine with no managed identity DefaultAzureCredential hangs for a long time
# probing the IMDS endpoint (169.254.169.254) before every model call. Not set in
# Foundry, where the platform-injected managed identity is used.
AZURE_TOKEN_CREDENTIALS=dev
@@ -0,0 +1,19 @@
# Foundry builds this image and runs it as the hosted agent. The build restores and publishes the
# project inside the container, so a contributor feed dropped into this folder (local-feed/ plus
# nuget.config) is picked up by the `dotnet restore` below without any change here.
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet restore
RUN dotnet publish -c Release -o /app/publish
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
WORKDIR /app
COPY --from=build /app/publish .
# Foundry probes port 8088 for readiness. The .NET base image defaults ASPNETCORE_URLS to port 80,
# so without this the probe never succeeds and every invoke fails with HTTP 424 session_not_ready.
EXPOSE 8088
ENV ASPNETCORE_URLS=http://+:8088
ENTRYPOINT ["dotnet", "HostedChatClientAgentDocker.dll"]
@@ -0,0 +1,46 @@
<Project>
<!--
Container deploy sample. Foundry builds the Dockerfile in this folder and runs the resulting
image, so unlike the source (ZIP) path there is no server-side `dotnet restore` on a bare
folder: the restore happens inside the container build.
ImportDirectoryPackagesProps has to be set before the SDK props are imported, hence the explicit
Sdk imports below instead of the usual Sdk attribute on the Project element. It stops MSBuild
from walking up to the repository's dotnet/Directory.Packages.props, which does two things this
sample must avoid: it turns on central package management, and it injects analyzer
PackageReference items whose versions it also supplies. Neither exists inside the container
build context, so without this the in-repo build would resolve differently from the image build.
-->
<PropertyGroup>
<ImportDirectoryPackagesProps>false</ImportDirectoryPackagesProps>
</PropertyGroup>
<Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk.Web" />
<PropertyGroup>
<!-- Single target: the Dockerfile publishes without -f, so the project must not multi-target.
The empty TargetFrameworks clears the value inherited from the repo's samples
Directory.Build.props for in-repo builds. -->
<TargetFramework>net10.0</TargetFramework>
<TargetFrameworks></TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>HostedChatClientAgentDocker</RootNamespace>
<AssemblyName>HostedChatClientAgentDocker</AssemblyName>
<UserSecretsId>7b1c3f04-24a1-4f0e-9a5e-0d2f6b8c1e57</UserSecretsId>
<AgentFrameworkVersion>1.15.0-preview.260722.1</AgentFrameworkVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="$(AgentFrameworkVersion)" />
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="$(AgentFrameworkVersion)" />
<PackageReference Include="Azure.AI.Projects" Version="2.1.0-beta.4" />
<PackageReference Include="Azure.Identity" Version="1.21.0" />
<PackageReference Include="DotNetEnv" Version="3.1.1" />
</ItemGroup>
<Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk.Web" />
</Project>
@@ -0,0 +1,61 @@
// Copyright (c) Microsoft. All rights reserved.
// Sample: a minimal general-purpose AI assistant hosted as a Foundry Hosted Agent
// using the Responses protocol. It is deployed to Foundry as a container image built
// from the Dockerfile in this folder.
//
// The sibling Hosted-ChatClientAgent sample is the same agent deployed the other way,
// straight from source with no container image. Compare the two folders to see exactly
// what the container path adds.
using Azure.AI.Projects;
using Azure.Identity;
using DotNetEnv;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry.Hosting;
// Load a local .env file when present (local development only). In Foundry the
// platform injects the required environment variables at runtime.
Env.TraversePath().Load();
var projectEndpoint = new Uri(System.Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."));
// Environment variables can arrive set but blank: azd substitutes an empty string when the azd
// environment does not define the variable referenced from azure.yaml. An empty string is not
// null, so a plain ?? chain would pass the blank straight through and fail deep inside the SDK.
var model = FirstNonBlank(
System.Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME"),
System.Environment.GetEnvironmentVariable("FOUNDRY_MODEL"),
"gpt-4o");
var agentName = System.Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-chat-client-agent-docker";
// WARNING: DefaultAzureCredential is convenient for development but requires careful
// consideration in production. Consider a specific credential (for example
// ManagedIdentityCredential) to avoid latency, unintended credential probing, and
// fallback security risks.
AIAgent agent = new AIProjectClient(projectEndpoint, new DefaultAzureCredential())
.AsAIAgent(
model: model,
instructions: """
You are a helpful AI assistant hosted as a Foundry Hosted Agent.
You can help with a wide range of tasks including answering questions,
providing explanations, brainstorming ideas, and offering guidance.
Be concise, clear, and helpful in your responses.
""",
name: agentName,
description: "A simple general-purpose AI assistant");
// Host the agent using the Responses protocol.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddFoundryResponses(agent);
var app = builder.Build();
app.MapFoundryResponses();
app.Run();
// Returns the first candidate that has an actual value, ignoring null and blank entries.
static string FirstNonBlank(params string?[] candidates) =>
Array.Find(candidates, c => !string.IsNullOrWhiteSpace(c))!;
@@ -0,0 +1,327 @@
# Hosted-ChatClientAgent-Dockerfile
A minimal general-purpose AI assistant hosted as a Foundry Hosted Agent using the Responses protocol. The agent is created inline via `AIProjectClient.AsAIAgent(model, instructions)` and served with `AddFoundryResponses` / `MapFoundryResponses`.
This sample deploys to Foundry as a **container image** built from the `Dockerfile` in this folder.
The sibling [`Hosted-ChatClientAgent`](../Hosted-ChatClientAgent/) sample is the same agent deployed the other way, straight from source with no container image. That is the default for .NET and needs no Docker, so prefer it unless you need control over the runtime image.
| | Source (ZIP) | Container (this sample) |
|---|---|---|
| Deploy mode | `code`, the default for .NET | `container`, opt in with `--deploy-mode container` |
| Extra files | none | `Dockerfile`, `.dockerignore` |
| Who builds | Foundry runs `dotnet restore` + `dotnet publish` on the upload | Foundry builds the `Dockerfile` |
| Docker required | no | no, `azd` builds remotely in Azure Container Registry |
| Listen port | the package binds it, or `env` in `azure.yaml` | `ENV ASPNETCORE_URLS` in the `Dockerfile` |
| Extra Azure resource | none | an Azure Container Registry |
## Prerequisites
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
- An **existing** Foundry project with an **existing** model deployment (for example `gpt-4o`).
This sample's `azure.yaml` declares no `deployments:` block, so `azd` connects to a project and
a deployment you already have rather than creating them. `azd ai agent init` prompts you to pick
the project, and takes the deployment name as the `-d` argument.
- Azure CLI logged in (`az login`)
- Azure Developer CLI (`azd`) with the AI agents extension: `azd extension install azure.ai.agents`
- Docker Desktop **only** if you switch to local image builds by setting `remoteBuild: false` under
the `docker:` block in `azure.yaml`. By default `azd` builds the image in Azure Container
Registry, so no local Docker is needed.
## Files
| File | Purpose |
|------|---------|
| `Program.cs` | The agent: builds the agent, hosts it with the Responses protocol. |
| `Dockerfile` | Builds the image Foundry runs. Restores and publishes the project inside the container, and pins the listen port to 8088. |
| `.dockerignore` | Keeps local-only files (notably `.env`) out of the image build context. |
| `azure.yaml` | The unified `azd` project file. Declares the Foundry project and the hosted agent with `language: docker` and no `codeConfiguration`, which is what selects the container path. |
| `HostedChatClientAgentDocker.csproj` | Self-contained project: single target framework and explicit package versions. It also opts out of the repository's central package management, which does not travel inside the image build context. |
| `.env.example` | Template for local configuration. |
## Configuration
Copy the template and fill in your project endpoint:
PowerShell:
```powershell
copy .env.example .env
```
Bash:
```bash
cp .env.example .env
```
```env
FOUNDRY_PROJECT_ENDPOINT=https://<your-account>.services.ai.azure.com/api/projects/<your-project>
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
ASPNETCORE_URLS=http://+:8088
AZURE_TOKEN_CREDENTIALS=dev
```
> `.env` is gitignored, and `.dockerignore` keeps it out of the image. The `.env.example` template
> is checked in as a reference.
> **Windows note:** write `.env` as UTF-8 **without** a byte order mark. `azd` reads the file
> during `azd ai agent init` and fails with `unexpected character "»" in variable name` when a mark
> is present. PowerShell's `Set-Content -Encoding UTF8BOM` adds one; use `-Encoding utf8NoBOM`.
> **Local development on a machine without a managed identity:** set `AZURE_TOKEN_CREDENTIALS=dev`.
> `Program.cs` authenticates with `DefaultAzureCredential` (the pattern the hosted platform
> expects, where a managed identity is injected). On a developer machine with no managed identity,
> `DefaultAzureCredential` probes the Azure Instance Metadata Service (IMDS, `169.254.169.254`) and
> blocks for a long time on the network timeout before every model call, so requests appear to
> hang. Setting `AZURE_TOKEN_CREDENTIALS=dev` restricts `DefaultAzureCredential` to developer
> credentials (Azure CLI, Visual Studio, `azd`) and skips the managed-identity probe. This variable
> is only for local runs; the deployed agent in Foundry uses the platform-injected managed identity.
## Run and test locally
Local runs use two terminals: one hosts the agent, the other is a code-first client that talks to it
using Agent Framework components, see the sibling [`Using-Samples`](../Using-Samples/) REPLs.
**Terminal 1 — host the agent:**
```
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent-Dockerfile
az login
dotnet run
```
The agent starts on `http://localhost:8088`.
**Terminal 2 — chat with it (code-first REPL):**
PowerShell:
```powershell
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent
$env:AZURE_AI_AGENT_NAME = "hosted-chat-client-agent-docker"
dotnet run -- --local
```
Bash:
```bash
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent
export AZURE_AI_AGENT_NAME="hosted-chat-client-agent-docker"
dotnet run -- --local
```
To exercise the image instead of the host build, build and run the container directly:
```
docker build -t hosted-chat-client-agent-docker .
docker run --rm -p 8088:8088 --env-file .env hosted-chat-client-agent-docker
```
## Deploy to Foundry (container)
`azd` scaffolds the project into a working folder, so every step below runs from an **empty
directory outside the repository**, and `-m` points at this sample's `azure.yaml`.
### Step 1: create the working directory and enter it
PowerShell:
```powershell
$work = Join-Path $env:TEMP "hosted-chat-docker-work"
mkdir $work
cd $work
```
Bash:
```bash
WORK="${TMPDIR:-/tmp}/hosted-chat-docker-work"
mkdir -p "$WORK"
cd "$WORK"
```
### Step 2: scaffold the project
`--deploy-mode container` is the argument that selects the container path. Without it `azd`
defaults to `code` for .NET, which ignores the `Dockerfile` and deploys the source as a ZIP.
`azd ai agent init` copies the sample into a subfolder named after the top-level `name:` in
`azure.yaml`, which is `hosted-chat-client-agent-docker`. It also writes the adopted `azure.yaml`
and the `azd` environment there.
`azd ai agent init` prompts you to pick the Foundry project, so no project argument is needed.
`-d` is the name of an existing model deployment in that project; omit it and `azd` prompts for
that too.
> Pick an **existing** project at the prompt. The prompt needs an interactive terminal: run
> non-interactively (in CI, for example) and `azd` skips it and provisions a brand new Foundry
> project and resource group instead. Pass `-p <project-resource-id>` when you need that to be
> unattended.
`azure.yaml` passes the model deployment to the container by reading it from the `azd` environment.
Confirm it landed there, and set it yourself if it did not:
```
azd env get-values
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME <model-deployment>
```
PowerShell:
```powershell
$sample = "<repo>/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent-Dockerfile/azure.yaml"
azd auth login
azd ai agent init -m $sample -d <model-deployment> --deploy-mode container
```
Bash:
```bash
SAMPLE="<repo>/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent-Dockerfile/azure.yaml"
azd auth login
azd ai agent init -m "$SAMPLE" -d <model-deployment> --deploy-mode container
```
### Step 3: provision and deploy
Contributors: if you are changing the Agent Framework source in this repository and want the
deployed agent to run **your** build rather than the published packages, do the extra step in
[Deploy your local framework changes](#deploy-your-local-framework-changes-contributors) now,
before the commands below. Everyone else can ignore it.
```
cd hosted-chat-client-agent-docker
azd provision
azd deploy
azd ai agent invoke "Hello!"
```
`azd provision` creates the Azure Container Registry the image is pushed to, alongside the rest of
the environment. `azd deploy` builds the image (remotely in that registry by default), pushes it,
and creates the agent version.
To build the image on your own machine instead, flip `remoteBuild` to `false` in `azure.yaml`:
```yaml
docker:
remoteBuild: false
```
That requires Docker Desktop, and on Apple Silicon or other ARM machines you must produce an
x86_64 image, since the hosting platform only runs `linux/amd64`.
You can also test the deployed agent with the REPL:
PowerShell:
```powershell
cd <repo>/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent
$env:FOUNDRY_PROJECT_ENDPOINT = "https://<your-account>.services.ai.azure.com/api/projects/<your-project>"
$env:AZURE_AI_AGENT_NAME = "hosted-chat-client-agent-docker"
dotnet run -- --remote
```
Bash:
```bash
cd <repo>/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent
export FOUNDRY_PROJECT_ENDPOINT="https://<your-account>.services.ai.azure.com/api/projects/<your-project>"
export AZURE_AI_AGENT_NAME="hosted-chat-client-agent-docker"
dotnet run -- --remote
```
### Step 4: clean up
```
azd down
```
Then delete the working directory.
## Deploy your local framework changes (contributors)
**Skip this section unless you are changing the Agent Framework itself.** Everything above is the
complete flow for using the sample. This section only applies when you are working on the framework
source in this repository, or when you otherwise need a build of it that is not published on
nuget.org.
The reason it exists: the project restores the **published** Agent Framework packages, and the
`dotnet restore` inside the image build pulls them from nuget.org. So editing framework source in
this repository changes nothing about the deployed agent, no matter how many times you rebuild
locally. The image build context is self-contained and knows nothing about your working tree.
The extra step packs your local framework source into NuGet packages and puts them **inside the
build context**, together with a `nuget.config` that points the restore at them. The restore inside
the image build then resolves the framework from the packages you shipped instead of from
nuget.org.
Run it in the flow above, **between step 2 and step 3**. Nothing else changes, and it is the same
script the source-deploy sample uses: the `Dockerfile` copies the whole folder before restoring, so
a feed dropped in this folder is picked up with no change to the `Dockerfile`.
Run it from `$work`, the working directory created in step 1, which now holds the
`hosted-chat-client-agent-docker` folder that `azd ai agent init` scaffolded:
PowerShell:
```powershell
cd $work
<repo>/dotnet/samples/04-hosting/FoundryHostedAgents/scripts/Add-LocalFrameworkFeed.ps1 -Path ./hosted-chat-client-agent-docker
```
Bash:
```bash
cd "$WORK"
<repo>/dotnet/samples/04-hosting/FoundryHostedAgents/scripts/add-local-framework-feed.sh ./hosted-chat-client-agent-docker
```
Then continue with step 3. The path argument is optional: called without it, the script uses the
current directory, so you can also run it from inside `hosted-chat-client-agent-docker`.
The script changes three things in the scaffolded folder:
| Change | Detail |
|--------|--------|
| Creates `local-feed/` | The Agent Framework packed from your local source, stamped with a version like `1.15.0-preview-local.<timestamp>` |
| Creates `nuget.config` | Resolves `Microsoft.Agents.AI*` from that folder and everything else from nuget.org |
| Edits the `.csproj` | Repoints its `AgentFrameworkVersion` property at the version just packed |
Neither generated file is excluded by `.dockerignore`, so both reach the image build context and
the restore inside the build uses them. The scaffolded folder is a throwaway copy, so the
repository is left untouched.
Two details worth knowing:
- The version carries a timestamp because NuGet caches by package id and version. Reusing a version
would silently restore the previously packed bits instead of the build you just made.
- The whole package closure is packed, not just the two packages the sample references. Packing
only the leaf packages lets NuGet fill the rest from nuget.org, mixing a published core with a
locally built host, which fails to compile.
Before spending a deploy, build the scaffolded folder locally. A restore problem surfaces in
seconds instead of after the image build:
```
cd hosted-chat-client-agent-docker
dotnet build -c Debug --tl:off
```
## Troubleshooting
**`azd ai agent invoke` fails with `404 not_found: Conversation '<id>' not found`**
`azd` saves the session and conversation per agent and reuses them on the next invoke. Once the
agent is redeployed, deleted, or restarted, that saved conversation no longer exists on the server,
so every following invoke fails even though the agent itself is healthy. Start a fresh one:
```
azd ai agent invoke --new-conversation "Hello!"
```
Add `--new-session` as well if the failure persists.
For the full hosted-agent deployment guide, see the [official container deployment doc](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
@@ -0,0 +1,44 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json
name: hosted-chat-client-agent-docker
services:
ai-project:
host: azure.ai.project
hosted-chat-client-agent-docker:
project: .
host: azure.ai.agent
# `docker` selects the container build path: Foundry builds the Dockerfile in this folder
# instead of restoring a plain source folder. There is no codeConfiguration block here,
# which is what tells the tooling this is a container deploy rather than a source deploy.
language: docker
docker:
# azd builds the image in Azure Container Registry by default. Set this to false to
# build on your own machine instead, which requires Docker Desktop.
remoteBuild: true
uses:
- ai-project
# ${AZURE_AI_MODEL_DEPLOYMENT_NAME} reads the model deployment `azd ai agent init` recorded
# in the active azd environment. Without it the container falls back to the default model
# name hardcoded in Program.cs, which may not exist in the target project.
#
# The listen port is not set here: the Dockerfile already sets ASPNETCORE_URLS.
env:
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${AZURE_AI_MODEL_DEPLOYMENT_NAME}
container:
resources:
cpu: "0.5"
memory: 1Gi
description: |
A simple general-purpose AI assistant hosted as a Foundry Hosted Agent, deployed as a container image.
kind: hosted
metadata:
tags:
- AI Agent Hosting
- Azure AI AgentServer
- Responses Protocol
- Streaming
- Agent Framework
name: hosted-chat-client-agent-docker
protocols:
- protocol: responses
version: 2.0.0
@@ -0,0 +1,30 @@
# Controls which files are excluded from the code-deploy ZIP upload (.gitignore syntax).
# Note: only the root .agentignore is read; subdirectory files are not supported.
#
# To include a file that is excluded by default, use negation: !filename
# azd tooling files
azure.yaml
.agentignore
# Security / secrets
.env
.env.*
.azure/
.git/
# .NET build output
bin/
obj/
*.user
*.suo
.vs/
# Agent session state written by FileSystemAgentSessionStore during local runs. The hosted
# runtime writes its own under the container's home directory, so uploading the local copy
# would ship stale sessions with the agent.
.checkpoints/
# Contributor mode (scripts/Add-LocalFrameworkFeed.ps1) generates local-feed/ and nuget.config.
# Those are deliberately NOT excluded: the server-side restore needs them to resolve the Agent
# Framework from the packages shipped in this upload instead of nuget.org.
@@ -1,6 +1,17 @@
# Foundry project endpoint (shape: https://<host>/api/projects/<project>)
FOUNDRY_PROJECT_ENDPOINT=<your-azure-ai-project-endpoint>
# Model deployment name in your Foundry project.
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
# Local development only. Bind the app to the port Foundry probes for readiness, which is the
# port the Using-Samples REPLs expect. Recent Microsoft.Agents.AI.Foundry.Hosting versions bind
# it themselves, so this only matters while the project is pinned to an older published package.
ASPNETCORE_URLS=http://+:8088
ASPNETCORE_ENVIRONMENT=Development
FOUNDRY_MODEL=gpt-4o
AGENT_NAME=hosted-chat-client-agent
AZURE_BEARER_TOKEN=DefaultAzureCredential
# Local development only. Restrict DefaultAzureCredential to developer credentials
# (Azure CLI, Visual Studio, azd) and skip the Managed Identity probe. Without this,
# on a machine with no managed identity DefaultAzureCredential hangs for a long time
# probing the IMDS endpoint (169.254.169.254) before every model call. Not set in
# Foundry, where the platform-injected managed identity is used.
AZURE_TOKEN_CREDENTIALS=dev
@@ -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", "HostedChatClientAgent.dll"]
@@ -1,19 +0,0 @@
# Dockerfile for contributors building from the agent-framework repository source.
#
# This project uses ProjectReference to the local Microsoft.Agents.AI.Foundry source,
# 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-chat-client-agent .
# docker run --rm -p 8088:8088 -e AGENT_NAME=hosted-chat-client-agent --env-file .env hosted-chat-client-agent
#
# 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", "HostedChatClientAgent.dll"]
@@ -1,33 +1,47 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project>
<!--
Source (ZIP) deploy sample. The code-deploy upload is a flat folder with no repo-level props,
so this project is intentionally self-contained: a single target framework and explicit package
versions. Foundry runs `dotnet restore` + `dotnet publish` on it during provisioning
(dependencyResolution: remote_build in azure.yaml).
ImportDirectoryPackagesProps has to be set before the SDK props are imported, hence the explicit
Sdk imports below instead of the usual Sdk attribute on the Project element. It stops MSBuild
from walking up to the repository's dotnet/Directory.Packages.props, which does two things this
sample must avoid: it turns on central package management, and it injects analyzer
PackageReference items whose versions it also supplies. Neither exists inside the ZIP, so
without this the in-repo build would resolve differently from the server-side build.
-->
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<ImportDirectoryPackagesProps>false</ImportDirectoryPackagesProps>
</PropertyGroup>
<Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk.Web" />
<PropertyGroup>
<!-- Single target: the Foundry dotnet_10 runtime publishes without -f, so the
project must not multi-target. The empty TargetFrameworks clears the value
inherited from the repo's samples Directory.Build.props for in-repo builds. -->
<TargetFramework>net10.0</TargetFramework>
<TargetFrameworks></TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
<RootNamespace>HostedChatClientAgent</RootNamespace>
<AssemblyName>HostedChatClientAgent</AssemblyName>
<NoWarn>$(NoWarn);</NoWarn>
<UserSecretsId>222d2622-da26-4da0-99b4-0507fb8d41b0</UserSecretsId>
<AgentFrameworkVersion>1.15.0-preview.260722.1</AgentFrameworkVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DotNetEnv" />
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="$(AgentFrameworkVersion)" />
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="$(AgentFrameworkVersion)" />
<PackageReference Include="Azure.AI.Projects" Version="2.1.0-beta.4" />
<PackageReference Include="Azure.Identity" Version="1.21.0" />
<PackageReference Include="DotNetEnv" Version="3.1.1" />
</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="..\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="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
</ItemGroup>
-->
<Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk.Web" />
</Project>
@@ -1,36 +1,39 @@
// Copyright (c) Microsoft. All rights reserved.
// Sample: a minimal general-purpose AI assistant hosted as a Foundry Hosted Agent
// using the Responses protocol. It is deployed to Foundry directly from source
// (code / ZIP upload), so the platform builds and runs it with no container image.
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;
// Load .env file if present (for local development)
// Load a local .env file when present (local development only). In Foundry the
// platform injects the required environment variables at runtime.
Env.TraversePath().Load();
var projectEndpoint = new Uri(Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
var projectEndpoint = new Uri(System.Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."));
var agentName = Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-chat-client-agent";
// Environment variables can arrive set but blank: azd substitutes an empty string when the azd
// environment does not define the variable referenced from azure.yaml. An empty string is not
// null, so a plain ?? chain would pass the blank straight through and fail deep inside the SDK.
var model = FirstNonBlank(
System.Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME"),
System.Environment.GetEnvironmentVariable("FOUNDRY_MODEL"),
"gpt-4o");
var deployment = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-4o";
var agentName = System.Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-chat-client-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.
// Use a chained credential: try a temporary dev token first (for local Docker debugging),
// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity running in foundry).
TokenCredential credential = new ChainedTokenCredential(
new DevTemporaryTokenCredential(),
new DefaultAzureCredential());
// Create the agent via the AI project client using the Responses API.
AIAgent agent = new AIProjectClient(projectEndpoint, credential)
// WARNING: DefaultAzureCredential is convenient for development but requires careful
// consideration in production. Consider a specific credential (for example
// ManagedIdentityCredential) to avoid latency, unintended credential probing, and
// fallback security risks.
AIAgent agent = new AIProjectClient(projectEndpoint, new DefaultAzureCredential())
.AsAIAgent(
model: deployment,
model: model,
instructions: """
You are a helpful AI assistant hosted as a Foundry Hosted Agent.
You can help with a wide range of tasks including answering questions,
@@ -40,16 +43,15 @@ AIAgent agent = new AIProjectClient(projectEndpoint, credential)
name: agentName,
description: "A simple general-purpose AI assistant");
// Host the agent as a Foundry Hosted Agent using the Responses API.
// Host the agent using the Responses protocol.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddFoundryResponses(agent);
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();
// Returns the first candidate that has an actual value, ignoring null and blank entries.
static string FirstNonBlank(params string?[] candidates) =>
Array.Find(candidates, c => !string.IsNullOrWhiteSpace(c))!;
@@ -1,135 +1,303 @@
# Hosted-ChatClientAgent
# Hosted-ChatClientAgent
A simple general-purpose AI assistant hosted as a Foundry Hosted Agent using the Agent Framework instance hosting pattern. The agent is created inline via `AIProjectClient.AsAIAgent(model, instructions)` and served using the Responses protocol.
A minimal general-purpose AI assistant hosted as a Foundry Hosted Agent using the Responses protocol. The agent is created inline via `AIProjectClient.AsAIAgent(model, instructions)` and served with `AddFoundryResponses` / `MapFoundryResponses`.
This sample deploys to Foundry **directly from source (code / ZIP upload)**: the platform builds and runs your code with no container image, so there is no Dockerfile to author or container registry to manage.
The sibling [`Hosted-ChatClientAgent-Dockerfile`](../Hosted-ChatClientAgent-Dockerfile/) sample is the same agent deployed the other way, as a container image built from a `Dockerfile`. Source deploy is the default for .NET, so start here and switch only if you need control over the runtime image.
## Prerequisites
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
- A Foundry project with a deployed model (e.g., `gpt-4o`)
- An **existing** Foundry project with an **existing** model deployment (for example `gpt-4o`).
This sample's `azure.yaml` declares no `deployments:` block, so `azd` connects to a project and
a deployment you already have rather than creating them. `azd ai agent init` prompts you to pick
the project, and takes the deployment name as the `-d` argument.
- Azure CLI logged in (`az login`)
- Azure Developer CLI (`azd`) with the AI agents extension: `azd extension install azure.ai.agents`
## Files
| File | Purpose |
|------|---------|
| `Program.cs` | The agent: builds the agent, hosts it with the Responses protocol. |
| `azure.yaml` | The unified `azd` project file. Declares the Foundry project and the hosted agent with `codeConfiguration` (source/ZIP deploy), and passes the listen port and the model deployment name to the container through `env`. |
| `.agentignore` | Controls which files are excluded from the code-deploy ZIP upload (`.gitignore` syntax). |
| `HostedChatClientAgent.csproj` | Self-contained project: single target framework and explicit package versions. It also opts out of the repository's central package management, which does not travel inside the ZIP. |
| `.env.example` | Template for local configuration. |
| `../../scripts/Add-LocalFrameworkFeed.ps1`, `../../scripts/add-local-framework-feed.sh` | Contributor-only helpers, see [Deploy your local framework changes](#deploy-your-local-framework-changes-contributors). |
## Configuration
Copy the template and fill in your project endpoint:
PowerShell:
```powershell
copy .env.example .env
```
Bash:
```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>
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
ASPNETCORE_URLS=http://+:8088
ASPNETCORE_ENVIRONMENT=Development
FOUNDRY_MODEL=gpt-4o
AZURE_TOKEN_CREDENTIALS=dev
```
> **Note:** `.env` is gitignored. The `.env.example` template is checked in as a reference.
> `.env` is gitignored. The `.env.example` template is checked in as a reference.
## Running directly (contributors)
> `ASPNETCORE_URLS` pins the local run to the port the `Using-Samples` REPLs expect. Recent
> `Microsoft.Agents.AI.Foundry.Hosting` versions bind that port themselves, so it only matters
> while this project is pinned to an older published package.
This project uses `ProjectReference` to build against the local Agent Framework source.
> **Windows note:** write `.env` as UTF-8 **without** a byte order mark. `azd` reads the file
> during `azd ai agent init` and fails with `unexpected character "»" in variable name` when a mark
> is present. PowerShell's `Set-Content -Encoding UTF8BOM` adds one; use `-Encoding utf8NoBOM`.
```bash
> **Local development on a machine without a managed identity:** set `AZURE_TOKEN_CREDENTIALS=dev`.
> `Program.cs` authenticates with `DefaultAzureCredential` (the pattern the hosted platform
> expects, where a managed identity is injected). On a developer machine with no managed identity,
> `DefaultAzureCredential` probes the Azure Instance Metadata Service (IMDS, `169.254.169.254`) and
> blocks for a long time on the network timeout before every model call, so requests appear to
> hang. Setting `AZURE_TOKEN_CREDENTIALS=dev` restricts `DefaultAzureCredential` to developer
> credentials (Azure CLI, Visual Studio, `azd`) and skips the managed-identity probe. This variable
> is only for local runs; the deployed agent in Foundry uses the platform-injected managed identity.
## Run and test locally
Local runs use two terminals: one hosts the agent, the other is a code-first client that talks to it
using Agent Framework components, see the sibling [`Using-Samples`](../Using-Samples/) REPLs.
`AddFoundryResponses` binds the app to the port Foundry probes for readiness (8088 by default,
overridable with the `PORT` environment variable), and `MapFoundryResponses` serves the standard
`POST /responses` route. That is the same route the platform routes to for a deployed agent, so the
local server needs no extra wiring: the client just points an OpenAI responses client at
`http://localhost:8088`.
**Terminal 1 — host the agent:**
```
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent
az login
dotnet run
```
The agent will start on `http://localhost:8088`.
The agent starts on `http://localhost:8088`.
### Test it
**Terminal 2 — chat with it (code-first REPL):**
Using the Azure Developer CLI:
PowerShell:
```bash
azd ai agent invoke --local "Hello!"
```powershell
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent
$env:AZURE_AI_AGENT_NAME = "hosted-chat-client-agent"
dotnet run -- --local
```
Or with curl (specifying the agent name explicitly):
Bash:
```bash
curl -X POST http://localhost:8088/responses \
-H "Content-Type: application/json" \
-d '{"input": "Hello!", "model": "hosted-chat-client-agent"}'
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent
export AZURE_AI_AGENT_NAME="hosted-chat-client-agent"
dotnet run -- --local
```
## Running with Docker
Without `--local` the REPL asks which agent to chat with; choose **2 (Local)**. Either way it
points an OpenAI responses client at the local server and streams the reply.
Since this project uses `ProjectReference`, the standard `Dockerfile` cannot resolve dependencies outside this folder. Use `Dockerfile.contributor` which takes a pre-published output.
## Deploy to Foundry (source / ZIP)
### 1. Publish for the container runtime (Linux Alpine)
`azd` scaffolds the project into a working folder, so every step below runs from an **empty
directory outside the repository**, and `-m` points at this sample's `azure.yaml`.
```bash
dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
### Step 1: create the working directory and enter it
PowerShell:
```powershell
$work = Join-Path $env:TEMP "hosted-chat-work"
mkdir $work
cd $work
```
### 2. Build the Docker image
Bash:
```bash
docker build -f Dockerfile.contributor -t hosted-chat-client-agent .
WORK="${TMPDIR:-/tmp}/hosted-chat-work"
mkdir -p "$WORK"
cd "$WORK"
```
### 3. Run the container
### Step 2: scaffold the project
Generate a bearer token on your host and pass it to the container:
`azd ai agent init` copies the sample into a subfolder named after the top-level `name:` in
`azure.yaml`, which is `hosted-chat-client-agent`. It also writes the adopted `azure.yaml` and the
`azd` environment there.
```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)
`azd ai agent init` prompts you to pick the Foundry project, so no project argument is needed.
`-d` is the name of an existing model deployment in that project; omit it and `azd` prompts for
that too.
# Run with token
docker run --rm -p 8088:8088 \
-e AGENT_NAME=hosted-chat-client-agent \
-e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \
--env-file .env \
hosted-chat-client-agent
> Pick an **existing** project at the prompt. The prompt needs an interactive terminal: run
> non-interactively (in CI, for example) and `azd` skips it and provisions a brand new Foundry
> project and resource group instead. Pass `-p <project-resource-id>` when you need that to be
> unattended.
`azure.yaml` passes the model deployment to the container by reading it from the `azd` environment.
Confirm it landed there, and set it yourself if it did not:
```
azd env get-values
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME <model-deployment>
```
> **Note:** `AGENT_NAME` is passed via `-e` to simulate the platform injection. `AZURE_BEARER_TOKEN` provides Azure credentials to the container (tokens expire after ~1 hour). The `.env` file provides the remaining configuration.
PowerShell:
### 4. Test it
```powershell
$sample = "<repo>/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/azure.yaml"
Using the Azure Developer CLI:
```bash
azd ai agent invoke --local "Hello!"
azd auth login
azd ai agent init -m $sample -d <model-deployment>
```
Or with curl (specifying the agent name explicitly):
Bash:
```bash
curl -X POST http://localhost:8088/responses \
-H "Content-Type: application/json" \
-d '{"input": "Hello!", "model": "hosted-chat-client-agent"}'
SAMPLE="<repo>/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/azure.yaml"
azd auth login
azd ai agent init -m "$SAMPLE" -d <model-deployment>
```
## Deploying to Foundry (azd spec)
### Step 3: provision and deploy
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
Contributors: if you are changing the Agent Framework source in this repository and want the
deployed agent to run **your** build rather than the published packages, do the extra step in
[Deploy your local framework changes](#deploy-your-local-framework-changes-contributors) now,
before the commands below. Everyone else can ignore it.
Initialize an `azd` project from this sample's manifest:
```bash
mkdir hosted-chat-client-agent && cd hosted-chat-client-agent
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/agent.manifest.yaml
```
Then deploy:
```bash
cd hosted-chat-client-agent
azd provision
azd deploy
azd ai agent invoke "Hello!"
```
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
`azd` packages the source into a ZIP (honoring `.agentignore`), uploads it, and Foundry runs
`dotnet restore` + `dotnet publish` on it during provisioning (`dependencyResolution: remote_build`
in `azure.yaml`). No Dockerfile, no container registry.
You can also test the deployed agent with the REPL:
PowerShell:
```powershell
cd <repo>/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent
$env:FOUNDRY_PROJECT_ENDPOINT = "https://<your-account>.services.ai.azure.com/api/projects/<your-project>"
$env:AZURE_AI_AGENT_NAME = "hosted-chat-client-agent"
dotnet run -- --remote
```
Bash:
```bash
azd env set AGENT_NAME hosted-chat-client-agent
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
cd <repo>/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent
export FOUNDRY_PROJECT_ENDPOINT="https://<your-account>.services.ai.azure.com/api/projects/<your-project>"
export AZURE_AI_AGENT_NAME="hosted-chat-client-agent"
dotnet run -- --remote
```
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).
### Step 4: clean up
## NuGet package users
```
azd down
```
If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor` — it performs a full `dotnet restore` and `dotnet publish` inside the container. See the commented section in `HostedChatClientAgent.csproj` for the `PackageReference` alternative.
Then delete the working directory.
## Deploy your local framework changes (contributors)
**Skip this section unless you are changing the Agent Framework itself.** Everything above is the
complete flow for using the sample. This section only applies when you are working on the framework
source in this repository, or when you otherwise need a build of it that is not published on
nuget.org.
The reason it exists: the project restores the **published** Agent Framework packages, and Foundry
restores from nuget.org when it builds the upload. So editing framework source in this repository
changes nothing about the deployed agent, no matter how many times you rebuild locally. The
uploaded folder is self-contained and knows nothing about your working tree.
The extra step packs your local framework source into NuGet packages and puts them **inside the
upload**, together with a `nuget.config` that points the restore at them. The server-side restore
then resolves the framework from the packages you shipped instead of from nuget.org.
Run it in the flow above, **between step 2 and step 3**. Nothing else changes.
Run it from `$work`, the working directory created in step 1, which now holds the
`hosted-chat-client-agent` folder that `azd ai agent init` scaffolded:
PowerShell:
```powershell
cd $work
<repo>/dotnet/samples/04-hosting/FoundryHostedAgents/scripts/Add-LocalFrameworkFeed.ps1 -Path ./hosted-chat-client-agent
```
Bash:
```bash
cd "$WORK"
<repo>/dotnet/samples/04-hosting/FoundryHostedAgents/scripts/add-local-framework-feed.sh ./hosted-chat-client-agent
```
Then continue with step 3. The path argument is optional: called without it, the script uses the
current directory, so you can also run it from inside `hosted-chat-client-agent`.
The script changes three things in the scaffolded folder:
| Change | Detail |
|--------|--------|
| Creates `local-feed/` | The Agent Framework packed from your local source, stamped with a version like `1.15.0-preview-local.<timestamp>` |
| Creates `nuget.config` | Resolves `Microsoft.Agents.AI*` from that folder and everything else from nuget.org |
| Edits the `.csproj` | Repoints its `AgentFrameworkVersion` property at the version just packed |
Both generated files ship inside the ZIP, so the server-side restore resolves the framework from
the upload. The scaffolded folder is a throwaway copy, so the repository is left untouched.
Two details worth knowing:
- The version carries a timestamp because NuGet caches by package id and version. Reusing a version
would silently restore the previously packed bits instead of the build you just made.
- The whole package closure is packed, not just the two packages the sample references. Packing
only the leaf packages lets NuGet fill the rest from nuget.org, mixing a published core with a
locally built host, which fails to compile.
Before spending a deploy, build the scaffolded folder locally. A restore problem surfaces in
seconds instead of after the server-side build:
```
cd hosted-chat-client-agent
dotnet build -c Debug --tl:off
```
## Troubleshooting
**`azd ai agent invoke` fails with `404 not_found: Conversation '<id>' not found`**
`azd` saves the session and conversation per agent and reuses them on the next invoke. Once the
agent is redeployed, deleted, or restarted, that saved conversation no longer exists on the server,
so every following invoke fails even though the agent itself is healthy. Start a fresh one:
```
azd ai agent invoke --new-conversation "Hello!"
```
Add `--new-session` as well if the failure persists.
For the full hosted-agent deployment guide, see the [official source-code deployment doc](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent-code).
@@ -1,28 +0,0 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml
name: hosted-chat-client-agent
displayName: "Hosted Chat Client Agent"
description: >
A simple general-purpose AI assistant hosted as a Foundry Hosted Agent
using the Agent Framework instance hosting pattern.
metadata:
tags:
- AI Agent Hosting
- Azure AI AgentServer
- Responses Protocol
- Streaming
- Agent Framework
template:
name: hosted-chat-client-agent
kind: hosted
protocols:
- protocol: responses
version: 2.0.0
resources:
cpu: "0.25"
memory: 0.5Gi
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-chat-client-agent
protocols:
- protocol: responses
version: 2.0.0
resources:
cpu: "0.25"
memory: 0.5Gi
@@ -0,0 +1,46 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json
name: hosted-chat-client-agent
services:
ai-project:
host: azure.ai.project
hosted-chat-client-agent:
project: .
host: azure.ai.agent
language: csharp
uses:
- ai-project
codeConfiguration:
dependencyResolution: remote_build
entryPoint: HostedChatClientAgent.dll
runtime: dotnet_10
# ASPNETCORE_URLS pins the listen port. Source deploy runs this project as a plain ASP.NET
# app, and the .NET base image defaults it to port 80, while Foundry probes port 8088 for
# readiness, so without it every invoke fails with HTTP 424 session_not_ready. Recent
# Microsoft.Agents.AI.Foundry.Hosting versions bind the port themselves and take precedence
# over this value, so it only matters when the project is pinned to an older package.
#
# ${AZURE_AI_MODEL_DEPLOYMENT_NAME} reads the model deployment `azd ai agent init` recorded
# in the active azd environment. Without it the container falls back to the default model
# name hardcoded in Program.cs, which may not exist in the target project.
env:
ASPNETCORE_URLS: http://+:8088
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${AZURE_AI_MODEL_DEPLOYMENT_NAME}
container:
resources:
cpu: "0.5"
memory: 1Gi
description: |
A simple general-purpose AI assistant hosted as a Foundry Hosted Agent using the Agent Framework instance hosting pattern.
kind: hosted
metadata:
tags:
- AI Agent Hosting
- Azure AI AgentServer
- Responses Protocol
- Streaming
- Agent Framework
name: hosted-chat-client-agent
protocols:
- protocol: responses
version: 2.0.0
@@ -145,3 +145,17 @@ If you are consuming the Agent Framework as a NuGet package (not building from s
| **Model/instructions** | Set in `Program.cs` | Set in Foundry UI/CLI/API |
| **Tools** | Defined in code | Configured in the platform |
| **Use case** | Full control over agent behavior | Platform-managed agent with centralized config |
## Troubleshooting
**`azd ai agent invoke` fails with `404 not_found: Conversation '<id>' not found`**
`azd` saves the session and conversation per agent and reuses them on the next invoke. Once the
agent is redeployed, deleted, or restarted, that saved conversation no longer exists on the server,
so every following invoke fails even though the agent itself is healthy. Start a fresh one:
```
azd ai agent invoke --new-conversation "Hello!"
```
Add `--new-session` as well if the failure persists.
@@ -157,3 +157,17 @@ 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.
## Troubleshooting
**`azd ai agent invoke` fails with `404 not_found: Conversation '<id>' not found`**
`azd` saves the session and conversation per agent and reuses them on the next invoke. Once the
agent is redeployed, deleted, or restarted, that saved conversation no longer exists on the server,
so every following invoke fails even though the agent itself is healthy. Start a fresh one:
```
azd ai agent invoke --new-conversation "Hello!"
```
Add `--new-session` as well if the failure persists.

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