Compare commits

..

79 Commits

Author SHA1 Message Date
Evan Mattson cd5d282827 Merge branch 'main' into feature/python-subworkflow-checkpoint 2026-07-24 12:25:13 +09:00
Tao Chen 535690cd1d Add warning 2026-07-23 15:25:58 -07: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
Tao Chen 85fde62a76 Fix syntax check 2026-07-20 14:14:24 -07:00
Tao Chen 85eb53d412 Address comments 2026-07-20 13:27:35 -07:00
Tao Chen 2d7c8da6b0 Merge branch 'main' into feature/python-subworkflow-checkpoint 2026-07-20 11:16:36 -07: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
Tao Chen e0b0b79d9e Do not allow checkpoint storage in sub workflow 2026-07-14 14:30:46 -07:00
Tao Chen a4f6c26990 Clean up 2026-07-14 11:28:48 -07:00
Tao Chen 1389f304f2 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.
2026-07-14 11:11:49 -07:00
Tao Chen 93719f4a34 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.
2026-07-14 10:46:14 -07:00
Tao Chen a486374fd8 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.
2026-07-13 15:57:37 -07:00
Tao Chen e78604103d 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.
2026-07-13 15:18:53 -07:00
531 changed files with 29964 additions and 4389 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,
};
+22 -9
View File
@@ -1,25 +1,38 @@
// 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
* @returns {Promise<{author: string|null, isTeamMember: boolean}>}
*/
async function checkTeamMembership({ github, context, core, teamSlug, issueNumber }) {
let author = context.payload.issue?.user?.login;
let author =
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;
+51 -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 () => ({
@@ -64,6 +82,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',
});
});
});
+40 -24
View File
@@ -15,6 +15,7 @@ on:
permissions:
contents: read
id-token: write
issues: write
pull-requests: write
@@ -31,6 +32,7 @@ env:
jobs:
team_check:
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 }}
@@ -64,6 +66,31 @@ jobs:
echo "pr_number=${pr_number}" >> "$GITHUB_OUTPUT"
echo "repo=${GITHUB_REPOSITORY}" >> "$GITHUB_OUTPUT"
- 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 PR author team membership
id: check
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
@@ -71,31 +98,16 @@ jobs:
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,
});
core.setOutput('is_team_member', isTeamMember ? 'true' : 'false');
if (isTeamMember) {
core.info(`Author ${author} is a team member; proceeding with review.`);
@@ -107,6 +119,10 @@ jobs:
runs-on: ubuntu-latest
needs: team_check
if: ${{ needs.team_check.outputs.is_team_member == 'true' }}
permissions:
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
@@ -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 -2
View File
@@ -9,16 +9,31 @@ 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
COPILOT_GITHUB_TOKEN:
required: true
OPENAI__APIKEY:
required: true
permissions:
contents: read
id-token: write
jobs:
dotnet-integration-tests:
permissions:
contents: read
id-token: write
strategy:
fail-fast: false
matrix:
@@ -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:
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 }}
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
OPENAI__APIKEY: ${{ secrets.OPENAI__APIKEY }}
python-integration-tests:
name: Python Integration Tests
needs: resolve-ref
if: needs.resolve-ref.outputs.python-changes == 'true'
permissions:
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 }}
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
FOUNDRY_MODELS_API_KEY: ${{ secrets.FOUNDRY_MODELS_API_KEY }}
OPENAI__APIKEY: ${{ secrets.OPENAI__APIKEY }}
+24 -2
View File
@@ -33,6 +33,7 @@ env:
jobs:
team_check:
runs-on: ubuntu-latest
environment: github-app-auth
if: >-
${{
github.event_name == 'workflow_dispatch'
@@ -68,10 +69,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 +98,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 +124,10 @@ jobs:
|| needs.team_check.outputs.is_team_member == 'false'
}}
environment: integration
permissions:
contents: read
id-token: write
issues: write
timeout-minutes: 60
steps:
+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())
+29 -2
View File
@@ -13,13 +13,27 @@ 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
COPILOT_GITHUB_TOKEN:
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 +113,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 +241,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 +278,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 +345,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 +402,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
+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
+2
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
+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' }}
+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`.
+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));
});
```
+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" />
+18 -2
View File
@@ -29,6 +29,7 @@
<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/google-gemini/Agent_With_GoogleGemini/Agent_With_GoogleGemini.csproj" />
<Project Path="samples/02-agents/AgentProviders/ollama/Agent_With_Ollama/Agent_With_Ollama.csproj" />
@@ -200,6 +201,7 @@
<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" />
</Folder>
<Folder Name="/Samples/02-agents/AgentProviders/openai/">
<File Path="samples/02-agents/AgentProviders/openai/README.md" />
@@ -313,7 +315,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/" />
@@ -639,7 +653,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" />
@@ -427,6 +427,15 @@ 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.",
},
// ── AgentWithRAG ────────────────────────────────────────────────────
new SampleDefinition
+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.15.0</VersionPrefix>
<RCNumber>1</RCNumber>
<DateSuffix>260703</DateSuffix>
<DateSuffix>260722</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.15.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,
@@ -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
```
@@ -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.
@@ -9,6 +9,7 @@ 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.|
> **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;
@@ -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 },
},
@@ -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)
@@ -0,0 +1,53 @@
# Agent Framework hosting samples (bring your own route)
These samples show how to expose an Agent Framework agent or workflow over the OpenAI Responses HTTP
protocol from an ASP.NET Core app that you write, where your app owns the HTTP route, authentication, and
where conversations are stored.
## Two ways to expose an agent over the Responses protocol
Agent Framework gives you two options:
1. **`MapOpenAIResponses` (batteries included).** A single call maps a ready-made `/responses` endpoint that
handles the protocol, routing, and session storage for you. Pick this when you want a working endpoint
quickly and the built-in behavior fits. See [AgentWebChat](../../05-end-to-end/AgentWebChat) for a sample
that uses it.
2. **Call the conversion helpers from your own route (these samples).** You write the ASP.NET Core route and
call the `OpenAIResponses` helper methods to translate between the Responses HTTP payloads and the agent.
The framework only does the protocol translation, so you keep full control of routing, authentication,
and where conversations are stored. Pick this when you need hosting behavior the built-in endpoint does
not provide.
## Samples
| Sample | What it shows |
|---|---|
| [`local_responses/`](./local_responses) | An agent behind an ASP.NET Core route you write, using the `OpenAIResponses` helper methods plus `AgentSessionStore` for conversation continuity. The simplest sample to start with. |
| [`local_responses_workflow/`](./local_responses_workflow) | A workflow behind an ASP.NET Core route you write, using the `OpenAIResponses` helper methods, `HostedWorkflowState`, an explicit `CheckpointManager`, and a checkpoint cursor your app keeps so a run resumes across turns. |
Each sample is a **client/server pair** split into two projects:
```
local_responses/
├── Server/ # exposes POST /responses using the OpenAIResponses helper methods
└── Client/ # consumes it two ways: a chat client and an agent
```
The `Client` shows the two ways to consume the endpoint from .NET, both against the same server:
- A plain `Microsoft.Extensions.AI.IChatClient` (the lower-level chat-client path).
- A Microsoft Agent Framework `AIAgent` (the higher-level agent path).
## Relationship to `../FoundryHostedAgents/`
The sibling [`../FoundryHostedAgents/`](../FoundryHostedAgents) directory contains samples for agents that
run inside the Foundry Hosted Agents platform, which hosts the agent and exposes the protocol for you. Use
those when you want the Foundry-managed hosting surface; use these when you want to host the agent in your
own ASP.NET Core app.
| Aspect | `af-hosting/` (this directory) | `FoundryHostedAgents/` |
|---|---|---|
| Server stack | An ASP.NET Core app you write plus the hosting protocol helpers | Foundry Hosted Agents runtime |
| Who exposes the route | Your app | The platform |
| When to pick this | You need custom hosting code | You want the Foundry-managed hosting surface |
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="OpenAI" />
</ItemGroup>
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,79 @@
// Copyright (c) Microsoft. All rights reserved.
// Client for the HostingResponsesAgent server sample. It shows the two idiomatic ways to consume an
// OpenAI Responses endpoint from .NET, both pointed at the same server route (written in the paired Server):
//
// 1. CC - a plain Microsoft.Extensions.AI IChatClient (the lower-level chat-client path).
// 2. MAF - a Microsoft Agent Framework AIAgent + AgentSession (the higher-level agent path).
//
// Both run the same three-turn conversation. The third turn only makes sense if the server remembered
// the first turn, so it also proves multi-turn session continuity across the rotating response-id chain.
using System.ClientModel;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;
using OpenAI.Responses;
string serverUrl = Environment.GetEnvironmentVariable("RESPONSES_SERVER_URL") ?? "http://localhost:5000";
// The server ignores the model id (it runs its own configured agent), but the OpenAI SDK requires one to
// shape the request. Reuse FOUNDRY_MODEL for parity with the server sample.
string model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
string[] prompts =
[
"What is the weather in Tokyo?",
"And what about Amsterdam?",
"Which of the two cities we just discussed is warmer?",
];
// A single ResponsesClient pointed at the local server backs both consumption paths. The api key is unused
// by the sample server, but the SDK requires a credential.
ResponsesClient responseClient = new OpenAIClient(
new ApiKeyCredential("not-needed"),
new OpenAIClientOptions { Endpoint = new Uri(serverUrl) })
.GetResponsesClient();
Console.WriteLine($"Connecting to {serverUrl}\n");
await RunWithChatClientAsync(responseClient, model, prompts).ConfigureAwait(false);
await RunWithAgentAsync(responseClient, model, prompts).ConfigureAwait(false);
// CC path: consume the endpoint through a Microsoft.Extensions.AI IChatClient. Continuity is threaded by
// hand: each response carries the server's response id as ChatResponse.ConversationId, which we pass back
// as the next turn's ChatOptions.ConversationId. Because it is a "resp_" id, the SDK sends it as
// previous_response_id, exactly what the server's GetSessionStoreId reads.
static async Task RunWithChatClientAsync(ResponsesClient responseClient, string model, string[] prompts)
{
Console.WriteLine("== CC: Microsoft.Extensions.AI IChatClient ==");
IChatClient chatClient = responseClient.AsIChatClient(model);
string? previousResponseId = null;
foreach (string prompt in prompts)
{
Console.WriteLine($"User: {prompt}");
ChatResponse response = await chatClient.GetResponseAsync(
prompt,
new ChatOptions { ConversationId = previousResponseId }).ConfigureAwait(false);
Console.WriteLine($"Agent: {response.Text}");
previousResponseId = response.ConversationId;
Console.WriteLine($"Response ID: {previousResponseId}\n");
}
}
// MAF path: consume the same endpoint through an Agent Framework AIAgent. A single AgentSession threads the
// rotating response-id chain automatically, so the caller only sends the new user message each turn.
static async Task RunWithAgentAsync(ResponsesClient responseClient, string model, string[] prompts)
{
Console.WriteLine("== MAF: Agent Framework AIAgent + AgentSession ==");
AIAgent agent = responseClient.AsAIAgent(model: model, name: "HostedResponsesClient");
AgentSession session = await agent.CreateSessionAsync().ConfigureAwait(false);
foreach (string prompt in prompts)
{
Console.WriteLine($"User: {prompt}");
AgentResponse response = await agent.RunAsync(prompt, session).ConfigureAwait(false);
Console.WriteLine($"Agent: {response.Text}\n");
}
}
@@ -0,0 +1,20 @@
# Client (Hosting Responses Agent)
Client half of the [Hosting Responses Agent](../README.md) sample.
Runs the same three-turn conversation twice against the server's `POST /responses` route, once per
consumption path:
- **CC** — a plain `Microsoft.Extensions.AI.IChatClient` from `ResponsesClient.AsIChatClient(model)`.
Continuity is threaded by hand: each response's `ChatResponse.ConversationId` (a `resp_` id) is passed
back as the next turn's `ChatOptions.ConversationId`, which the SDK sends as `previous_response_id`.
- **MAF** — a Microsoft Agent Framework `AIAgent` from `ResponsesClient.AsAIAgent(...)`. A single
`AgentSession` threads the rotating response-id chain automatically.
The third turn asks about the first turn, so a correct answer proves multi-turn session continuity.
```bash
dotnet run
```
Defaults to `http://localhost:5000`; override with `RESPONSES_SERVER_URL`. Start the server first.
@@ -0,0 +1,57 @@
# Hosting Responses Agent (client / server)
A client/server pair showing how to expose an `AIAgent` over the OpenAI Responses protocol from an
ASP.NET Core route you write, and how to consume it from .NET two different ways.
```
local_responses/
├── Server/ # exposes POST /responses using the OpenAIResponses helpers
└── Client/ # consumes it two ways: CC (IChatClient) and MAF (AIAgent)
```
## Server
The server owns routing, authentication, and session storage. The framework provides only the protocol
conversion via `OpenAIResponses` (`ToAgentRunRequest`, `GetSessionStoreId`, `WriteResponse` /
`WriteResponseStreamAsync`), instead of the batteries-included `MapOpenAIResponses` endpoint. The agent has a
deterministic `lookup_weather` tool. Session continuity uses an in-memory `AgentSessionStore` directly. It
binds to `http://localhost:5000`.
See [Server/README.md](Server/README.md).
## Client
A single program that runs the same three-turn conversation twice, once per consumption path:
- **CC** — a plain `Microsoft.Extensions.AI.IChatClient` (the lower-level chat-client path).
- **MAF** — a Microsoft Agent Framework `AIAgent` + `AgentSession` (the higher-level agent path).
Both point at the same server. The third turn asks about the first turn, proving multi-turn session
continuity across the rotating response-id chain.
See [Client/README.md](Client/README.md).
## Run
Start the server in one shell:
```bash
export FOUNDRY_PROJECT_ENDPOINT="https://<your-resource>.services.ai.azure.com/api/projects/<your-project>"
export FOUNDRY_MODEL="gpt-5.4-mini" # optional, defaults to gpt-5.4-mini
dotnet run --project Server
```
Then run the client in another shell:
```bash
dotnet run --project Client
```
The client defaults to `http://localhost:5000`; override with `RESPONSES_SERVER_URL`.
## Security note
`OpenAIResponses.GetSessionStoreId(...)` returns an untrusted candidate key. The server's `Authorize(...)` is a
placeholder; a real application must authenticate the caller and authorize/bind the id to the authenticated
principal before using it as a session key. For multi-user hosts, scope the store with
`IsolationKeyScopedAgentSessionStore`.
@@ -0,0 +1,113 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample shows how an application can own its own ASP.NET Core route and expose an AIAgent over the
// OpenAI Responses protocol by calling the Agent Framework OpenAIResponses conversion helpers, instead of
// using the batteries-included MapOpenAIResponses server. The application keeps control of routing, auth,
// and session storage; the helpers provide only the protocol <-> agent conversion.
using System.ComponentModel;
using System.Text.Json;
using Azure.AI.Projects;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hosting;
using Microsoft.Agents.AI.Hosting.OpenAI;
using Microsoft.Extensions.AI;
var builder = WebApplication.CreateBuilder(args);
// Configuration via environment variables (never hardcode secrets).
string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
string model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
// A deterministic weather tool.
[Description("Return a deterministic weather report for a city.")]
static string LookupWeather([Description("The city to look up weather for.")] string location)
{
int highTemp = 5 + (System.Text.Encoding.UTF8.GetBytes(location).Sum(b => b) % 21);
return location switch
{
"Seattle" => $"Seattle is rainy with a high of {highTemp}°C.",
"Amsterdam" => $"Amsterdam is cloudy with a high of {highTemp}°C.",
"Tokyo" => $"Tokyo is clear with a high of {highTemp}°C.",
_ => $"{location} is sunny with a high of {highTemp}°C.",
};
}
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
AIAgent agent = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential())
.AsAIAgent(
model: model,
instructions: "You are a friendly weather assistant. Use the lookup_weather tool for any weather " +
"question and answer in one short sentence.",
name: "WeatherAgent",
tools: [AIFunctionFactory.Create(LookupWeather, name: "lookup_weather")]);
// The application owns session storage directly. The in-memory store's GetSessionAsync creates a session
// on first use and returns an independent instance per call; no shared holder is needed. A real app that
// runs concurrent turns against the same session id owns any coordination it needs.
AgentSessionStore sessionStore = new InMemoryAgentSessionStore();
var app = builder.Build();
// The application owns this route. It parses the OpenAI Responses body with the helpers, runs the agent
// itself, and renders the response with the helpers. Binding the body as JsonElement lets ASP.NET Core
// deserialize the JSON request body directly, so there is no JsonDocument to own or dispose.
app.MapPost("/responses", async (JsonElement body, HttpContext http, CancellationToken cancellationToken) =>
{
// Parse the request first, then read the continuation id off the parsed request (no second parse).
OpenAIResponsesRunRequest run = OpenAIResponses.ToAgentRunRequest(body);
// The candidate continuation id is untrusted. A real app authenticates the caller and authorizes/binds
// this key to the principal before using it. This sample simply falls back to a fresh id.
string? candidateSessionStoreId = OpenAIResponses.GetSessionStoreId(run);
string sessionStoreId = Authorize(http, candidateSessionStoreId) ?? OpenAIResponses.CreateResponseId();
AgentSession session = await sessionStore.GetSessionAsync(agent, sessionStoreId, cancellationToken).ConfigureAwait(false);
string responseId = OpenAIResponses.CreateResponseId();
// Choose where to persist the post-run session, which depends on how the caller continued the thread:
// - A stable "conversation" id is a MUTABLE HEAD: write the advanced session back under the same id so
// the next turn on that conversation sees this turn. Concurrent runs against one conversation id are
// NOT serialized here; a production app must provide its own per-conversation single-writer coordination.
// - Otherwise (a "previous_response_id" continuation or a first turn) the new response id is an IMMUTABLE
// SNAPSHOT: persist under it so a later previous_response_id can branch from this exact point, and two
// branches from the same prior response stay independent.
string? conversationId = run.ConversationId is { Length: > 0 } cid && cid == sessionStoreId ? cid : null;
string saveId = conversationId ?? responseId;
bool stream = body.TryGetProperty("stream", out JsonElement streamProp) && streamProp.ValueKind == JsonValueKind.True;
if (stream)
{
http.Response.ContentType = "text/event-stream";
var updates = agent.RunStreamingAsync(run.Messages, session, run.Options, cancellationToken);
await foreach (string frame in OpenAIResponses.WriteResponseStreamAsync(updates, responseId, responseId, cancellationToken).ConfigureAwait(false))
{
await http.Response.WriteAsync(frame, cancellationToken).ConfigureAwait(false);
await http.Response.Body.FlushAsync(cancellationToken).ConfigureAwait(false);
}
// Persist the post-run session under the selected continuation id (see saveId above).
await sessionStore.SaveSessionAsync(agent, saveId, session, cancellationToken).ConfigureAwait(false);
// The SSE body was already written straight to http.Response above, so return an empty result:
// this returns from the handler (the non-streaming code below does not run) without writing a body.
return Results.Empty;
}
AgentResponse result = await agent.RunAsync(run.Messages, session, run.Options, cancellationToken).ConfigureAwait(false);
await sessionStore.SaveSessionAsync(agent, saveId, session, cancellationToken).ConfigureAwait(false);
return Results.Json(OpenAIResponses.WriteResponse(result, responseId, responseId));
});
// Bind to a fixed local URL so the paired client sample has a deterministic default.
// Override with the ASPNETCORE_URLS environment variable when needed.
app.Run("http://localhost:5000");
// Application-owned trust decision. Replace with real authentication + authorization: verify the caller,
// then authorize/bind the candidate id to the authenticated principal before returning it.
static string? Authorize(HttpContext http, string? candidateSessionStoreId) => candidateSessionStoreId;
@@ -0,0 +1,45 @@
# Server (Hosting Responses Agent)
Server half of the [Hosting Responses Agent](../README.md) sample.
Exposes an `AIAgent` over the OpenAI Responses protocol on a `POST /responses` route you write:
- `OpenAIResponses.ToAgentRunRequest(body)` parses the request into messages, run options, and the
continuation ids.
- `OpenAIResponses.GetSessionStoreId(run)` reads the untrusted continuation-id candidate off the parsed
request.
- `OpenAIResponses.WriteResponse(...)` / `WriteResponseStreamAsync(...)` render the agent output back to the
Responses wire shape (non-streaming JSON and SSE).
Session continuity uses an in-memory `AgentSessionStore` directly. `GetSessionAsync(agent, id)` creates a
session on first use and returns an independent instance per call; the store does no internal locking, so a
route that runs concurrent turns against the same id owns any coordination it needs.
The route persists each turn under a continuation id chosen by how the caller continued the thread:
- A stable **`conversation` id is a mutable head**: the advanced session is written back under the same id,
so the next turn on that conversation sees this one. Concurrent runs against a single conversation id are
not serialized by the store; a production app must supply its own per-conversation single-writer
coordination.
- A **`previous_response_id` continuation (or a first turn) is an immutable snapshot**: the session is saved
under the newly minted response id, so a later `previous_response_id` can branch from that exact point and
two branches from the same prior response stay independent.
The agent has a deterministic `lookup_weather` tool. Binds to `http://localhost:5000` (override with
`ASPNETCORE_URLS`).
```bash
export FOUNDRY_PROJECT_ENDPOINT="https://<your-resource>.services.ai.azure.com/api/projects/<your-project>"
export FOUNDRY_MODEL="gpt-5.4-mini"
dotnet run
```
You can also call it directly with curl:
```bash
curl -s http://localhost:5000/responses -H "content-type: application/json" \
-d '{ "input": "What is the weather in Tokyo?" }'
curl -N http://localhost:5000/responses -H "content-type: application/json" \
-d '{ "input": "What is the weather in Tokyo?", "stream": true }'
```
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" />
</ItemGroup>
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.OpenAI\Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="OpenAI" />
</ItemGroup>
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,69 @@
// Copyright (c) Microsoft. All rights reserved.
// Client for the local_responses_workflow server sample. Like the agent client, it shows the two idiomatic
// ways to consume an OpenAI Responses endpoint from .NET, both pointed at the same workflow route (written in the paired Server):
//
// 1. CC - a plain Microsoft.Extensions.AI IChatClient (the lower-level chat-client path).
// 2. MAF - a Microsoft Agent Framework AIAgent + AgentSession (the higher-level agent path).
//
// The server implements previous_response_id continuation only (it rejects conversation-id continuity), so
// both paths follow the rotating response-id chain: the first turn sends a JSON brief, the follow-up turn
// continues from the first turn's response id. The workflow resumes its checkpoint across that chain.
using System.ClientModel;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;
using OpenAI.Responses;
string serverUrl = Environment.GetEnvironmentVariable("RESPONSES_SERVER_URL") ?? "http://localhost:5001";
string model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
const string Brief = """{ "topic": "electric SUV", "style": "playful", "audience": "young families" }""";
const string FollowUp = "Make it a little more premium, but still family friendly.";
ResponsesClient responseClient = new OpenAIClient(
new ApiKeyCredential("not-needed"),
new OpenAIClientOptions { Endpoint = new Uri(serverUrl) })
.GetResponsesClient();
Console.WriteLine($"Connecting to {serverUrl}\n");
await RunWithChatClientAsync(responseClient, model).ConfigureAwait(false);
await RunWithAgentAsync(responseClient, model).ConfigureAwait(false);
// CC path: consume the endpoint through a Microsoft.Extensions.AI IChatClient. Continuity is threaded by
// hand: each response's ChatResponse.ConversationId (a "resp_" id) is passed back as the next turn's
// ChatOptions.ConversationId, which the SDK sends as previous_response_id.
static async Task RunWithChatClientAsync(ResponsesClient responseClient, string model)
{
Console.WriteLine("== CC: Microsoft.Extensions.AI IChatClient ==");
IChatClient chatClient = responseClient.AsIChatClient(model);
Console.WriteLine($"User: {Brief}");
ChatResponse first = await chatClient.GetResponseAsync(Brief).ConfigureAwait(false);
Console.WriteLine($"Workflow: {first.Text}\n");
Console.WriteLine($"User: {FollowUp}");
ChatResponse second = await chatClient.GetResponseAsync(
FollowUp,
new ChatOptions { ConversationId = first.ConversationId }).ConfigureAwait(false);
Console.WriteLine($"Workflow: {second.Text}\n");
}
// MAF path: consume the same endpoint through an Agent Framework AIAgent. A single AgentSession threads the
// rotating previous_response_id chain automatically, so the caller only sends the new input each turn.
static async Task RunWithAgentAsync(ResponsesClient responseClient, string model)
{
Console.WriteLine("== MAF: Agent Framework AIAgent + AgentSession ==");
AIAgent agent = responseClient.AsAIAgent(model: model, name: "HostedWorkflowClient");
AgentSession session = await agent.CreateSessionAsync().ConfigureAwait(false);
Console.WriteLine($"User: {Brief}");
AgentResponse first = await agent.RunAsync(Brief, session).ConfigureAwait(false);
Console.WriteLine($"Workflow: {first.Text}\n");
Console.WriteLine($"User: {FollowUp}");
AgentResponse second = await agent.RunAsync(FollowUp, session).ConfigureAwait(false);
Console.WriteLine($"Workflow: {second.Text}\n");
}
@@ -0,0 +1,20 @@
# Client (Hosting Responses Workflow)
Client half of the [Hosting Responses Workflow](../README.md) sample.
Runs the same two-turn conversation twice against the server's `POST /responses` route, once per consumption
path:
- **CC** — a plain `Microsoft.Extensions.AI.IChatClient` from `ResponsesClient.AsIChatClient(model)`.
- **MAF** — a Microsoft Agent Framework `AIAgent` + `AgentSession` from `ResponsesClient.AsAIAgent(...)`.
Both send a JSON brief on the first turn and a refinement on the second, following the rotating
`previous_response_id` chain (the CC path threads it by hand via `ChatOptions.ConversationId`; the MAF path
lets `AgentSession` do it). The follow-up only makes sense if the workflow resumed the first turn's
checkpoint, so it proves checkpoint continuity.
```bash
dotnet run
```
Defaults to `http://localhost:5001`; override with `RESPONSES_SERVER_URL`. Start the server first.
@@ -0,0 +1,64 @@
# Hosting Responses Workflow (client / server)
A client/server pair showing how to expose a **workflow** over the OpenAI Responses protocol from an
ASP.NET Core route you write, with per-session checkpoint resume, and how to consume it from .NET two
different ways.
```
local_responses_workflow/
├── Server/ # exposes POST /responses; previous_response_id continuation with checkpoint resume
└── Client/ # consumes it two ways: CC (IChatClient) and MAF (AIAgent)
```
## Server
The server owns routing, authentication, and checkpoint storage. It uses the `OpenAIResponses` conversion
helpers for the wire protocol and `HostedWorkflowState` for per-session checkpoint resume. The workflow is a
brief adapter, a slogan-writer agent, and a formatter that renders one slogan line. The first turn runs the
workflow forward; later turns restore the latest checkpoint and run forward with the new brief. It binds to
`http://localhost:5001`.
The server supports **`previous_response_id` continuation only** and **rejects `conversation` continuity
with HTTP 400**. Because `previous_response_id` rotates every turn, the app owns a cursor that maps each
response id to the stable workflow session id, so the whole rotating chain resumes the same checkpointed
run.
See [Server/README.md](Server/README.md).
## Client
A single program that runs the same two-turn conversation twice, once per consumption path:
- **CC** — a plain `Microsoft.Extensions.AI.IChatClient` (the lower-level chat-client path).
- **MAF** — a Microsoft Agent Framework `AIAgent` + `AgentSession` (the higher-level agent path).
Both send a JSON brief on the first turn and a refinement on the second, following the rotating
`previous_response_id` chain (the CC path threads it by hand; the MAF path lets `AgentSession` do it). The
follow-up only makes sense if the workflow resumed the first turn's checkpoint.
See [Client/README.md](Client/README.md).
## Run
Start the server in one shell:
```bash
export FOUNDRY_PROJECT_ENDPOINT="https://<your-resource>.services.ai.azure.com/api/projects/<your-project>"
export FOUNDRY_MODEL="gpt-5.4-mini" # optional, defaults to gpt-5.4-mini
dotnet run --project Server
```
Then run the client in another shell:
```bash
dotnet run --project Client
```
The client defaults to `http://localhost:5001`; override with `RESPONSES_SERVER_URL`.
## Why previous_response_id needs a cursor
`previous_response_id` changes every turn, so it cannot key checkpoint storage directly. The app maps each
response id to the stable workflow session id, so every id in the rotating chain resumes the same
checkpointed run. Sending `conversation` is rejected with HTTP 400 to keep this sample focused on one
continuation mode.
@@ -0,0 +1,202 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample shows how an application can own its own ASP.NET Core route and expose a workflow over the
// OpenAI Responses protocol. It uses the OpenAIResponses conversion helpers for the wire protocol and
// HostedWorkflowState for per-session checkpoint resume. The application keeps control of routing, auth,
// and checkpoint storage.
//
// This server demonstrates previous_response_id continuation ONLY. It rejects conversation-id continuity
// with HTTP 400. Because previous_response_id rotates every turn, the app owns a cursor store that maps each
// response id to the stable workflow session id, so the whole rotating chain resumes the same checkpointed
// run.
using System.Collections.Concurrent;
using System.Text.Json;
using Azure.AI.Projects;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hosting;
using Microsoft.Agents.AI.Hosting.OpenAI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;
var builder = WebApplication.CreateBuilder(args);
// Configuration via environment variables (never hardcode secrets).
string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
string model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
var projectClient = new AIProjectClient(new Uri(endpoint), new AzureCliCredential());
AIAgent writer = projectClient.AsAIAgent(
model: model,
instructions: "You are an excellent slogan writer. Create one short slogan from the given brief.",
name: "writer");
// Workflow shape: a brief adapter turns the Responses input into the writer's
// prompt and drives the agent turn, then a formatter renders the writer's output as a single slogan line.
// A factory builds a fresh workflow instance per run so independent sessions can run concurrently.
static Workflow BuildWorkflow(AIAgent writer)
{
var briefExecutor = new BriefExecutor();
var formatterExecutor = new SloganFormatterExecutor();
return new WorkflowBuilder(briefExecutor)
.AddEdge(briefExecutor, writer)
.AddEdge(writer, formatterExecutor)
.WithOutputFrom(formatterExecutor)
.Build();
}
// Optional shared execution state: the factory constructor builds a fresh workflow instance per run (the
// default, cacheWorkflow: false, shown explicitly here), so independent sessions run in parallel — a single
// shared instance cannot run concurrent turns. Pass cacheWorkflow: true instead to build the workflow once,
// lazily on first use, and reuse it (a deferred, cached target that, like a shared instance, cannot run
// concurrent turns). It is paired with an in-memory CheckpointManager and a per-session
// sessionId -> CheckpointInfo head cursor so a session can resume from its last checkpoint; a resume rehydrates
// a fresh instance from that shared checkpoint store.
var state = new HostedWorkflowState(_ => new ValueTask<Workflow>(BuildWorkflow(writer)), cacheWorkflow: false);
// The app keeps a response-id -> workflow-session-id cursor. previous_response_id rotates each turn, so every id in
// a conversation's chain maps to the same workflow session, and resuming any of them restores that session's
// latest checkpoint. In-memory for this local sample; a real app persists this per tenant/user.
var responseToSession = new ConcurrentDictionary<string, string>(StringComparer.Ordinal);
var app = builder.Build();
// The application owns this route. Binding the body as JsonElement lets ASP.NET Core deserialize the JSON
// request body directly, so there is no JsonDocument to own or dispose.
app.MapPost("/responses", async (JsonElement body, CancellationToken cancellationToken) =>
{
OpenAIResponsesRunRequest run;
try
{
run = OpenAIResponses.ToAgentRunRequest(body);
}
catch (ArgumentException)
{
return Results.BadRequest();
}
// This sample supports previous_response_id continuation only, read off the already-parsed request.
// A conversation id is not implemented here, so reject it. The candidate is untrusted: a real app
// authenticates the caller and authorizes/binds it before use.
if (run.ConversationId is not null)
{
return Results.Problem(
detail: "This server supports previous_response_id continuation only; conversation is not implemented.",
statusCode: StatusCodes.Status400BadRequest);
}
string? previousResponseId = run.PreviousResponseId;
string responseId = OpenAIResponses.CreateResponseId();
// Resolve the workflow session: continue the chain's session when previous_response_id is known, otherwise
// start a fresh workflow continuation.
string sessionStoreId = previousResponseId is not null && responseToSession.TryGetValue(previousResponseId, out string? existing)
? existing
: Guid.NewGuid().ToString("N");
// Runs the workflow forward on the first call for this session, or restores the session's latest checkpoint
// and runs forward with this turn's brief thereafter, then records the new head checkpoint.
string brief = ExtractBrief(run.Messages);
HostedWorkflowRunResult result = await state.RunOrResumeAsync(sessionStoreId, brief, cancellationToken).ConfigureAwait(false);
// Map this response id onto the workflow session so the next previous_response_id continues the same run.
responseToSession[responseId] = sessionStoreId;
AgentResponse response = BuildWorkflowResponse(result);
return Results.Json(OpenAIResponses.WriteResponse(response, responseId, previousResponseId));
});
// Bind to a fixed local URL so the paired client sample has a deterministic default.
// Override with the ASPNETCORE_URLS environment variable when needed.
app.Run("http://localhost:5001");
// Flattens the Responses input messages into a single brief string for the workflow's start executor.
static string ExtractBrief(IEnumerable<ChatMessage> messages)
=> string.Join("\n", messages.Select(m => m.Text).Where(t => !string.IsNullOrWhiteSpace(t))).Trim();
// Extracts the workflow's final string output (the formatted slogan) from its output events, falling back to a
// short run summary when the workflow emitted no string output this turn.
static AgentResponse BuildWorkflowResponse(HostedWorkflowRunResult result)
{
string? slogan = null;
foreach (WorkflowEvent evt in result.Events)
{
if (evt is WorkflowOutputEvent output && output.Data is string text && !string.IsNullOrWhiteSpace(text))
{
slogan = text;
}
}
return new AgentResponse(new ChatMessage(
ChatRole.Assistant,
slogan ?? $"{result.Events.Count} workflow event(s) processed."));
}
/// <summary>
/// Adapts the Responses brief into the writer agent's turn. It builds the writer prompt from the brief (a plain
/// topic string or a JSON object with topic/style/audience), sends it as a user message, and emits the
/// <see cref="TurnToken"/> that drives the downstream agent. This keeps the workflow non-chat-protocol (its
/// output is a plain string) while still driving the agent.
/// </summary>
[SendsMessage(typeof(ChatMessage))]
[SendsMessage(typeof(TurnToken))]
internal sealed class BriefExecutor() : Executor<string>("brief")
{
public override async ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
string topic = message.Trim();
string style = "modern";
string audience = "general";
if (topic.StartsWith('{'))
{
try
{
using JsonDocument doc = JsonDocument.Parse(topic);
JsonElement root = doc.RootElement;
if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty("topic", out JsonElement topicElement))
{
topic = topicElement.GetString() ?? topic;
style = root.TryGetProperty("style", out JsonElement styleElement) ? styleElement.GetString() ?? style : style;
audience = root.TryGetProperty("audience", out JsonElement audienceElement) ? audienceElement.GetString() ?? audience : audience;
}
}
catch (JsonException)
{
// Not a JSON brief; treat the whole text as the topic.
}
}
if (string.IsNullOrWhiteSpace(topic))
{
topic = "a generic product";
}
string prompt =
$"Topic: {topic}\n" +
$"Style: {style}\n" +
$"Audience: {audience}\n\n" +
"Write a single short slogan that fits the topic, style, and audience.";
await context.SendMessageAsync(new ChatMessage(ChatRole.User, prompt), cancellationToken: cancellationToken).ConfigureAwait(false);
await context.SendMessageAsync(new TurnToken(emitEvents: true), cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
/// <summary>
/// Formats the writer agent's output as the workflow's final response: one terminal-friendly slogan line.
/// </summary>
internal sealed class SloganFormatterExecutor() : Executor<List<ChatMessage>, string>("terminal_formatter")
{
public override ValueTask<string> HandleAsync(List<ChatMessage> message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
string slogan = string.Join("\n", message.Select(m => m.Text ?? string.Empty)).Trim().Trim('"');
return ValueTask.FromResult($"Slogan: \"{slogan}\"");
}
}
@@ -0,0 +1,40 @@
# Server (Hosting Responses Workflow)
Server half of the [Hosting Responses Workflow](../README.md) sample.
Exposes a workflow over the OpenAI Responses protocol on a `POST /responses` route you write. The workflow
is a brief adapter, a slogan-writer agent, and a formatter that renders one slogan line. It uses the
`OpenAIResponses` conversion helpers for the wire protocol and `HostedWorkflowState` for per-session
checkpoint resume.
This server supports **`previous_response_id` continuation only** and **rejects `conversation` continuity
with HTTP 400**. Because `previous_response_id` rotates every turn, the app owns a cursor that maps each
response id to the stable workflow session id, so the whole rotating chain resumes the same checkpointed
run. The first turn runs the workflow forward; later turns restore the latest checkpoint and run forward
with the new brief. Binds to `http://localhost:5001` (override with `ASPNETCORE_URLS`).
`HostedWorkflowState` is constructed with a **workflow factory** and `cacheWorkflow: false` (the default,
shown explicitly), so it builds a fresh workflow instance for every run. This lets independent sessions run
concurrently — a single shared `Workflow` instance permits only one active run, so the instance constructor
cannot process turns concurrently. A resume rehydrates a fresh instance from the session's checkpoint in the
shared `CheckpointManager`, so per-run instances still continue the same run. (Passing `cacheWorkflow: true`
would instead build the workflow once, lazily, and reuse it — a deferred, cached target that, like a shared
instance, cannot run concurrent turns.) Concurrent turns against the *same* session id are the application's
responsibility; a production app owns that per-session single-writer coordination.
```bash
export FOUNDRY_PROJECT_ENDPOINT="https://<your-resource>.services.ai.azure.com/api/projects/<your-project>"
export FOUNDRY_MODEL="gpt-5.4-mini"
dotnet run
```
Call it directly, following the response-id chain across turns (the second call continues the first):
```bash
curl -s http://localhost:5001/responses -H "content-type: application/json" \
-d '{ "input": "{\"topic\": \"electric SUV\", \"style\": \"playful\", \"audience\": \"young families\"}" }'
# Take the "id" (resp_...) from the response above and pass it as previous_response_id:
curl -s http://localhost:5001/responses -H "content-type: application/json" \
-d '{ "input": "Make it a little more premium.", "previous_response_id": "resp_..." }'
```
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" />
</ItemGroup>
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.OpenAI\Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
</ItemGroup>
</Project>
+1 -1
View File
@@ -30,7 +30,7 @@ dotnet/samples/
│ │ └── openai/ # OpenAI provider samples
│ ├── AgentOpenTelemetry/ # OpenTelemetry integration
│ ├── AgentSkills/ # Agent skills patterns
│ ├── AgentWithMemory/ # Memory providers (chat history, Mem0, Foundry)
│ ├── AgentWithMemory/ # Memory providers (chat history, Mem0, Valkey, Foundry, AgentMemory)
│ ├── AgentWithRAG/ # RAG patterns (text, vector store, Foundry)
│ ├── AGUI/ # AG-UI protocol samples
│ ├── DeclarativeAgents/ # Declarative agent definitions
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.Agents.Core.Models;
using Microsoft.Extensions.AI;
@@ -38,8 +39,27 @@ internal static class ActivityProcessor
private static ChatMessage CreateChatMessageFromActivity(IActivity activity, IEnumerable<AIContent> messageContent) =>
new(ChatRole.Assistant, [.. messageContent])
{
AdditionalProperties = MapAdditionalProperties(activity),
AuthorName = activity.From?.Name,
CreatedAt = activity.Timestamp,
MessageId = activity.Id,
RawRepresentation = activity
};
private static AdditionalPropertiesDictionary? MapAdditionalProperties(IActivity activity)
{
IDictionary<string, JsonElement>? properties = activity.Properties;
if (properties is null || properties.Count == 0)
{
return null;
}
var additionalProperties = new AdditionalPropertiesDictionary();
foreach (KeyValuePair<string, JsonElement> property in properties)
{
additionalProperties[property.Key] = property.Value;
}
return additionalProperties;
}
}
@@ -4,6 +4,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Runtime.ExceptionServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@@ -98,14 +99,7 @@ public class CopilotStudioAgent : AIAgent
responseMessagesList.Add(message);
}
// TODO: Review list of ChatResponse properties to ensure we set all availble values.
// Setting ResponseId and MessageId end up being particularly important for streaming consumers
// so that they can tell things like response boundaries.
return new AgentResponse(responseMessagesList)
{
AgentId = this.Id,
ResponseId = responseMessagesList.LastOrDefault()?.MessageId,
};
return CreateAgentResponse(responseMessagesList, this.Id);
}
/// <inheritdoc/>
@@ -132,24 +126,113 @@ public class CopilotStudioAgent : AIAgent
string question = string.Join("\n", messages.Select(m => m.Text));
var responseMessages = ActivityProcessor.ProcessActivityAsync(this.Client.AskQuestionAsync(question, typedSession.ConversationId, cancellationToken), streaming: true, this._logger);
// Enumerate the response messages
await foreach (ChatMessage message in responseMessages.ConfigureAwait(false))
await foreach (AgentResponseUpdate update in CreateAgentResponseUpdatesAsync(responseMessages, this.Id, cancellationToken).ConfigureAwait(false))
{
// TODO: Review list of ChatResponse properties to ensure we set all availble values.
// Setting ResponseId and MessageId end up being particularly important for streaming consumers
// so that they can tell things like response boundaries.
yield return new AgentResponseUpdate(message.Role, message.Contents)
{
AgentId = this.Id,
AdditionalProperties = message.AdditionalProperties,
AuthorName = message.AuthorName,
RawRepresentation = message.RawRepresentation,
ResponseId = message.MessageId,
MessageId = message.MessageId,
};
yield return update;
}
}
/// <summary>
/// Builds an <see cref="AgentResponse"/> from the messages returned by the Copilot Studio agent,
/// populating the response-level metadata (such as <see cref="AgentResponse.CreatedAt"/>,
/// <see cref="AgentResponse.FinishReason"/> and <see cref="AgentResponse.RawRepresentation"/>) from the
/// final message so that consumers see the same surface as other <see cref="AIAgent"/> implementations.
/// </summary>
internal static AgentResponse CreateAgentResponse(IList<ChatMessage> messages, string? agentId)
{
ChatMessage? lastMessage = messages.Count > 0 ? messages[messages.Count - 1] : null;
return new AgentResponse(messages)
{
AgentId = agentId,
ResponseId = lastMessage?.MessageId,
CreatedAt = lastMessage?.CreatedAt,
FinishReason = ChatFinishReason.Stop,
RawRepresentation = lastMessage?.RawRepresentation,
AdditionalProperties = lastMessage?.AdditionalProperties,
};
}
/// <summary>
/// Projects the streamed <see cref="ChatMessage"/> sequence onto <see cref="AgentResponseUpdate"/> instances,
/// carrying per-update metadata and setting <see cref="AgentResponseUpdate.FinishReason"/> only on the terminal
/// update so streaming consumers can detect the response boundary.
/// </summary>
internal static async IAsyncEnumerable<AgentResponseUpdate> CreateAgentResponseUpdatesAsync(
IAsyncEnumerable<ChatMessage> messages,
string? agentId,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
// Buffer a single message so we know which update is the terminal one (it carries the finish reason).
// Manual enumeration lets us still emit any already-received content if the source faults mid-stream,
// preserving the original streaming behavior, before re-throwing the original exception.
ChatMessage? pending = null;
ExceptionDispatchInfo? failure = null;
IAsyncEnumerator<ChatMessage> enumerator = messages.GetAsyncEnumerator(cancellationToken);
try
{
while (true)
{
bool moved;
try
{
moved = await enumerator.MoveNextAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
failure = ExceptionDispatchInfo.Capture(ex);
break;
}
if (!moved)
{
break;
}
if (pending is not null)
{
yield return CreateAgentResponseUpdate(pending, agentId, finishReason: null);
}
pending = enumerator.Current;
}
}
finally
{
try
{
await enumerator.DisposeAsync().ConfigureAwait(false);
}
catch when (failure is not null)
{
// A fault was already captured from the stream; don't let a disposal
// exception override the original streaming exception.
}
}
if (pending is not null)
{
// The last received message is the terminal update only when the stream completed successfully.
yield return CreateAgentResponseUpdate(pending, agentId, finishReason: failure is null ? ChatFinishReason.Stop : null);
}
failure?.Throw();
}
private static AgentResponseUpdate CreateAgentResponseUpdate(ChatMessage message, string? agentId, ChatFinishReason? finishReason) =>
new(message.Role, message.Contents)
{
AgentId = agentId,
AdditionalProperties = message.AdditionalProperties,
AuthorName = message.AuthorName,
CreatedAt = message.CreatedAt,
FinishReason = finishReason,
RawRepresentation = message.RawRepresentation,
ResponseId = message.MessageId,
MessageId = message.MessageId,
};
private async Task<string> StartNewConversationAsync(CancellationToken cancellationToken)
{
string? conversationId = null;
@@ -19,6 +19,10 @@
<PackageReference Include="Microsoft.Agents.CopilotStudio.Client" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Agents.AI.UnitTests" />
</ItemGroup>
<PropertyGroup>
<!-- NuGet Package Settings -->
<Title>Microsoft Agent Framework Copilot Studio</Title>
@@ -1,17 +1,14 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Agents.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Extensions.AI;
/// <summary>
/// Provides extension methods for creating a <see cref="HarnessAgent"/> from an <see cref="IChatClient"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public static class ChatClientHarnessExtensions
{
/// <summary>
@@ -2,16 +2,11 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using Microsoft.Agents.AI.Compaction;
#if NET
using Microsoft.Agents.AI.Tools.Shell;
#endif
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
@@ -51,7 +46,6 @@ namespace Microsoft.Agents.AI;
/// <list type="bullet">
/// <item><description><see cref="FileAccessProvider"/> — shared file access providing read/write tools for a working directory. Enable by setting <see cref="HarnessAgentOptions.FileAccessStore"/>; configure via <see cref="HarnessAgentOptions.FileAccessProviderOptions"/>.</description></item>
/// <item><description><see cref="BackgroundAgentsProvider"/> — enables delegation to background agents for parallel work. Enable by setting <see cref="HarnessAgentOptions.BackgroundAgents"/>.</description></item>
/// <item><description><c>ShellEnvironmentProvider</c> — injects OS/shell/CWD information and a shell execution tool. Enable by setting <c>HarnessAgentOptions.ShellExecutor</c> (.NET only).</description></item>
/// </list>
/// </para>
/// <para>
@@ -80,7 +74,6 @@ namespace Microsoft.Agents.AI;
/// and combined with agent-specific instructions via <see cref="ChatOptions.Instructions"/>.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class HarnessAgent : DelegatingAIAgent
{
/// <summary>
@@ -222,6 +215,15 @@ public sealed class HarnessAgent : DelegatingAIAgent
// Build ChatClient stack
ChatClientBuilder chatClientBuilder = chatClient.AsBuilder();
// Registered first so it sits as the outermost decorator, above the approval-not-required bypassing
// and function invocation middleware, so it can bind inbound approval responses to the requests the
// framework surfaced. The harness uses UseProvidedChatClientAsIs, so this is added manually here rather
// than via the default ChatClientAgent pipeline.
if (options?.DisableApprovalResponseBinding is not true)
{
chatClientBuilder.UseApprovalResponseBinding();
}
if (options?.DisableApprovalNotRequiredFunctionBypassing is not true)
{
chatClientBuilder.UseApprovalNotRequiredFunctionBypassing();
@@ -279,16 +281,6 @@ public sealed class HarnessAgent : DelegatingAIAgent
result.Tools.Add(new HostedWebSearchTool());
}
#if NET
if (options?.ShellExecutor is ShellExecutor shellExecutor)
{
result.Tools ??= [];
result.Tools.Add(options.ShellToolName is { } shellToolName
? shellExecutor.AsAIFunction(shellToolName, options.ShellToolDescription, !options.DisableShellToolApproval)
: shellExecutor.AsAIFunction(description: options.ShellToolDescription, requireApproval: !options.DisableShellToolApproval));
}
#endif
return result;
}
@@ -343,13 +335,6 @@ public sealed class HarnessAgent : DelegatingAIAgent
}
}
#if NET
if (options?.ShellExecutor is ShellExecutor shellExecutor)
{
providers.Add(new ShellEnvironmentProvider(shellExecutor, options.ShellEnvironmentProviderOptions));
}
#endif
if (options?.AIContextProviders is IEnumerable<AIContextProvider> userProviders)
{
providers.AddRange(userProviders);
@@ -3,9 +3,6 @@
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Agents.AI.Compaction;
#if NET
using Microsoft.Agents.AI.Tools.Shell;
#endif
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
@@ -14,7 +11,6 @@ namespace Microsoft.Agents.AI;
/// <summary>
/// Represents configuration options for a <see cref="HarnessAgent"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class HarnessAgentOptions
{
/// <summary>
@@ -46,6 +42,7 @@ public sealed class HarnessAgentOptions
/// <see langword="true"/>.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public int? MaxContextWindowTokens { get; set; }
/// <summary>
@@ -62,6 +59,7 @@ public sealed class HarnessAgentOptions
/// is provided and <see cref="DisableCompaction"/> is <see langword="false"/>.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public int? MaxOutputTokens { get; set; }
/// <summary>
@@ -81,6 +79,7 @@ public sealed class HarnessAgentOptions
/// This property is ignored when <see cref="DisableCompaction"/> is <see langword="true"/>.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public CompactionStrategy? CompactionStrategy { get; set; }
/// <summary>
@@ -92,6 +91,7 @@ public sealed class HarnessAgentOptions
/// <see cref="CompactionProvider"/> is added to the chat client pipeline, and the default
/// <see cref="InMemoryChatHistoryProvider"/> is configured without a chat reducer.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public bool DisableCompaction { get; set; }
/// <summary>
@@ -162,6 +162,7 @@ public sealed class HarnessAgentOptions
/// as a single-shot agent.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public IEnumerable<LoopEvaluator>? LoopEvaluators { get; set; }
/// <summary>
@@ -171,6 +172,7 @@ public sealed class HarnessAgentOptions
/// When <see langword="null"/>, the <see cref="LoopAgent"/> uses its default settings. This property is ignored
/// when <see cref="LoopEvaluators"/> is <see langword="null"/> or empty.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public LoopAgentOptions? LoopAgentOptions { get; set; }
/// <summary>
@@ -216,6 +218,19 @@ public sealed class HarnessAgentOptions
/// </remarks>
public bool DisableApprovalNotRequiredFunctionBypassing { get; set; }
/// <summary>
/// Gets or sets a value indicating whether binding inbound tool-approval responses to the
/// model-originated approval requests that the framework surfaced is disabled.
/// </summary>
/// <remarks>
/// When <see langword="false"/> (the default), the underlying chat client pipeline includes the decorator
/// added by <see cref="ChatClientBuilderExtensions.UseApprovalResponseBinding"/> as the outermost decorator
/// above the function invocation middleware. It records each surfaced approval request and, on the next
/// request, binds every approval response to its recorded request so an approved call matches exactly what
/// was surfaced for approval.
/// </remarks>
public bool DisableApprovalResponseBinding { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the <see cref="FileMemoryProvider"/> is disabled.
/// </summary>
@@ -234,6 +249,7 @@ public sealed class HarnessAgentOptions
/// a default <see cref="FileSystemAgentFileStore"/> is created.
/// This property is ignored when <see cref="DisableFileMemory"/> is <see langword="true"/>.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public AgentFileStore? FileMemoryStore { get; set; }
/// <summary>
@@ -245,6 +261,7 @@ public sealed class HarnessAgentOptions
/// included in the agent's context providers, backed by the supplied store and configured with
/// <see cref="FileAccessProviderOptions"/> when provided.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public AgentFileStore? FileAccessStore { get; set; }
/// <summary>
@@ -254,6 +271,7 @@ public sealed class HarnessAgentOptions
/// This property is only used when <see cref="FileAccessStore"/> is set (file access is opt-in).
/// When <see langword="null"/>, the provider uses its default options.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public FileAccessProviderOptions? FileAccessProviderOptions { get; set; }
/// <summary>
@@ -348,6 +366,7 @@ public sealed class HarnessAgentOptions
/// (case-insensitive). If these requirements are not met, <see cref="BackgroundAgentsProvider"/> will throw
/// an <see cref="System.ArgumentException"/> during construction.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public IEnumerable<AIAgent>? BackgroundAgents { get; set; }
/// <summary>
@@ -357,76 +376,6 @@ public sealed class HarnessAgentOptions
/// Use this to customize instructions or agent list formatting for the background agents feature.
/// This property is ignored when <see cref="BackgroundAgents"/> is <see langword="null"/> or empty.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public BackgroundAgentsProviderOptions? BackgroundAgentsProviderOptions { get; set; }
#if NET
/// <summary>
/// Gets or sets the shell executor used to enable shell tool and environment probing via <see cref="ShellEnvironmentProvider"/>.
/// </summary>
/// <remarks>
/// When non-null, a <see cref="ShellEnvironmentProvider"/> is automatically included in the agent's context
/// providers (injecting OS/shell/CWD information into the system prompt), and the executor's
/// <see cref="ShellExecutor.AsAIFunction"/> is registered as a callable tool.
/// When <see langword="null"/> (the default), no shell features are enabled.
/// </remarks>
public ShellExecutor? ShellExecutor { get; set; }
/// <summary>
/// Gets or sets the name of the shell execution tool exposed to the model.
/// </summary>
/// <remarks>
/// <para>
/// When <see langword="null"/> (the default), the shell executor's default tool name (<c>run_shell</c>) is used.
/// This property is ignored when <see cref="ShellExecutor"/> is <see langword="null"/>.
/// </para>
/// <para>
/// <b>Security warning:</b> auto-approval rules may match tool calls solely by name. Pay attention to
/// the tool names approved by auto-approval rules for other features. Setting this property to a
/// value that collides with a tool name that is approved by an auto-approval rule for another feature will cause
/// the shell tool to also be auto-approved, bypassing the human approval boundary. Choose a unique
/// name that no other registered tool uses.
/// </para>
/// </remarks>
public string? ShellToolName { get; set; }
/// <summary>
/// Gets or sets the description of the shell execution tool shown to the model.
/// </summary>
/// <remarks>
/// When <see langword="null"/> (the default), the shell executor's built-in description is used.
/// This property is ignored when <see cref="ShellExecutor"/> is <see langword="null"/>.
/// </remarks>
public string? ShellToolDescription { get; set; }
/// <summary>
/// Gets or sets a value indicating whether approval is disabled for the shell execution tool.
/// </summary>
/// <remarks>
/// <para>
/// When <see langword="false"/> (the default), the shell tool is wrapped in an <see cref="ApprovalRequiredAIFunction"/>
/// so every command requires explicit approval before executing. When <see langword="true"/>, the tool can be invoked
/// without approval. This property is ignored when <see cref="ShellExecutor"/> is <see langword="null"/>.
/// </para>
/// <para>
/// Setting this to <see langword="true"/> also requires the underlying <see cref="ShellExecutor"/> to permit
/// unapproved use. The inverse of this value is forwarded as the <c>requireApproval</c> argument to
/// <see cref="ShellExecutor.AsAIFunction"/>, and some executors enforce their own security boundary:
/// <see cref="LocalShellExecutor"/> throws an <see cref="System.InvalidOperationException"/> unless it was
/// constructed with <see cref="LocalShellExecutorOptions.AcknowledgeUnsafe"/> set to <see langword="true"/>,
/// because running unapproved commands directly on the host is inherently unsafe. Sandboxed executors such as
/// <see cref="DockerShellExecutor"/> impose no such requirement.
/// </para>
/// </remarks>
public bool DisableShellToolApproval { get; set; }
/// <summary>
/// Gets or sets optional configuration for the <see cref="ShellEnvironmentProvider"/>.
/// </summary>
/// <remarks>
/// Use this to customize which tools are probed, the probe timeout, shell family override,
/// or the instructions formatter.
/// This property is ignored when <see cref="ShellExecutor"/> is <see langword="null"/>.
/// </remarks>
public ShellEnvironmentProviderOptions? ShellEnvironmentProviderOptions { get; set; }
#endif
}
@@ -1,24 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsReleaseCandidate>false</IsReleaseCandidate>
<IsReleased>true</IsReleased>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
<NoWarn>$(NoWarn);MAAI001</NoWarn>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<!-- Disable package validation baseline until the first release -->
<PropertyGroup>
<PackageValidationBaselineVersion />
<EnablePackageValidation>false</EnablePackageValidation>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
</ItemGroup>
<ItemGroup Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
<ProjectReference Include="..\Microsoft.Agents.AI.Tools.Shell\Microsoft.Agents.AI.Tools.Shell.csproj" />
</ItemGroup>
<PropertyGroup>
<!-- NuGet Package Settings -->
<Title>Microsoft Agent Framework Harness</Title>
@@ -0,0 +1,164 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Hosting.OpenAI.Responses;
using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hosting.OpenAI;
/// <summary>
/// Side-effect-free helpers that convert between the OpenAI Responses wire protocol and Agent Framework
/// run values, for applications that own their own HTTP route, authentication, middleware, and storage.
/// </summary>
/// <remarks>
/// <para>
/// These helpers are the app-owned-routing counterpart to <c>MapOpenAIResponses</c>.
/// <c>MapOpenAIResponses</c> owns routing and storage; these helpers let an application own those concerns
/// and reuse only the protocol conversion. Both share the same internal conversion logic.
/// </para>
/// <para>
/// <strong>Trust boundary.</strong> <see cref="GetSessionStoreId(OpenAIResponsesRunRequest)"/> returns an
/// untrusted candidate continuation key. The application must authenticate the caller and authorize/bind the
/// id to the authenticated principal before using it as a session or checkpoint key. The helpers never
/// perform I/O.
/// </para>
/// </remarks>
public static class OpenAIResponses
{
/// <summary>
/// Converts an OpenAI Responses request body into Agent Framework run values (messages and options).
/// </summary>
/// <param name="body">The OpenAI Responses-shaped request body.</param>
/// <param name="mapOptions">
/// Optional options controlling how request settings are mapped onto the run. By default no request
/// setting is mapped onto the run.
/// </param>
/// <returns>The parsed messages and mapped run options.</returns>
/// <exception cref="ArgumentException">The body could not be parsed as an OpenAI Responses request.</exception>
/// <exception cref="NotSupportedException">A request setting is not supported by the configured mapping.</exception>
public static OpenAIResponsesRunRequest ToAgentRunRequest(JsonElement body, OpenAIResponsesMapOptions? mapOptions = null)
{
CreateResponse request;
try
{
request = body.Deserialize(OpenAIHostingJsonContext.Default.CreateResponse)
?? throw new ArgumentException("The request body could not be parsed as an OpenAI Responses request.", nameof(body));
}
catch (JsonException ex)
{
throw new ArgumentException("The request body could not be parsed as an OpenAI Responses request.", nameof(body), ex);
}
if (request.Input is null)
{
throw new ArgumentException("The request body is missing the required 'input' field.", nameof(body));
}
AgentRunOptions? options = (mapOptions ?? new OpenAIResponsesMapOptions()).RunOptionsFactory(request.ToRequestInfo());
var messages = new List<ChatMessage>();
foreach (InputMessage inputMessage in request.Input.GetInputMessages())
{
messages.Add(inputMessage.ToChatMessage());
}
return new OpenAIResponsesRunRequest(messages, options, request.PreviousResponseId, request.Conversation?.Id);
}
/// <summary>
/// Converts a final <see cref="AgentResponse"/> into an OpenAI Responses-shaped payload.
/// </summary>
/// <param name="response">The agent response to render.</param>
/// <param name="responseId">The id to assign to the rendered response (see <see cref="CreateResponseId"/>).</param>
/// <param name="conversationId">
/// The optional conversation id to surface on the rendered response.
/// </param>
/// <returns>An OpenAI Responses-shaped <see cref="JsonElement"/>.</returns>
public static JsonElement WriteResponse(AgentResponse response, string responseId, string? conversationId = null)
{
ArgumentNullException.ThrowIfNull(response);
ArgumentException.ThrowIfNullOrEmpty(responseId);
AgentInvocationContext context = CreateContext(responseId, conversationId);
Response wire = response.ToResponse(EmptyRequest(), context);
return JsonSerializer.SerializeToElement(wire, OpenAIHostingJsonContext.Default.Response);
}
/// <summary>
/// Converts a stream of <see cref="AgentResponseUpdate"/> into OpenAI Responses Server-Sent-Event frames.
/// </summary>
/// <param name="updates">The agent streaming updates.</param>
/// <param name="responseId">The id to assign to the rendered response (see <see cref="CreateResponseId"/>).</param>
/// <param name="conversationId">The optional conversation id to surface on the rendered response.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>An async sequence of SSE frame strings, each terminated by a blank line.</returns>
public static async IAsyncEnumerable<string> WriteResponseStreamAsync(
IAsyncEnumerable<AgentResponseUpdate> updates,
string responseId,
string? conversationId = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(updates);
ArgumentException.ThrowIfNullOrEmpty(responseId);
AgentInvocationContext context = CreateContext(responseId, conversationId);
await foreach (StreamingResponseEvent streamingEvent in updates
.ToStreamingResponseAsync(EmptyRequest(), context, cancellationToken)
.ConfigureAwait(false))
{
string json = JsonSerializer.Serialize(streamingEvent, OpenAIHostingJsonContext.Default.StreamingResponseEvent);
yield return $"event: {streamingEvent.Type}\ndata: {json}\n\n";
}
}
/// <summary>
/// Extracts the id under which the session should be stored from an already-parsed
/// <see cref="OpenAIResponsesRunRequest"/>.
/// </summary>
/// <param name="request">The parsed request produced by <see cref="ToAgentRunRequest(JsonElement, OpenAIResponsesMapOptions?)"/>.</param>
/// <returns>
/// The <c>previous_response_id</c> when present; otherwise the <c>conversation</c> id when present;
/// otherwise <see langword="null"/> when the request carries neither.
/// </returns>
/// <exception cref="ArgumentNullException"><paramref name="request"/> is <see langword="null"/>.</exception>
/// <remarks>
/// This reads the ids off the already-parsed request rather than re-parsing the body, so an application
/// calls <see cref="ToAgentRunRequest(JsonElement, OpenAIResponsesMapOptions?)"/> once and then this. It is kept
/// separate so the trust boundary stays visible: using a request-derived key is an explicit application
/// decision, and the returned value is an <strong>untrusted candidate key</strong> until the application has
/// authorized it for the caller. A <see langword="null"/> result means only that the request carried no
/// continuation id (unparseable bodies are already rejected by <see cref="ToAgentRunRequest(JsonElement, OpenAIResponsesMapOptions?)"/>).
/// <para>
/// The Responses protocol treats <c>previous_response_id</c> and <c>conversation</c> as mutually exclusive; if a
/// request carries both, this helper prefers <c>previous_response_id</c> (the response-chain pointer). Note that
/// <c>previous_response_id</c> changes each turn and is therefore not a stable partition key; use
/// <see cref="OpenAIResponsesRunRequest.ConversationId"/> when a stable key is required (for example a workflow
/// checkpoint cursor key).
/// </para>
/// </remarks>
public static string? GetSessionStoreId(OpenAIResponsesRunRequest request)
{
ArgumentNullException.ThrowIfNull(request);
return request.PreviousResponseId ?? request.ConversationId;
}
/// <summary>
/// Creates a new OpenAI Responses-shaped response id (a <c>resp_*</c> value).
/// </summary>
/// <returns>A new response id.</returns>
public static string CreateResponseId() => IdGenerator.NewId("resp");
private static AgentInvocationContext CreateContext(string responseId, string? conversationId)
=> new(new IdGenerator(responseId, conversationId));
// The rendering converters never read the request input; a minimal request lets the facade render
// a response without requiring the caller to supply the originating request object.
private static CreateResponse EmptyRequest() => new() { Input = ResponseInput.FromText(string.Empty) };
}
@@ -0,0 +1,50 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hosting.OpenAI;
/// <summary>
/// The result of converting an OpenAI Responses request body into Agent Framework run values via
/// <see cref="OpenAIResponses.ToAgentRunRequest(System.Text.Json.JsonElement, OpenAIResponsesMapOptions?)"/>.
/// </summary>
/// <remarks>
/// This type carries the values an application passes to <see cref="AIAgent.RunAsync(IEnumerable{ChatMessage}, AgentSession?, AgentRunOptions?, System.Threading.CancellationToken)"/>
/// (or the streaming equivalent) when it owns its own hosting route. It does not run the agent; the
/// application remains in control of when and how the run happens.
/// </remarks>
public sealed class OpenAIResponsesRunRequest
{
internal OpenAIResponsesRunRequest(IList<ChatMessage> messages, AgentRunOptions? options, string? previousResponseId = null, string? conversationId = null)
{
this.Messages = messages;
this.Options = options;
this.PreviousResponseId = previousResponseId;
this.ConversationId = conversationId;
}
/// <summary>
/// Gets the chat messages parsed from the request body, ready to pass to an <see cref="AIAgent"/> run.
/// </summary>
public IList<ChatMessage> Messages { get; }
/// <summary>
/// Gets the run options mapped from the request, or <see langword="null"/> when no request setting is
/// mapped onto the run. The mapping is controlled by <see cref="OpenAIResponsesMapOptions.RunOptionsFactory"/>;
/// by default no request setting is mapped.
/// </summary>
public AgentRunOptions? Options { get; }
/// <summary>
/// Gets the request's <c>previous_response_id</c> continuation pointer, or <see langword="null"/> when absent.
/// This changes each turn (it follows the response chain), so it is not a stable partition key.
/// </summary>
public string? PreviousResponseId { get; }
/// <summary>
/// Gets the request's <c>conversation</c> id, or <see langword="null"/> when absent. Unlike
/// <see cref="PreviousResponseId"/>, this is stable across turns, so it is a valid stable partition key.
/// </summary>
public string? ConversationId { get; }
}
@@ -17,17 +17,17 @@ namespace Microsoft.Agents.AI.Hosting;
/// or different service instances in hosted scenarios.
/// </para>
/// <para>
/// <strong>Trust model.</strong> The <c>conversationId</c> passed to
/// <see cref="GetSessionAsync"/> and <see cref="SaveSessionAsync"/> typically originates
/// from the wire (for example, an AG-UI <c>RunAgentInput.ThreadId</c> or an A2A
/// <c>contextId</c>). It is a chain-resume identifier, <em>not</em> an authorization
/// token, and the <c>(agent, conversationId)</c> tuple carries no principal/owner
/// <strong>Trust model.</strong> The <c>sessionStoreId</c> passed to
/// <see cref="GetSessionAsync"/> and <see cref="SaveSessionAsync"/> is the id under which the session is
/// stored. It typically originates from the wire (for example, an AG-UI <c>RunAgentInput.ThreadId</c> or an
/// A2A <c>contextId</c>). It is a chain-resume identifier, <em>not</em> an authorization
/// token, and the <c>(agent, sessionStoreId)</c> tuple carries no principal/owner
/// dimension. Hosts that serve more than one user from the same registered store must
/// therefore compose a principal dimension into the lookup key, otherwise any caller
/// who knows or guesses another caller's <c>conversationId</c> can resume
/// who knows or guesses another caller's <c>sessionStoreId</c> can resume
/// that other caller's persisted thread. The framework provides
/// <see cref="IsolationKeyScopedAgentSessionStore"/> as a decorator that rewrites
/// <c>conversationId</c> to include an isolation key resolved from a
/// <c>sessionStoreId</c> to include an isolation key resolved from a
/// <see cref="SessionIsolationKeyProvider"/> (for example, the ASP.NET Core
/// <c>ClaimsIdentitySessionIsolationKeyProvider</c> wired up via
/// <c>UseClaimsBasedSessionIsolation(...)</c>). When no provider is registered, the
@@ -36,12 +36,12 @@ namespace Microsoft.Agents.AI.Hosting;
/// </para>
/// <para>
/// <strong>Implementer guidance.</strong> Implementations should treat
/// <c>conversationId</c> as opaque: do not parse it, do not impose length
/// <c>sessionStoreId</c> as opaque: do not parse it, do not impose length
/// or character-set constraints on it, and do not assume it round-trips to the value
/// the caller originally supplied (decorators such as
/// <see cref="IsolationKeyScopedAgentSessionStore"/> may rewrite it before forwarding).
/// Be aware that any logging, telemetry, or audit sink that surfaces
/// <c>conversationId</c> will also surface the isolation prefix when a
/// <c>sessionStoreId</c> will also surface the isolation prefix when a
/// scoping decorator is in the chain.
/// </para>
/// </remarks>
@@ -51,13 +51,13 @@ public abstract class AgentSessionStore
/// Saves a serialized agent session to persistent storage.
/// </summary>
/// <param name="agent">The agent that owns this session.</param>
/// <param name="conversationId">The unique identifier for the conversation/session.</param>
/// <param name="sessionStoreId">The id under which the session is stored.</param>
/// <param name="session">The session to save.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>A task that represents the asynchronous save operation.</returns>
public abstract ValueTask SaveSessionAsync(
AIAgent agent,
string conversationId,
string sessionStoreId,
AgentSession session,
CancellationToken cancellationToken = default);
@@ -65,15 +65,44 @@ public abstract class AgentSessionStore
/// Retrieves a serialized agent session from persistent storage.
/// </summary>
/// <param name="agent">The agent that owns this session.</param>
/// <param name="conversationId">The unique identifier for the conversation/session to retrieve.</param>
/// <param name="sessionStoreId">The id under which the session is stored.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>
/// A task that represents the asynchronous retrieval operation.
/// The task result contains the serialized session state, or <see langword="null"/> if not found.
/// A task that represents the asynchronous retrieval operation. The task result contains the
/// restored <see cref="AgentSession"/>, or a newly created session when nothing is stored for the id.
/// </returns>
/// <remarks>
/// <strong>Isolation.</strong> Each call must return an <em>independent</em> <see cref="AgentSession"/>
/// instance. Callers may mutate the returned session, and may run several concurrent branches from the
/// same <paramref name="sessionStoreId"/> (for example forking from an OpenAI Responses
/// <c>previous_response_id</c>), without those branches observing one another's mutations or altering the
/// stored state. The in-box stores satisfy this by returning a fresh instance rehydrated from a serialized
/// snapshot on every call; implementations that cache a live <see cref="AgentSession"/> must return an
/// independent copy (for example by round-tripping through
/// <see cref="AIAgent.SerializeSessionAsync(AgentSession, System.Text.Json.JsonSerializerOptions?, CancellationToken)"/>
/// and <see cref="AIAgent.DeserializeSessionAsync(System.Text.Json.JsonElement, System.Text.Json.JsonSerializerOptions?, CancellationToken)"/>)
/// rather than handing back the shared instance.
/// </remarks>
public abstract ValueTask<AgentSession> GetSessionAsync(
AIAgent agent,
string conversationId,
string sessionStoreId,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a stored agent session, if present.
/// </summary>
/// <param name="agent">The agent that owns this session.</param>
/// <param name="sessionStoreId">The id under which the session is stored.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>A task that represents the asynchronous delete operation.</returns>
/// <remarks>
/// Implementations that support removal delete the session and treat a missing session as a no-op.
/// Implementations that genuinely cannot support deletion should throw <see cref="NotSupportedException"/>.
/// </remarks>
/// <exception cref="NotSupportedException">The store does not support deletion.</exception>
public abstract ValueTask DeleteSessionAsync(
AIAgent agent,
string sessionStoreId,
CancellationToken cancellationToken = default);
/// <summary>Asks the <see cref="AgentSessionStore"/> for an object of the specified type <paramref name="serviceType"/>.</summary>
@@ -53,12 +53,16 @@ public abstract class DelegatingAgentSessionStore : AgentSessionStore
protected AgentSessionStore InnerStore { get; }
/// <inheritdoc/>
public override ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
=> this.InnerStore.GetSessionAsync(agent, conversationId, cancellationToken);
public override ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default)
=> this.InnerStore.GetSessionAsync(agent, sessionStoreId, cancellationToken);
/// <inheritdoc/>
public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
=> this.InnerStore.SaveSessionAsync(agent, conversationId, session, cancellationToken);
public override ValueTask SaveSessionAsync(AIAgent agent, string sessionStoreId, AgentSession session, CancellationToken cancellationToken = default)
=> this.InnerStore.SaveSessionAsync(agent, sessionStoreId, session, cancellationToken);
/// <inheritdoc/>
public override ValueTask DeleteSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default)
=> this.InnerStore.DeleteSessionAsync(agent, sessionStoreId, cancellationToken);
/// <inheritdoc/>
/// <remarks>
@@ -0,0 +1,34 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
namespace Microsoft.Agents.AI.Hosting;
/// <summary>
/// The result of a <see cref="HostedWorkflowState"/> run or resume.
/// </summary>
public sealed class HostedWorkflowRunResult
{
internal HostedWorkflowRunResult(string sessionId, IReadOnlyList<Workflows.WorkflowEvent> events, Workflows.CheckpointInfo? checkpoint)
{
this.SessionId = sessionId;
this.Events = events;
this.Checkpoint = checkpoint;
}
/// <summary>
/// Gets the application-selected session id this run was executed under.
/// </summary>
public string SessionId { get; }
/// <summary>
/// Gets the workflow events emitted during this run.
/// </summary>
public IReadOnlyList<Workflows.WorkflowEvent> Events { get; }
/// <summary>
/// Gets the head checkpoint recorded for the session after this run, or <see langword="null"/> when
/// checkpointing produced no checkpoint.
/// </summary>
public Workflows.CheckpointInfo? Checkpoint { get; }
}
@@ -0,0 +1,392 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.InProc;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Hosting;
/// <summary>
/// Optional shared execution state for applications that own their own hosting route and want to expose a
/// workflow with per-session checkpoint resume. Pairs a <see cref="Workflow"/> target with a
/// <see cref="CheckpointManager"/> and an application-scoped <c>sessionId -&gt; CheckpointInfo</c> head cursor.
/// </summary>
/// <remarks>
/// <para>
/// The .NET workflow checkpoint store is already keyed by session id, but <see cref="CheckpointInfo"/> carries
/// no ordering, so this holder remembers the head checkpoint per session to resume the correct one. It does not
/// own routing, authentication, or storage policy.
/// </para>
/// <para>
/// The in-memory head cursor accelerates the common case, but when it misses (for example a new holder or a
/// process restart) the holder falls back to <see cref="CheckpointManager.GetLatestCheckpointAsync"/>. A durable
/// <see cref="CheckpointManager"/> therefore resumes correctly across restarts; the default in-memory manager does
/// not persist, so with it a restart starts the session fresh.
/// </para>
/// <para>
/// <strong>Trust boundary.</strong> <c>sessionId</c> is an application-selected partition key. When it originates
/// from the wire, the application must authenticate the caller and authorize the key before using it here. The
/// checkpoint boundary must be at least as specific as the authorized session boundary.
/// </para>
/// </remarks>
public sealed class HostedWorkflowState
{
private readonly CheckpointManager _checkpointManager;
private readonly IWorkflowExecutionEnvironment _executionEnvironment;
private readonly Workflow? _workflow;
private readonly Func<CancellationToken, ValueTask<Workflow>>? _workflowFactory;
// Cached-factory mode: the factory runs once, on first use, guarded by _cacheSync, and the built workflow task
// is reused for every run thereafter.
private readonly bool _cacheWorkflow;
private readonly object _cacheSync = new();
private Task<Workflow>? _cachedWorkflowTask;
private readonly ILogger _logger;
private readonly ConcurrentDictionary<string, CheckpointInfo> _cursor = new(StringComparer.Ordinal);
/// <summary>
/// Initializes a new instance of the <see cref="HostedWorkflowState"/> class over a single shared workflow
/// instance.
/// </summary>
/// <param name="workflow">The workflow target.</param>
/// <param name="checkpointManager">
/// The checkpoint manager to use. Defaults to <see cref="CheckpointManager.CreateInMemory"/> when not provided.
/// </param>
/// <param name="executionEnvironment">
/// The workflow execution environment used to run and resume the workflow. Defaults to an in-process environment
/// (<see cref="InProcessExecutionEnvironment"/>) configured with <paramref name="checkpointManager"/>. Supplying a
/// custom environment (for example a future durable/out-of-process environment) is supported; the supplied
/// environment must be configured to checkpoint into the same store as <paramref name="checkpointManager"/>, since
/// the holder reads that manager directly to recover the head checkpoint when its in-memory cursor misses.
/// </param>
/// <param name="loggerFactory">
/// The logger factory used to report resume diagnostics (for example, a resume turn that made no progress).
/// Defaults to <see cref="NullLoggerFactory"/> when not provided.
/// </param>
/// <exception cref="ArgumentNullException"><paramref name="workflow"/> is <see langword="null"/>.</exception>
/// <remarks>
/// A single workflow instance cannot be run by two runners at once, so concurrent runs against this holder are
/// not supported; process turns one at a time. To run independent sessions concurrently, use the factory
/// constructor
/// (<see cref="HostedWorkflowState(Func{CancellationToken, ValueTask{Workflow}}, CheckpointManager?, IWorkflowExecutionEnvironment?, ILoggerFactory?, bool)"/>),
/// which builds a fresh workflow instance per run.
/// </remarks>
public HostedWorkflowState(
Workflow workflow,
CheckpointManager? checkpointManager = null,
IWorkflowExecutionEnvironment? executionEnvironment = null,
ILoggerFactory? loggerFactory = null)
{
_ = Throw.IfNull(workflow);
this._workflow = workflow;
this._checkpointManager = checkpointManager ?? CheckpointManager.CreateInMemory();
this._executionEnvironment = executionEnvironment ?? InProcessExecution.Default.WithCheckpointing(this._checkpointManager);
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(typeof(HostedWorkflowState));
}
/// <summary>
/// Initializes a new instance of the <see cref="HostedWorkflowState"/> class that builds its workflow from a
/// factory.
/// </summary>
/// <param name="workflowFactory">
/// A factory that produces a workflow instance. Every produced instance must have the same executor topology,
/// because a resume rehydrates an instance from the session's checkpoint in the shared
/// <paramref name="checkpointManager"/>. By default (<paramref name="cacheWorkflow"/> is <see langword="false"/>)
/// it is invoked once per run, so independent sessions each get their own instance and run concurrently. When
/// <paramref name="cacheWorkflow"/> is <see langword="true"/> it is invoked once, on first use, and the built
/// instance is reused for every run.
/// </param>
/// <param name="checkpointManager">
/// The checkpoint manager to use. Defaults to <see cref="CheckpointManager.CreateInMemory"/> when not provided.
/// </param>
/// <param name="executionEnvironment">
/// The workflow execution environment used to run and resume the workflow. Defaults to an in-process environment
/// (<see cref="InProcessExecutionEnvironment"/>) configured with <paramref name="checkpointManager"/>. A supplied
/// environment must checkpoint into the same store as <paramref name="checkpointManager"/>, since the holder reads
/// that manager directly to recover the head checkpoint when its in-memory cursor misses.
/// </param>
/// <param name="loggerFactory">
/// The logger factory used to report resume diagnostics (for example, a resume turn that made no progress).
/// Defaults to <see cref="NullLoggerFactory"/> when not provided.
/// </param>
/// <param name="cacheWorkflow">
/// When <see langword="false"/> (the default), the factory is invoked once per run, so independent sessions run
/// in parallel. When <see langword="true"/>, the factory is invoked once, lazily on first use, and the built
/// workflow is cached and reused for every run, a deferred, cached target. Because that reuses a single
/// instance (which cannot be run by two runners at once), a cached workflow's turns cannot run concurrently,
/// exactly like the instance constructor. The cached build uses <see cref="CancellationToken.None"/> because the
/// single built instance is shared across runs and must not be tied to one request's cancellation. If a cached
/// build faults or is canceled, it is not reused: the next run starts a fresh build so a transient setup failure
/// does not poison every later run.
/// </param>
/// <exception cref="ArgumentNullException"><paramref name="workflowFactory"/> is <see langword="null"/>.</exception>
/// <remarks>
/// With the default (uncached) factory, turns are not serialized: independent sessions run in parallel, and
/// concurrent turns against the <em>same</em> session id are not serialized either — an application that needs a
/// single writer per session owns that coordination.
/// </remarks>
public HostedWorkflowState(
Func<CancellationToken, ValueTask<Workflow>> workflowFactory,
CheckpointManager? checkpointManager = null,
IWorkflowExecutionEnvironment? executionEnvironment = null,
ILoggerFactory? loggerFactory = null,
bool cacheWorkflow = false)
{
_ = Throw.IfNull(workflowFactory);
this._workflowFactory = workflowFactory;
this._cacheWorkflow = cacheWorkflow;
this._checkpointManager = checkpointManager ?? CheckpointManager.CreateInMemory();
this._executionEnvironment = executionEnvironment ?? InProcessExecution.Default.WithCheckpointing(this._checkpointManager);
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(typeof(HostedWorkflowState));
}
// Resolves the workflow for a turn: the shared instance in instance mode; the cached instance in cached-factory
// mode (built once on first use); or a fresh instance from the factory in the default (uncached) factory mode.
private async ValueTask<Workflow> ResolveWorkflowAsync(CancellationToken cancellationToken)
{
if (this._workflow is not null)
{
return this._workflow;
}
if (!this._cacheWorkflow)
{
return await this._workflowFactory!(cancellationToken).ConfigureAwait(false);
}
// Cached factory: build the workflow once. The lock guards only the one-time task assignment (and the
// factory's synchronous prefix); the actual build is awaited outside the lock. CancellationToken.None is
// used because the single built instance is shared across runs and must not be tied to one request.
// A previously cached build that faulted or was canceled is not reused: the next call starts a fresh
// build so a transient setup failure does not poison every later run with the same cached failure.
Task<Workflow> buildTask;
lock (this._cacheSync)
{
// Reuse the cached build only when it exists and has not faulted or been canceled; otherwise start a
// fresh build so a transient setup failure does not poison every later run.
if (this._cachedWorkflowTask is not { IsFaulted: false, IsCanceled: false })
{
this._cachedWorkflowTask = this._workflowFactory!(CancellationToken.None).AsTask();
}
buildTask = this._cachedWorkflowTask;
}
return await buildTask.ConfigureAwait(false);
}
/// <summary>
/// Runs the workflow forward for <paramref name="sessionId"/> with checkpointing on the first turn, or, on
/// subsequent turns, restores the session's recorded head checkpoint and then runs the workflow forward with
/// the new turn's <paramref name="input"/>. The new head checkpoint is recorded for the session afterwards.
/// </summary>
/// <remarks>
/// The resume semantics restore then run: each turn restores the latest checkpoint to rehydrate accumulated
/// workflow state and then applies the new input, rather than continuing a halted run with no input (which
/// would leave the run waiting for input indefinitely). For agent (chat-protocol) workflows the new input is
/// accompanied by a
/// <see cref="TurnToken"/> so the turn is driven, matching the fresh-run path.
/// </remarks>
/// <typeparam name="TInput">The workflow input type.</typeparam>
/// <param name="sessionId">The application-selected session id.</param>
/// <param name="input">The input to run on this turn (used both when starting a new run and when resuming).</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>The run result, including the events emitted on this turn and the recorded head checkpoint.</returns>
public async ValueTask<HostedWorkflowRunResult> RunOrResumeAsync<TInput>(string sessionId, TInput input, CancellationToken cancellationToken = default)
where TInput : notnull
{
_ = Throw.IfNullOrEmpty(sessionId);
_ = Throw.IfNull(input);
return await this.RunOrResumeCoreAsync(sessionId, input, cancellationToken).ConfigureAwait(false);
}
private async ValueTask<HostedWorkflowRunResult> RunOrResumeCoreAsync<TInput>(string sessionId, TInput input, CancellationToken cancellationToken)
where TInput : notnull
{
Workflow workflow = await this.ResolveWorkflowAsync(cancellationToken).ConfigureAwait(false);
if (!this._cursor.TryGetValue(sessionId, out CheckpointInfo? head))
{
// The in-memory cursor is empty for this session. Fall back to the checkpoint manager so a durable
// manager still resumes after the cursor is lost (for example a process restart or a new holder over
// the same store).
head = await this._checkpointManager.GetLatestCheckpointAsync(sessionId, cancellationToken).ConfigureAwait(false);
}
if (head is null)
{
// First turn for this session: run the workflow forward from its start executor with the input.
Run freshRun = await this._executionEnvironment.RunAsync(workflow, input, sessionId, cancellationToken).ConfigureAwait(false);
await using (freshRun.ConfigureAwait(false))
{
return this.Record(sessionId, freshRun.OutgoingEvents.ToList(), freshRun.LastCheckpoint);
}
}
// Subsequent turn: restore the session's latest checkpoint to rehydrate accumulated workflow state, then
// run the workflow forward with the new turn's input. Agent workflows use the chat protocol, which requires
// a TurnToken to drive the turn (mirroring how the fresh-run path seeds one).
//
// The streaming resume restores state without draining to a halt first; the non-streaming resume would
// block waiting for input immediately after restore (before we can deliver the new input).
ProtocolDescriptor descriptor = await workflow.DescribeProtocolAsync(cancellationToken).ConfigureAwait(false);
StreamingRun resumed = await this._executionEnvironment.ResumeStreamingAsync(workflow, head, cancellationToken).ConfigureAwait(false);
await using (resumed.ConfigureAwait(false))
{
await resumed.TrySendMessageAsync(input).ConfigureAwait(false);
if (descriptor.IsChatProtocol() && input is not TurnToken)
{
await resumed.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false);
}
List<WorkflowEvent> events = [];
// Drain non-blocking on pending requests, matching the first-turn RunAsync path
// (Run.RunToNextHaltAsync also uses blockOnPendingRequest: false): the workflow may halt awaiting an
// external response, and blocking there would wait indefinitely.
await foreach (WorkflowEvent evt in resumed.WatchStreamAsync(blockOnPendingRequest: false, cancellationToken).ConfigureAwait(false))
{
events.Add(evt);
}
if (events.Count == 0)
{
this.WarnOnNoProgress(sessionId);
}
return this.Record(sessionId, events, resumed.LastCheckpoint);
}
}
/// <summary>
/// Streams the events of a run-or-resume turn as they occur, applying the same restore-then-run semantics as
/// <see cref="RunOrResumeAsync{TInput}(string, TInput, CancellationToken)"/>: the first turn runs the workflow
/// forward from its start executor, and subsequent turns restore the session's latest checkpoint and run
/// forward with <paramref name="input"/>. The session's head checkpoint is recorded when the stream ends,
/// including when the consumer abandons enumeration early.
/// </summary>
/// <remarks>
/// The head checkpoint is recorded from the run's last committed checkpoint when the stream ends — whether it
/// completes normally or the consumer disposes it early — so an interrupted turn still advances the session
/// cursor.
/// </remarks>
/// <typeparam name="TInput">The workflow input type.</typeparam>
/// <param name="sessionId">The application-selected session id.</param>
/// <param name="input">The input to run on this turn.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>An asynchronous stream of the <see cref="WorkflowEvent"/>s emitted during this turn.</returns>
public async IAsyncEnumerable<WorkflowEvent> RunOrResumeStreamingAsync<TInput>(string sessionId, TInput input, [EnumeratorCancellation] CancellationToken cancellationToken = default)
where TInput : notnull
{
_ = Throw.IfNullOrEmpty(sessionId);
_ = Throw.IfNull(input);
Workflow workflow = await this.ResolveWorkflowAsync(cancellationToken).ConfigureAwait(false);
if (!this._cursor.TryGetValue(sessionId, out CheckpointInfo? head))
{
head = await this._checkpointManager.GetLatestCheckpointAsync(sessionId, cancellationToken).ConfigureAwait(false);
}
ProtocolDescriptor descriptor = await workflow.DescribeProtocolAsync(cancellationToken).ConfigureAwait(false);
// The fresh streaming run enqueues the input itself; the streaming resume restores state and needs the
// input delivered explicitly. Neither streaming entry point seeds a TurnToken, so drive chat-protocol
// workflows with one on both paths.
StreamingRun run = head is null
? await this._executionEnvironment.RunStreamingAsync(workflow, input, sessionId, cancellationToken).ConfigureAwait(false)
: await this._executionEnvironment.ResumeStreamingAsync(workflow, head, cancellationToken).ConfigureAwait(false);
await using (run.ConfigureAwait(false))
{
if (head is not null)
{
await run.TrySendMessageAsync(input).ConfigureAwait(false);
}
if (descriptor.IsChatProtocol() && input is not TurnToken)
{
await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false);
}
int eventCount = 0;
try
{
// Drain non-blocking on pending requests (see RunOrResumeCoreAsync) so a workflow that halts
// awaiting an external response ends the stream instead of blocking indefinitely.
await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false, cancellationToken).ConfigureAwait(false))
{
eventCount++;
yield return evt;
}
if (eventCount == 0 && head is not null)
{
this.WarnOnNoProgress(sessionId);
}
}
finally
{
// Record the head checkpoint even when the consumer abandons the stream (for example an SSE
// client disconnect), so an interrupted turn still advances the session cursor to the last
// committed checkpoint and a later turn resumes from there rather than re-running prior work.
this.UpdateCursor(sessionId, run.LastCheckpoint);
}
}
}
private HostedWorkflowRunResult Record(string sessionId, List<WorkflowEvent> events, CheckpointInfo? checkpoint)
{
this.UpdateCursor(sessionId, checkpoint);
return new HostedWorkflowRunResult(sessionId, events, checkpoint);
}
private void UpdateCursor(string sessionId, CheckpointInfo? checkpoint)
{
if (checkpoint is not null)
{
this._cursor[sessionId] = checkpoint;
}
}
private void WarnOnNoProgress(string sessionId)
// The resumed turn drove no work: the checkpoint may be stale or the input may not match the workflow's
// expected type, so the session's state may not have progressed.
=> this._logger.LogWorkflowResumeMadeNoProgress(sessionId);
/// <summary>
/// Gets the recorded head checkpoint for <paramref name="sessionId"/>, if any.
/// </summary>
/// <param name="sessionId">The application-selected session id.</param>
/// <param name="checkpoint">When this method returns, the recorded head checkpoint, or <see langword="null"/>.</param>
/// <returns><see langword="true"/> when a checkpoint is recorded for the session; otherwise <see langword="false"/>.</returns>
/// <remarks>
/// Internal cursor-inspection helper used by tests; not part of the public surface.
/// </remarks>
internal bool TryGetCheckpoint(string sessionId, out CheckpointInfo? checkpoint)
{
_ = Throw.IfNullOrEmpty(sessionId);
return this._cursor.TryGetValue(sessionId, out checkpoint);
}
}
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
internal static partial class HostedWorkflowStateLogMessages
{
[LoggerMessage(
Level = LogLevel.Warning,
Message = "Resuming workflow session '{SessionId}' produced no events; the checkpoint may be stale or the input may not match the workflow's expected input type. Session state may not have progressed.")]
public static partial void LogWorkflowResumeMadeNoProgress(this ILogger logger, string sessionId);
}
@@ -63,47 +63,54 @@ public class IsolationKeyScopedAgentSessionStore : DelegatingAgentSessionStore
}
/// <summary>
/// Escapes special characters in the isolation key to ensure unambiguous scoped conversation IDs.
/// Escapes special characters in the isolation key to ensure unambiguous scoped session store IDs.
/// </summary>
/// <param name="key">The raw isolation key.</param>
/// <returns>The escaped isolation key.</returns>
/// <remarks>
/// Backslashes are escaped first (\ becomes \\), then colons (: becomes \:).
/// This ensures the scoped conversation ID format {key}::{conversationId} can be parsed correctly.
/// This ensures the scoped session store ID format {key}::{sessionStoreId} can be parsed correctly.
/// </remarks>
private static string EscapeIsolationKey(string key) => key.Replace("\\", "\\\\").Replace(":", "\\:");
/// <summary>
/// Constructs a scoped conversation ID by prefixing the bare conversation ID with the escaped isolation key.
/// Constructs a scoped session store ID by prefixing the bare session store ID with the escaped isolation key.
/// </summary>
/// <param name="bareConversationId">The original conversation ID.</param>
/// <param name="bareSessionStoreId">The original session store ID.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>
/// The scoped conversation ID in the format {escapedKey}::{conversationId}, or the bare conversation ID
/// The scoped session store ID in the format {escapedKey}::{sessionStoreId}, or the bare session store ID
/// if no isolation key is available and non-strict mode is enabled.
/// </returns>
private async ValueTask<string> GetScopedConversationIdAsync(string bareConversationId, CancellationToken cancellationToken)
private async ValueTask<string> GetScopedSessionStoreIdAsync(string bareSessionStoreId, CancellationToken cancellationToken)
{
string? key = await this.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false);
if (key == null)
{
return bareConversationId;
return bareSessionStoreId;
}
return $"{EscapeIsolationKey(key)}::{bareConversationId}";
return $"{EscapeIsolationKey(key)}::{bareSessionStoreId}";
}
/// <inheritdoc />
public override async ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
public override async ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default)
{
string scopedConversationId = await this.GetScopedConversationIdAsync(conversationId, cancellationToken).ConfigureAwait(false);
return await this.InnerStore.GetSessionAsync(agent, scopedConversationId, cancellationToken).ConfigureAwait(false);
string scopedSessionStoreId = await this.GetScopedSessionStoreIdAsync(sessionStoreId, cancellationToken).ConfigureAwait(false);
return await this.InnerStore.GetSessionAsync(agent, scopedSessionStoreId, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public override async ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
public override async ValueTask SaveSessionAsync(AIAgent agent, string sessionStoreId, AgentSession session, CancellationToken cancellationToken = default)
{
string scopedConversationId = await this.GetScopedConversationIdAsync(conversationId, cancellationToken).ConfigureAwait(false);
await this.InnerStore.SaveSessionAsync(agent, scopedConversationId, session, cancellationToken).ConfigureAwait(false);
string scopedSessionStoreId = await this.GetScopedSessionStoreIdAsync(sessionStoreId, cancellationToken).ConfigureAwait(false);
await this.InnerStore.SaveSessionAsync(agent, scopedSessionStoreId, session, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public override async ValueTask DeleteSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default)
{
string scopedSessionStoreId = await this.GetScopedSessionStoreIdAsync(sessionStoreId, cancellationToken).ConfigureAwait(false);
await this.InnerStore.DeleteSessionAsync(agent, scopedSessionStoreId, cancellationToken).ConfigureAwait(false);
}
}
@@ -26,8 +26,8 @@ namespace Microsoft.Agents.AI.Hosting;
/// </para>
/// <para>
/// <strong>Multi-user warning.</strong> This store keys threads by
/// <c>(agent.Id, conversationId)</c> only — it has no principal/owner dimension. When
/// the conversation identifier originates from the wire (for example, an AG-UI
/// <c>(agent.Id, sessionStoreId)</c> only — it has no principal/owner dimension. When
/// the session store id originates from the wire (for example, an AG-UI
/// <c>RunAgentInput.ThreadId</c> or an A2A <c>contextId</c>), any caller who knows
/// or guesses another caller's identifier can resume that other caller's persisted
/// thread. Multi-user hosts must wrap this store in
@@ -44,16 +44,16 @@ public sealed class InMemoryAgentSessionStore : AgentSessionStore
private readonly ConcurrentDictionary<string, JsonElement> _threads = new();
/// <inheritdoc/>
public override async ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
public override async ValueTask SaveSessionAsync(AIAgent agent, string sessionStoreId, AgentSession session, CancellationToken cancellationToken = default)
{
var key = GetKey(conversationId, agent.Id);
var key = GetKey(sessionStoreId, agent.Id);
this._threads[key] = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public override async ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
public override async ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default)
{
var key = GetKey(conversationId, agent.Id);
var key = GetKey(sessionStoreId, agent.Id);
JsonElement? sessionContent = this._threads.TryGetValue(key, out var existingSession) ? existingSession : null;
return sessionContent switch
@@ -63,5 +63,12 @@ public sealed class InMemoryAgentSessionStore : AgentSessionStore
};
}
private static string GetKey(string conversationId, string agentId) => $"{agentId}:{conversationId}";
/// <inheritdoc/>
public override ValueTask DeleteSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default)
{
this._threads.TryRemove(GetKey(sessionStoreId, agent.Id), out _);
return default;
}
private static string GetKey(string sessionStoreId, string agentId) => $"{agentId}:{sessionStoreId}";
}
@@ -12,14 +12,20 @@ namespace Microsoft.Agents.AI.Hosting;
public sealed class NoopAgentSessionStore : AgentSessionStore
{
/// <inheritdoc/>
public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
public override ValueTask SaveSessionAsync(AIAgent agent, string sessionStoreId, AgentSession session, CancellationToken cancellationToken = default)
{
return new ValueTask();
return default;
}
/// <inheritdoc/>
public override ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
public override ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default)
{
return agent.CreateSessionAsync(cancellationToken);
}
/// <inheritdoc/>
public override ValueTask DeleteSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default)
{
return default;
}
}
@@ -271,6 +271,7 @@ class _CodeValidator(ast.NodeVisitor):
self._allowed_builtins = allowed_builtins if allowed_builtins is not None else ALLOWED_BUILTINS
self._blocked_builtins = blocked_builtins if blocked_builtins is not None else BLOCKED_BUILTINS
self._allowed_os_attrs = allowed_os_attrs if allowed_os_attrs is not None else ALLOWED_OS_ATTRS
self._os_aliases: set[str] = {"os"}
def validate(self, code: str) -> None:
"""Validate code and raise CodeValidationError if it violates policy."""
@@ -280,6 +281,7 @@ class _CodeValidator(ast.NodeVisitor):
raise CodeValidationError(f"Syntax error in generated code: {exc}") from exc
self._errors = []
self._os_aliases = {"os"}
self.visit(tree)
if self._errors:
@@ -303,6 +305,10 @@ class _CodeValidator(ast.NodeVisitor):
self._errors.append(f"Import of '{alias_node.name}' is not allowed (blocked: {module_name})")
elif module_name not in self._allowed_imports:
self._errors.append(f"Import of '{alias_node.name}' is not allowed (not in allow-list)")
if alias_node.name == "os":
self._os_aliases.add(alias_node.asname or "os")
elif alias_node.name.startswith("os.") and alias_node.asname is None:
self._os_aliases.add("os")
self.generic_visit(node)
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
@@ -324,6 +330,32 @@ class _CodeValidator(ast.NodeVisitor):
self._errors.append(f"Import from 'os' of '{alias_node.name}' is not allowed")
self.generic_visit(node)
def visit_Assign(self, node: ast.Assign) -> None:
"""Track re-bindings of the ``os`` module."""
for target in node.targets:
self._track_os_alias_targets(target, node.value)
self.generic_visit(node)
def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
"""Track annotated re-bindings of the ``os`` module."""
if (
isinstance(node.value, ast.Name)
and node.value.id in self._os_aliases
and isinstance(node.target, ast.Name)
):
self._os_aliases.add(node.target.id)
self.generic_visit(node)
def _track_os_alias_targets(self, target: ast.AST, value: ast.AST) -> None:
if isinstance(target, ast.Starred):
target = target.value
if isinstance(target, ast.Name) and isinstance(value, ast.Name) and value.id in self._os_aliases:
self._os_aliases.add(target.id)
elif isinstance(target, (ast.Tuple, ast.List)) and isinstance(value, (ast.Tuple, ast.List)):
for target_item, value_item in zip(target.elts, value.elts):
self._track_os_alias_targets(target_item, value_item)
def visit_Call(self, node: ast.Call) -> None:
"""Validate function calls.
@@ -357,7 +389,7 @@ class _CodeValidator(ast.NodeVisitor):
# Enforce the `os` attribute allow-list. Anything outside `ALLOWED_OS_ATTRS`
# (file I/O, process control, mutating helpers, etc.) is rejected so the
# validator matches the documented `os.environ` / `os.path`-only contract.
if isinstance(node.value, ast.Name) and node.value.id == "os" and node.attr not in self._allowed_os_attrs:
if isinstance(node.value, ast.Name) and node.value.id in self._os_aliases and node.attr not in self._allowed_os_attrs:
self._errors.append(f"Access to os.{node.attr} is not allowed")
# Block access to certain dangerous attributes
@@ -21,9 +21,10 @@ namespace Microsoft.Agents.AI.Tools.Shell;
/// <para>
/// The buffer counts UTF-8 bytes (matching the public <c>maxOutputBytes</c> contract
/// and <see cref="ShellSession.TruncateHeadTail"/>). Append happens one rune at a time
/// — when the head fills, the next rune's UTF-8 bytes go to the tail as an indivisible
/// unit, and the oldest rune is dropped from the tail. This guarantees the final
/// string never contains a split rune (no orphan surrogates, no invalid UTF-8).
/// — once a complete rune no longer fits in the head, it and all later runes go to
/// the tail as indivisible units. After the total exceeds the cap, the oldest tail
/// runes are dropped. This guarantees the final string never contains a split rune
/// (no orphan surrogates, no invalid UTF-8).
/// </para>
/// </remarks>
internal sealed class HeadTailBuffer
@@ -37,6 +38,7 @@ internal sealed class HeadTailBuffer
private readonly Queue<byte[]> _tail = new();
private int _tailBytes;
private long _totalBytes;
private bool _headSealed;
public HeadTailBuffer(int cap)
{
@@ -63,19 +65,22 @@ internal sealed class HeadTailBuffer
var n = rune.EncodeToUtf8(scratch);
this._totalBytes += n;
if (this._head.Count + n <= this._headCap)
if (!this._headSealed && this._head.Count + n <= this._headCap)
{
for (var i = 0; i < n; i++) { this._head.Add(scratch[i]); }
continue;
}
// Head is full — append to tail as a single rune-sized chunk.
// Once a complete rune cannot fit in the head, seal it and keep all later runes in the tail.
this._headSealed = true;
var bytes = scratch[..n].ToArray();
this._tail.Enqueue(bytes);
this._tailBytes += n;
// Evict whole runes from the front of the tail until we fit.
while (this._tailBytes > this._tailCap && this._tail.Count > 0)
while (this._totalBytes > this._cap &&
this._tailBytes > this._tailCap &&
this._tail.Count > 0)
{
var dropped = this._tail.Dequeue();
this._tailBytes -= dropped.Length;
@@ -1,6 +1,8 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
@@ -22,19 +24,56 @@ internal static class AgentProviderExtensions
{
IAsyncEnumerable<AgentResponseUpdate> agentUpdates = agentProvider.InvokeAgentAsync(agentName, null, conversationId, inputMessages, inputArguments, cancellationToken);
// Determine whether the target conversation is the workflow conversation
// (used below to decide whether to mirror messages into the workflow conversation
// when an agent runs against a different conversation). The caller's autoSend
// value is honored as-is — when the workflow.yaml specifies autoSend: false the
// raw agent output must not be streamed to the caller, even when the agent is
// running on the workflow conversation.
// Foundry managed workflows treat responses produced on the workflow conversation
// as workflow output even when autoSend is explicitly false. Preserve that direct-run
// contract here. Workflow.AsAIAgent separately removes matching streamed/completed
// message duplicates at its hosting boundary.
bool isWorkflowConversation = context.IsWorkflowConversation(conversationId, out string? workflowConversationId);
autoSend |= isWorkflowConversation;
// Process the agent response updates.
// Assign stable IDs to content-bearing chat updates before emitting and aggregating them.
// Contentless updates may carry only metadata and must not become empty messages.
List<AgentResponseUpdate> updates = [];
string? generatedMessageId = null;
string? generatedMessageResponseId = null;
ChatRole? generatedMessageRole = null;
await foreach (AgentResponseUpdate update in agentUpdates.ConfigureAwait(false))
{
await AssignConversationIdAsync(((ChatResponseUpdate?)update.RawRepresentation)?.ConversationId).ConfigureAwait(false);
await AssignConversationIdAsync((update.RawRepresentation as ChatResponseUpdate)?.ConversationId).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(update.MessageId))
{
bool hasContent =
update.Contents.Any(
content => content is not TextContent textContent || !string.IsNullOrEmpty(textContent.Text));
if (hasContent)
{
if (generatedMessageId is null
|| (generatedMessageResponseId is not null
&& update.ResponseId is not null
&& !string.Equals(generatedMessageResponseId, update.ResponseId, StringComparison.Ordinal))
|| (generatedMessageRole is not null
&& update.Role is not null
&& generatedMessageRole != update.Role))
{
generatedMessageId = Guid.NewGuid().ToString("N");
}
generatedMessageResponseId = update.ResponseId ?? generatedMessageResponseId;
generatedMessageRole = update.Role ?? generatedMessageRole;
update.MessageId = generatedMessageId;
if (update.RawRepresentation is ChatResponseUpdate rawUpdate)
{
rawUpdate.MessageId = generatedMessageId;
}
}
}
else
{
generatedMessageId = null;
generatedMessageResponseId = null;
generatedMessageRole = null;
}
updates.Add(update);
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
@@ -21,21 +22,29 @@ internal sealed class SendActivityExecutor(SendActivity model, WorkflowFormulaSt
await context.AddEventAsync(new MessageActivityEvent(activityText.Trim()), cancellationToken).ConfigureAwait(false);
ChatMessage message = new(ChatRole.Assistant, activityText);
string responseId = Guid.NewGuid().ToString("N");
string messageId = Guid.NewGuid().ToString("N");
ChatMessage message = new(ChatRole.Assistant, activityText) { MessageId = messageId };
// Emit an AgentResponseUpdateEvent so chat protocols (e.g. AsAIAgent) receive the
// activity text as streaming chat content. This event is yielded by WorkflowSession
// unconditionally, mirroring how AgentProviderExtensions surfaces autoSend agent
// updates — without it, SendActivity output is dropped whenever the host runs with
// includeWorkflowOutputsInResponse = false (the default).
AgentResponseUpdate update = new(ChatRole.Assistant, activityText) { AuthorName = this.Id };
AgentResponseUpdate update =
new(ChatRole.Assistant, activityText)
{
AuthorName = this.Id,
MessageId = messageId,
ResponseId = responseId,
};
await context.AddEventAsync(new AgentResponseUpdateEvent(this.Id, update), cancellationToken).ConfigureAwait(false);
// Route through YieldOutputAsync so the activity participates in the workflow's
// output-filter pipeline. The runner currently special-cases AgentResponse to
// produce an AgentResponseEvent identical to the one we'd build by hand, which
// is the gated summary surfaced only when includeWorkflowOutputsInResponse = true.
AgentResponse response = new([message]);
AgentResponse response = new([message]) { ResponseId = responseId };
await context.YieldOutputAsync(response, cancellationToken).ConfigureAwait(false);
}
@@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Checkpointing;
@@ -58,4 +59,29 @@ public sealed class CheckpointManager : ICheckpointManager
ValueTask<IEnumerable<CheckpointInfo>> ICheckpointManager.RetrieveIndexAsync(string sessionId, CheckpointInfo? withParent)
=> this._impl.RetrieveIndexAsync(sessionId, withParent);
/// <summary>
/// Retrieves the most recently committed checkpoint for the specified session, or <see langword="null"/>
/// when the session has no checkpoints.
/// </summary>
/// <param name="sessionId">The session identifier whose latest checkpoint should be retrieved.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>
/// The latest <see cref="CheckpointInfo"/> for <paramref name="sessionId"/>, or <see langword="null"/> when no
/// checkpoint has been committed for that session.
/// </returns>
public async ValueTask<CheckpointInfo?> GetLatestCheckpointAsync(string sessionId, CancellationToken cancellationToken = default)
{
// ICheckpointStore.RetrieveIndexAsync is contractually required to return checkpoints in commit order
// (oldest first, most recently committed last), so the last enumerated entry is the latest checkpoint.
IEnumerable<CheckpointInfo> index = await this._impl.RetrieveIndexAsync(sessionId, withParent: null).ConfigureAwait(false);
CheckpointInfo? latest = null;
foreach (CheckpointInfo info in index)
{
latest = info;
}
return latest;
}
}
@@ -34,7 +34,19 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos
private FileStream? _indexFile;
internal DirectoryInfo Directory { get; }
// O(1) membership set used to allocate unique checkpoint ids (GetUnusedCheckpointInfo) and to guard
// RetrieveCheckpointAsync. It intentionally coexists with OrderedCheckpointIndex below: this set answers
// "does this checkpoint exist?" in O(1), while the list preserves commit order. A single HashSet cannot do
// both because HashSet enumeration order is not a contract.
internal HashSet<CheckpointInfo> CheckpointIndex { get; }
// Insertion-ordered mirror of CheckpointIndex. HashSet enumeration order is not a contract (it can diverge
// from insertion order once a slot is freed by a rollback and reused), so RetrieveIndexAsync enumerates this
// list to return checkpoints in commit order, which callers such as CheckpointManager.GetLatestCheckpointAsync
// rely on to identify the head checkpoint.
private List<CheckpointInfo> OrderedCheckpointIndex { get; } = [];
private Dictionary<CheckpointInfo, string?> CheckpointParents { get; } = [];
private HashSet<CheckpointInfo> CheckpointsWithKnownParent { get; } = [];
@@ -80,7 +92,11 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos
{
// We never actually use the file names from the index entries since they can be derived from the CheckpointInfo, but it is useful to
// have the UrlEncoded file names in the index file for human readability
this.CheckpointIndex.Add(entry.CheckpointInfo);
if (this.CheckpointIndex.Add(entry.CheckpointInfo))
{
this.OrderedCheckpointIndex.Add(entry.CheckpointInfo);
}
this.CheckpointParents[entry.CheckpointInfo] = entry.ParentCheckpointId;
if (entry.HasParentMetadata)
{
@@ -129,6 +145,8 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos
key = new(sessionId);
} while (!this.CheckpointIndex.Add(key));
this.OrderedCheckpointIndex.Add(key);
return key;
}
@@ -164,6 +182,7 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos
catch (Exception ex)
{
this.CheckpointIndex.Remove(key);
this.OrderedCheckpointIndex.Remove(key);
this.CheckpointParents.Remove(key);
this.CheckpointsWithKnownParent.Remove(key);
@@ -202,7 +221,7 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos
{
this.CheckDisposed();
return new(this.CheckpointIndex
return new(this.OrderedCheckpointIndex
.Where(checkpoint => checkpoint.SessionId == sessionId &&
(withParent is null ||
!this.CheckpointsWithKnownParent.Contains(checkpoint) ||
@@ -19,8 +19,14 @@ public interface ICheckpointStore<TStoreObject>
/// <param name="withParent">An optional parent checkpoint to filter the results. If specified, only checkpoints with the given parent are
/// returned; otherwise, all checkpoints for the session are included.</param>
/// <returns>A value task representing the asynchronous operation. The result contains a collection of <see
/// cref="CheckpointInfo"/> objects associated with the specified session. The collection is empty if no checkpoints are
/// found.</returns>
/// cref="CheckpointInfo"/> objects associated with the specified session, ordered by commit time from the oldest to the
/// most recently committed. The collection is empty if no checkpoints are found.</returns>
/// <remarks>
/// Implementations must return checkpoints in the order they were committed, with the most recently committed checkpoint
/// last. This ordering is a contract of the store: callers such as <see cref="CheckpointManager"/> rely on it to identify
/// the latest checkpoint for a session, so a store that returns checkpoints unordered (or newest-first) will cause the
/// wrong checkpoint to be resumed.
/// </remarks>
ValueTask<IEnumerable<CheckpointInfo>> RetrieveIndexAsync(string sessionId, CheckpointInfo? withParent = null);
/// <summary>
@@ -0,0 +1,154 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// The built-in English prompt templates the Magentic manager uses. These are exposed so callers can read them and
/// base a <see cref="MagenticPromptOverrides"/> value on the default (for example, translating it or appending a
/// language instruction) instead of writing a prompt from scratch.
/// </summary>
/// <remarks>
/// Each template uses named single-brace placeholders (e.g. <c>{task}</c>) that the framework substitutes at render
/// time; the available placeholders per prompt are documented on the corresponding <see cref="MagenticPromptOverrides"/>
/// property. A progress-ledger override must keep the <c>{schema}</c> placeholder so the JSON schema can be injected.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public static class MagenticDefaultPrompts
{
/// <summary>The default template for gathering the initial fact sheet. Placeholders: <c>{task}</c>.</summary>
public static readonly string TaskLedgerFactsPrompt = """
Below I will present you a request.
Before we begin addressing the request, please answer the following pre-survey to the best of your ability.
Keep in mind that you are Ken Jennings-level with trivia, and Mensa-level with puzzles, so there should be
a deep well to draw from.
Here is the request:
{task}
Here is the pre-survey:
1. Please list any specific facts or figures that are GIVEN in the request itself. It is possible that
there are none.
2. Please list any facts that may need to be looked up, and WHERE SPECIFICALLY they might be found.
In some cases, authoritative sources are mentioned in the request itself.
3. Please list any facts that may need to be derived (e.g., via logical deduction, simulation, or computation)
4. Please list any facts that are recalled from memory, hunches, well-reasoned guesses, etc.
When answering this survey, keep in mind that "facts" will typically be specific names, dates, statistics, etc.
Your answer should use headings:
1. GIVEN OR VERIFIED FACTS
2. FACTS TO LOOK UP
3. FACTS TO DERIVE
4. EDUCATED GUESSES
DO NOT include any other headings or sections in your response. DO NOT list next steps or plans until asked to do so.
""";
/// <summary>The default template for updating the fact sheet on a replan. Placeholders: <c>{task}</c>, <c>{old_facts}</c>.</summary>
public static readonly string TaskLedgerFactsUpdatePrompt = """
As a reminder, we are working to solve the following task:
{task}
It is clear we are not making as much progress as we would like, but we may have learned something new.
Please rewrite the following fact sheet, updating it to include anything new we have learned that may be helpful.
Example edits can include (but are not limited to) adding new guesses, moving educated guesses to verified facts
if appropriate, etc. Updates may be made to any section of the fact sheet, and more than one section of the fact
sheet can be edited. This is an especially good time to update educated guesses, so please at least add or update
one educated guess or hunch, and explain your reasoning.
Here is the old fact sheet:
{old_facts}
""";
/// <summary>The default template for creating the initial plan. Placeholders: <c>{team}</c>.</summary>
public static readonly string TaskLedgerPlanPrompt = """
Fantastic. To address this request we have assembled the following team:
{team}
Based on the team composition, and known and unknown facts, please devise a short bullet-point plan for addressing the
original request. Remember, there is no requirement to involve all team members. A team member's particular expertise
may not be needed for this task.
""";
/// <summary>The default template for updating the plan on a replan. Placeholders: <c>{team}</c>.</summary>
public static readonly string TaskLedgerPlanUpdatePrompt = """
Please briefly explain what went wrong on this last run
(the root cause of the failure), and then come up with a new plan that takes steps and includes hints to overcome prior
challenges and especially avoids repeating the same mistakes. As before, the new plan should be concise, expressed in
bullet-point form, and consider the following team composition:
{team}
""";
/// <summary>
/// The default template for the full task ledger (the plan-event text combining facts and plan).
/// Placeholders: <c>{task}</c>, <c>{team}</c>, <c>{facts}</c>, <c>{plan}</c>.
/// </summary>
public static readonly string TaskLedgerFullPrompt = """
We are working to address the following user request:
{task}
To answer this request we have assembled the following team:
{team}
Here is an initial fact sheet to consider:
{facts}
Here is the plan to follow as best as possible:
{plan}
""";
/// <summary>
/// The default progress-ledger template. Placeholders: <c>{task}</c>, <c>{team}</c>, <c>{questions}</c>,
/// <c>{schema}</c>. An override must keep <c>{schema}</c> so the JSON schema the response is parsed against can
/// be injected.
/// </summary>
public static readonly string ProgressLedgerPrompt = """
Recall we are working on the following request:
{task}
And we have assembled the following team:
{team}
To make progress on the request, please answer the following questions, including necessary reasoning:
{questions}
Please output an answer in pure JSON format according to the following schema. The JSON object must be parsable as-is.
DO NOT OUTPUT ANYTHING OTHER THAN JSON, AND DO NOT DEVIATE FROM THIS SCHEMA:
{schema}
""";
/// <summary>The default template for synthesizing the final answer. Placeholders: <c>{task}</c>.</summary>
public static readonly string FinalAnswerPrompt = """
We are working on the following task:
{task}
We have completed the task.
The above messages contain the conversation that took place to complete the task.
Based on the information gathered, provide the final answer to the original request.
The answer should be phrased as if you were speaking to the user.
""";
}
@@ -0,0 +1,72 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Optional overrides for the internal prompt templates the Magentic manager uses to plan, track progress, and
/// synthesize the final answer. Any property left <see langword="null"/> keeps the built-in English template.
/// </summary>
/// <remarks>
/// <para>
/// Overrides are supplied to <see cref="MagenticWorkflowBuilder.WithPromptOverrides(MagenticPromptOverrides)"/>.
/// Each template may contain named single-brace placeholders that the framework substitutes at render time
/// (e.g. <c>{task}</c>). Unlike Python's <c>str.format</c>, literal braces (such as JSON in the progress-ledger
/// prompt) do <b>not</b> need to be escaped - only the documented placeholders are replaced.
/// </para>
/// <para>
/// The available placeholders differ per prompt and are documented on each property. A placeholder that is not
/// available for a given prompt is left untouched.
/// </para>
/// <para>
/// To base an override on a built-in template (for example, to translate it), read the corresponding member on
/// <see cref="MagenticDefaultPrompts"/>.
/// </para>
/// <para>
/// If <c>MagenticWorkflowBuilder.WithResponseLanguage</c> is also set, its language directive is appended after the
/// (possibly overridden) template body, so overrides and the language pin compose.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed record MagenticPromptOverrides
{
/// <summary>
/// Overrides the prompt that gathers the initial fact sheet. Placeholders: <c>{task}</c>.
/// </summary>
public string? TaskLedgerFactsPrompt { get; init; }
/// <summary>
/// Overrides the prompt that creates the initial plan. Placeholders: <c>{team}</c>.
/// </summary>
public string? TaskLedgerPlanPrompt { get; init; }
/// <summary>
/// Overrides the prompt that renders the full task ledger (the plan-event text combining facts and plan).
/// Placeholders: <c>{task}</c>, <c>{team}</c>, <c>{facts}</c>, <c>{plan}</c>.
/// </summary>
public string? TaskLedgerFullPrompt { get; init; }
/// <summary>
/// Overrides the prompt that updates the fact sheet during a replan. Placeholders: <c>{task}</c>, <c>{old_facts}</c>.
/// </summary>
public string? TaskLedgerFactsUpdatePrompt { get; init; }
/// <summary>
/// Overrides the prompt that updates the plan during a replan. Placeholders: <c>{team}</c>.
/// </summary>
public string? TaskLedgerPlanUpdatePrompt { get; init; }
/// <summary>
/// Overrides the progress-ledger prompt. Placeholders: <c>{task}</c>, <c>{team}</c>, <c>{questions}</c>,
/// <c>{schema}</c>. The <c>{schema}</c> placeholder is required - the framework injects the JSON schema the
/// response is parsed against, so omitting it would break progress-ledger parsing and next-speaker routing.
/// </summary>
public string? ProgressLedgerPrompt { get; init; }
/// <summary>
/// Overrides the prompt that synthesizes the final answer. Placeholders: <c>{task}</c>.
/// </summary>
public string? FinalAnswerPrompt { get; init; }
}
@@ -2,8 +2,10 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Specialized.Magentic;
using Microsoft.Shared.DiagnosticIds;
using ExecutorFactoryFunc = System.Func<Microsoft.Agents.AI.Workflows.ExecutorConfig<Microsoft.Agents.AI.Workflows.ExecutorOptions>,
string,
@@ -33,6 +35,8 @@ public class MagenticWorkflowBuilder(AIAgent managerAgent) : OrchestrationBuilde
private int? _maxRounds;
private int? _maxResets;
private bool _requirePlanSignoff = true;
private string? _responseLanguage;
private MagenticPromptOverrides? _promptOverrides;
/// <inheritdoc cref="GroupChatWorkflowBuilder.AddParticipants(IEnumerable{AIAgent})"/>
public MagenticWorkflowBuilder AddParticipants(params IEnumerable<AIAgent> agents)
@@ -82,13 +86,72 @@ public class MagenticWorkflowBuilder(AIAgent managerAgent) : OrchestrationBuilde
return this;
}
/// <summary>
/// Set the concrete language (e.g. "English", "Chinese") that the Magentic manager's internally generated
/// messages - the task ledger, progress ledger, and final answer - must be written in.
/// </summary>
/// <remarks>
/// <para>
/// When set, the manager is instructed to write all natural-language content in this exact language. This is more
/// reliable than relying on the model to infer and match the request language, which some models fail to do for the
/// progress ledger's JSON free-text fields, causing those internal messages to appear in an unexpected language.
/// </para>
/// <para>
/// When left unset (the default), the built-in English prompt templates are used as-is.
/// </para>
/// <para>
/// If a prompt is also overridden via <see cref="WithPromptOverrides(MagenticPromptOverrides)"/>, this language
/// directive is appended after that override's body, so the two compose.
/// </para>
/// <para>
/// This option is experimental and may change or be removed in a future release.
/// </para>
/// </remarks>
/// <param name="responseLanguage">
/// The language name to use for internally generated messages, or <see langword="null"/> to use the built-in
/// English templates as-is.
/// </param>
/// <returns>This builder instance, for chaining.</returns>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public MagenticWorkflowBuilder WithResponseLanguage(string? responseLanguage = null)
{
this._responseLanguage = string.IsNullOrWhiteSpace(responseLanguage) ? null : responseLanguage!.Trim();
return this;
}
/// <summary>
/// Override any of the Magentic manager's internal prompt templates (task ledger, progress ledger, final answer).
/// </summary>
/// <remarks>
/// <para>
/// Any property left <see langword="null"/> on <paramref name="promptOverrides"/> keeps the built-in English
/// template. Templates use named single-brace placeholders (e.g. <c>{task}</c>) documented on
/// <see cref="MagenticPromptOverrides"/>; the framework substitutes them at render time.
/// </para>
/// <para>
/// A progress-ledger override must contain the <c>{schema}</c> placeholder (validated at <see cref="Build"/>) so
/// the framework can inject the JSON schema the response is parsed against.
/// </para>
/// <para>
/// This option is experimental and may change or be removed in a future release.
/// </para>
/// </remarks>
/// <param name="promptOverrides">The prompt overrides to apply, or <see langword="null"/> to clear any overrides.</param>
/// <returns>This builder instance, for chaining.</returns>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public MagenticWorkflowBuilder WithPromptOverrides(MagenticPromptOverrides? promptOverrides = null)
{
this._promptOverrides = promptOverrides;
return this;
}
private WorkflowBuilder ReduceToWorkflowBuilder()
{
// Create a copy of the team so that improper modifications by using the builder after .Build() do not affect the
// workflow in unexpected ways.
List<AIAgent> team = [.. this._team];
ExecutorBinding orchestrator = CreateOrchestratorBinding(managerAgent, team, this.Limits, this._requirePlanSignoff);
ExecutorBinding orchestrator = CreateOrchestratorBinding(managerAgent, team, this.Limits, this._requirePlanSignoff, this._responseLanguage, this._promptOverrides);
WorkflowBuilder result = new(orchestrator);
AIAgentHostOptions options = new()
@@ -131,6 +194,13 @@ public class MagenticWorkflowBuilder(AIAgent managerAgent) : OrchestrationBuilde
throw new InvalidOperationException("At least one participant must be added via AddParticipants() before building the workflow.");
}
if (this._promptOverrides?.ProgressLedgerPrompt is { } progressLedgerPrompt && !progressLedgerPrompt.Contains("{schema}"))
{
throw new InvalidOperationException(
"A progress-ledger prompt override must contain the '{schema}' placeholder so the required JSON schema can be injected; " +
"otherwise progress-ledger parsing and next-speaker routing would break.");
}
return this.ReduceToWorkflowBuilder().Build();
}
@@ -139,14 +209,14 @@ public class MagenticWorkflowBuilder(AIAgent managerAgent) : OrchestrationBuilde
MaxResetCount: this._maxResets,
MaxStallCount: this._maxStalls);
private static ExecutorBinding CreateOrchestratorBinding(AIAgent managerAgent, List<AIAgent> team, TaskLimits limits, bool requirePlanSignoff)
private static ExecutorBinding CreateOrchestratorBinding(AIAgent managerAgent, List<AIAgent> team, TaskLimits limits, bool requirePlanSignoff, string? responseLanguage, MagenticPromptOverrides? promptOverrides)
{
ExecutorFactoryFunc factory = CreateOrchestratorAsync;
return factory.BindExecutor(nameof(MagenticOrchestrator));
ValueTask<MagenticOrchestrator> CreateOrchestratorAsync(ExecutorConfig<ExecutorOptions> options, string sessionId)
{
return new(new MagenticOrchestrator(managerAgent, team, limits, requirePlanSignoff));
return new(new MagenticOrchestrator(managerAgent, team, limits, requirePlanSignoff, responseLanguage, promptOverrides));
}
}
}
@@ -4,162 +4,132 @@ using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
internal sealed class MessageMerger
{
private sealed class MessageMergeState(string? messageId)
{
public string? MessageId { get; } = messageId;
public List<AgentResponseUpdate> Updates { get; } = [];
}
private sealed class ResponseMergeState(string? responseId)
{
public string? ResponseId { get; } = responseId;
private readonly Dictionary<string, MessageMergeState> _messageStates = [];
private readonly List<MessageMergeState> _messageStatesInOrder = [];
private MessageMergeState? _lastObservedState;
public Dictionary<string, List<AgentResponseUpdate>> UpdatesByMessageId { get; } = [];
public List<AgentResponseUpdate> DanglingUpdates { get; } = [];
public string? ResponseId { get; } = responseId;
public void AddUpdate(AgentResponseUpdate update)
{
if (update.MessageId is null)
MessageMergeState state = this.GetOrCreateMessageState(update.MessageId);
state.Updates.Add(update);
this._lastObservedState = state;
}
private MessageMergeState GetOrCreateMessageState(string? messageId)
{
if (messageId is null)
{
this.DanglingUpdates.Add(update);
}
else
{
if (!this.UpdatesByMessageId.TryGetValue(update.MessageId, out List<AgentResponseUpdate>? updates))
if (this._lastObservedState is { MessageId: null })
{
this.UpdatesByMessageId[update.MessageId] = updates = [];
return this._lastObservedState;
}
updates.Add(update);
MessageMergeState state = new(null);
this._messageStatesInOrder.Add(state);
return state;
}
if (!this._messageStates.TryGetValue(messageId, out MessageMergeState? existingState))
{
existingState = new(messageId);
this._messageStates[messageId] = existingState;
this._messageStatesInOrder.Add(existingState);
}
return existingState;
}
public AgentResponse ComputeMerged(string messageId)
public List<AgentResponse> ComputeMerged()
{
if (this.UpdatesByMessageId.TryGetValue(Throw.IfNull(messageId), out List<AgentResponseUpdate>? updates))
// Message buckets keep their first-seen order. Grouping updates into messages is delegated
// to M.E.AI (ToAgentResponse), which coalesces contiguous updates by message id exactly like
// a directly-invoked agent. Folding an id-less segment (e.g. a streamed reasoning summary)
// into the following id'd message of the same role is handled once, at the flattened-message
// level in MessageMerger.ComputeMerged, so it works both within a single response bucket and
// across buckets (see https://github.com/microsoft/agent-framework/issues/6329).
List<MessageMergeState> ordered = this._messageStatesInOrder;
List<AgentResponse> responses = new(ordered.Count);
foreach (MessageMergeState current in ordered)
{
return updates.ToAgentResponse();
responses.Add(current.Updates.ToAgentResponse());
}
throw new KeyNotFoundException($"No updates found for message ID '{messageId}' in response '{this.ResponseId}'.");
}
public AgentResponse ComputeDangling()
{
if (this.DanglingUpdates.Count == 0)
{
throw new InvalidOperationException("No dangling updates to compute a response from.");
}
return this.DanglingUpdates.ToAgentResponse();
return responses;
}
public List<ChatMessage> ComputeFlattened()
{
List<ChatMessage> result = this.UpdatesByMessageId.Keys.SelectMany(AggregateUpdatesToMessage).ToList();
if (this.DanglingUpdates.Count > 0)
{
result.AddRange(this.ComputeDangling().Messages);
}
return result;
IList<ChatMessage> AggregateUpdatesToMessage(string messageId)
{
List<AgentResponseUpdate> updates = this.UpdatesByMessageId[messageId];
if (updates.Count == 0)
{
throw new InvalidOperationException($"No updates found for message ID '{messageId}' in response '{this.ResponseId}'.");
}
return updates.Select(oldUpdate => oldUpdate.AsChatResponseUpdate()).ToChatResponse().Messages;
}
}
=> this.ComputeMerged().SelectMany(response => response.Messages).ToList();
}
private readonly Dictionary<string, ResponseMergeState> _mergeStates = [];
private readonly List<string> _responseIdsInOrder = [];
private readonly ResponseMergeState _danglingState = new(null);
public void AddUpdate(AgentResponseUpdate update)
{
if (update.ResponseId is null)
{
this._danglingState.DanglingUpdates.Add(update);
this._danglingState.AddUpdate(update);
}
else
{
if (!this._mergeStates.TryGetValue(update.ResponseId, out ResponseMergeState? state))
{
this._mergeStates[update.ResponseId] = state = new ResponseMergeState(update.ResponseId);
this._responseIdsInOrder.Add(update.ResponseId);
}
state.AddUpdate(update);
}
}
private int CompareByDateTimeOffset(AgentResponse left, AgentResponse right)
{
const int LESS = -1, EQ = 0, GREATER = 1;
if (left.CreatedAt == right.CreatedAt)
{
return EQ;
}
if (!left.CreatedAt.HasValue)
{
return GREATER;
}
if (!right.CreatedAt.HasValue)
{
return LESS;
}
return left.CreatedAt.Value.CompareTo(right.CreatedAt.Value);
}
public AgentResponse ComputeMerged(string primaryResponseId, string? primaryAgentId = null, string? primaryAgentName = null)
{
List<ChatMessage> messages = [];
Dictionary<string, AgentResponse> responses = [];
List<AgentResponse> responses = [];
HashSet<string> agentIds = [];
HashSet<ChatFinishReason> finishReasons = [];
foreach (string responseId in this._mergeStates.Keys)
foreach (string responseId in this._responseIdsInOrder)
{
ResponseMergeState mergeState = this._mergeStates[responseId];
List<AgentResponse> responseList = mergeState.UpdatesByMessageId.Keys.Select(mergeState.ComputeMerged).ToList();
if (mergeState.DanglingUpdates.Count > 0)
{
responseList.Add(mergeState.ComputeDangling());
}
responseList.Sort(this.CompareByDateTimeOffset);
responses[responseId] = responseList.Aggregate(MergeResponses);
messages.AddRange(GetMessagesWithCreatedAt(responses[responseId]));
List<AgentResponse> responseList = mergeState.ComputeMerged();
AgentResponse response = responseList.Aggregate(MergeResponses);
responses.Add(response);
messages.AddRange(GetMessagesWithCreatedAt(response));
}
UsageDetails? usage = null;
AdditionalPropertiesDictionary? additionalProperties = null;
HashSet<DateTimeOffset> createdTimes = [];
foreach (AgentResponse response in responses.Values)
foreach (AgentResponse response in responses)
{
if (response.AgentId is not null)
{
agentIds.Add(response.AgentId);
}
if (response.CreatedAt.HasValue)
{
createdTimes.Add(response.CreatedAt.Value);
_ = agentIds.Add(response.AgentId);
}
if (response.FinishReason.HasValue)
{
finishReasons.Add(response.FinishReason.Value);
_ = finishReasons.Add(response.FinishReason.Value);
}
usage = MergeUsage(usage, response.Usage);
@@ -168,6 +138,36 @@ internal sealed class MessageMerger
messages.AddRange(this._danglingState.ComputeFlattened());
// Fold an id-less message that is immediately followed by an id'd message of the same role
// into that message. A streamed reasoning summary often arrives without a message id and, when
// an agent is hosted inside a workflow, can land in a different response bucket than the answer
// text that follows it. The per-response fold cannot merge across buckets, so we also fold here
// at the flattened-message level to keep the reasoning and the answer in a single assistant
// message (see https://github.com/microsoft/agent-framework/issues/6329).
// We iterate backward so that a run of consecutive id-less messages preceding an id'd message
// all cascade into that message: once folded, the merged message adopts next.MessageId, so a
// forward pass would never re-examine the preceding id-less entry.
for (int i = messages.Count - 1; i > 0; i--)
{
ChatMessage current = messages[i - 1];
ChatMessage next = messages[i];
if (current.MessageId is null && next.MessageId is not null && current.Role == next.Role)
{
messages[i] = new ChatMessage
{
Role = next.Role,
AuthorName = next.AuthorName ?? current.AuthorName,
Contents = [.. current.Contents, .. next.Contents],
MessageId = next.MessageId,
CreatedAt = current.CreatedAt ?? next.CreatedAt,
RawRepresentation = next.RawRepresentation,
AdditionalProperties = next.AdditionalProperties,
};
messages.RemoveAt(i - 1);
}
}
// Remove any empty text contents or messages that are now empty.
foreach (var m in messages)
{
@@ -180,7 +180,8 @@ internal sealed class MessageMerger
}
}
}
messages.RemoveAll(m => m.Contents.Count == 0);
_ = messages.RemoveAll(m => m.Contents.Count == 0);
return new AgentResponse(messages)
{
@@ -242,8 +243,9 @@ internal sealed class MessageMerger
AuthorName = message.AuthorName,
Contents = message.Contents,
MessageId = message.MessageId,
CreatedAt = createdAt,
RawRepresentation = message.RawRepresentation
CreatedAt = message.CreatedAt ?? createdAt,
RawRepresentation = message.RawRepresentation,
AdditionalProperties = message.AdditionalProperties
});
}
@@ -2,11 +2,12 @@
<PropertyGroup>
<IsReleased>true</IsReleased>
<NoWarn>$(NoWarn);MEAI001;MAAIW001</NoWarn>
<NoWarn>$(NoWarn);MEAI001;MAAIW001;MAAI001</NoWarn>
</PropertyGroup>
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>

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