Compare commits

...
Author SHA1 Message Date
copilot-swe-agent[bot]andGitHub 021d9f2dc4 fix: improve autonomous mode comments per code review 2026-05-12 00:38:26 +00:00
copilot-swe-agent[bot]andGitHub a47da4de3d refactor: remove redundant autonomous turn counter reset from ContinueTurnAsync 2026-05-12 00:35:10 +00:00
copilot-swe-agent[bot]andGitHub 0681e046ea fix: reset autonomous turn counter at start of each new HandoffState turn 2026-05-12 00:32:36 +00:00
copilot-swe-agent[bot]andGitHub 05c29347ff feat: add autonomous workflow mode to .NET handoff 2026-05-12 00:29:25 +00:00
copilot-swe-agent[bot]andGitHub 0987ce3d31 Initial plan 2026-05-12 00:17:07 +00:00
4ad96b64e7 Python: [BREAKING] Migrate agent-framework-a2a to a2a-sdk v1.0 (#5752)
* Python: Migrate agent-framework-a2a to a2a-sdk v1.0

Upgrade the a2a-sdk dependency from v0.3.x to v1.0.0 and migrate all
source, tests, samples, and documentation to the v1.0 API.

Key changes:
- Dependency: a2a-sdk>=1.0.0,<2 (was >=0.3.5,<0.3.24)
- Types are now protobuf-based: Part replaces TextPart/FilePart/DataPart
- Enums use SCREAMING_SNAKE_CASE (e.g. TaskState.TASK_STATE_COMPLETED)
- Roles: Role.ROLE_AGENT, Role.ROLE_USER
- Client: SendMessageRequest wrapper, subscribe() replaces resubscribe()
- Server: A2AStarletteApplication replaced by Starlette + route factories
- DefaultRequestHandler now requires agent_card parameter
- TaskUpdater: final parameter removed, add_artifact gains last_chunk
- AgentCard.url removed; use supported_interfaces with AgentInterface
- Stream yields StreamResponse with WhichOneof('payload')

Closes #5661

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

* Address PR review: validate fallback URL, remove unused task_id vars

- Raise ValueError with clear message when transport negotiation fails
  and no fallback URL is available (neither url arg nor supported_interfaces)
- Remove unused task_id local in status_update branch
- Inline artifact_event.task_id directly in artifact_update branch

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-11 22:46:12 +00:00
Evan MattsonandGitHub e3875f2c91 .NET: DevUI: add configurable access controls for the DevUI HTTP surface (#5739)
* .NET: DevUI: add configurable access controls for the DevUI HTTP surface

* .NET: DevUI: address review and fix dotnet format

- Restore parameterless AddDevUI overloads for binary compatibility on
  IServiceCollection and IHostApplicationBuilder.
- Keep /meta outside the auth-filtered group so the frontend can discover
  whether a bearer token is required before prompting for one. Surface the
  actual requirement via MetaResponse.auth_required.
- Invoke DevUIOptions.ConfigureEndpoints before mapping protected endpoints
  so RouteGroupBuilder conventions (RequireAuthorization, rate limiting)
  reliably apply.
- Treat a null RemoteIpAddress as non-loopback in DevUIAuthFilter; tests
  now set IPAddress.Loopback explicitly when exercising the loopback path.
- Add a DEVUI_AUTH_TOKEN env-var fallback test and a /meta-public test.
- Fix dotnet format: add UTF-8 BOM to new files, simplify a cref in
  DevUIOptions, and drop an unused using in the new test.

* .NET: DevUI: add missing authRequired param XML tag

* .NET: DevUI tests: set loopback/AllowRemoteAccess for null-RemoteIp default

DevUIIntegrationTests use the default TestServer which leaves RemoteIpAddress
null. With the new conservative loopback default those tests now hit 403; set
AllowRemoteAccess on the option since those tests are not exercising access
control. Also add the missing SimulateRemoteIp call in the wrong-bearer test.

* .NET: DevUI tests: capture DEVUI_AUTH_TOKEN before parallel tests can see it

The env-var test was leaking DEVUI_AUTH_TOKEN into parallel DevUIIntegrationTests,
intermittently causing their requests to be rejected as 401. Eagerly resolve the
singleton DevUIAuthFilter so its constructor captures the token, then restore the
env var before any HTTP requests run.
2026-05-11 22:45:41 +00:00
9199c84d42 .NET: Remove Foundry Toolbox server-side tools support (#5753)
* .NET: Remove Foundry Toolbox server-side tools support

Mirrors the Python cleanup in microsoft/agent-framework#5671. Passing
toolbox tools as server-side Responses tools is not the experience we
want to support; the hosted-agent MCP toolbox path (HostedMcpToolboxAITool
+ FoundryToolboxService) remains the supported way to consume Foundry
Toolboxes.

Removed:
- FoundryToolbox static class (GetToolboxVersionAsync / GetToolsAsync /
  ToAITools / SanitizeAndConvert)
- AIProjectClient.GetToolboxToolsAsync extension
- Agent_Step25_ToolboxServerSideTools sample (+ slnx entry)
- FoundryToolboxTests, TestDataUtil, HttpHandlerAssert, and the toolbox
  JSON fixtures only those tests referenced
- ToolboxHostedAgentTests and ToolboxHostedAgentFixture; the "toolbox"
  switch arm + CreateToolboxAgent helper in TestContainer; matching
  README scenario row and bootstrap script entry

Kept (MCP path, unchanged):
- HostedMcpToolboxAITool, FoundryAITool.CreateHostedMcpToolbox,
  FoundryAIToolExtensions.CreateHostedMcpToolbox(ToolboxRecord/Version)
- FoundryToolboxService, AddFoundryToolboxes, marker injection in
  AgentFrameworkResponseHandler, InputConverter.ReadMcpToolboxMarkers
- Hosted-Toolbox sample, McpToolbox* tests, FoundryToolboxServiceTests

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

* .NET: Add Foundry Toolbox MCP sample (Agent_Step25_FoundryToolboxMcp)

Adds a non-hosted-agent equivalent of the Python foundry_chat_client_with_toolbox.py sample. The agent connects to a Foundry Toolbox's MCP endpoint via Streamable HTTP, injects a fresh Azure AI bearer token on every request, and discovers the toolbox's tools at runtime via McpClient.ListToolsAsync.

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

* .NET: Tighten Agent_Step25_FoundryToolboxMcp README/Program comments

Drop 'non-hosted agent' framing from README (this sample isn't related to hosted agents) and remove narrative comparison to server-side tools from the Program.cs header comment.

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

* Drop python sample reference from Agent_Step25 README

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

* Drop incorrect .NET 10 prereq from Agent_Step25 README

Toolboxes don't require .NET 10 (Microsoft.Agents.AI.Foundry targets net8.0+); the parent AgentsWithFoundry README already lists the sample SDK prereq.

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

* Fix Toolsets api-version in Agent_Step25 example endpoint

Use 2025-05-01-preview to match FoundryToolboxOptions.ApiVersion. The placeholder 'v1' is not accepted by the Toolsets endpoint.

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

---------

Co-authored-by: alliscode <bentho@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-11 22:05:14 +00:00
0bbedc4fa2 .NET: Fix/per service input persistence on stream error (#5744)
* .NET: Persist input messages on streaming errors in PerServiceCallChatHistoryPersistingChatClient

When the underlying chat service emits an in-stream error (for example a
`response.error` SSE event from the OpenAI Responses API on rate limit),
the OpenAI client surfaces it as an `ErrorContent` update and ends the
stream without throwing. Previously, `PerServiceCallChatHistoryPersistingChatClient`
only persisted history when the streaming loop completed successfully and
`NotifyProvidersOfNewMessagesAsync` was called at the end. On the
in-stream-error path, the input messages handed to that iteration -
typically `FunctionResultContent` produced by `FunctionInvokingChatClient`
in the previous iteration - were never persisted. The next run would
replay session history with a dangling `FunctionCallContent` and the
service would reject the request with `No tool output found for function
call <id>`.

This change:

- Adds a `PersistInputOnErrorAsync` helper that persists the input
  messages (with no response messages) so function-call/function-result
  pairings are not split across failures.
- Calls the helper from every error path: pre-loop enumerator creation,
  the first `MoveNextAsync`, the in-loop `MoveNextAsync`, and a new
  `finally` that handles abnormal iterator disposal.
- After the streaming loop, scans the assembled response for any
  `ErrorContent` and, if present, persists the input, notifies
  providers of failure, and throws `InvalidOperationException` so the
  error is surfaced to the caller instead of silently corrupting history.
- Hardens `InMemoryChatHistoryProvider.StoreChatHistoryAsync` to treat
  a null `RequestMessages` as empty, since the new error path can
  invoke it with no response messages.

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

* Fix dropped FunctionResultContent on streaming pipeline early-disposal

When a consumer of ChatClientAgent.RunStreamingAsync stops iterating early
(e.g. ToolApprovalAgent yields the approval request and then `yield break`),
the framework cascades DisposeAsync down the stream. C# async iterators do
not auto-dispose IAsyncDisposable locals, so the inner enumerator returned
by IChatClient.GetStreamingResponseAsync(...).GetAsyncEnumerator(ct) was
left suspended. That suspended FunctionInvokingChatClient downstream, which
suspended PerServiceCallChatHistoryPersistingChatClient at its `yield
return`, so its finally block never ran and the in-flight
FunctionResultContent for the just-completed tool call was not persisted
to chat history. The next turn then loaded a session that contained a
FunctionCallContent with no matching FunctionResultContent and the model
returned HTTP 400 `No tool output found for function call`.

Fixes:

* ChatClientAgent.RunStreamingAsync: wrap the iteration in
  try/finally that disposes the inner enumerator. Disposal now cascades
  through the pipeline and PerService's finally runs on early exit.
* PerServiceCallChatHistoryPersistingChatClient: in the streaming path,
  snapshot input messages with `messages.ToList()` (the caller, FICC,
  reuses a single mutable buffer across iterations and may mutate it
  before our finally / error path persists), wrap GetAsyncEnumerator,
  the first MoveNextAsync, and in-loop MoveNextAsync in try/catch each
  calling PersistInputOnErrorAsync + NotifyProvidersOfFailureAsync, and
  add a finally that calls PersistInputOnErrorAsync when the loop did
  not exit normally so per-iteration FRCs are persisted on early
  disposal as well as on errors.

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

* .NET: Add tests for PerService streaming error/dispose persistence paths

Adds five regression tests covering the new error-path persistence in

PerServiceCallChatHistoryPersistingChatClient.GetStreamingResponseInnerAsync:

- Persists input messages when GetStreamingResponseAsync throws synchronously.

- Persists input messages when the first MoveNextAsync throws.

- Persists input messages when a mid-stream MoveNextAsync throws.

- Persists input messages when the consumer abandons enumeration early

  (the ToolApprovalAgent yield-break / disposal-cascade case).

- Throws and persists input when the stream emits an in-band ErrorContent.

All 66 tests in the class pass on net10.0 and net472.

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

* .NET: Address PR feedback on PerService streaming error persistence

Two follow-ups from PR #5744 review:

1. Prevent duplicate persistence on the in-loop MoveNextAsync catch path.

   The inner catch persists input messages, then rethrows, which propagates

   through the surrounding try/finally where loopExitedNormally is still false,

   causing the finally to persist again. Introduced an inputPersisted flag

   that the inner catch sets after persisting; the finally now skips when

   inputPersisted is true.

2. Use the caller's CancellationToken in the abnormal-exit finally instead

   of CancellationToken.None, so cleanup remains responsive to cancellation.

   Fall back to CancellationToken.None only when the caller's token is

   already canceled (otherwise the persist call would observe the

   cancellation, throw, and mask the original early-exit reason).

Tightened all five new streaming-error tests from Times.AtLeastOnce to

Times.Once on the input-persistence matcher to regression-guard against

duplicate persistence. All 66 tests in the class still pass (net10.0 + net472).

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

* .NET: Scope PerService streaming changes to cooperative early-exit only

Per discussion on PR #5744, scope this PR back to fix only the original
ToolApprovalAgent dropped-FunctionResultContent bug and address the
enumerator-disposal review comment. Specifically:

- Remove input-message persistence from the GetAsyncEnumerator and
  MoveNextAsync error paths. Routing failed service calls through the
  success notification channel was breaking the provider contract; we
  will instead rely on inner-agent retries for transient errors. Failure
  paths still call NotifyProvidersOfFailureAsync as before.
- Remove the in-stream ErrorContent detection block (same rationale).
- Keep the try/finally that calls the (now narrower) early-exit input
  notification on cooperative disposal (e.g. ToolApprovalAgent yield
  break). A new serviceErrorOccurred flag ensures we do NOT renotify
  on exception paths.
- Always DisposeAsync the underlying enumerator on every exit path,
  addressing the copilot-reviewer comment about leaked HTTP/streams.
- Rename PersistInputOnErrorAsync -> NotifyProvidersOfEarlyExitInputAsync
  to better reflect what it does and when it runs (rogerbarreto nit).
- Apply rogerbarreto nit on InMemoryChatHistoryProvider null-coalescing.
- Drop the four tests that covered the removed error-path behavior;
  keep RunStreamingAsync_PersistsInputMessages_WhenConsumerAbandons
  EnumerationAsync (regression guard for the cooperative-pause path).

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

---------

Co-authored-by: alliscode <bentho@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-11 20:28:14 +00:00
Roger BarretoandGitHub 18d7a46a54 .NET: Hosted Agents - RAG Sample with Azure AI Search (#5693) (#5701)
* .NET: Hosted Agents - RAG Sample with Azure AI Search (#5693)

Adds a Hosted-AzureSearchRag sample plus a live Foundry.Hosting integration
test scenario backed by a real Azure AI Search index.

Sample (Hosted-AzureSearchRag): keyword-only Azure AI Search via
SearchClient adapter into TextSearchProvider, scope-aware
DevTemporaryTokenCredential consuming AZURE_BEARER_TOKEN_FOUNDRY +
AZURE_BEARER_TOKEN_SEARCH for local Docker, Dockerfile + contributor
Dockerfile mirroring Hosted-TextRag.

Integration test: AzureSearchRagHostedAgentFixture extends the PR #5598
HostedAgentFixture with the new azure-search-rag scenario branch in the
shared test container; AzureSearchRagHostedAgentTests asserts the model
returns canary tokens (TR-CANARY-7821, SHIP-CANARY-4493) that exist only
in the seeded documents - real proof the agent grounded its answer in
retrieved content rather than training data.

* Address PR 5701 Copilot review feedback

- Sample README: drop stale 'bootstraps the index on first run' line; index is pre-provisioned out of band

- Sample + TestContainer search adapters: propagate CancellationToken to await foreach via .WithCancellation()
2026-05-11 13:59:42 +00:00
Roger BarretoandGitHub 9d8c3f8cb7 Simplify ClientHeadersScope, drop redundant using/Dispose (#5676)
Wesley pointed out (with a clean demo) that AsyncLocal<T> mutations made
inside an awaited async method do not leak back to the caller after the
method returns - the runtime restores the caller's view automatically.

ClientHeadersAgent.RunCoreAsync and RunCoreStreamingAsync are the only
callers of the scope, both are async methods awaited by their callers,
so the explicit using/Dispose pattern was doing work the runtime already
does for us.

* ClientHeadersScope collapsed to a single Current { get; set; } property
  over an AsyncLocal<IReadOnlyDictionary<string,string>?>. Drops Push,
  the Scope struct, and Dispose. XML doc explains the AsyncLocal natural-
  restoration semantics so the design intent is self-documenting.
* ClientHeadersAgent uses a direct ClientHeadersScope.Current = snapshot
  before delegating. Drops the local RunAsyncCoreAsync helper and the
  snapshot-passed-as-parameter dance.
* Test 10 renamed to ClientHeadersScope_IsAsyncLocalIsolatedAndAutoRestoresAsync;
  drops the LIFO claim, keeps the parallel-isolation assertion, and adds
  a Wesley-style 'set inside async, caller sees null on return' assertion.
* Test 12 switches from using ClientHeadersScope.Push to direct
  Current = ... with try/finally for test isolation.

Snapshot deep-copy in TrySnapshot stays - it defends against caller
mutating the source Dictionary mid-run, which is independent of the
AsyncLocal restoration mechanism.
2026-05-11 13:38:14 +00:00
Roger BarretoandGitHub 9faf52de4f .NET: Hosted-Files sample + AgentSessionFiles SDK companion + integration test (#5698)
* .NET: Add Hosted-Files sample + alpha AgentSessionFiles SDK companion + integration test

Closes #5691

- Hosted-Files server sample (mirrors python 06_files): 3 local tools reading
  the per-session \C:\Users\rbarreto sandbox volume.
- SessionFilesClient REPL companion: code-first equivalent of
  zd ai agent files upload using the alpha
  Azure.AI.Projects.AgentSessionFiles SDK (upload/ls/download/rm + session
  lifecycle with isolation key).
- session-files scenario added to the Foundry.Hosting.IntegrationTests
  multi-scenario harness (PR #5598): SessionFilesHostedAgentFixture +
  SessionFilesHostedAgentTests.UploadAndAgentReadsFileAsync, end-to-end
  validating upload then agent-reads-file (agent_session_id pinned via
  CreateResponseOptions.Patch). Bundled testdata is linked from the sample
  so there is a single source of truth.

* .NET: Hosted-Files: REPL companion now demonstrates file-as-knowledge end-to-end

Adds an 'ask <prompt>' command to SessionFilesClient that pins
agent_session_id (via CreateResponseOptions.Patch) so the agent invoked from
the REPL reads files this REPL just uploaded. Surfaces the file content as
agent knowledge in the same in-process loop instead of telling the user to
shell out to azd ai agent invoke.

* .NET: Reshape Hosted-Files sample - bake files into image, SessionFilesClient becomes thin chat REPL

The previous SessionFilesClient leaned on the alpha AgentSessionFiles SDK
to upload files at runtime, which made it diverge from the canonical
Using-Samples shape (SimpleAgent / SimpleInvocationsAgent: tiny chat REPLs).

This change:

- Bakes the sample resources/ directory into the published output via a
  Content Include in HostedFiles.csproj. Inside the container the files live
  at /app/resources/. Two local function tools (ListFiles, ReadFile) surface
  them to the model.
- Reshapes SessionFilesClient as a thin FoundryAgent chat REPL, identical
  shape to SimpleAgent. AGENT_ENDPOINT + AGENT_NAME, that is it.
- Demo flow: user asks 'Give me the total revenue in the contoso file' and
  the agent answers with the figure read from its bundled file. Validated
  end-to-end locally against Hosted-Files on http://localhost:60419.
- Bypasses SampleEnvironment alias on optional env vars to avoid stdin
  prompts when running unattended.

The Foundry.Hosting.IntegrationTests session-files scenario continues to
validate the alpha AgentSessionFiles SDK end-to-end (upload + agent reads
from session HOME) and is unchanged.

* .NET: Foundry.Hosting.IntegrationTests TestContainer - constrain session-files tools to $HOME

Addresses the path-traversal review comment on the session-files scenario:
ResolveSessionPath in TestContainer used to allow absolute paths and ..
traversals, which (when chained with indirect prompt injection in an
uploaded file) would let the model read or list arbitrary container files
via the ReadFile / ListFiles tools.

Mirrors the canonicalize + StartsWith(home) pattern from the framework's
own FileSystemAgentFileStore.ResolveSafePath: rejects rooted paths, calls
Path.GetFullPath, and verifies the result stays under $HOME, throwing
ArgumentException otherwise.

The Hosted-Files sample is already safe (uses Path.GetFileName which strips
any directory component) so no change there. The integration test continues
to upload and read 'contoso_q1_2026_report.txt', a single relative filename
which passes the new validation unchanged.

* .NET: SessionFilesHostedAgentTests - shrink to alpha SDK round-trip

The previous test attempted to pin agent_session_id into the /responses
payload via JsonPatch so the agent would read the file uploaded through
AgentSessionFiles. The Foundry alpha service now consistently rejects the
explicit-session-id pin with HTTP 400 conflict on /responses, regardless
of whether the session was pre-created via AgentAdministrationClient or
left to be auto-provisioned, so the agent leg of the test is no longer
reachable from the SDK surface.

Reshape the test to exercise what the alpha SDK actually guarantees:
create session, upload, list (assert presence + size), download (assert
deterministic token), delete (assert removed), cleanup. Everything stays
inside Azure.AI.Projects.Agents.AgentSessionFiles.

Verified live against tao-foundry-prj:
  UploadListDownloadAndDeleteAsync passed in 30s.
  Full Foundry.Hosting.IntegrationTests run: 25 total, 6 passed, 19
  skipped (existing placeholders), 0 failed.

* .NET: SessionFilesHostedAgentTests - rewrite as upload-then-FoundryAgent.RunAsync e2e

Per review feedback the integration test must validate the hosted agent
itself: client uploads a file via the alpha AgentSessionFiles SDK, then
FoundryAgent.RunAsync invokes the deployed agent and the agent's
container-side ReadFile tool surfaces the uploaded file content into the
response.

Test flow:
  1. agent.RunAsync(warmup) - platform provisions a per-session container.
  2. AgentAdministrationClient.GetSessionsAsync(latest) - resolve the
     just-provisioned agent_session_id.
  3. AgentSessionFiles.UploadSessionFileAsync - upload contoso file to
     that session, asserts BytesWritten + GetSessionFiles listing.
  4. agent.RunAsync(real prompt, options=PreviousResponseId chain) -
     chained to warmup so the platform routes back to the same container.
  5. Assert response contains '1,482.6' (deterministic token from file).
  6. Best-effort cleanup.

The test is annotated with [Fact(Skip=...)] right now: the Foundry alpha
service consistently returns HTTP 400 conflict on /responses requests
that link to a prior session via previous_response_id, conversation_id,
or agent_session_id pinning - verified across multiple retries with
multiple chaining strategies. Without that link we cannot route the
second invocation to the same container the file was uploaded to. When
the platform regression is resolved, removing the Skip will exercise
the full flow.

Full Foundry.Hosting.IntegrationTests run with this change: 25 total,
5 passed, 20 skipped (existing placeholders + this one), 0 failed.

* .NET: SessionFilesHostedAgentTests - end-to-end upload-then-FoundryAgent.RunAsync now passes

The blocker was a routing problem combined with a platform race:

1. Routing two /responses calls to the same per-session container.
   - agent_session_id pin in body -> 400 (platform treats it as create)
   - conversation_id created at project root -> 404 at agent endpoint
   - previous_response_id chain -> different session
   The working answer is to create the conversation on a per-agent
   ProjectOpenAIClient (AgentName option, URL becomes
   /agents/{name}/endpoint/protocols/openai/conversations) and pass that
   conversation_id on both calls. Both then resolve to the SAME
   x-agent-session-id (verified by capturing the response header).

2. Race after AgentSessionFiles upload. The upload mutates session/
   conversation revision; a /responses call issued immediately after
   400-conflicts with 'modified concurrently. Please retry.' Bounded
   exponential retry handles it (5 attempts, 2*attempt seconds).

Test flow:
  1. Create per-agent OpenAI client + ProjectConversationsClient + ProjectResponsesClient.
  2. CreateProjectConversationAsync on the per-agent client.
  3. Warm-up agent.RunAsync(prompt, ChatOptions { ConversationId = ... })
     - captures x-agent-session-id from the response header via a custom pipeline policy.
  4. AgentSessionFiles.UploadSessionFileAsync to that session id.
  5. ProjectResponsesClient.CreateResponseAsync (raw, retry-on-conflict)
     with the same conversation_id -> routes back to the same container.
  6. Assert response contains '1,482.6' (deterministic token from file).
  7. Cleanup: delete file, leave session for TTL.

Verified live against tao-foundry-prj:
  UploadedFile_IsReadByHostedAgentAsync passed in 24.9s.
  Full Foundry.Hosting.IntegrationTests run: 25 total, 6 passed, 19
  skipped (existing placeholders), 0 failed.

* .NET: address Copilot PR review findings

- agent.manifest.yaml: description + tags now reflect bundled-files agent (image-baked /app/resources), not the obsolete session-sandbox tools the prior shape claimed.
- SessionFilesHostedAgentTests: wrap test body in try/finally to call DeleteConversationAsync on the conversation we created (matches HappyPathHostedAgentTests pattern; prevents conversation leakage across runs).
- ResponseHeaderCapturePolicy: drop unused LastRequestBody capture left over from diagnosis.

Test still passes live (40s).

* .NET: Hosted-Files: split into bundled vs session-file tool pairs

The previous Hosted-Files agent only exposed bundled (image-baked) file
knowledge. The platform also surfaces session-uploaded files at \C:\Users\rbarreto
inside the per-session container per container-image-spec.md line 172
(verified live by SessionFilesHostedAgentTests). The sample now teaches
both patterns.

Two distinct tool pairs, each scoped to its own root:

  Bundled (image-baked):    ListBundledFiles, ReadBundledFile
                            -> /app/resources/ (BUNDLED_FILES_DIR override)

  Session-uploaded (\C:\Users\rbarreto): ListSessionFiles, ReadSessionFile
                            -> \C:\Users\rbarreto (default /home/session per container spec)

Security model -- distinct tools, distinct sandboxes:
  - Tool input is a fileName, not a path. Schema-level: model cannot
    request directories or traversals.
  - Path.GetFileName(input) strips any directory components.
  - Path.GetFullPath + StartsWith(root) check rejects anything outside
    the tool's root, mirroring FileSystemAgentFileStore.ResolveSafePath.
  - Read-only, non-recursive listing. No glob, no '..'.
  - Failures non-revealing: 'File <name> not found in <scope>.'

The two roots are physically isolated (image-baked vs platform-mounted
per-session volume). A bundled-root tool can never reach a session file
and vice-versa, even if the implementation has a bug.

README updated to document both flows, the security pattern, and cite
the container-image-spec.md line 172 contract for \C:\Users\rbarreto. Live IT
SessionFilesHostedAgentTests.UploadedFile_IsReadByHostedAgentAsync
re-passed in 42s after the change (TestContainer is unchanged; the
sample-agent split does not affect the IT).

* .NET: Hosted-Files README - fix broken relative link to IT (4..5 dots)
2026-05-11 11:56:58 +00:00
Roger BarretoandGitHub d2ce0e9087 .NET: Foundry.Hosting IT - eliminate MSBuild parallel-output races (#5725)
* .NET: Foundry.Hosted IT - fix MSBuild parallel-output races

Two surgical changes inside the dotnet-foundry-hosted-it job:

1. Replace dotnet build <slnx> -f net10.0 with dotnet build <test.csproj>. The test csproj pins TargetFrameworks=net10.0 and its ProjectReference closure gives MSBuild a single-rooted graph, eliminating the duplicate inner-builds that race on bin/obj. Drops the two New-FilteredSolution.ps1 steps.

2. In it-build-image.ps1, drop the -UsePrebuiltProjectReferences switch and always pass --no-dependencies to dotnet publish. Publish now resolves TestContainer's framework refs by reading prebuilt DLLs and never re-touches them. Replaces the partial-mitigation in PR #5689 with a structural fix.

Local validation confirmed published Foundry.dll has identical mtime and bytes as the prebuild output.

* .NET: dotnet test - use --project flag for Microsoft Testing Platform
2026-05-11 09:39:13 +00:00
westeyandGitHub 0557b5782b .NET: Add IChatMessageInjector for message injection during function loop (#5679)
* Adding the ability to inject messages during the function call loop

* Split message injection functionality

* Remove interface, since it is not required not that we split the chat client.

* Address conversation id propogation

* Fix formatting issue
2026-05-08 17:16:03 +00:00
Roger BarretoandGitHub eb709d8fc9 .NET: Update FoundryAgent to address HostedAgents strict URL routing (#5677)
* .NET: Foundry agent-endpoint constructor uses ProjectOpenAIClient directly to fix hosted-agent URL routing

Fixes the experimental FoundryAgent(Uri agentEndpoint, AuthenticationTokenProvider, ...)
constructor so it actually works against Foundry hosted agents.

The previous implementation routed through AzureAIProjectChatClient, which
internally called aiProjectClient.GetProjectOpenAIClient().GetProjectResponsesClientForAgent(...).
For an agent-endpoint URL of the canonical shape

  https://<host>/api/projects/<project>/agents/<agentName>/endpoint/protocols/openai

the chain produced

  POST https://<host>/api/projects/<project>/openai/v1/responses

(project-level path, no /agents/ segment). The Foundry service rejects this with
HTTP 400 "Hosted agents can only be called through the agent endpoint:
.../agents/<agentName>/endpoint/protocols/openai/responses".

The constructor also extracted the agent name via
agentEndpoint.Segments[^1].TrimEnd('/'), which returns "openai" (the last segment),
not the agent name.

What changed
- Public ctor signature: clientOptions parameter type changed from
  AIProjectClientOptions? to ProjectOpenAIClientOptions?. The constructor is
  fundamentally building a ProjectOpenAIClient; accepting AIProjectClientOptions
  was a leaky abstraction whose translation silently dropped any pipeline
  policies the caller added via AddPolicy(...). With the direct type, caller
  policies pass through to the per-agent traffic verbatim.
- Per-agent client construction: `new ProjectOpenAIClient(BearerTokenPolicy, ProjectOpenAIClientOptions)`
  with Endpoint and AgentName set, then `GetProjectResponsesClient().AsIChatClient()`.
  The SDK auto-appends ?api-version=v1 when AgentName is set.
- New private static ParseAgentEndpoint helper: single source of truth for both
  agent-name extraction and project-root derivation. Tolerates trailing slash,
  case variants on /agents/ and the suffix segment, strips query/fragment, and
  throws ArgumentException with paramName=nameof(agentEndpoint) for malformed input.
- Project-level client (used by CreateConversationSessionAsync) is built fresh
  from the derived project root with primitive properties copied
  (RetryPolicy/NetworkTimeout/Transport/UserAgentApplicationId) plus MEAI UA.
- New GetService<ProjectOpenAIClient>() entry alongside the existing
  GetService<AIProjectClient>() (the latter returns null in agent-endpoint mode
  since no AIProjectClient is constructed on that path).
- Endpoint and AgentName on caller-supplied ProjectOpenAIClientOptions are
  overridden by values derived from agentEndpoint.

Compatibility
- FoundryAgent is [Experimental(OPENAI001)]. No GA surface touched. The Foundry
  project does not maintain PublicAPI.*.txt baselines so there is no shipped
  baseline to update.
- The Microsoft.Agents.AI.Foundry csproj pins
  Azure.AI.Projects to VersionOverride 2.1.0-beta.1 (matching what the IT and
  hosting projects already use); the central pin in Directory.Packages.props
  stays at 2.0.0.
- WireClientHeaders from PR #5652 is invoked on the agent-endpoint path so
  per-call x-client-* headers behave identically across both ctors.

Tests
- 23 new unit tests in FoundryAgentTests.cs:
  - 12 for the agent-endpoint constructor (URL routing for non-streaming and
    streaming, conversations URL shape, MEAI UA stamping, caller-policy
    passthrough on the per-agent pipeline, Endpoint/AgentName override
    semantics, GetService matrix, ProjectOpenAIClient propagation,
    UserAgentApplicationId propagation, null-arg validation, ID/Name slug)
  - 9 for ParseAgentEndpoint (standard shape, trailing slash, casing,
    sovereign-cloud host without /api/projects/ literal prefix, special chars
    in agent name, query/fragment stripping, three negative cases)
  - 2 null-arg tests for the public ctor
- All 250 Microsoft.Agents.AI.Foundry.UnitTests pass (was 221 baseline plus
  29 from PR #5652 plus 23 new in this PR equals 273; pre-existing tests
  collapsed by the rebase merge keep the total at 250).
- All 225 Microsoft.Agents.AI.Foundry.Hosting.UnitTests pass; no behavioral
  change to the hosting layer.
- dotnet build clean across net8/9/10/netstandard2.0/net472 with
  TreatWarningsAsErrors=true.
- dotnet format --verify-no-changes clean for the touched src and test projects.

* .NET: Bump central Azure.AI.Projects pin to 2.1.0-beta.1 and flip Microsoft.Agents.AI.Foundry to preview

Required to fix the NU1109 downgrade chain that broke CI on the agent-endpoint
constructor rewire (#5677). Microsoft.Agents.AI.Foundry now depends on
ProjectOpenAIClientOptions.AgentName and the (AuthenticationPolicy, options)
constructor that only exist in Azure.AI.Projects 2.1.0-beta.1.

Changes:
* Directory.Packages.props: Azure.AI.Projects 2.0.0 -> 2.1.0-beta.1.
* Microsoft.Agents.AI.Foundry.csproj: drop IsReleased=true so the package ships
  as preview (matches the beta SDK we now depend on). Add a comment noting the
  flip is temporary and should revert once Azure.AI.Projects ships a stable
  2.1.0.
* Drop redundant VersionOverride="2.1.0-beta.1" from the 10 csprojs that had it
  as a workaround; the central pin now suffices.

Verified:
* dotnet build agent-framework-dotnet.slnx --warnaserror clean across all TFMs.
* Microsoft.Agents.AI.Foundry.UnitTests 250/250 pass.
* Microsoft.Agents.AI.Foundry.Hosting.UnitTests 211/211 pass.
* dotnet format --verify-no-changes clean for the touched src and test projects.
2026-05-08 14:46:52 +00:00
westeyandGitHub 226c004b53 Add hyperlight to release slnf (#5695) 2026-05-08 09:28:35 +00:00
Jacob AlberandGitHub 3aae3cb9de Update version for release (#5703) 2026-05-08 00:17:44 +00:00
0340b7596b Python: bump package versions for 1.3.0 release (#5706)
* Python: bump package versions for 1.3.0 release

MINOR bump on the released cohort (agent-framework, agent-framework-core,
agent-framework-openai, agent-framework-foundry: 1.2.2 -> 1.3.0). All 22
beta packages stamp 1.0.0b260507 and all 3 alpha packages stamp
1.0.0a260507 per the lockstep convention. Date stamp reflects 2026-05-07
Pacific.

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

* Address review: bump foundry_local openai floor, fix devui orchestrations pin, clarify breaking scope

- foundry_local: bump agent-framework-openai lower bound from >=1.1.0 to >=1.3.0
- devui: update stale agent-framework-orchestrations dev pin from 1.0.0b260402 to 1.0.0b260507
- CHANGELOG: clarify [BREAKING] applies to experimental skills API only

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

* Revert devui orchestrations pin to 1.0.0b260402 to avoid breaking DevUI

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-08 08:57:02 +09:00
76772ffc19 .NET: Fix function_call_output.output to be a JSON string on the wire (#5705)
* Fix function_call_output.output to be a JSON string on the wire

OutputConverter was passing the JSON serialization of complex tool results (e.g. List<TodoItem>) directly into OutputItemFunctionToolCallOutput via BinaryData.FromString. The Responses SDK treats that BinaryData as the *raw JSON value* for the field, so non-string results landed on the wire as an unquoted JSON array (e.g. `"output":[{...}]`) instead of a JSON string.

The OpenAI Responses spec requires `function_call_output.output` to be a JSON string. The strict-parsing OpenAI .NET client (FunctionCallOutputResponseItem) consequently failed when threading a follow-up turn that replayed such an item, with: `The JSON value could not be converted... requires an element of type 'String', but the target element has type 'Array'`.

Always wrap the payload as a JSON string literal:

  - string s   -> JSON-encode s (quoted, with escapes)

  - object o   -> JSON-serialize o, then JSON-encode the resulting text

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

* Address PR feedback: JsonElement special-case, symmetric inbound unwrap, tests

OutputConverter: extract EncodeFunctionResultAsJsonStringPayload helper
that special-cases JsonElement / JsonDocument so a string-kind element
does not get double-encoded into "\"value\"". Other JsonElement kinds
(object/array/number/bool) round-trip via GetRawText() and are then
JSON-string-wrapped, matching the spec.

InputConverter: symmetric DecodeFunctionResultPayload added to
ConvertFunctionCallOutput and ConvertFunctionToolCallOutput so
previously-stored function_call_output items replayed via
previous_response_id unwrap back to the original tool result text
instead of leaking the JSON-encoded form into FunctionResultContent.Result.
Legacy non-conforming raw-JSON-value payloads pass through unchanged.

Tests:
  - Replace ConvertUpdatesToEventsAsync_FunctionResultStringPayload_EmittedAsRawTextAsync
    with EmittedAsJsonStringAsync asserting the new wire contract ("sunny" -> "\"sunny\"").
  - Add coverage for object payloads, JsonElement string kind (no double-encoding),
    and JsonElement array kind (JSON-stringified).
  - Add InputConverter round-trip tests for spec-compliant JSON-string payloads
    and legacy raw-JSON-array payloads.

All 663 tests pass on net8/net9/net10. Verified end-to-end against the local
hosted-harness sample: T1-T4 (incl. TodoList tool replay across turns) all
succeed with no SDK parse errors.

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

---------

Co-authored-by: alliscode <bentho@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-07 23:27:57 +00:00
Jacob AlberGitHublokitothcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
27324a8013 .NET: Mark Magentic Orchestration Experimental (#5704)
* fix: Mark Magentic Orchestration Experimental

* Apply [Experimental] to all public Magentic types and suppress MAAIW001 in project

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/957a07c1-a805-40eb-989d-bd3425d4c0af

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>
2026-05-07 22:54:01 +00:00
57fb32efc8 Python: Upgrade github-copilot-sdk to v1.0.0b2 with new features (#5665)
* Upgrade github-copilot-sdk to v1.0.0b1 and implement new features

- Bump github-copilot-sdk dependency from 0.2.1 to 1.0.0b1
- Fix breaking type renames: ErrorClass -> ToolExecutionCompleteError,
  Result -> ToolExecutionCompleteResult
- Add instruction_directories support in GitHubCopilotOptions (session-level)
- Add copilot_home support in GitHubCopilotSettings (client-level)
- Add sample: github_copilot_with_instruction_directories.py
- Update README with new env var and sample entry
- Add 8 new unit tests covering the new features (103 total, 96% coverage)

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

* mypy fix

* small fix

* Address PR feedback: fix resume path, remove copilot_home from Options, bump to beta.2

- Forward runtime_options through _resume_session (fixes silent drop of
  instruction_directories/model/etc on resumed sessions)
- Remove copilot_home from GitHubCopilotOptions (client-level setting only
  consumed at startup, not per-call)
- Bump github-copilot-sdk from 1.0.0b1 to 1.0.0b2
- Add test for instruction_directories override on resumed sessions
- Update existing resume test to match new _resume_session signature

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-07 21:43:47 +00:00
3c1e2c40b8 Fix typo:sesionEleme -> sessionElement (#5674)
Co-authored-by: Jacob Alber <jaalber@microsoft.com>
2026-05-07 21:05:41 +00:00
d3518ad19d fix(security): non-thread-safe sequence number generation may cau (#5320)
`SequenceNumber.Increment()` uses `this._sequenceNumber++` without synchronization. In concurrent streaming scenarios, this can produce race conditions and inconsistent sequencing, which may break event ordering guarantees and potentially allow response-mixing or state confusion.

Affected files: SequenceNumber.cs

Signed-off-by: tuanaiseo <221258316+tuanaiseo@users.noreply.github.com>
Co-authored-by: Jacob Alber <jaalber@microsoft.com>
2026-05-07 20:42:49 +00:00
c06af9a1b3 .NET: Python: Add dotnet integration test report to CI (#5515)
* Add dotnet integration test report to CI

- Add --report-junit flag to dotnet integration test step to generate
  JUnit XML alongside TRX, with explicit --results-directory to
  centralize output in IntegrationTestResults/
- Upload JUnit XML artifacts from each matrix leg (net10.0/ubuntu,
  net472/windows) as dotnet-test-results-{framework}-{os}
- Add dotnet-integration-test-report job that downloads artifacts,
  runs the existing aggregate.py script, posts markdown to Job Summary,
  and saves trend history via actions/cache
- Refactor aggregate.py to discover JUnit XML files recursively,
  supporting both pytest (pytest.xml) and xunit (*.junit.xml) layouts
- Handle provider name derivation for dotnet artifact naming convention
- Fix nodeid collision when same test runs under multiple frameworks
  by qualifying keys with provider when collisions are detected
- Improve module extraction for dotnet C# classnames (recognizes
  IntegrationTests/UnitTests namespace segments)

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

* chore: trigger dotnet CI for report validation

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

* fix: use .junit extension (not .junit.xml) for xunit v3 output

xUnit v3 generates files with .junit extension, not .junit.xml.
Update upload glob and aggregate.py discovery to match.

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

* fix: use deterministic provider-qualified keys for dotnet tests

Always prefix dotnet test keys with provider (e.g. net10.0 (ubuntu)::TestName)
to ensure stable, comparable counts across runs regardless of file parse order.
Also show Executed (passed+failed) instead of Total in summary table.

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

* fix: match Python report summary format (Total, passed/total, etc.)

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

* feat: split dotnet report into per-framework tables

Dotnet tests run on multiple frameworks (net10.0, net472). Instead of
one combined table with unstable totals, show separate sections per
framework — each with its own summary row and per-test table. Python
reports retain the original single-table format.

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

* Re-enable 7 flaky dotnet integration tests with increased timeouts

Increase timeouts to reduce timing-related flakiness in LLM-backed
integration tests (issue #4971):

- ExternalClientTests: 60s -> 120s default timeout
- SamplesValidationBase: 60s -> 120s default timeout
- ConsoleAppSamplesValidation: 90s -> 150s for long-running tests
- AzureFunctions SamplesValidation: 2min -> 3min orchestration timeout,
  60s -> 90s per-step WaitForConditionAsync timeouts

Remove all Skip=Flaky annotations and unused SkipFlakyTimingTest constants.

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

* Re-skip LLM non-determinism flaky tests, keep timeout fixes

Re-skip SingleAgentOrchestrationHITLSampleValidationAsync and
LongRunningToolsSampleValidationAsync - these fail due to LLM producing
extra review notifications, not timeouts. Updated skip reasons to
accurately describe the root cause. Reverted unnecessary timeout change
on the skipped LongRunningTools test.

The remaining 5 re-enabled tests with timeout increases are stable.

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

* Enable Anthropic integration tests in CI

Replace hardcoded skip with conditional skip pattern (matching
CopilotStudio approach): tests gracefully skip when ANTHROPIC_API_KEY
is missing, and run when present.

Changes:
- AnthropicChatCompletionFixture: try/catch in InitializeAsync with
  Assert.Skip on missing config (replaces hardcoded SkipReason)
- AnthropicSkillsIntegrationTests: same pattern per test method
- dotnet-build-and-test.yml: wire up ANTHROPIC_API_KEY,
  ANTHROPIC_CHAT_MODEL_NAME, and ANTHROPIC_REASONING_MODEL_NAME
  env vars to the integration test step

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

* Fix missing System using in AnthropicSkillsIntegrationTests

Add 'using System;' for InvalidOperationException in try/catch blocks.

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

* Skip flaky SingleAgentOrchestrationChainingSampleValidationAsync

LLM non-determinism causes Assert.NotNull failures on orchestration
results. Skip until test logic is hardened against non-deterministic
LLM responses.

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

* Re-enable HITL and LongRunningTools tests with timeout and flexibility fixes

- Remove Skip attribute from SingleAgentOrchestrationHITLSampleValidationAsync
- Remove Skip attribute from LongRunningToolsSampleValidationAsync
- Increase timeout from 120s/90s to 180s to accommodate 2+ LLM round-trips
- Replace rigid 2-cycle assertion with flexible approval logic that handles
  extra review cycles from LLM non-determinism

Fixes the two failure modes identified in #4971:
1. Timeout: 120s/90s was insufficient for multiple LLM calls under CI load
2. Extra notifications: Assert.Fail on 3rd+ review cycle was too rigid

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

* Increase AzureFunctions LongRunningTools test timeouts from 90s to 180s

The LongRunningToolsSampleValidationAsync test in the AzureFunctions integration
tests was failing in CI with TimeoutException at the 'Content published
notification is logged' step. The 90-second timeouts are too tight for CI
environments where LLM calls and orchestration overhead can be slow.

Increased all three WaitForConditionAsync timeouts from 90s to 180s:
- Waiting for human feedback notification
- Waiting for publish notification (the step that was failing)
- Waiting for orchestration completion

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

* Merge main and fix dotnet report path after flaky_report rename

Merge upstream/main which renamed scripts/flaky_report/ to
scripts/integration_test_report/ (from Python PR #5454). Update the
dotnet-build-and-test workflow to reference the new path.

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

* Add RetryFact to DurableTask and AzureFunctions integration tests

These tests interact with LLMs via stdin/stdout (DurableTask) or HTTP
(AzureFunctions) and are inherently non-deterministic. Unlike the Python
side which uses pytest-retry, the dotnet tests had no retry mechanism
and a single transient failure would fail the entire CI run.

Changes:
- Switch [Fact] to [RetryFact(2, 5000)] on all LLM-dependent tests
  across ConsoleAppSamplesValidation, ExternalClientTests,
  WorkflowConsoleAppSamplesValidation, and AzureFunctions SamplesValidation
- Add re-prompt mechanism to LongRunningToolsSampleValidationAsync:
  if the LLM doesn't invoke the tool within 60s, re-send the prompt
  (up to 2 retries) instead of burning the full timeout
- Reduce LongRunningTools timeout from 240s to 180s (re-prompt makes
  the extra buffer unnecessary)
- Leave simple/deterministic tests as [Fact] (SingleAgent, unit tests)

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

* Add persist-credentials: false to Integration Test Report checkout step

Matches the convention used by other checkout steps in this workflow
to avoid leaving GITHUB_TOKEN credentials in the local git config.

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

* small fixes

* disable anthropic failing tests

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-07 20:39:32 +00:00
1d94518f37 Python: Add ClassSkill for class-based skill definitions (#5678)
* Python: Add ClassSkill for class-based skill definitions

Add ClassSkill abstract base class with decorator-based resource and script
discovery, porting .NET's AgentClassSkill (PRs #5027 and #5183) to Python.

- Add ClassSkill(Skill, ABC) with instructions abstract property, cached
  content/resources/scripts properties
- Add @ClassSkill.resource and @ClassSkill.script static method decorators
  for auto-discovery of methods and properties
- Extract _build_skill_content() and _create_resource_element() shared
  helpers from InlineSkill for reuse
- Add _discover_marked_members() for scanning class hierarchies
- Add _make_method_name() for Python-to-skill name conversion
- Add class_based_skill sample (UnitConverterSkill)
- Update mixed_skills sample with TemperatureConverterSkill
- Add 58 new tests covering ClassSkill, decorator discovery, property
  resources, inheritance, kwargs forwarding, and duplicate detection
- Export ClassSkill from agent_framework public API

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

* fix: replace try/except/continue with assignment to satisfy bandit B112

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

* address PR review feedback

- Walk cls.__mro__ in _discover_marked_members for inherited property resources
- Use inspect.getattr_static for MRO-aware is_property check
- Return defensive copies from resources/scripts properties
- Raise TypeError on wrong decorator stacking order (@resource above @property)
- Log warning instead of silently swallowing descriptor errors during discovery
- Validate explicit name= at decoration time via _validate_member_name
- Add tests for all of the above

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

* Fix temperature converter skill: make resource necessary for script

Refactor TemperatureConverterSkill so the agent must read the
formulas resource (factor/offset) before calling the script,
aligning with the volume-converter pattern.

- Resource: numeric factor/offset table instead of symbolic formulas
- Script: generic linear transform (value * factor + offset)
- Instructions: updated to reflect new workflow

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-07 19:39:12 +00:00
Roger BarretoandGitHub a478d1b53c .NET: Foundry.Hosting IT: avoid MSB3026 in publish; fix telemetry UT flake (#5689)
CI publish step: gate the BuildProjectReferences=false fast-path on an explicit -UsePrebuiltProjectReferences switch (passed by the workflow) instead of marker detection. Adds a preflight error when stale obj/Release/net10.0 outputs would cause CS0579, with actionable recovery instructions.

Telemetry UT flake: AgentFrameworkResponseHandlerTelemetryTests was using a plain List<Activity> for OTel's InMemoryExporter. The exporter writes from background Activity completion callbacks while parallel tests on the same global ActivitySource feed every listener, racing against the assertion's enumeration and throwing 'Collection was modified'. Replaced with a small thread-safe ConcurrentActivityList that locks add/enumerate and returns a snapshot for assertions.
2026-05-07 18:54:46 +00:00
Jacob AlberandGitHub ce70ca1a9f .NET: feat: Implement Magentic Orchestration for .NET (#5595)
* feat: Implement Magentic Orchestration for .NET

* fixup: Update for review comments

* fix: Fix FenceJsonRegexPattern

* fix: Format

* fix: Updates for PR feedback

* fix: Add missing serialized types to source gen for trimming

* fix: Address PR Comments
2026-05-07 18:36:15 +00:00
2a9b68d1bd Python: Fix MCPStreamableHTTPTool leaking asyncio.CancelledError when MCP server is unreachable (#5687)
* fix: wrap asyncio.CancelledError in ToolException in _connect_on_owner (#5667)

asyncio.CancelledError is a BaseException (not Exception) in Python 3.8+.
When an MCP server is unreachable, the MCP library's internal anyio task
group raises CancelledError, which escaped all three 'except Exception'
handlers in _connect_on_owner(). This propagated through
_run_lifecycle_owner -> _run_on_lifecycle_owner -> connect -> __aenter__,
bypassing user except Exception blocks entirely.

Fix: change the three except-Exception clauses in _connect_on_owner to
'except (Exception, asyncio.CancelledError)' so spurious CancelledErrors
from the MCP transport layer are caught and wrapped in ToolException,
consistent with the method's documented contract.

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

* fix(mcp): propagate genuine task CancelledError in connect() (#5667)

On Python >= 3.11, check task.cancelling() > 0 before wrapping
CancelledError as ToolException in the three except blocks inside
_connect_on_owner(). When the current task is being cancelled by its
caller, the CancelledError now propagates after cleanup, consistent
with the existing pattern at _mcp.py:560-564 and _runner.py:115-120.

On Python < 3.11 task.cancelling() is unavailable, so MCP-internal
CancelledErrors still cannot be reliably distinguished from
caller-driven cancellation; they continue to be wrapped as
ToolException with a comment documenting the trade-off.

Tests:
- Add cleanup assertion to transport-creation CancelledError test
- Add MCPStdioTool variants exercising the 'command' message branches
  for both transport-creation and initialize CancelledError paths
- Add Python 3.11+-gated tests verifying genuine task cancellation
  propagates (and still cleans up) for transport and initialize stages

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

* fix(mcp): log CancelledError with exc_info before wrapping in ToolException (#5667)

CancelledError inherits from BaseException (not Exception) on Python >= 3.8,
so the 'inner_exception=ex if isinstance(ex, Exception) else None' guard
always yields None for CancelledError. This means ToolException.__init__
calls logger.log(level, message, exc_info=None), dropping the traceback.

Add an explicit logger.debug(error_msg, exc_info=ex) before each
raise ToolException(...) in the three CancelledError handlers so the
full traceback is preserved in debug logs when MCP-internal cancellation
is wrapped rather than propagated.

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

* Address review feedback for #5667: Python: [Bug]: Error Handling Issue regarding Python MCPStreamableHTTPTool Class

* refactor(_mcp): extract cancellation helper, fix session error msg and exc_info

- Extract _should_propagate_cancelled_error() helper to eliminate duplicated
  genuine-cancellation detection logic across the three connect() except blocks
- Fix session-creation ToolException message to include exception details
  (e.g. 'Failed to create MCP session: <ex>') matching the transport and
  initialize failure paths
- Change exc_info=ex to exc_info=True in all three logger.debug() calls
  for idiomatic logging
- Add tests for _should_propagate_cancelled_error helper
- Add regression test asserting session error message includes exception text
- Add test verifying logger.debug is called with exc_info=True

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

* refactor: factor out _close_and_check_cancelled helper in _connect_on_owner

Addresses review comment on PR #5687:

1. Add _close_and_check_cancelled() helper method that combines
   _safe_close_exit_stack() + _should_propagate_cancelled_error() into a
   single await-able call. This eliminates the duplicated close-then-check
   pattern that appeared identically in all three connect phases (transport,
   session, initialize), reducing future drift risk.

2. Comments 2 and 3 (missing {ex} in session error message and non-idiomatic
   exc_info=ex) were already addressed in the current code: all error messages
   include {ex} and all logger.debug calls use exc_info=True.

3. Add test_connect_genuine_cancellation_during_session_creation_propagates
   to cover the previously untested genuine-cancellation path in the
   session-creation phase (transport and initialize phases already had tests).

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

* Address review feedback for #5667: review comment fixes

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-07 17:58:30 +00:00
1489d6620e .NET: feat: Update Github Copilot SDK to 1.0.0-beta.2 (#5699)
* feat: Update Github Copilot SDK to 1.0.0-beta.2

* Fix formatting

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

* fix: Update for breaking changes in Github.Copilot.SDK

* fix sample project

* fix: whitespace formatting

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-07 19:15:10 +01:00
8bb4692678 Python: Add base_url parameter to AnthropicClient and RawAnthropicClient (#5685)
* feat(anthropic): add base_url parameter to AnthropicClient and RawAnthropicClient

Add base_url support to AnthropicSettings TypedDict, RawAnthropicClient,
and AnthropicClient so users can point the client at Foundry or other
Anthropic-compatible endpoints without having to construct AsyncAnthropic
manually.

- Add base_url field to AnthropicSettings (resolved from ANTHROPIC_BASE_URL env var)
- Add base_url parameter to RawAnthropicClient.__init__ and pass it to AsyncAnthropic
- Add base_url parameter to AnthropicClient.__init__ and forward to super
- Add unit tests for base_url on both client classes

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

* Python: Add `base_url` parameter to `AnthropicClient` and `RawAnthropicClient`

Fixes #5683

* test: add ANTHROPIC_BASE_URL env fallback tests for issue #5683

Add unit tests verifying that both AnthropicClient and RawAnthropicClient
pick up base_url from the ANTHROPIC_BASE_URL environment variable via
load_settings when base_url is not passed explicitly as a constructor arg.

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

* test(anthropic): explicit base_url kwarg beats ANTHROPIC_BASE_URL env var (#5683)

Add regression tests asserting that when both ANTHROPIC_BASE_URL is set
in the environment *and* an explicit base_url kwarg is passed to
AnthropicClient / RawAnthropicClient, the explicit kwarg wins.

This closes the priority-ordering contract (explicit arg > env var) that
the existing tests left implicit.

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

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-07 17:57:09 +00:00
187 changed files with 10296 additions and 2016 deletions
+97 -26
View File
@@ -60,6 +60,7 @@ jobs:
- 'dotnet/src/Microsoft.Agents.AI.Workflows/**'
- 'dotnet/tests/Foundry.Hosting.IntegrationTests/**'
- 'dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/**'
- 'dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/**'
- 'dotnet/Directory.Packages.props'
- 'dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-build-image.ps1'
- '.github/workflows/dotnet-build-and-test.yml'
@@ -273,6 +274,8 @@ jobs:
-c ${{ matrix.configuration }} `
--no-build -v Normal `
--report-xunit-trx `
--report-junit `
--results-directory ../IntegrationTestResults/ `
--ignore-exit-code 8 `
--filter-not-trait "Category=IntegrationDisabled" `
--filter-not-trait "Category=FoundryHostedAgents" `
@@ -294,6 +297,10 @@ jobs:
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }}
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZURE_AI_MODEL_DEPLOYMENT_NAME }}
AZURE_AI_BING_CONNECTION_ID: ${{ vars.AZURE_AI_BING_CONNECTION_ID }}
# Anthropic Models
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_CHAT_MODEL_NAME: ${{ vars.ANTHROPIC_CHAT_MODEL_NAME }}
ANTHROPIC_REASONING_MODEL_NAME: ${{ vars.ANTHROPIC_REASONING_MODEL_NAME }}
# Generate test reports and check coverage
- name: Generate test reports
@@ -316,6 +323,14 @@ jobs:
shell: pwsh
run: ./dotnet/eng/scripts/dotnet-check-coverage.ps1 -JsonReportPath "TestResults/Reports/Summary.json" -CoverageThreshold $env:COVERAGE_THRESHOLD
- name: Upload integration test results
if: always() && github.event_name != 'pull_request' && matrix.integration-tests
uses: actions/upload-artifact@v7
with:
name: dotnet-test-results-${{ matrix.targetFramework }}-${{ matrix.os }}
path: IntegrationTestResults/**/*.junit
if-no-files-found: ignore
# The Foundry hosted-agent IT is costly (it builds a container, pushes to ACR, and provisions
# live agents on a separate Foundry project). Running it in its own job keeps the overall
# workflow time roughly flat: it executes in parallel to dotnet-build and dotnet-test and is
@@ -326,7 +341,6 @@ jobs:
runs-on: ubuntu-latest
environment: integration
env:
targetFramework: net10.0
configuration: Release
steps:
- uses: actions/checkout@v6
@@ -343,31 +357,15 @@ jobs:
with:
global-json-file: ${{ github.workspace }}/dotnet/global.json
- name: Generate test solution (no samples)
shell: pwsh
run: |
./dotnet/eng/scripts/New-FilteredSolution.ps1 `
-Solution dotnet/agent-framework-dotnet.slnx `
-TargetFramework $env:targetFramework `
-Configuration $env:configuration `
-ExcludeSamples `
-OutputPath dotnet/filtered.slnx `
-Verbose
- name: Generate Foundry hosted IT filtered solution
shell: pwsh
run: |
./dotnet/eng/scripts/New-FilteredSolution.ps1 `
-Solution dotnet/filtered.slnx `
-TargetFramework $env:targetFramework `
-Configuration $env:configuration `
-TestProjectNameFilter "Foundry.Hosting.IntegrationTests*" `
-OutputPath dotnet/filtered-foundry-hosted.slnx `
-Verbose
# Build the test csproj directly instead of a filtered slnx + -f override.
# The test project pins TargetFrameworks=net10.0 and its ProjectReference closure
# gives MSBuild a single-rooted graph, so each multi-targeted dependency is invoked
# exactly once for net10.0. This avoids the MSB3026/MSB3491/MSB4018/MSB3883 file-lock
# collisions caused by parallel inner-builds racing on shared bin/obj output paths
# under the previous slnx + global TFM override approach.
- name: Build Foundry hosted IT (and its deps)
shell: bash
run: dotnet build dotnet/filtered-foundry-hosted.slnx -c "$configuration" -f "$targetFramework" --warnaserror
run: dotnet build dotnet/tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj -c "$configuration" --warnaserror
- name: Azure CLI Login
uses: azure/login@v2
@@ -379,6 +377,13 @@ jobs:
# We rebuild and push the test container image on every IT run so framework code changes
# are picked up; the image tag is content-hashed across the test container source AND its
# framework project references, so identical content is a no-op push.
#
# The script always passes --no-dependencies to dotnet publish so publish never re-touches
# the framework lib DLLs the prior "Build Foundry hosted IT (and its deps)" step produced.
# This structurally eliminates the MSB3026 collision that VBCSCompiler from the prebuild
# would otherwise cause by holding file handles to those DLLs. Do not remove the prebuild
# step: the subsequent `dotnet test --no-build` step and the publish's ProjectReference
# resolution both depend on the prebuilt outputs being present.
- name: Build and push Foundry Hosted Agents test container
id: build-foundry-hosted-image
shell: pwsh
@@ -394,8 +399,7 @@ jobs:
shell: pwsh
working-directory: dotnet
run: |
dotnet test --solution ./filtered-foundry-hosted.slnx `
-f $env:targetFramework `
dotnet test --project tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj `
-c $env:configuration `
--no-build -v Normal `
--report-xunit-trx `
@@ -404,6 +408,12 @@ jobs:
env:
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.IT_HOSTED_AGENT_PROJECT_ENDPOINT }}
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.IT_HOSTED_AGENT_MODEL_DEPLOYMENT_NAME }}
# Azure AI Search (for the azure-search-rag scenario). Reuses the integration
# environment secrets shared with python-sample-validation.yml. The index is
# provisioned out of band; see dotnet/tests/Foundry.Hosting.IntegrationTests/README.md
# for the required schema and seed content.
AZURE_SEARCH_ENDPOINT: ${{ secrets.AZURE_SEARCH_ENDPOINT }}
AZURE_SEARCH_INDEX_NAME: ${{ secrets.AZURE_SEARCH_INDEX_NAME }}
# IT_HOSTED_AGENT_IMAGE was exported into $GITHUB_ENV by the previous step.
# This final job is required to satisfy the merge queue. It must only run (or succeed) if no tests failed
@@ -448,3 +458,64 @@ jobs:
uses: actions/github-script@v8
with:
script: core.setFailed('Integration Tests Cancelled!')
# Integration test trend report (aggregates JUnit XML results from dotnet test jobs)
dotnet-integration-test-report:
name: Integration Test Report
if: >
always() &&
github.event_name != 'pull_request' &&
(contains(join(needs.*.result, ','), 'success') ||
contains(join(needs.*.result, ','), 'failure'))
needs: [dotnet-test]
runs-on: ubuntu-latest
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
sparse-checkout: |
.github/actions/python-setup
python
- name: Set up python and install the project
uses: ./.github/actions/python-setup
with:
python-version: "3.13"
os: ${{ runner.os }}
- name: Download all test results from current run
uses: actions/download-artifact@v4
with:
pattern: dotnet-test-results-*
path: dotnet-test-results/
- name: Restore report history cache
uses: actions/cache/restore@v4
with:
path: python/dotnet-integration-report-history.json
key: dotnet-integration-report-history-${{ github.run_id }}
restore-keys: |
dotnet-integration-report-history-
- name: Generate trend report
run: >
uv run python scripts/integration_test_report/aggregate.py
../dotnet-test-results/
dotnet-integration-report-history.json
dotnet-integration-test-report.md
- name: Post to Job Summary
if: always()
run: cat dotnet-integration-test-report.md >> $GITHUB_STEP_SUMMARY
- name: Save report history cache
if: always()
uses: actions/cache/save@v4
with:
path: python/dotnet-integration-report-history.json
key: dotnet-integration-report-history-${{ github.run_id }}
- name: Upload trend report
if: always()
uses: actions/upload-artifact@v7
with:
name: dotnet-integration-test-report
path: |
python/dotnet-integration-test-report.md
python/dotnet-integration-report-history.json
+3 -2
View File
@@ -25,7 +25,8 @@
<PackageVersion Include="Azure.AI.AgentServer.Core" Version="1.0.0-beta.23" />
<PackageVersion Include="Azure.AI.AgentServer.Invocations" Version="1.0.0-beta.3" />
<PackageVersion Include="Azure.AI.AgentServer.Responses" Version="1.0.0-beta.4" />
<PackageVersion Include="Azure.AI.Projects" Version="2.0.0" />
<PackageVersion Include="Azure.Search.Documents" Version="12.0.0" />
<PackageVersion Include="Azure.AI.Projects" Version="2.1.0-beta.1" />
<PackageVersion Include="Azure.AI.Agents.Persistent" Version="1.2.0-beta.10" />
<PackageVersion Include="Azure.AI.OpenAI" Version="2.9.0-beta.1" />
<PackageVersion Include="Azure.Core" Version="1.53.0" />
@@ -98,7 +99,7 @@
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.InMemory" Version="1.67.0-preview" />
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.Qdrant" Version="1.67.0-preview" />
<!-- Agent SDKs -->
<PackageVersion Include="GitHub.Copilot.SDK" Version="0.1.29" />
<PackageVersion Include="GitHub.Copilot.SDK" Version="1.0.0-beta.2" />
<PackageVersion Include="Microsoft.Agents.CopilotStudio.Client" Version="1.3.171-beta" />
<!-- M365 Agents SDK -->
<PackageVersion Include="AdaptiveCards" Version="3.1.0" />
+1
View File
@@ -33,3 +33,4 @@ Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Fram
- [Design Documents](../docs/design)
- [Architectural Decision Records](../docs/decisions)
- [MSFT Learn Docs](https://learn.microsoft.com/agent-framework/overview/agent-framework-overview)
+9 -2
View File
@@ -167,7 +167,7 @@
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Agent_Step22_MemorySearch.csproj" />
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Agent_Step23_LocalMCP.csproj" />
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/Agent_Step24_CodeInterpreterFileDownload.csproj" />
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Agent_Step25_ToolboxServerSideTools.csproj" />
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step25_FoundryToolboxMcp/Agent_Step25_FoundryToolboxMcp.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/Evaluation/">
<Project Path="samples/02-agents/Evaluation/Evaluation_CustomEvals/Evaluation_CustomEvals.csproj" />
@@ -313,6 +313,9 @@
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/HostedFoundryAgent.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/HostedFiles.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/HostedLocalTools.csproj" />
</Folder>
@@ -325,6 +328,9 @@
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/HostedAzureSearchRag.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/HostedTextRag.csproj" />
</Folder>
@@ -332,6 +338,7 @@
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/HostedWorkflowSimple.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/SessionFilesClient.csproj" />
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent/SimpleAgent.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/">
@@ -366,7 +373,7 @@
<Project Path="samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj" />
<Project Path="samples/02-agents/A2A/A2AAgent_ProtocolSelection/A2AAgent_ProtocolSelection.csproj" />
<Project Path="samples/02-agents/A2A/A2AAgent_StreamReconnection/A2AAgent_StreamReconnection.csproj" />
</Folder>
</Folder>
<Folder Name="/Samples/05-end-to-end/">
<Project Path="samples/05-end-to-end/AgentWithPurview/AgentWithPurview.csproj" />
<Project Path="samples/05-end-to-end/M365Agent/M365Agent.csproj" />
+2 -1
View File
@@ -30,7 +30,8 @@
"src\\Microsoft.Agents.AI.Workflows.Generators\\Microsoft.Agents.AI.Workflows.Generators.csproj",
"src\\Microsoft.Agents.AI.Workflows\\Microsoft.Agents.AI.Workflows.csproj",
"src\\Microsoft.Agents.AI\\Microsoft.Agents.AI.csproj",
"src\\Aspire.Hosting.AgentFramework.DevUI\\Aspire.Hosting.AgentFramework.DevUI.csproj"
"src\\Aspire.Hosting.AgentFramework.DevUI\\Aspire.Hosting.AgentFramework.DevUI.csproj",
"src\\Microsoft.Agents.AI.Hyperlight\\Microsoft.Agents.AI.Hyperlight.csproj"
]
}
}
+3 -3
View File
@@ -1,14 +1,14 @@
<Project>
<PropertyGroup>
<!-- Central version prefix - applies to all nuget packages. -->
<VersionPrefix>1.4.0</VersionPrefix>
<VersionPrefix>1.5.0</VersionPrefix>
<RCNumber>1</RCNumber>
<DateSuffix>260505</DateSuffix>
<DateSuffix>260507</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.4.0</GitTag>
<GitTag>1.5.0</GitTag>
<Configurations>Debug;Release;Publish</Configurations>
<IsPackable>true</IsPackable>
@@ -50,12 +50,12 @@ Console.WriteLine(await agent.RunAsync("My name is Ruaidhrí", session));
Console.WriteLine(await agent.RunAsync("I am 20 years old", session));
// We can serialize the session. The serialized state will include the state of the memory component.
JsonElement sesionElement = await agent.SerializeSessionAsync(session);
JsonElement sessionElement = await agent.SerializeSessionAsync(session);
Console.WriteLine("\n>> Use deserialized session with previously created memories\n");
// Later we can deserialize the session and continue the conversation with the previous memory component state.
var deserializedSession = await agent.DeserializeSessionAsync(sesionElement);
var deserializedSession = await agent.DeserializeSessionAsync(sessionElement);
Console.WriteLine(await agent.RunAsync("What is my name and age?", deserializedSession));
Console.WriteLine("\n>> Read memories using memory component\n");
@@ -12,7 +12,9 @@ static Task<PermissionRequestResult> PromptPermission(PermissionRequest request,
Console.Write("Approve? (y/n): ");
string? input = Console.ReadLine()?.Trim().ToUpperInvariant();
string kind = input is "Y" or "YES" ? "approved" : "denied-interactively-by-user";
PermissionRequestResultKind kind = input is "Y" or "YES"
? PermissionRequestResultKind.Approved
: PermissionRequestResultKind.Rejected;
return Task.FromResult(new PermissionRequestResult { Kind = kind });
}
@@ -8,6 +8,11 @@
// even if the process is interrupted mid-loop, but may also result in chat history that is not
// yet finalized (e.g., tool calls without results) being persisted, which may be undesirable in some cases.
//
// Additionally, this sample demonstrates the MessageInjectingChatClient feature, which allows tool
// code to inject new user messages during the function execution loop. When a tool or anything else enqueues
// a message via MessageInjectingChatClient.EnqueueMessages during the tool execution loop, the PerServiceCallChatHistoryPersistingChatClient
// detects the pending message before the next service call and includes the injected message in the request.
//
// To use end-of-run persistence instead (atomic run semantics), remove the
// RequirePerServiceCallChatHistoryPersistence = true setting (or set it to false). End-of-run
// persistence is the default behavior.
@@ -54,6 +59,37 @@ static string GetTime([Description("The city name.")] string city) =>
_ => $"{city}: time data not available."
};
// This tool demonstrates message injection during the function execution loop.
// When called, it checks travel advisories for a city. If an advisory is active, it uses
// the ambient run context to resolve MessageInjectingChatClient and injects a follow-up user message
// asking for alternative destinations. The model will process this injected message on the next
// service call — even though the parent FunctionInvokingChatClient loop would otherwise stop.
[Description("Check current travel advisories for a city.")]
static string CheckTravelAdvisory([Description("The city name.")] string city)
{
// Simulated travel advisory data.
var advisory = city.ToUpperInvariant() switch
{
"LONDON" => "Travel advisory: Severe fog warnings in London. Flights may be delayed or cancelled.",
"SEATTLE" => "Travel advisory: Heavy rainfall expected. Flooding possible in low-lying areas.",
_ => null
};
if (advisory is null)
{
return $"{city}: No active travel advisories.";
}
// When an advisory is found, inject a follow-up question so the model automatically
// suggests alternatives without the user needing to ask.
var runContext = AIAgent.CurrentRunContext!;
runContext.Agent.GetService<MessageInjectingChatClient>()?.EnqueueMessages(
runContext.Session!,
[new ChatMessage(ChatRole.User, $"Given the travel advisory for {city}, what alternative cities would you recommend instead?")]);
return advisory;
}
// Create the agent — per-service-call persistence is enabled via RequirePerServiceCallChatHistoryPersistence.
// The in-memory ChatHistoryProvider is used by default when the service does not require service stored chat
// history, so for those cases, we can inspect the chat history via session.TryGetInMemoryChatHistory().
@@ -65,10 +101,11 @@ AIAgent agent = chatClient.AsAIAgent(
{
Name = "WeatherAssistant",
RequirePerServiceCallChatHistoryPersistence = true,
EnableMessageInjection = true,
ChatOptions = new()
{
Instructions = "You are a helpful assistant. When asked about multiple cities, call the appropriate tool for each city.",
Tools = [AIFunctionFactory.Create(GetWeather), AIFunctionFactory.Create(GetTime)]
Instructions = "You are a helpful travel assistant. When asked about cities, call the appropriate tools for each city.",
Tools = [AIFunctionFactory.Create(GetWeather), AIFunctionFactory.Create(GetTime), AIFunctionFactory.Create(CheckTravelAdvisory)]
},
});
@@ -109,6 +146,18 @@ async Task RunNonStreamingAsync()
response = await agent.RunAsync(FollowUp2, session);
PrintAgentResponse(response.Text);
PrintChatHistory(session, "After third run", ref lastChatHistorySize, ref lastConversationId);
// Fourth turn — demonstrates message injection during the function loop.
// The CheckTravelAdvisory tool detects an advisory for London and injects a follow-up
// user message asking for alternative cities. After the tool completes, the internal loop
// in PerServiceCallChatHistoryPersistingChatClient detects the pending injected message
// and calls the service again, so the model answers the follow-up automatically.
const string TravelPrompt = "I'm planning to travel to London next week. Check if there are any travel advisories.";
PrintUserMessage(TravelPrompt);
response = await agent.RunAsync(TravelPrompt, session);
PrintAgentResponse(response.Text);
PrintChatHistory(session, "After travel advisory run", ref lastChatHistorySize, ref lastConversationId);
}
async Task RunStreamingAsync()
@@ -181,6 +230,30 @@ async Task RunStreamingAsync()
Console.WriteLine();
PrintChatHistory(session, "After third run", ref lastChatHistorySize, ref lastConversationId);
// Fourth turn — demonstrates message injection during the function loop (streaming).
// The CheckTravelAdvisory tool detects an advisory for London and injects a follow-up
// user message asking for alternative cities. After the tool completes, the internal loop
// in PerServiceCallChatHistoryPersistingChatClient detects the pending injected message
// and calls the service again, so the model answers the follow-up automatically.
const string TravelPrompt = "I'm planning to travel to London next week. Check if there are any travel advisories.";
PrintUserMessage(TravelPrompt);
Console.ForegroundColor = ConsoleColor.Cyan;
Console.Write("\n[Agent] ");
Console.ResetColor();
await foreach (var update in agent.RunStreamingAsync(TravelPrompt, session))
{
Console.Write(update);
// During streaming we should be able to see updates to the chat history
// before the full run completes, as each service call is made and persisted.
PrintChatHistory(session, "During travel advisory run", ref lastChatHistorySize, ref lastConversationId);
}
Console.WriteLine();
PrintChatHistory(session, "After travel advisory run", ref lastChatHistorySize, ref lastConversationId);
}
void PrintUserMessage(string message)
@@ -6,12 +6,16 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="ModelContextProtocol" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
</ItemGroup>
</Project>
@@ -1,93 +1,80 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample shows how to load a Foundry toolbox and pass its tools as server-side
// tools when creating an agent. The Foundry platform handles tool execution — the agent
// process does not invoke tools locally.
// Foundry Toolbox via MCP (Streamable HTTP).
//
// Point an `McpClient` at a Foundry Toolbox's MCP endpoint. The agent
// discovers the toolbox's tools at runtime and invokes them locally.
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Net.Http.Headers;
using Azure.AI.Projects;
using Azure.AI.Projects.Agents;
using Azure.Core;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using ModelContextProtocol.Client;
using OpenAI.Responses;
#pragma warning disable OPENAI001 // Experimental API
#pragma warning disable AAIP001 // AgentToolboxes is experimental
#pragma warning disable CS8321 // Local functions may be commented-out alternatives
// Replace with your own Foundry toolbox name.
// Must match the `<name>` segment of FOUNDRY_TOOLBOX_ENDPOINT.
const string ToolboxName = "research_toolbox";
// Used only by CombineToolboxes — swap in a second toolbox you own.
const string SecondToolboxName = "analysis_toolbox";
// Replace with any question that exercises the tools configured in your toolbox.
const string Query = "Introduce yourself and briefly describe the tools you can use to help me.";
const string Query = "What tools do you have access to?";
string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("Set FOUNDRY_PROJECT_ENDPOINT to your Foundry project endpoint.");
string model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
string toolboxEndpoint = Environment.GetEnvironmentVariable("FOUNDRY_TOOLBOX_ENDPOINT")
?? throw new InvalidOperationException(
"FOUNDRY_TOOLBOX_ENDPOINT is not set. Example: " +
"https://<account>.services.ai.azure.com/api/projects/<project>/toolsets/<name>/mcp?api-version=2025-05-01-preview");
TokenCredential credential = new DefaultAzureCredential();
// Comment out if the toolbox already exists in your Foundry project.
await CreateSampleToolboxAsync(ToolboxName, endpoint, credential);
// Inject a fresh Azure AI bearer token on every MCP request.
using var httpClient = new HttpClient(new BearerTokenHandler(credential, "https://ai.azure.com/.default")
{
InnerHandler = new HttpClientHandler(),
});
Console.WriteLine($"Connecting to toolbox MCP endpoint: {toolboxEndpoint}");
await using McpClient mcpClient = await McpClient.CreateAsync(
new HttpClientTransport(
new HttpClientTransportOptions
{
Endpoint = new Uri(toolboxEndpoint),
Name = "foundry_toolbox",
},
httpClient));
IList<McpClientTool> mcpTools = await mcpClient.ListToolsAsync();
Console.WriteLine($"Toolbox MCP tools available: {string.Join(", ", mcpTools.Select(t => t.Name))}");
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
var projectClient = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential());
AIProjectClient aiProjectClient = new(new Uri(endpoint), credential);
await Main(projectClient, model, endpoint);
// await CombineToolboxes(projectClient, model, endpoint);
AIAgent agent = aiProjectClient.AsAIAgent(
model: deploymentName,
instructions: "You are a helpful assistant. Use the available toolbox tools to answer the user.",
name: "ToolboxMcpAgent",
tools: [.. mcpTools.Cast<AITool>()]);
Console.WriteLine($"\nUser: {Query}\n");
Console.WriteLine($"Assistant: {await agent.RunAsync(Query)}");
// ---------------------------------------------------------------------------
// Main: single toolbox
// Helper: create (or replace) a sample toolbox so the sample runs end-to-end
// ---------------------------------------------------------------------------
static async Task Main(AIProjectClient projectClient, string model, string endpoint)
{
Console.WriteLine("=== Foundry Toolbox Server-Side Tools Example ===");
// Comment out if the toolbox already exists in your Foundry project.
await CreateSampleToolboxAsync(ToolboxName, endpoint);
// Omit the version to resolve the toolbox's current default version at runtime.
var tools = await projectClient.GetToolboxToolsAsync(ToolboxName);
AIAgent agent = projectClient
.AsAIAgent(
model: model,
instructions: "You are a research assistant. Use the available tools to answer questions.",
tools: tools.ToList());
Console.WriteLine($"User: {Query}");
Console.WriteLine($"Result: {await agent.RunAsync(Query)}\n");
}
// ---------------------------------------------------------------------------
// Alternative: combine tools from multiple toolboxes
// ---------------------------------------------------------------------------
static async Task CombineToolboxes(AIProjectClient projectClient, string model, string endpoint)
{
Console.WriteLine("=== Combine Toolboxes Example ===");
// Comment out if the toolboxes already exist in your Foundry project.
await CreateSampleToolboxAsync(ToolboxName, endpoint);
await CreateSampleToolboxAsync(SecondToolboxName, endpoint);
var toolboxA = await projectClient.GetToolboxToolsAsync(ToolboxName);
var toolboxB = await projectClient.GetToolboxToolsAsync(SecondToolboxName);
var allTools = toolboxA.Concat(toolboxB).ToList();
AIAgent agent = projectClient
.AsAIAgent(
model: model,
instructions: "You are a research assistant. Use all available tools to answer questions.",
tools: allTools);
Console.WriteLine($"User: {Query}");
Console.WriteLine($"Combined-toolbox result: {await agent.RunAsync(Query)}\n");
}
// ---------------------------------------------------------------------------
// Helper: create (or replace) a sample toolbox so the sample works out-of-the-box
// ---------------------------------------------------------------------------
static async Task CreateSampleToolboxAsync(string name, string endpoint)
static async Task CreateSampleToolboxAsync(string name, string endpoint, TokenCredential credential)
{
// Toolboxes are normally configured in the Foundry portal or a deployment
// script, not the application itself. This helper exists so the sample can
@@ -96,10 +83,7 @@ static async Task CreateSampleToolboxAsync(string name, string endpoint)
// The Foundry-Features header is currently required for toolbox CRUD operations.
var options = new AgentAdministrationClientOptions();
options.AddPolicy(new FoundryFeaturesPolicy("Toolboxes=V1Preview"), PipelinePosition.PerCall);
var adminClient = new AgentAdministrationClient(
new Uri(endpoint),
new DefaultAzureCredential(),
options);
var adminClient = new AgentAdministrationClient(new Uri(endpoint), credential, options);
var toolboxClient = adminClient.GetAgentToolboxes();
// Delete existing toolbox if present (ignore 404).
@@ -128,7 +112,7 @@ static async Task CreateSampleToolboxAsync(string name, string endpoint)
}
// ---------------------------------------------------------------------------
// Pipeline policy that adds the Foundry-Features header for toolbox CRUD
// Pipeline policy: adds the Foundry-Features header for toolbox CRUD calls
// ---------------------------------------------------------------------------
internal sealed class FoundryFeaturesPolicy(string feature) : PipelinePolicy
{
@@ -146,3 +130,18 @@ internal sealed class FoundryFeaturesPolicy(string feature) : PipelinePolicy
return ProcessNextAsync(message, pipeline, currentIndex);
}
}
// ---------------------------------------------------------------------------
// DelegatingHandler: attaches a fresh Azure AI bearer token to every request
// ---------------------------------------------------------------------------
internal sealed class BearerTokenHandler(TokenCredential credential, string scope) : DelegatingHandler
{
private readonly TokenRequestContext _tokenContext = new([scope]);
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
AccessToken token = await credential.GetTokenAsync(this._tokenContext, cancellationToken).ConfigureAwait(false);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
}
}
@@ -0,0 +1,31 @@
# Foundry Toolbox via MCP
This sample shows how to use a Foundry Toolbox by pointing an `McpClient` at the toolbox's MCP endpoint. The agent discovers the toolbox's tools at runtime and invokes them locally over MCP.
## What this sample demonstrates
- Connecting to a Foundry toolbox's MCP endpoint via Streamable HTTP transport
- Injecting a fresh Azure AI bearer token (`https://ai.azure.com/.default`) on every MCP request
- Passing the discovered MCP tools to `AIProjectClient.AsAIAgent(...)`
- Optional helper to create (or replace) a sample toolbox in the project so the sample is runnable end-to-end
## Prerequisites
- A Microsoft Foundry project with a toolbox configured (or let the sample create one for you)
- Azure CLI installed and authenticated (`az login`)
Set the following environment variables:
```powershell
$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project"
$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini"
$env:FOUNDRY_TOOLBOX_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project/toolsets/research_toolbox/mcp?api-version=2025-05-01-preview"
```
The `<name>` segment of `FOUNDRY_TOOLBOX_ENDPOINT` must match the `ToolboxName` constant in `Program.cs`.
## Run the sample
```powershell
dotnet run
```
@@ -1,46 +0,0 @@
# Agent_Step25_ToolboxServerSideTools
This sample demonstrates loading a named Foundry toolbox and passing its tools as
**server-side tools** when creating an agent via `AsAIAgent()`.
When tools from a toolbox are passed this way, they are sent as tool definitions in
the Responses API request. The Foundry platform handles tool execution — the agent
process does not invoke tools locally.
This is the dotnet equivalent of the Python sample:
`python/samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox.py`
## Prerequisites
- A Microsoft Foundry project
- `AZURE_AI_PROJECT_ENDPOINT` environment variable set to your Foundry project endpoint
- `AZURE_AI_MODEL_DEPLOYMENT_NAME` environment variable set (defaults to `gpt-5.4-mini`)
The sample recreates the toolbox on each run, replacing any existing toolbox with
the same name. Comment out the `CreateSampleToolboxAsync` call if you want to keep
an existing toolbox unchanged.
## How it works
1. `projectClient.GetToolboxVersionAsync(name)` fetches the toolbox definition from the
Foundry project API (resolving the default version if none is specified)
2. `ToolboxVersion.ToAITools()` converts each tool definition to an `AITool` instance
3. The tools are passed to `AsAIAgent(tools: ...)` which includes them in the Responses
API request as server-side tool definitions
For a one-liner, use `projectClient.GetToolboxToolsAsync(name)` to fetch and convert in one call.
## Sample flows
| Flow | Description |
|------|-------------|
| `Main` (default) | Loads a single toolbox and runs an agent with its tools |
| `CombineToolboxes` | Loads two toolboxes and merges their tools into one agent |
Uncomment the desired flow in the top-level statements to try each one.
## Running the sample
```bash
dotnet run
```
@@ -73,6 +73,7 @@ Some samples require extra tool-specific environment variables. See each sample
| [Memory search](./Agent_Step22_MemorySearch/) | Memory search tool |
| [Local MCP](./Agent_Step23_LocalMCP/) | Local MCP client with HTTP transport |
| [Code interpreter file download](./Agent_Step24_CodeInterpreterFileDownload/) | Download container files generated by code interpreter |
| [Foundry toolbox via MCP](./Agent_Step25_FoundryToolboxMcp/) | Use a Foundry Toolbox from a non-hosted agent via its MCP endpoint |
## Running the samples
@@ -0,0 +1,8 @@
AZURE_AI_PROJECT_ENDPOINT=<your-azure-ai-project-endpoint>
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
AZURE_SEARCH_ENDPOINT=<your-azure-search-endpoint>
AZURE_SEARCH_INDEX_NAME=contoso-outdoors
AZURE_BEARER_TOKEN_FOUNDRY=DefaultAzureCredential
AZURE_BEARER_TOKEN_SEARCH=DefaultAzureCredential
ASPNETCORE_URLS=http://+:8088
ASPNETCORE_ENVIRONMENT=Development
@@ -0,0 +1,17 @@
# Use the official .NET 10.0 ASP.NET runtime as a parent image
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
WORKDIR /app
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet restore
RUN dotnet publish -c Release -o /app/publish
# Final stage
FROM base AS final
WORKDIR /app
COPY --from=build /app/publish .
EXPOSE 8088
ENV ASPNETCORE_URLS=http://+:8088
ENTRYPOINT ["dotnet", "HostedAzureSearchRag.dll"]
@@ -0,0 +1,23 @@
# Dockerfile for contributors building from the agent-framework repository source.
#
# This project uses ProjectReference to the local Microsoft.Agents.AI.Foundry source,
# which means a standard multi-stage Docker build cannot resolve dependencies outside
# this folder. Instead, pre-publish the app targeting the container runtime and copy
# the output into the container:
#
# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
# docker build -f Dockerfile.contributor -t hosted-azure-search-rag .
# docker run --rm -p 8088:8088 \
# -e AGENT_NAME=hosted-azure-search-rag \
# -e AZURE_BEARER_TOKEN_FOUNDRY=$AZURE_BEARER_TOKEN_FOUNDRY \
# -e AZURE_BEARER_TOKEN_SEARCH=$AZURE_BEARER_TOKEN_SEARCH \
# --env-file .env hosted-azure-search-rag
#
# For end-users consuming the NuGet package (not ProjectReference), use the standard
# Dockerfile which performs a full dotnet restore + publish inside the container.
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
WORKDIR /app
COPY out/ .
EXPOSE 8088
ENV ASPNETCORE_URLS=http://+:8088
ENTRYPOINT ["dotnet", "HostedAzureSearchRag.dll"]
@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
<RootNamespace>HostedAzureSearchRag</RootNamespace>
<AssemblyName>HostedAzureSearchRag</AssemblyName>
<NoWarn>$(NoWarn);</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Azure.Search.Documents" />
<PackageReference Include="DotNetEnv" />
</ItemGroup>
<!-- For contributors: uses ProjectReference to build against local source -->
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReferences above
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0" />
</ItemGroup>
-->
</Project>
@@ -0,0 +1,171 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample shows how to add Retrieval Augmented Generation (RAG) capabilities to a hosted
// agent using Azure AI Search. The sample assumes the search index has already been provisioned
// and populated out of band (see README.md for the required schema and example seed content).
// A SearchClient-backed adapter is plugged into TextSearchProvider, which runs a keyword search
// against the index before each model invocation and injects the matching documents into the
// model context.
using Azure;
using Azure.AI.Projects;
using Azure.Core;
using Azure.Identity;
using Azure.Search.Documents;
using Azure.Search.Documents.Models;
using DotNetEnv;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Extensions.AI;
using OpenAI.Chat;
// Load .env file if present (for local development)
Env.TraversePath().Load();
string projectEndpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o";
string searchEndpoint = Environment.GetEnvironmentVariable("AZURE_SEARCH_ENDPOINT")
?? throw new InvalidOperationException("AZURE_SEARCH_ENDPOINT is not set.");
string searchIndexName = Environment.GetEnvironmentVariable("AZURE_SEARCH_INDEX_NAME")
?? throw new InvalidOperationException("AZURE_SEARCH_INDEX_NAME is not set.");
// Use a chained credential. Try a temporary dev token first (for local Docker debugging),
// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity in
// production). The dev credential is scope aware so a single instance serves both Foundry and
// Azure AI Search clients (each Azure SDK client requests a token for its own audience).
TokenCredential credential = new ChainedTokenCredential(
new DevTemporaryTokenCredential(),
new DefaultAzureCredential());
// Connect to the pre-provisioned search index. The caller is expected to have created the
// index and populated it with documents matching the schema (id / content / sourceName /
// sourceLink) before running this sample. See README.md for an example provisioning script.
var searchClient = new SearchClient(new Uri(searchEndpoint), searchIndexName, credential);
TextSearchProviderOptions textSearchOptions = new()
{
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
RecentMessageMemoryLimit = 6,
};
AIAgent agent = new AIProjectClient(new Uri(projectEndpoint), credential)
.AsAIAgent(new ChatClientAgentOptions
{
Name = Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-azure-search-rag",
ChatOptions = new ChatOptions
{
ModelId = deploymentName,
Instructions = "You are a helpful support specialist for Contoso Outdoors. " +
"Answer questions using the provided context and cite the source document when available.",
},
AIContextProviders = [new TextSearchProvider(CreateSearchAdapter(searchClient), textSearchOptions)]
});
// Host the agent as a Foundry Hosted Agent using the Responses API.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddFoundryResponses(agent);
var app = builder.Build();
app.MapFoundryResponses();
if (app.Environment.IsDevelopment())
{
app.MapFoundryResponses("openai/v1");
}
app.Run();
// ── Search adapter ───────────────────────────────────────────────────────────
// Wraps a SearchClient as the delegate TextSearchProvider expects. Keyword/full-text only;
// no embeddings. Returns the top results and projects them into TextSearchResult entries
// the provider will inject into the model context.
static Func<string, CancellationToken, Task<IEnumerable<TextSearchProvider.TextSearchResult>>>
CreateSearchAdapter(SearchClient client, int top = 3) =>
async (query, cancellationToken) =>
{
var options = new SearchOptions { Size = top };
Response<SearchResults<SearchDocument>> response =
await client.SearchAsync<SearchDocument>(query, options, cancellationToken).ConfigureAwait(false);
var results = new List<TextSearchProvider.TextSearchResult>();
await foreach (SearchResult<SearchDocument> hit in response.Value.GetResultsAsync().WithCancellation(cancellationToken).ConfigureAwait(false))
{
results.Add(new TextSearchProvider.TextSearchResult
{
SourceName = hit.Document.TryGetValue("sourceName", out var name) ? name?.ToString() ?? string.Empty : string.Empty,
SourceLink = hit.Document.TryGetValue("sourceLink", out var link) ? link?.ToString() ?? string.Empty : string.Empty,
Text = hit.Document.TryGetValue("content", out var content) ? content?.ToString() ?? string.Empty : string.Empty,
RawRepresentation = hit
});
}
return results;
};
/// <summary>
/// A scope aware <see cref="TokenCredential"/> for local Docker debugging only.
/// Reads pre-fetched bearer tokens from environment variables, dispensing the right token
/// based on the requested scope:
/// <list type="bullet">
/// <item><description><c>ai.azure.com</c> scopes -> <c>AZURE_BEARER_TOKEN_FOUNDRY</c></description></item>
/// <item><description><c>search.azure.com</c> scopes -> <c>AZURE_BEARER_TOKEN_SEARCH</c></description></item>
/// </list>
/// For any other scope, throws <see cref="CredentialUnavailableException"/> so a chained
/// credential will fall through. This should NOT be used in production: tokens expire (~1 hour)
/// and cannot be refreshed.
///
/// Generate the tokens on your host and pass them to the container:
/// <code>
/// export AZURE_BEARER_TOKEN_FOUNDRY=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
/// export AZURE_BEARER_TOKEN_SEARCH=$(az account get-access-token --resource https://search.azure.com --query accessToken -o tsv)
/// docker run -e AZURE_BEARER_TOKEN_FOUNDRY -e AZURE_BEARER_TOKEN_SEARCH ...
/// </code>
/// </summary>
internal sealed class DevTemporaryTokenCredential : TokenCredential
{
private const string FoundryEnvironmentVariable = "AZURE_BEARER_TOKEN_FOUNDRY";
private const string SearchEnvironmentVariable = "AZURE_BEARER_TOKEN_SEARCH";
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
=> Resolve(requestContext.Scopes);
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
=> new(Resolve(requestContext.Scopes));
private static AccessToken Resolve(IReadOnlyList<string> scopes)
{
string? envVar = null;
foreach (var scope in scopes)
{
if (scope.Contains("search.azure.com", StringComparison.OrdinalIgnoreCase))
{
envVar = SearchEnvironmentVariable;
break;
}
if (scope.Contains("ai.azure.com", StringComparison.OrdinalIgnoreCase))
{
envVar = FoundryEnvironmentVariable;
break;
}
}
if (envVar is null)
{
throw new CredentialUnavailableException(
$"DevTemporaryTokenCredential cannot serve scopes [{string.Join(", ", scopes)}]; falling through.");
}
var token = Environment.GetEnvironmentVariable(envVar);
if (string.IsNullOrEmpty(token) || string.Equals(token, "DefaultAzureCredential", StringComparison.Ordinal))
{
throw new CredentialUnavailableException(
$"{envVar} environment variable is not set; falling through to next credential.");
}
return new AccessToken(token, DateTimeOffset.UtcNow.AddHours(1));
}
}
@@ -0,0 +1,179 @@
# Hosted-AzureSearchRag
A hosted agent with **Retrieval Augmented Generation (RAG)** capabilities backed by **Azure AI Search**. The agent grounds its answers in product documentation by running a keyword search against an Azure AI Search index before each model invocation, then citing the source in its response.
This sample is the Azure AI Search counterpart to `Hosted-TextRag`. Where `Hosted-TextRag` uses a mock in-process search function, this sample talks to a real Azure AI Search index that is provisioned out of band (see "Provisioning the search index" below).
## Prerequisites
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
- An Azure AI Foundry project with a deployed model (e.g., `gpt-4o`)
- An Azure AI Search service ([create one](https://learn.microsoft.com/azure/search/search-create-service-portal))
- **A pre-provisioned search index** with the schema and content described in the next section
- Azure CLI logged in (`az login`)
### Required RBAC
Your identity (or the Managed Identity running the container in production) needs:
- **Azure AI User** on the Foundry project scope
- **Search Index Data Reader** on the Azure AI Search service (the sample only reads from the index)
## Provisioning the search index (one time)
The sample assumes the search index already exists and contains documents the agent can retrieve from. Provision it once via the Azure Portal, the [REST API](https://learn.microsoft.com/azure/search/search-how-to-create-search-index), or the snippet below.
### Index schema
| Field | Type | Attributes |
|---|---|---|
| `id` | `Edm.String` | key, filterable |
| `content` | `Edm.String` | searchable (full-text) |
| `sourceName` | `Edm.String` | retrievable, filterable |
| `sourceLink` | `Edm.String` | retrievable |
### Example: provision and seed via Azure CLI + REST
```bash
SEARCH_ENDPOINT="https://<your-search>.search.windows.net"
INDEX_NAME="contoso-outdoors"
TOKEN=$(az account get-access-token --resource https://search.azure.com --query accessToken -o tsv)
# 1. Create the index.
curl -X PUT "$SEARCH_ENDPOINT/indexes/$INDEX_NAME?api-version=2024-07-01" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"name": "contoso-outdoors",
"fields": [
{ "name": "id", "type": "Edm.String", "key": true, "filterable": true, "searchable": false, "retrievable": true },
{ "name": "content", "type": "Edm.String", "key": false, "filterable": false, "searchable": true, "retrievable": true, "analyzer": "standard.lucene" },
{ "name": "sourceName", "type": "Edm.String", "key": false, "filterable": true, "searchable": false, "retrievable": true },
{ "name": "sourceLink", "type": "Edm.String", "key": false, "filterable": false, "searchable": false, "retrievable": true }
]
}'
# 2. Upload three Contoso Outdoors documents matching the queries below.
curl -X POST "$SEARCH_ENDPOINT/indexes/$INDEX_NAME/docs/index?api-version=2024-07-01" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"value": [
{ "@search.action": "mergeOrUpload", "id": "return-policy", "sourceName": "Contoso Outdoors Return Policy", "sourceLink": "https://contoso.com/policies/returns", "content": "Customers may return any item within 30 days of delivery. Items should be unused and include original packaging. Refunds are issued to the original payment method within 5 business days of inspection. As a thank you, every accepted return ships back with a complimentary Contoso TrailRunner sticker pack, item code TR-CANARY-7821." },
{ "@search.action": "mergeOrUpload", "id": "shipping-guide", "sourceName": "Contoso Outdoors Shipping Guide", "sourceLink": "https://contoso.com/help/shipping", "content": "Standard shipping is free on orders over $50 and typically arrives in 3-5 business days within the continental United States. Expedited options are available at checkout. Use promo code SHIP-CANARY-4493 at checkout for a one-time free overnight upgrade on your first order." },
{ "@search.action": "mergeOrUpload", "id": "tent-care", "sourceName": "TrailRunner Tent Care Instructions", "sourceLink": "https://contoso.com/manuals/trailrunner-tent", "content": "Clean the tent fabric with lukewarm water and a non-detergent soap. Allow it to air dry completely before storage and avoid prolonged UV exposure to extend the lifespan of the waterproof coating. Replacement waterproofing kits are stocked under SKU TENT-CANARY-9067." }
]
}'
```
You can also point the sample at any existing index that exposes the four fields above; the sample reads `content`, `sourceName`, and `sourceLink` as projected by the search results.
## Configuration
Copy the template and fill in your endpoints:
```bash
cp .env.example .env
```
Edit `.env`:
```env
AZURE_AI_PROJECT_ENDPOINT=https://<your-account>.services.ai.azure.com/api/projects/<your-project>
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
AZURE_SEARCH_ENDPOINT=https://<your-search>.search.windows.net
AZURE_SEARCH_INDEX_NAME=contoso-outdoors
AZURE_BEARER_TOKEN_FOUNDRY=DefaultAzureCredential
AZURE_BEARER_TOKEN_SEARCH=DefaultAzureCredential
ASPNETCORE_URLS=http://+:8088
ASPNETCORE_ENVIRONMENT=Development
```
> **Note:** `.env` is gitignored. The `.env.example` template is checked in as a reference.
## Running directly (contributors)
This project uses `ProjectReference` to build against the local Agent Framework source.
```bash
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag
AGENT_NAME=hosted-azure-search-rag dotnet run
```
The agent will start on `http://localhost:8088`. The sample assumes the search index has already been provisioned and seeded (see "Provisioning the search index" above).
### Test it
Using the Azure Developer CLI:
```bash
azd ai agent invoke --local "What is your return policy?"
azd ai agent invoke --local "How long does shipping take?"
azd ai agent invoke --local "How do I clean my tent?"
```
Or with curl:
```bash
curl -X POST http://localhost:8088/responses \
-H "Content-Type: application/json" \
-d '{"input": "What is your return policy?", "model": "hosted-azure-search-rag"}'
```
## Running with Docker
Since this project uses `ProjectReference`, use `Dockerfile.contributor` which takes a pre-published output.
### 1. Publish for the container runtime (Linux Alpine)
```bash
dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
```
### 2. Build the Docker image
```bash
docker build -f Dockerfile.contributor -t hosted-azure-search-rag .
```
### 3. Run the container
Generate two bearer tokens on your host (one per audience) and pass them to the container. A single Azure AD token has only one `aud` claim, so Foundry and Azure AI Search require separate tokens.
```bash
# Generate tokens (each expires in ~1 hour)
export AZURE_BEARER_TOKEN_FOUNDRY=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
export AZURE_BEARER_TOKEN_SEARCH=$(az account get-access-token --resource https://search.azure.com --query accessToken -o tsv)
# Run with both tokens
docker run --rm -p 8088:8088 \
-e AGENT_NAME=hosted-azure-search-rag \
-e AZURE_BEARER_TOKEN_FOUNDRY=$AZURE_BEARER_TOKEN_FOUNDRY \
-e AZURE_BEARER_TOKEN_SEARCH=$AZURE_BEARER_TOKEN_SEARCH \
--env-file .env \
hosted-azure-search-rag
```
### 4. Test it
Using the Azure Developer CLI:
```bash
azd ai agent invoke --local "What is your return policy?"
```
## How RAG works in this sample
The `TextSearchProvider` runs a keyword search against the configured Azure AI Search index **before each model invocation**. When the index is seeded with the three Contoso Outdoors documents from the provisioning section above:
| User query mentions | Search result injected |
|---|---|
| "return", "refund" | Contoso Outdoors Return Policy (canary token: `TR-CANARY-7821`) |
| "shipping", "promo" | Contoso Outdoors Shipping Guide (canary token: `SHIP-CANARY-4493`) |
| "tent", "fabric" | TrailRunner Tent Care Instructions (canary token: `TENT-CANARY-9067`) |
The model receives the top three search results as additional context and cites the source in its response. Each seeded document includes a unique `*-CANARY-*` token that does not exist in any model training data, so the integration tests can prove an answer was grounded in retrieved content (not fabricated from training) by asking for the canary and asserting it appears in the response.
Replace the seed documents (or point the sample at an existing index with your own content) to ground the agent in your own knowledge base.
## NuGet package users
If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedAzureSearchRag.csproj` for the `PackageReference` alternative.
@@ -0,0 +1,31 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml
name: hosted-azure-search-rag
displayName: "Hosted Azure AI Search RAG Agent"
description: >
A support specialist agent for Contoso Outdoors with RAG capabilities backed by
Azure AI Search. Uses TextSearchProvider with a SearchClient adapter to ground
answers in product documentation indexed in Azure AI Search before each model
invocation.
metadata:
tags:
- AI Agent Hosting
- Azure AI AgentServer
- Responses Protocol
- RAG
- Azure AI Search
- Agent Framework
template:
name: hosted-azure-search-rag
kind: hosted
protocols:
- protocol: responses
version: 1.0.0
resources:
cpu: "0.25"
memory: 0.5Gi
parameters:
properties: []
resources: []
@@ -0,0 +1,9 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
kind: hosted
name: hosted-azure-search-rag
protocols:
- protocol: responses
version: 1.0.0
resources:
cpu: "0.25"
memory: 0.5Gi
@@ -0,0 +1,6 @@
**/bin
**/obj
**/.vs
**/.vscode
.env
*.user
@@ -0,0 +1,5 @@
AZURE_AI_PROJECT_ENDPOINT=<your-azure-ai-project-endpoint>
ASPNETCORE_URLS=http://+:8088
ASPNETCORE_ENVIRONMENT=Development
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
AZURE_BEARER_TOKEN=DefaultAzureCredential
@@ -0,0 +1,17 @@
# Use the official .NET 10.0 ASP.NET runtime as a parent image
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
WORKDIR /app
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet restore
RUN dotnet publish -c Release -o /app/publish
# Final stage
FROM base AS final
WORKDIR /app
COPY --from=build /app/publish .
EXPOSE 8088
ENV ASPNETCORE_URLS=http://+:8088
ENTRYPOINT ["dotnet", "HostedFiles.dll"]
@@ -0,0 +1,19 @@
# Dockerfile for contributors building from the agent-framework repository source.
#
# This project uses ProjectReference to the local Microsoft.Agents.AI.Foundry source,
# which means a standard multi-stage Docker build cannot resolve dependencies outside
# this folder. Instead, pre-publish the app targeting the container runtime and copy
# the output into the container:
#
# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
# docker build -f Dockerfile.contributor -t hosted-files .
# docker run --rm -p 8088:8088 -e AGENT_NAME=hosted-files -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN --env-file .env hosted-files
#
# For end-users consuming the NuGet package (not ProjectReference), use the standard
# Dockerfile which performs a full dotnet restore + publish inside the container.
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
WORKDIR /app
COPY out/ .
EXPOSE 8088
ENV ASPNETCORE_URLS=http://+:8088
ENTRYPOINT ["dotnet", "HostedFiles.dll"]
@@ -0,0 +1,40 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
<RootNamespace>HostedFiles</RootNamespace>
<AssemblyName>HostedFiles</AssemblyName>
<NoWarn>$(NoWarn);</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="DotNetEnv" />
</ItemGroup>
<ItemGroup>
<!-- Bake demo resources into the published output so the deployed agent's
tools can read them from /app/resources/ inside the container. -->
<Content Include="resources\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<!-- For contributors: uses ProjectReference to build against local source -->
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
</ItemGroup>
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
</ItemGroup>
-->
</Project>
@@ -0,0 +1,223 @@
// Copyright (c) Microsoft. All rights reserved.
// Hosted Files Agent - A hosted agent that exposes two distinct file knowledge sources
// through scoped, security-hardened tools:
//
// * Bundled files (image-baked) — files copied into the published output via the csproj
// <Content Include="resources\**"> rule. Live at /app/resources/ inside the container.
// Author-shipped knowledge that ships with every session.
//
// * Session files (per-session $HOME volume) — files uploaded at runtime via the alpha
// Azure.AI.Projects.AgentSessionFiles SDK. Live at $HOME inside the per-session
// container, which the platform sets to /home/session by default
// (container-image-spec.md line 127, "If you use the session files API, $HOME is
// also the base path for those operations").
//
// Each source is exposed via a separate tool pair, each rooted at its own directory.
// Tools take a fileName, not a path: Path.GetFileName strips any directory components,
// then a canonicalize + StartsWith(root) check enforces the boundary. The model cannot
// be tricked into reading /etc/passwd or any path outside its tool's root, even via
// indirect prompt injection in an uploaded file.
//
// Required environment variables:
// AZURE_AI_PROJECT_ENDPOINT - Azure AI Foundry project endpoint
// AZURE_AI_MODEL_DEPLOYMENT_NAME - Model deployment name (default: gpt-4o)
//
// Optional:
// AGENT_NAME - Agent name (default: hosted-files)
// BUNDLED_FILES_DIR - Override the bundled-files root
// (default: <baseDir>/resources, i.e. /app/resources/)
// HOME - Standard env var; the per-session sandbox volume
// (default: /home/session in the platform-managed container)
using System.ComponentModel;
using Azure.AI.Projects;
using Azure.Core;
using Azure.Identity;
using DotNetEnv;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Extensions.AI;
// Load .env file if present (for local development)
Env.TraversePath().Load();
// Bypass SampleEnvironment alias (which prompts on missing env vars) for optional values.
string? GetOptionalEnv(string key) => System.Environment.GetEnvironmentVariable(key);
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
string deploymentName = GetOptionalEnv("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o";
// Use a chained credential: try a temporary dev token first (for local Docker debugging),
// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity in production).
TokenCredential credential = new ChainedTokenCredential(
new DevTemporaryTokenCredential(),
new DefaultAzureCredential());
// ── File roots (canonicalized once) ──────────────────────────────────────────
// Bundled root: where csproj <Content Include="resources\**"> lands at runtime.
// In the container that resolves to /app/resources/.
string bundledRoot = Path.GetFullPath(
GetOptionalEnv("BUNDLED_FILES_DIR")
?? Path.Combine(AppContext.BaseDirectory, "resources"));
// Session root: the per-session $HOME volume mounted by the Foundry platform.
// Files uploaded via AgentSessionFiles.UploadSessionFileAsync(sessionStoragePath: "foo")
// land at $HOME/foo per container-image-spec.md line 172.
string sessionRoot = Path.GetFullPath(
GetOptionalEnv("HOME")
?? "/home/session");
// ── Tools: bundled files (image-baked, /app/resources/) ──────────────────────
[Description("List the names of files bundled with the agent (built-in knowledge that ships with the image).")]
string ListBundledFiles() => SafeListNames(bundledRoot);
[Description("Read the full text contents of a bundled file by name. Bundled files are built-in knowledge shipped with the agent image.")]
string ReadBundledFile(
[Description("Name of the bundled file (no directory components). Must be one of the names returned by ListBundledFiles.")] string fileName)
=> SafeRead(bundledRoot, fileName, scope: "bundled files");
// ── Tools: session files (per-session $HOME) ─────────────────────────────────
[Description("List the names of files uploaded into the current session sandbox by the user (e.g., via AgentSessionFiles.UploadSessionFileAsync).")]
string ListSessionFiles() => SafeListNames(sessionRoot);
[Description("Read the full text contents of a file uploaded into the current session by name. Session files are user-supplied data that lives only for the lifetime of this session.")]
string ReadSessionFile(
[Description("Name of the session file (no directory components). Must be one of the names returned by ListSessionFiles.")] string fileName)
=> SafeRead(sessionRoot, fileName, scope: "session files");
// ── Path-safe helpers (defense-in-depth: GetFileName + canonicalize + StartsWith(root)) ──
string SafeListNames(string root)
{
try
{
if (!Directory.Exists(root))
{
return string.Empty;
}
return string.Join(
Environment.NewLine,
Directory.EnumerateFiles(root).Select(Path.GetFileName));
}
catch (Exception ex)
{
return $"Error listing files: {ex.Message}";
}
}
string SafeRead(string root, string fileName, string scope)
{
try
{
// Step 1: strip any directory components the model might have included.
string safeName = Path.GetFileName(fileName);
if (string.IsNullOrEmpty(safeName))
{
return $"File '{fileName}' not found in {scope}.";
}
// Step 2: combine with the root and canonicalize.
string fullPath = Path.GetFullPath(Path.Combine(root, safeName));
// Step 3: enforce the prefix boundary so a crafted name still cannot escape.
string rootPrefix = root.EndsWith(Path.DirectorySeparatorChar)
? root
: root + Path.DirectorySeparatorChar;
if (!fullPath.StartsWith(rootPrefix, StringComparison.Ordinal))
{
return $"File '{fileName}' not found in {scope}.";
}
return File.Exists(fullPath)
? File.ReadAllText(fullPath)
: $"File '{fileName}' not found in {scope}.";
}
catch (Exception ex)
{
return $"Error reading '{fileName}': {ex.Message}";
}
}
// ── Create and host the agent ────────────────────────────────────────────────
AIAgent agent = new AIProjectClient(new Uri(endpoint), credential)
.AsAIAgent(
model: deploymentName,
instructions: """
You are a friendly assistant that answers questions over two file sources:
- Bundled files: built-in knowledge that ships with the agent image
(e.g., reference reports the author packaged with you). Tools:
ListBundledFiles, ReadBundledFile.
- Session files: user-uploaded data for this session only (e.g., a CSV
the user wants you to analyse). Tools: ListSessionFiles, ReadSessionFile.
Pick the tool pair by intent. If a name could match either source, list
both first. Always read the file before answering; do not guess. Quote
numbers and figures verbatim from the file.
""",
name: GetOptionalEnv("AGENT_NAME") ?? "hosted-files",
description: "Hosted agent that answers questions over bundled (image-baked) and session-uploaded files via two scoped tool pairs.",
tools:
[
AIFunctionFactory.Create(ListBundledFiles),
AIFunctionFactory.Create(ReadBundledFile),
AIFunctionFactory.Create(ListSessionFiles),
AIFunctionFactory.Create(ReadSessionFile),
]);
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddFoundryResponses(agent);
var app = builder.Build();
app.MapFoundryResponses();
if (app.Environment.IsDevelopment())
{
app.MapFoundryResponses("openai/v1");
}
app.Run();
/// <summary>
/// A <see cref="TokenCredential"/> for local Docker debugging only.
/// Reads a pre-fetched bearer token from the <c>AZURE_BEARER_TOKEN</c> environment variable
/// once at startup. This should NOT be used in production.
///
/// Generate a token on your host and pass it to the container:
/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ...
/// </summary>
internal sealed class DevTemporaryTokenCredential : TokenCredential
{
private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
private readonly string? _token;
public DevTemporaryTokenCredential()
{
this._token = System.Environment.GetEnvironmentVariable(EnvironmentVariable);
}
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
=> this.GetAccessToken();
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
=> new(this.GetAccessToken());
private AccessToken GetAccessToken()
{
if (string.IsNullOrEmpty(this._token) || this._token == "DefaultAzureCredential")
{
throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set.");
}
return new AccessToken(this._token, DateTimeOffset.UtcNow.AddHours(1));
}
}
@@ -0,0 +1,128 @@
# Hosted-Files
A hosted agent that demonstrates **two distinct file knowledge sources** through scoped, security-hardened tools:
- **Bundled files** (image-baked) — files the author packages with the agent at build time. Live at `/app/resources/` inside the container, copied from this project's [`resources/`](./resources/) folder via the csproj `<Content Include="resources\**\*" CopyToOutputDirectory="PreserveNewest" />` rule.
- **Session files** (per-session `$HOME` volume) — files the user uploads at runtime via the alpha `Azure.AI.Projects.AgentSessionFiles` SDK. Live at `$HOME` inside the per-session container. The Foundry platform sets `HOME=/home/session` by default and roots the session-files API there per [`container-image-spec.md` line 172](https://github.com/microsoft/foundrysdk-specs/blob/main/specs/agents/hosted_agents/container-spec/docs/container-image-spec.md): *"If you use the session files API, `$HOME` is also the base path for those operations; any paths given in those API endpoints will be relative to `$HOME`."*
## Tool surface
Each source is exposed via its own tool pair, rooted at its own directory. The model picks by intent.
| Tool | Source | Root |
|------|--------|------|
| `ListBundledFiles` | Bundled (image-baked) | `/app/resources/` |
| `ReadBundledFile` | Bundled (image-baked) | `/app/resources/` |
| `ListSessionFiles` | Session-uploaded | `$HOME` (`/home/session`) |
| `ReadSessionFile` | Session-uploaded | `$HOME` (`/home/session`) |
## Security model — distinct tools, distinct sandboxes
Each tool takes a `fileName` (no directory components allowed) and enforces three layers of defence inside the implementation:
1. **`Path.GetFileName(input)`** strips any directory parts from the model-supplied name. `"../../etc/passwd"` becomes `"passwd"`.
2. **`Path.GetFullPath(Combine(root, name))`** canonicalises the path.
3. **`fullPath.StartsWith(root + DirectorySeparatorChar)`** rejects anything that resolves outside the tool's root.
Failures return a controlled `"File '<input>' not found in <scope>."` rather than throwing or exposing the canonical path.
This is why the agent has four narrowly-scoped tools instead of a single `ReadFile(path)`:
- **Smaller per-tool attack surface.** Each tool has one purpose, one root, and no path-typed parameter. Even a buggy implementation can only leak its own directory.
- **Cross-boundary access is impossible by schema.** A prompt-injection attempt to make the bundled tool read a session path (or vice versa) does not even compile in the tool schema the model sees.
- **Read-only, non-recursive listing.** No write tools, no glob, no `..`.
## Companion
[`Using-Samples/SessionFilesClient`](../Using-Samples/SessionFilesClient/) — a thin chat REPL (same shape as [`SimpleAgent`](../Using-Samples/SimpleAgent/)) that points at the deployed Hosted-Files endpoint via `FoundryAgent` and lets you ask questions whose answers come from either file source.
## Live proof of the session-files contract
The end-to-end alpha-SDK round trip (client uploads via `AgentSessionFiles.UploadSessionFileAsync` → file arrives at `$HOME/<name>` inside the per-session container → agent's `ReadSessionFile` tool reads it → response quotes the verbatim contents) is exercised live by [`SessionFilesHostedAgentTests.UploadedFile_IsReadByHostedAgentAsync`](../../../../../tests/Foundry.Hosting.IntegrationTests/SessionFilesHostedAgentTests.cs) against the matching `session-files` scenario in the integration test container.
## Prerequisites
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
- An Azure AI Foundry project with a deployed model (e.g., `gpt-4o`)
- Azure CLI logged in (`az login`)
## Configuration
Copy the template and fill in your project endpoint:
```bash
cp .env.example .env
```
Edit `.env`:
```env
AZURE_AI_PROJECT_ENDPOINT=https://<your-account>.services.ai.azure.com/api/projects/<your-project>
ASPNETCORE_URLS=http://+:8088
ASPNETCORE_ENVIRONMENT=Development
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
```
> `.env` is gitignored. The `.env.example` template is checked in as a reference.
## Running directly (contributors)
```bash
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files
AGENT_NAME=hosted-files dotnet run
```
The agent starts on `http://localhost:8088`.
## Try it from the SessionFilesClient REPL
### Bundled files (works against any deployment, including local)
```bash
cd ../Using-Samples/SessionFilesClient
$env:AGENT_ENDPOINT = "http://localhost:8088"
$env:AGENT_NAME = "hosted-files"
dotnet run
You> What is the total revenue in the contoso file?
Agent> The contoso file reports total revenue of "$1,482.6M".
```
The agent calls `ListBundledFiles`, sees `contoso_q1_2026_report.txt`, calls `ReadBundledFile("contoso_q1_2026_report.txt")` (which resolves under `/app/resources/`), and quotes the figure verbatim.
### Session files (against a deployed agent)
Upload a file to a specific session via `azd ai agent files upload` or via the alpha `AgentSessionFiles` SDK (see the integration test for the SDK call), then ask the agent about it. The agent's `ReadSessionFile` tool reads from `$HOME` and surfaces the content the same way.
## Running with Docker
This project uses `ProjectReference`, so use `Dockerfile.contributor` which takes a pre-published output:
```bash
dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
docker build -f Dockerfile.contributor -t hosted-files .
export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
docker run --rm -p 8088:8088 \
-e AGENT_NAME=hosted-files \
-e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \
--env-file .env \
hosted-files
```
The bundled `resources/` folder is part of the published output and ships inside the image.
## NuGet package users
If consuming the Agent Framework as a NuGet package, use the standard `Dockerfile` instead of `Dockerfile.contributor` and switch the `ProjectReference` entries in `HostedFiles.csproj` to `PackageReference` (commented section in the csproj).
## Adding more bundled files
Drop additional text files into [`resources/`](./resources/). The csproj `<Content Include="resources\**\*" CopyToOutputDirectory="PreserveNewest" />` rule picks them up on the next `dotnet build` / `docker build`.
## Overrides
| Env var | Purpose | Default |
|---------|---------|---------|
| `BUNDLED_FILES_DIR` | Override the bundled-files root the tools read from. | `<process base dir>/resources` (`/app/resources/` in container) |
| `HOME` | The per-session sandbox volume root the session-files tools read from. Set by the Foundry platform; can be overridden for local testing. | `/home/session` |
@@ -0,0 +1,30 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml
name: hosted-files
displayName: "Hosted Files Agent"
description: >
A hosted agent that answers questions over a small set of files bundled
with its container image (under /app/resources/). Two local C# function
tools (ListFiles, ReadFile) surface the bundled file contents to the model.
metadata:
tags:
- AI Agent Hosting
- Azure AI AgentServer
- Responses Protocol
- Bundled Files
- Local Tools
- Agent Framework
template:
name: hosted-files
kind: hosted
protocols:
- protocol: responses
version: 1.0.0
resources:
cpu: "0.25"
memory: 0.5Gi
parameters:
properties: []
resources: []
@@ -0,0 +1,9 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
kind: hosted
name: hosted-files
protocols:
- protocol: responses
version: 1.0.0
resources:
cpu: "0.25"
memory: 0.5Gi
@@ -0,0 +1,121 @@
Contoso Corporation
Quarterly Report — Q1 2026 (Three months ended March 31, 2026)
DISCLAIMER
This document contains fictional data for sample/demo purposes only.
Contoso is a fictional company; all figures below are fabricated.
------------------------------------------------------------
1. EXECUTIVE SUMMARY
------------------------------------------------------------
Contoso delivered a solid first quarter, with total revenue of
$1,482.6M, up 11.4% year-over-year. Growth was led by the Cloud
Services segment (+22.7% YoY) and continued double-digit expansion
in International markets. Operating margin expanded 140 basis points
to 23.8% on disciplined cost management and improved gross margin.
Key highlights:
- Revenue: $1,482.6M (YoY +11.4%)
- Gross profit: $912.0M (gross margin 61.5%)
- Operating income: $352.9M (operating margin 23.8%)
- Net income: $268.4M (net margin 18.1%)
- Diluted EPS: $1.27 (vs. $1.04 prior year)
- Free cash flow: $311.5M
- Cash & equivalents: $2,140.8M
------------------------------------------------------------
2. INCOME STATEMENT (USD millions, unaudited)
------------------------------------------------------------
Q1 2026 Q1 2025 YoY %
Revenue 1,482.6 1,330.7 +11.4%
Cost of revenue 570.6 538.9 +5.9%
Gross profit 912.0 791.8 +15.2%
Gross margin 61.5% 59.5% +200 bps
Operating expenses
Research & development 241.4 220.5 +9.5%
Sales & marketing 218.7 205.1 +6.6%
General & administrative 99.0 88.6 +11.7%
Total operating expenses 559.1 514.2 +8.7%
Operating income 352.9 277.6 +27.1%
Operating margin 23.8% 20.9% +290 bps
Other income / (expense), net 8.4 5.1
Income before taxes 361.3 282.7
Provision for income taxes 92.9 72.6
Net income 268.4 210.1 +27.7%
Diluted EPS (USD) 1.27 1.04 +22.1%
------------------------------------------------------------
3. REVENUE BY SEGMENT (USD millions)
------------------------------------------------------------
Segment Q1 2026 Q1 2025 YoY %
Cloud Services 612.4 499.1 +22.7%
Productivity Software 448.9 422.6 +6.2%
Devices & Hardware 267.0 260.4 +2.5%
Professional Services 154.3 148.6 +3.8%
Total revenue 1,482.6 1,330.7 +11.4%
------------------------------------------------------------
4. REVENUE BY GEOGRAPHY (USD millions)
------------------------------------------------------------
Region Q1 2026 Q1 2025 YoY %
North America 812.1 756.0 +7.4%
EMEA 388.5 340.2 +14.2%
Asia-Pacific 221.7 183.4 +20.9%
Latin America 60.3 51.1 +18.0%
Total revenue 1,482.6 1,330.7 +11.4%
------------------------------------------------------------
5. SELECTED BALANCE SHEET ITEMS (USD millions)
------------------------------------------------------------
Mar 31, Dec 31,
2026 2025
Cash & equivalents 2,140.8 1,902.3
Short-term investments 845.6 820.4
Accounts receivable, net 1,012.7 988.5
Total current assets 4,510.2 4,190.6
Goodwill & intangibles 2,330.1 2,338.9
Total assets 9,884.5 9,512.0
Total current liabilities 2,118.4 2,054.7
Long-term debt 1,750.0 1,750.0
Total liabilities 4,402.6 4,310.5
Total stockholders' equity 5,481.9 5,201.5
------------------------------------------------------------
6. CASH FLOW HIGHLIGHTS (USD millions)
------------------------------------------------------------
Q1 2026 Q1 2025
Net cash from operating activities 382.0 298.7
Capital expenditures (70.5) (62.1)
Free cash flow 311.5 236.6
Share repurchases (120.0) (90.0)
Dividends paid (54.2) (48.6)
------------------------------------------------------------
7. KEY OPERATING METRICS
------------------------------------------------------------
Cloud paid seats (millions) 48.6 39.7 +22.4%
Cloud net revenue retention 118% 114%
Active enterprise customers 18,420 16,905 +9.0%
Headcount (end of period) 22,140 20,610 +7.4%
------------------------------------------------------------
8. OUTLOOK — Q2 2026 GUIDANCE
------------------------------------------------------------
Revenue: $1,520M $1,560M (YoY +10% to +13%)
Operating margin: 23.5% 24.5%
Diluted EPS: $1.30 $1.36
Capital expenditures: ~$80M
Management remains confident in the full-year plan and reiterates
fiscal-year 2026 revenue growth of 1012% and operating-margin
expansion of 100150 basis points versus FY 2025.
------------------------------------------------------------
9. NOTES
------------------------------------------------------------
- All figures are unaudited and rounded to one decimal place.
- Year-over-year comparisons are versus the same period in 2025.
- "Free cash flow" is defined as net cash from operating activities
less capital expenditures, and is a non-GAAP measure.
- This sample report is intended solely for demonstration of an
agent-driven document analysis pipeline.
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
@@ -11,7 +11,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="DotNetEnv" />
</ItemGroup>
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
@@ -11,7 +11,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="ModelContextProtocol" VersionOverride="1.2.0" />
<PackageReference Include="DotNetEnv" />
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
@@ -11,7 +11,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="DotNetEnv" />
</ItemGroup>
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
@@ -11,7 +11,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="DotNetEnv" />
</ItemGroup>
@@ -11,7 +11,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="DotNetEnv" />
</ItemGroup>
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
@@ -11,7 +11,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="DotNetEnv" />
</ItemGroup>
@@ -0,0 +1,116 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ClientModel.Primitives;
using Azure.AI.Extensions.OpenAI;
using Azure.AI.Projects;
using Azure.Identity;
using DotNetEnv;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry;
// Load .env file if present (for local development)
Env.TraversePath().Load();
Uri agentEndpoint = new(Environment.GetEnvironmentVariable("AGENT_ENDPOINT")
?? "http://localhost:8088");
var agentName = Environment.GetEnvironmentVariable("AGENT_NAME")
?? throw new InvalidOperationException("AGENT_NAME is not set.");
// ── Create an agent-framework agent backed by the remote Hosted-Files agent ──
var options = new AIProjectClientOptions();
if (agentEndpoint.Scheme == "http")
{
// For local HTTP dev: tell AIProjectClient the endpoint is HTTPS (to satisfy
// BearerTokenPolicy's TLS check), then swap the scheme back to HTTP right
// before the request hits the wire.
agentEndpoint = new UriBuilder(agentEndpoint) { Scheme = "https" }.Uri;
options.AddPolicy(new HttpSchemeRewritePolicy(), PipelinePosition.BeforeTransport);
}
var aiProjectClient = new AIProjectClient(agentEndpoint, new AzureCliCredential(), options);
FoundryAgent agent = aiProjectClient.AsAIAgent(new AgentReference(agentName));
AgentSession session = await agent.CreateSessionAsync();
// ── REPL ──────────────────────────────────────────────────────────────────────
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine($"""
══════════════════════════════════════════════════════════
Session Files Client
Connected to: {agentEndpoint}
Try: "Give me the total revenue in the contoso file."
Type a message or 'quit' to exit
""");
Console.ResetColor();
Console.WriteLine();
while (true)
{
Console.ForegroundColor = ConsoleColor.Green;
Console.Write("You> ");
Console.ResetColor();
string? input = Console.ReadLine();
if (string.IsNullOrWhiteSpace(input)) { continue; }
if (input.Equals("quit", StringComparison.OrdinalIgnoreCase)) { break; }
try
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.Write("Agent> ");
Console.ResetColor();
await foreach (var update in agent.RunStreamingAsync(input, session))
{
Console.Write(update);
}
Console.WriteLine();
}
catch (Exception ex)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"Error: {ex.Message}");
Console.ResetColor();
}
Console.WriteLine();
}
Console.WriteLine("Goodbye!");
/// <summary>
/// For Local Development Only
/// Rewrites HTTPS URIs to HTTP right before transport, allowing AIProjectClient
/// to target a local HTTP dev server while satisfying BearerTokenPolicy's TLS check.
/// </summary>
internal sealed class HttpSchemeRewritePolicy : PipelinePolicy
{
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
RewriteScheme(message);
ProcessNext(message, pipeline, currentIndex);
}
public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
RewriteScheme(message);
await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false);
}
private static void RewriteScheme(PipelineMessage message)
{
var uri = message.Request.Uri!;
if (uri.Scheme == Uri.UriSchemeHttps)
{
message.Request.Uri = new UriBuilder(uri) { Scheme = "http" }.Uri;
}
}
}
@@ -0,0 +1,50 @@
# SessionFilesClient
A thin chat REPL that connects to a deployed [`Hosted-Files`](../../Hosted-Files/) agent via `FoundryAgent` and lets you ask questions whose answers come from the files bundled with that agent. Same shape as [`SimpleAgent`](../SimpleAgent/) — point it at an `AGENT_ENDPOINT`, build a `FoundryAgent`, run.
The agent's container-side `ListFiles` and `ReadFile` tools surface the bundled file contents to the model. The client knows nothing about files; that is entirely the agent's concern.
## Prerequisites
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
- A running [`Hosted-Files`](../../Hosted-Files/) agent (locally via `dotnet run` or deployed to Foundry)
- Azure CLI logged in (`az login`)
## Configuration
```env
AGENT_ENDPOINT=http://localhost:8088
AGENT_NAME=hosted-files
```
`AGENT_ENDPOINT` defaults to `http://localhost:8088`. Override with the deployed agent endpoint when chatting against Foundry.
## Run
```bash
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient
$env:AGENT_ENDPOINT = "http://localhost:8088"
$env:AGENT_NAME = "hosted-files"
dotnet run
```
## End-to-end demo
With the [`Hosted-Files`](../../Hosted-Files/) agent running:
```text
══════════════════════════════════════════════════════════
Session Files Client
Connected to: http://localhost:8088/
Try: "Give me the total revenue in the contoso file."
Type a message or 'quit' to exit
══════════════════════════════════════════════════════════
You> Give me the total revenue in the contoso file.
Agent> The contoso file reports total revenue of "$1,482.6M".
You> quit
Goodbye!
```
The agent looked at its bundled files via `ListFiles`, picked `contoso_q1_2026_report.txt`, called `ReadFile`, and quoted the figure verbatim. The client only sent a chat prompt.
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
<RootNamespace>SessionFilesClient</RootNamespace>
<AssemblyName>session-files-client</AssemblyName>
<NoWarn>$(NoWarn);NU1605;OPENAI001</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="DotNetEnv" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
</ItemGroup>
</Project>
@@ -105,7 +105,7 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
State state = this._sessionState.GetOrInitializeState(context.Session);
// Add request and response messages to the provider
var allNewMessages = context.RequestMessages.Concat(context.ResponseMessages ?? []);
var allNewMessages = (context.RequestMessages ?? []).Concat(context.ResponseMessages ?? []);
state.Messages.AddRange(allNewMessages);
if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded && this.ChatReducer is not null)
@@ -0,0 +1,106 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Net;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers;
namespace Microsoft.Agents.AI.DevUI;
/// <summary>
/// Endpoint filter that enforces the DevUI security posture: loopback-only
/// access by default, plus optional bearer-token authentication.
/// </summary>
internal sealed class DevUIAuthFilter : IEndpointFilter
{
private const string BearerScheme = "Bearer";
private readonly DevUIOptions _options;
private readonly byte[]? _expectedTokenBytes;
private readonly ILogger<DevUIAuthFilter> _logger;
/// <summary>
/// Gets a value indicating whether a bearer token is required by this filter
/// (either via <see cref="DevUIOptions.AuthToken"/> or the
/// <c>DEVUI_AUTH_TOKEN</c> environment variable).
/// </summary>
public bool TokenRequired => this._expectedTokenBytes is { Length: > 0 };
public DevUIAuthFilter(IOptions<DevUIOptions> options, ILogger<DevUIAuthFilter> logger)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(logger);
this._options = options.Value;
this._logger = logger;
var configuredToken = !string.IsNullOrEmpty(this._options.AuthToken)
? this._options.AuthToken
: Environment.GetEnvironmentVariable(DevUIOptions.AuthTokenEnvironmentVariable);
this._expectedTokenBytes = !string.IsNullOrEmpty(configuredToken)
? Encoding.UTF8.GetBytes(configuredToken)
: null;
}
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
var httpContext = context.HttpContext;
var remoteIp = httpContext.Connection.RemoteIpAddress;
var isLoopback = remoteIp is not null && IPAddress.IsLoopback(remoteIp);
if (!isLoopback && !this._options.AllowRemoteAccess)
{
this._logger.LogWarning(
"Rejected non-loopback DevUI request from {RemoteIp}. Set DevUIOptions.AllowRemoteAccess to permit remote callers.",
remoteIp);
return Results.Problem(
statusCode: StatusCodes.Status403Forbidden,
title: "DevUI access denied",
detail: "DevUI is restricted to loopback callers by default. Enable AllowRemoteAccess to permit remote access.");
}
if (this._expectedTokenBytes is { Length: > 0 } expected && !TokenIsValid(httpContext.Request, expected))
{
httpContext.Response.Headers[HeaderNames.WWWAuthenticate] = BearerScheme;
return Results.Problem(
statusCode: StatusCodes.Status401Unauthorized,
title: "DevUI authentication required",
detail: "Provide a valid bearer token via the Authorization header.");
}
return await next(context).ConfigureAwait(false);
}
private static bool TokenIsValid(HttpRequest request, byte[] expected)
{
if (!request.Headers.TryGetValue(HeaderNames.Authorization, out var headerValues))
{
return false;
}
foreach (var header in headerValues)
{
if (string.IsNullOrEmpty(header))
{
continue;
}
const int PrefixLength = 7; // "Bearer "
if (header.Length <= PrefixLength ||
!header.StartsWith(BearerScheme, StringComparison.OrdinalIgnoreCase) ||
header[BearerScheme.Length] != ' ')
{
continue;
}
var presented = Encoding.UTF8.GetBytes(header.AsSpan(PrefixLength).Trim().ToString());
if (CryptographicOperations.FixedTimeEquals(presented, expected))
{
return true;
}
}
return false;
}
}
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.Options;
namespace Microsoft.Agents.AI.DevUI;
@@ -13,12 +14,19 @@ public static class DevUIExtensions
/// Maps an endpoint that serves the DevUI from the '/devui' path.
/// </summary>
/// <remarks>
/// <para>
/// DevUI requires the OpenAI Responses and Conversations services to be registered with
/// <see cref="MicrosoftAgentAIHostingOpenAIServiceCollectionExtensions.AddOpenAIResponses(IServiceCollection)"/> and
/// <see cref="MicrosoftAgentAIHostingOpenAIServiceCollectionExtensions.AddOpenAIConversations(IServiceCollection)"/>,
/// and the corresponding endpoints to be mapped using
/// <see cref="MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExtensions.MapOpenAIResponses(IEndpointRouteBuilder)"/> and
/// <see cref="MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExtensions.MapOpenAIConversations(IEndpointRouteBuilder)"/>.
/// </para>
/// <para>
/// DevUI is restricted to loopback callers unless
/// <see cref="DevUIOptions.AllowRemoteAccess"/> is set. See <see cref="DevUIOptions"/>
/// for the available authentication and authorization hooks.
/// </para>
/// </remarks>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the endpoint to.</param>
/// <returns>A <see cref="IEndpointConventionBuilder"/> that can be used to add authorization or other endpoint configuration.</returns>
@@ -30,11 +38,29 @@ public static class DevUIExtensions
public static IEndpointConventionBuilder MapDevUI(
this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("");
group.MapDevUI(pattern: "/devui");
group.MapMeta();
group.MapEntities();
return group;
ArgumentNullException.ThrowIfNull(endpoints);
var authFilter = endpoints.ServiceProvider.GetRequiredService<DevUIAuthFilter>();
var options = endpoints.ServiceProvider.GetRequiredService<IOptions<DevUIOptions>>().Value;
var startupLogger = endpoints.ServiceProvider.GetRequiredService<ILogger<DevUIAuthFilter>>();
WarnIfInsecurelyExposed(startupLogger, options);
// /meta must remain reachable without authentication so the frontend can
// discover whether a bearer token is required before prompting for one.
endpoints.MapMeta(authRequired: authFilter.TokenRequired);
var protectedGroup = endpoints.MapGroup("");
// Conventions must be applied before endpoints are added to the group so
// they reliably attach to every protected DevUI endpoint.
options.ConfigureEndpoints?.Invoke(protectedGroup);
protectedGroup.AddEndpointFilter(authFilter);
protectedGroup.MapDevUI(pattern: "/devui");
protectedGroup.MapEntities();
return protectedGroup;
}
/// <summary>
@@ -66,4 +92,18 @@ public static class DevUIExtensions
.WithName($"DevUI at {cleanPattern}")
.WithDescription("Interactive developer interface for Microsoft Agent Framework");
}
private static void WarnIfInsecurelyExposed(ILogger logger, DevUIOptions options)
{
var tokenConfigured = !string.IsNullOrEmpty(options.AuthToken)
|| !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(DevUIOptions.AuthTokenEnvironmentVariable));
if (options.AllowRemoteAccess && !tokenConfigured && options.ConfigureEndpoints is null)
{
logger.LogWarning(
"DevUI is configured with AllowRemoteAccess=true and no authentication. " +
"Set DevUIOptions.AuthToken, the {EnvVar} environment variable, or attach an authorization policy via ConfigureEndpoints.",
DevUIOptions.AuthTokenEnvironmentVariable);
}
}
}
@@ -0,0 +1,59 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.DevUI;
/// <summary>
/// Options that control the security posture of the DevUI HTTP surface.
/// </summary>
/// <remarks>
/// DevUI exposes agent metadata that is sensitive in production contexts:
/// system instructions, tool definitions, model identifiers, and workflow
/// structure. By default, DevUI rejects any request whose remote endpoint
/// is not a loopback address. Hosts that intentionally expose DevUI on a
/// non-loopback interface must opt in via <see cref="AllowRemoteAccess"/>
/// and should also configure <see cref="AuthToken"/> or
/// <see cref="ConfigureEndpoints"/> to attach an authorization policy.
/// </remarks>
public sealed class DevUIOptions
{
/// <summary>
/// Environment variable inspected for a default bearer token when
/// <see cref="AuthToken"/> is not explicitly set.
/// </summary>
public const string AuthTokenEnvironmentVariable = "DEVUI_AUTH_TOKEN";
/// <summary>
/// Gets or sets a value indicating whether DevUI may be served to
/// non-loopback callers. Defaults to <see langword="false"/>.
/// </summary>
/// <remarks>
/// When <see langword="false"/>, any request whose
/// <see cref="ConnectionInfo.RemoteIpAddress"/> is
/// not a loopback address (or is missing) is rejected with HTTP 403 before
/// reaching the DevUI handlers. Enable only when the host is responsible
/// for fronting DevUI with its own authentication, network policy, or both.
/// </remarks>
public bool AllowRemoteAccess { get; set; }
/// <summary>
/// Gets or sets a shared bearer token required on every DevUI request.
/// When <see langword="null"/> or empty, the value of the
/// <c>DEVUI_AUTH_TOKEN</c> environment variable is used instead.
/// </summary>
/// <remarks>
/// When a token is configured, requests must include the header
/// <c>Authorization: Bearer &lt;token&gt;</c>. Comparison is performed
/// in constant time. This is a convenience for development scenarios.
/// Production hosts should prefer a real ASP.NET Core authentication
/// scheme attached via <see cref="ConfigureEndpoints"/>.
/// </remarks>
public string? AuthToken { get; set; }
/// <summary>
/// Gets or sets a callback invoked with the DevUI endpoint group so the
/// host can attach authorization, rate limiting, or other endpoint
/// conventions (for example
/// <c>group.RequireAuthorization("DevUIPolicy")</c>).
/// </summary>
public Action<IEndpointConventionBuilder>? ConfigureEndpoints { get; set; }
}
@@ -1,5 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.DevUI;
namespace Microsoft.Extensions.Hosting;
/// <summary>
@@ -13,10 +15,19 @@ public static class MicrosoftAgentAIDevUIHostApplicationBuilderExtensions
/// <param name="builder">The <see cref="IHostApplicationBuilder"/> to configure.</param>
/// <returns>The <see cref="IHostApplicationBuilder"/> for method chaining.</returns>
public static IHostApplicationBuilder AddDevUI(this IHostApplicationBuilder builder)
=> AddDevUI(builder, configure: null);
/// <summary>
/// Adds DevUI services to the host application builder.
/// </summary>
/// <param name="builder">The <see cref="IHostApplicationBuilder"/> to configure.</param>
/// <param name="configure">Optional callback used to configure <see cref="DevUIOptions"/>.</param>
/// <returns>The <see cref="IHostApplicationBuilder"/> for method chaining.</returns>
public static IHostApplicationBuilder AddDevUI(this IHostApplicationBuilder builder, Action<DevUIOptions>? configure)
{
ArgumentNullException.ThrowIfNull(builder);
builder.Services.AddDevUI();
builder.Services.AddDevUI(configure);
return builder;
}
@@ -13,6 +13,7 @@ internal static class MetaApiExtensions
/// Maps the HTTP API endpoint for retrieving server metadata.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the route to.</param>
/// <param name="authRequired">Value reported via <c>auth_required</c> in the meta response so the frontend can decide whether to prompt for a bearer token.</param>
/// <returns>The <see cref="IEndpointConventionBuilder"/> for method chaining.</returns>
/// <remarks>
/// This extension method registers the following endpoint:
@@ -22,16 +23,16 @@ internal static class MetaApiExtensions
/// The endpoint is compatible with the Python DevUI frontend and provides essential
/// configuration information needed for proper frontend initialization.
/// </remarks>
public static IEndpointConventionBuilder MapMeta(this IEndpointRouteBuilder endpoints)
public static IEndpointConventionBuilder MapMeta(this IEndpointRouteBuilder endpoints, bool authRequired = false)
{
return endpoints.MapGet("/meta", GetMeta)
return endpoints.MapGet("/meta", () => GetMeta(authRequired))
.WithName("GetMeta")
.WithSummary("Get server metadata and configuration")
.WithDescription("Returns server metadata including UI mode, version, framework identifier, capabilities, and authentication requirements. Used by the frontend for initialization and feature detection.")
.Produces<MetaResponse>(StatusCodes.Status200OK, contentType: "application/json");
}
private static IResult GetMeta()
private static IResult GetMeta(bool authRequired)
{
// TODO: Consider making these configurable via IOptions<DevUIOptions>
// For now, using sensible defaults that match Python DevUI behavior
@@ -53,7 +54,7 @@ internal static class MetaApiExtensions
// Deployment capability - not currently supported in .NET DevUI
["deployment"] = false
},
AuthRequired = false // Could be made configurable based on authentication middleware
AuthRequired = authRequired
};
return Results.Json(meta, EntitiesJsonContext.Default.MetaResponse);
@@ -2,6 +2,9 @@
This package provides a web interface for testing and debugging AI agents during development.
> [!WARNING]
> DevUI is intended for development only. Its endpoints surface agent system instructions, tool definitions, model identifiers, and workflow structure. Do not expose DevUI to untrusted callers. By default, DevUI rejects any request whose remote endpoint is not a loopback address; see [Security](#security) below for the available options.
## Installation
```bash
@@ -48,3 +51,30 @@ if (builder.Environment.IsDevelopment())
app.Run();
```
## Security
DevUI exposes `/v1/entities` and `/v1/entities/{id}/info`, which return agent metadata including the system prompt (`ChatClientAgent.Instructions`). To prevent accidental disclosure, the DevUI route group is wrapped in a small endpoint filter that:
- Rejects requests from any non-loopback `RemoteIpAddress` with HTTP 403 by default.
- Optionally requires a shared bearer token on every request.
Configure via `DevUIOptions`:
```csharp
builder.AddDevUI(options =>
{
// Allow non-loopback callers. Set this only when the host fronts DevUI with
// its own authentication or network policy.
options.AllowRemoteAccess = true;
// Optional: require Authorization: Bearer <token> on every request.
// Falls back to the DEVUI_AUTH_TOKEN environment variable when null.
options.AuthToken = builder.Configuration["DevUI:AuthToken"];
// Optional: attach a real authorization policy or rate limiting.
options.ConfigureEndpoints = group => group.RequireAuthorization("DevUIPolicy");
});
```
The bundled bearer-token check uses constant-time comparison and is intended as a convenience for development scenarios. Production hosts should prefer a real ASP.NET Core authentication scheme via `ConfigureEndpoints`.
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.DevUI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Shared.Diagnostics;
@@ -17,9 +18,26 @@ public static class MicrosoftAgentAIDevUIServiceCollectionsExtensions
/// <param name="services">The <see cref="IServiceCollection"/> to configure.</param>
/// <returns>The <see cref="IServiceCollection"/> for method chaining.</returns>
public static IServiceCollection AddDevUI(this IServiceCollection services)
=> AddDevUI(services, configure: null);
/// <summary>
/// Adds services required for DevUI integration.
/// </summary>
/// <param name="services">The <see cref="IServiceCollection"/> to configure.</param>
/// <param name="configure">Optional callback used to configure <see cref="DevUIOptions"/>.</param>
/// <returns>The <see cref="IServiceCollection"/> for method chaining.</returns>
public static IServiceCollection AddDevUI(this IServiceCollection services, Action<DevUIOptions>? configure)
{
ArgumentNullException.ThrowIfNull(services);
var optionsBuilder = services.AddOptions<DevUIOptions>();
if (configure is not null)
{
optionsBuilder.Configure(configure);
}
services.AddSingleton<DevUIAuthFilter>();
// a factory that tries to construct an AIAgent from Workflow,
// even if workflow was not explicitly registered as an AIAgent.
@@ -1,56 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
#pragma warning disable OPENAI001
#pragma warning disable AAIP001 // AgentToolboxes is experimental in Azure.AI.Projects.Agents
namespace Azure.AI.Projects;
/// <summary>
/// Provides extension methods on <see cref="AIProjectClient"/> for fetching
/// Foundry toolbox definitions as server-side tools.
/// </summary>
/// <remarks>
/// Provides a single call on the project client to retrieve tools ready for use
/// with <c>AsAIAgent(model, instructions, tools: ...)</c>.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public static class AIProjectClientToolboxExtensions
{
/// <summary>
/// Fetches a toolbox from the Foundry project and returns its tools as <see cref="AITool"/> instances
/// ready for use as server-side tools in the Responses API.
/// </summary>
/// <param name="projectClient">The <see cref="AIProjectClient"/> to use. Cannot be <see langword="null"/>.</param>
/// <param name="name">The name of the toolbox to fetch.</param>
/// <param name="version">
/// The specific toolbox version to fetch. When <see langword="null"/>, the toolbox's
/// default version is resolved automatically.
/// </param>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <returns>A read-only list of <see cref="AITool"/> instances from the toolbox.</returns>
/// <exception cref="System.ArgumentNullException">
/// Thrown when <paramref name="projectClient"/> or <paramref name="name"/> is <see langword="null"/>.
/// </exception>
public static async Task<IReadOnlyList<AITool>> GetToolboxToolsAsync(
this AIProjectClient projectClient,
string name,
string? version = null,
CancellationToken cancellationToken = default)
{
Throw.IfNull(projectClient);
Throw.IfNullOrWhitespace(name);
var toolboxClient = projectClient.AgentAdministrationClient.GetAgentToolboxes();
var toolboxVersion = await FoundryToolbox.GetToolboxVersionCoreAsync(toolboxClient, name, version, cancellationToken).ConfigureAwait(false);
return toolboxVersion.ToAITools();
}
}
@@ -1,220 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.Projects.Agents;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
using OpenAI.Responses;
#pragma warning disable OPENAI001
#pragma warning disable AAIP001 // AgentToolboxes is experimental in Azure.AI.Projects.Agents
#pragma warning disable IL2026 // ModelReaderWriter.Read<ResponseTool> uses reflection; suppressed for Azure SDK model types.
#pragma warning disable IL3050 // ModelReaderWriter.Read<ResponseTool> requires dynamic code; suppressed for Azure SDK model types.
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Provides methods for fetching Foundry toolbox definitions and converting their tools
/// to <see cref="AITool"/> instances for use as server-side tools in the Responses API.
/// </summary>
/// <remarks>
/// <para>
/// When tools from a toolbox are passed to a Foundry agent (e.g. via <c>AsAIAgent(model, instructions, tools: ...)</c>),
/// they are sent as server-side tool definitions in the Responses API request. The Foundry platform
/// handles tool execution — the agent process does not invoke tools locally.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public static class FoundryToolbox
{
/// <summary>
/// Fetches a toolbox version from the Foundry project and returns the raw SDK <see cref="ToolboxVersion"/>.
/// </summary>
/// <param name="projectEndpoint">The Foundry project endpoint URI.</param>
/// <param name="credential">The authentication credential used to access the Foundry project.</param>
/// <param name="name">The name of the toolbox to fetch.</param>
/// <param name="version">
/// The specific toolbox version to fetch. When <see langword="null"/>, the toolbox's
/// default version is resolved automatically (requires an additional API call).
/// </param>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <returns>The <see cref="ToolboxVersion"/> containing tool definitions.</returns>
/// <exception cref="ArgumentNullException">
/// Thrown when <paramref name="projectEndpoint"/>, <paramref name="credential"/>, or <paramref name="name"/> is <see langword="null"/>.
/// </exception>
/// <exception cref="ClientResultException">Thrown when the Foundry project API returns an error.</exception>
public static async Task<ToolboxVersion> GetToolboxVersionAsync(
Uri projectEndpoint,
AuthenticationTokenProvider credential,
string name,
string? version = null,
CancellationToken cancellationToken = default)
{
Throw.IfNull(projectEndpoint);
Throw.IfNull(credential);
Throw.IfNullOrWhitespace(name);
var toolboxClient = CreateToolboxClient(projectEndpoint, credential);
return await GetToolboxVersionCoreAsync(toolboxClient, name, version, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Fetches a toolbox from the Foundry project and returns its tools as <see cref="AITool"/> instances
/// ready for use as server-side tools in the Responses API.
/// </summary>
/// <param name="projectEndpoint">The Foundry project endpoint URI.</param>
/// <param name="credential">The authentication credential used to access the Foundry project.</param>
/// <param name="name">The name of the toolbox to fetch.</param>
/// <param name="version">
/// The specific toolbox version to fetch. When <see langword="null"/>, the toolbox's
/// default version is resolved automatically.
/// </param>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <returns>A read-only list of <see cref="AITool"/> instances from the toolbox.</returns>
/// <exception cref="ArgumentNullException">
/// Thrown when <paramref name="projectEndpoint"/>, <paramref name="credential"/>, or <paramref name="name"/> is <see langword="null"/>.
/// </exception>
/// <exception cref="ClientResultException">Thrown when the Foundry project API returns an error.</exception>
public static async Task<IReadOnlyList<AITool>> GetToolsAsync(
Uri projectEndpoint,
AuthenticationTokenProvider credential,
string name,
string? version = null,
CancellationToken cancellationToken = default)
{
var toolboxVersion = await GetToolboxVersionAsync(projectEndpoint, credential, name, version, cancellationToken).ConfigureAwait(false);
return toolboxVersion.ToAITools();
}
/// <summary>
/// Converts the tools in a <see cref="ToolboxVersion"/> to <see cref="AITool"/> instances
/// suitable for use as server-side tools in the Responses API.
/// </summary>
/// <param name="toolboxVersion">The toolbox version whose tools to convert.</param>
/// <returns>A read-only list of <see cref="AITool"/> instances.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="toolboxVersion"/> is <see langword="null"/>.</exception>
/// <remarks>
/// <para>
/// Each <see cref="ProjectsAgentTool"/> in the toolbox is cast to <see cref="ResponseTool"/>
/// and converted via <c>AsAITool()</c>. Non-function hosted tools (MCP, web_search,
/// code_interpreter, etc.) are included as server-side tool definitions — the Foundry
/// platform handles their execution.
/// </para>
/// <para>
/// Non-function tools are sanitized to remove decoration fields (<c>name</c>, <c>description</c>)
/// that the toolbox API returns but the Responses API rejects.
/// </para>
/// </remarks>
public static IReadOnlyList<AITool> ToAITools(this ToolboxVersion toolboxVersion)
{
Throw.IfNull(toolboxVersion);
if (toolboxVersion.Tools?.Any() != true)
{
return [];
}
return toolboxVersion.Tools
.Select(SanitizeAndConvert)
.ToList();
}
#region Internal helpers (visible to unit tests via InternalsVisibleTo)
/// <summary>
/// Sanitizes a <see cref="ProjectsAgentTool"/> by removing decoration fields that the
/// toolbox API returns but the Responses API rejects, then converts to <see cref="AITool"/>.
/// </summary>
/// <remarks>
/// The Azure AI Projects toolbox API may return <c>name</c> and <c>description</c> on
/// hosted tool objects (MCP, code_interpreter, file_search, etc.). The Responses API
/// rejects at least <c>name</c> with "Unknown parameter: 'tools[0].name'". We strip
/// these decoration fields for non-function tools. Function tools keep them since
/// <c>name</c> and <c>description</c> are expected parts of the function schema.
/// </remarks>
internal static AITool SanitizeAndConvert(ProjectsAgentTool tool)
{
var toolJson = ModelReaderWriter.Write(tool, new ModelReaderWriterOptions("J"));
var node = JsonNode.Parse(toolJson.ToString());
if (node is not JsonObject obj)
{
return ((ResponseTool)tool).AsAITool();
}
var toolType = obj["type"]?.GetValue<string>();
// Function tools need name/description — don't strip
if (toolType is "function" or "custom")
{
return ((ResponseTool)tool).AsAITool();
}
// Strip decoration fields that the Responses API rejects
bool modified = false;
modified |= obj.Remove("name");
modified |= obj.Remove("description");
if (!modified)
{
return ((ResponseTool)tool).AsAITool();
}
var sanitizedJson = obj.ToJsonString();
var sanitizedTool = ModelReaderWriter.Read<ResponseTool>(BinaryData.FromString(sanitizedJson))!;
return sanitizedTool.AsAITool();
}
internal static async Task<ToolboxVersion> GetToolboxVersionAsync(
Uri projectEndpoint,
AuthenticationTokenProvider credential,
string name,
string? version,
AgentAdministrationClientOptions? clientOptions,
CancellationToken cancellationToken)
{
Throw.IfNull(projectEndpoint);
Throw.IfNull(credential);
Throw.IfNullOrWhitespace(name);
var toolboxClient = CreateToolboxClient(projectEndpoint, credential, clientOptions);
return await GetToolboxVersionCoreAsync(toolboxClient, name, version, cancellationToken).ConfigureAwait(false);
}
internal static AgentToolboxes CreateToolboxClient(
Uri projectEndpoint,
AuthenticationTokenProvider credential,
AgentAdministrationClientOptions? clientOptions = null)
{
clientOptions ??= new AgentAdministrationClientOptions();
var adminClient = new AgentAdministrationClient(projectEndpoint, credential, clientOptions);
return adminClient.GetAgentToolboxes();
}
internal static async Task<ToolboxVersion> GetToolboxVersionCoreAsync(
AgentToolboxes toolboxClient,
string name,
string? version,
CancellationToken cancellationToken)
{
if (version is null)
{
var record = await toolboxClient.GetToolboxAsync(name, cancellationToken).ConfigureAwait(false);
version = record.Value.DefaultVersion
?? throw new InvalidOperationException($"Toolbox '{name}' does not have a default version. Specify an explicit version.");
}
var result = await toolboxClient.GetToolboxVersionAsync(name, version, cancellationToken).ConfigureAwait(false);
return result.Value;
}
#endregion
}
@@ -190,7 +190,7 @@ internal static class InputConverter
private static ChatMessage ConvertFunctionCallOutput(FunctionCallOutputItemParam funcOutput)
{
var output = funcOutput.Output?.ToString() ?? string.Empty;
var output = DecodeFunctionResultPayload(funcOutput.Output);
return new ChatMessage(
ChatRole.Tool,
[new FunctionResultContent(funcOutput.CallId, output)]);
@@ -482,9 +482,54 @@ internal static class InputConverter
private static ChatMessage ConvertFunctionToolCallOutput(OutputItemFunctionToolCallOutput funcOutput)
{
var output = DecodeFunctionResultPayload(funcOutput.Output);
return new ChatMessage(
ChatRole.Tool,
[new FunctionResultContent(funcOutput.CallId, funcOutput.Output)]);
[new FunctionResultContent(funcOutput.CallId, output)]);
}
/// <summary>
/// Decodes the wire payload of a <c>function_call_output.output</c> field back into the
/// underlying tool-result text suitable for replay as <see cref="FunctionResultContent.Result"/>.
/// </summary>
/// <remarks>
/// Mirrors <c>OutputConverter.EncodeFunctionResultAsJsonStringPayload</c>. Per the OpenAI
/// Responses spec, <c>output</c> is a JSON string; we extract its underlying value. Legacy
/// producers that emitted raw JSON values (arrays/objects) are tolerated by passing the raw
/// bytes through unchanged.
/// </remarks>
private static string DecodeFunctionResultPayload(BinaryData? rawOutput)
{
if (rawOutput is null)
{
return string.Empty;
}
var raw = rawOutput.ToString();
if (string.IsNullOrEmpty(raw))
{
return string.Empty;
}
try
{
using var doc = JsonDocument.Parse(raw);
if (doc.RootElement.ValueKind == JsonValueKind.String)
{
return doc.RootElement.GetString() ?? string.Empty;
}
// Legacy/non-conforming producers may have emitted a raw JSON value
// (array/object/number/bool/null). Pass the raw text through as the
// payload so the replayed FunctionResultContent.Result preserves the
// original tool output shape.
return raw;
}
catch (JsonException)
{
// Not valid JSON — treat the bytes as a literal string payload.
return raw;
}
}
private static ChatRole ConvertMessageRole(MessageRole role)
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
@@ -31,7 +31,7 @@
<ItemGroup>
<PackageReference Include="Azure.AI.AgentServer.Responses" />
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="ModelContextProtocol" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
@@ -279,12 +279,7 @@ internal static class OutputConverter
accumulatedText = null;
previousMessageId = null;
var outputText = functionResult.Result switch
{
null => string.Empty,
string s => s,
_ => JsonSerializer.Serialize(functionResult.Result),
};
var outputText = EncodeFunctionResultAsJsonStringPayload(functionResult.Result);
var itemId = GenerateItemId("fc");
var outputItem = new OutputItemFunctionToolCallOutput(
@@ -448,4 +443,44 @@ internal static class OutputConverter
var body = Convert.ToHexString(bytes); // 50 hex chars, uppercase
return $"{prefix}_{body}";
}
/// <summary>
/// Encodes a <see cref="FunctionResultContent.Result"/> value into the wire payload for
/// the OpenAI Responses <c>function_call_output.output</c> field.
/// </summary>
/// <remarks>
/// The OpenAI Responses spec requires <c>output</c> to be a JSON string. The Responses
/// SDK's <see cref="OutputItemFunctionToolCallOutput"/> accepts a <see cref="BinaryData"/>
/// containing the *raw JSON value* for the field, so the returned text is always a JSON
/// string literal (quoted, with escapes). This avoids two bugs:
/// <list type="bullet">
/// <item>Complex results (e.g. <c>List&lt;TodoItem&gt;</c>) landing on the wire as an
/// unquoted JSON array, which the strict-parsing OpenAI .NET client
/// (<c>FunctionCallOutputResponseItem</c>) rejects with
/// "requires an element of type 'String', but the target element has type 'Array'".</item>
/// <item>Numeric- or JSON-shaped string results (e.g. <c>"42"</c> or <c>"{\"k\":1}"</c>)
/// silently changing type on the wire because <c>BinaryData</c> auto-detects JSON.</item>
/// </list>
/// <see cref="JsonElement"/> / <see cref="JsonDocument"/> values are unwrapped first so
/// a string-kind element does not get double-encoded into <c>"\"value\""</c>.
/// </remarks>
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing function call result payload.")]
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing function call result payload.")]
private static string EncodeFunctionResultAsJsonStringPayload(object? result)
{
string innerText = result switch
{
null => string.Empty,
string s => s,
JsonElement je => je.ValueKind == JsonValueKind.String
? (je.GetString() ?? string.Empty)
: je.GetRawText(),
JsonDocument jd => jd.RootElement.ValueKind == JsonValueKind.String
? (jd.RootElement.GetString() ?? string.Empty)
: jd.RootElement.GetRawText(),
_ => JsonSerializer.Serialize(result),
};
return JsonSerializer.Serialize(innerText);
}
}
@@ -42,23 +42,15 @@ internal sealed class ClientHeadersAgent : DelegatingAIAgent
CancellationToken cancellationToken = default)
{
var snapshot = TrySnapshot(options);
if (snapshot is null)
if (snapshot is not null)
{
return this.InnerAgent.RunAsync(messages, session, options, cancellationToken);
// AsyncLocal mutations made inside an awaited async method do not leak back to the
// caller after the method returns, so we do not need an explicit restore step here.
// See ClientHeadersScope remarks.
ClientHeadersScope.Current = snapshot;
}
return RunAsyncCoreAsync(messages, session, options, snapshot, cancellationToken);
async Task<AgentResponse> RunAsyncCoreAsync(
IEnumerable<ChatMessage> innerMessages,
AgentSession? innerSession,
AgentRunOptions? innerOptions,
Dictionary<string, string> innerSnapshot,
CancellationToken innerCt)
{
using var _ = ClientHeadersScope.Push(innerSnapshot);
return await this.InnerAgent.RunAsync(innerMessages, innerSession, innerOptions, innerCt).ConfigureAwait(false);
}
return this.InnerAgent.RunAsync(messages, session, options, cancellationToken);
}
/// <inheritdoc/>
@@ -69,7 +61,10 @@ internal sealed class ClientHeadersAgent : DelegatingAIAgent
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var snapshot = TrySnapshot(options);
using var _ = snapshot is null ? default : ClientHeadersScope.Push(snapshot);
if (snapshot is not null)
{
ClientHeadersScope.Current = snapshot;
}
await foreach (var update in this.InnerAgent.RunStreamingAsync(messages, session, options, cancellationToken).ConfigureAwait(false))
{
@@ -11,39 +11,31 @@ namespace Microsoft.Agents.AI.Foundry;
/// <see cref="ClientHeadersPolicy"/> running inside the SCM transport pipeline.
/// </summary>
/// <remarks>
/// AsyncLocal flows the value into downstream awaits but does not roll the value back when the
/// setting method returns. This type pairs each <see cref="Push(IReadOnlyDictionary{string, string}?)"/>
/// with a disposable that explicitly restores the prior value, giving stack-style LIFO semantics
/// for nested or sequential per-call scopes on the same async flow.
/// <para>
/// <see cref="AsyncLocal{T}"/> propagates the value forward into every <c>await</c> on the same
/// async flow, but mutations made inside an awaited <c>async</c> method do <em>not</em> leak back
/// to the caller after the method returns. This means a method that assigns
/// <see cref="Current"/> at the top and then awaits inner work does not need any explicit
/// restoration step: the runtime restores the caller's view of the AsyncLocal automatically when
/// the method's task completes.
/// </para>
/// <para>
/// Setting <see cref="Current"/> from synchronous code, however, will leak to the caller because
/// no async-method boundary is crossed. All Agent Framework call sites of this carrier are
/// inside <c>async</c> methods (<see cref="ClientHeadersAgent"/>), so the natural restoration
/// suffices for our needs.
/// </para>
/// </remarks>
internal static class ClientHeadersScope
{
private static readonly AsyncLocal<IReadOnlyDictionary<string, string>?> s_current = new();
/// <summary>Gets the dictionary captured by the most recent <see cref="Push(IReadOnlyDictionary{string, string}?)"/> on this async flow.</summary>
public static IReadOnlyDictionary<string, string>? Current => s_current.Value;
/// <summary>
/// Pushes a new value as the current scope. Disposing the returned token restores the previous value.
/// Gets or sets the per-async-flow client-header snapshot read by <see cref="ClientHeadersPolicy"/>.
/// </summary>
/// <param name="headers">The header dictionary to surface to the policy. May be <see langword="null"/>.</param>
public static Scope Push(IReadOnlyDictionary<string, string>? headers)
public static IReadOnlyDictionary<string, string>? Current
{
var previous = s_current.Value;
s_current.Value = headers;
return new Scope(previous);
}
/// <summary>Disposable token that restores the previous scope on <see cref="Dispose"/>.</summary>
internal readonly struct Scope : System.IDisposable
{
private readonly IReadOnlyDictionary<string, string>? _previous;
internal Scope(IReadOnlyDictionary<string, string>? previous)
{
this._previous = previous;
}
public void Dispose() => s_current.Value = this._previous;
get => s_current.Value;
set => s_current.Value = value;
}
}
@@ -2,6 +2,7 @@
using System;
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
@@ -38,7 +39,28 @@ namespace Microsoft.Agents.AI.Foundry;
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public sealed class FoundryAgent : DelegatingAIAgent
{
private readonly AIProjectClient _aiProjectClient;
/// <summary>
/// Default OAuth scope for the Azure AI resource. Matches the scope used by
/// <c>Azure.AI.Extensions.OpenAI</c>'s internal authentication helper so the bearer token is
/// accepted by the Foundry control plane.
/// </summary>
private const string AzureAiResourceScope = "https://ai.azure.com/.default";
/// <summary>
/// The cached <see cref="AIProjectClient"/> when one was supplied or constructed by the active
/// constructor. Null when the agent was constructed via the agent-endpoint constructor, which
/// does not build a full <see cref="AIProjectClient"/>.
/// </summary>
private readonly AIProjectClient? _aiProjectClient;
/// <summary>
/// Project-scoped <see cref="ProjectOpenAIClient"/>. Always non-null. Used for project-level
/// operations such as <see cref="CreateConversationSessionAsync(CancellationToken)"/>.
/// In agent-endpoint mode this is built directly from the project root derived from the
/// supplied agent endpoint; in project-endpoint mode it is the cached client returned by
/// <see cref="AIProjectClient"/>.
/// </summary>
private readonly ProjectOpenAIClient _projectOpenAIClient;
/// <summary>
/// Initializes a new instance of the <see cref="FoundryAgent"/> class using the direct Responses API path.
@@ -72,30 +94,49 @@ public sealed class FoundryAgent : DelegatingAIAgent
out var aiProjectClient))
{
this._aiProjectClient = aiProjectClient;
this._projectOpenAIClient = aiProjectClient.GetProjectOpenAIClient();
}
/// <summary>
/// Initializes a new instance of the <see cref="FoundryAgent"/> class from an agent-specific endpoint.
/// </summary>
/// <param name="agentEndpoint">The agent-specific endpoint URI (must contain the agent name in the path).</param>
/// <param name="agentEndpoint">
/// The agent-specific endpoint URI. Must be of the shape
/// <c>https://&lt;host&gt;/.../projects/&lt;project&gt;/agents/&lt;agentName&gt;/endpoint/protocols/openai</c>.
/// </param>
/// <param name="credential">The authentication credential.</param>
/// <param name="clientOptions">Optional configuration options for the <see cref="AIProjectClient"/>.</param>
/// <param name="clientOptions">
/// Optional configuration for the underlying <see cref="ProjectOpenAIClient"/>. When supplied:
/// <list type="bullet">
/// <item><description>The instance is passed through to the per-agent client; pipeline policies added via <c>AddPolicy(...)</c> on it execute on the per-agent traffic.</description></item>
/// <item><description><c>Endpoint</c> and <see cref="ProjectOpenAIClientOptions.AgentName"/> are owned by this constructor and are overwritten with values derived from <paramref name="agentEndpoint"/>; any caller value is replaced.</description></item>
/// <item><description>For the project-level conversations client a separate fresh options bag is built that copies only <see cref="ClientPipelineOptions.RetryPolicy"/>, <see cref="ClientPipelineOptions.NetworkTimeout"/>, <see cref="ClientPipelineOptions.Transport"/>, and <c>UserAgentApplicationId</c>; pipeline policies added via <c>AddPolicy(...)</c> do <strong>not</strong> propagate to the conversations pipeline.</description></item>
/// </list>
/// </param>
/// <param name="tools">Optional tools to use when interacting with the agent.</param>
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/>.</param>
/// <param name="services">Optional service provider for resolving dependencies required by AI functions.</param>
/// <exception cref="ArgumentNullException"><paramref name="agentEndpoint"/> or <paramref name="credential"/> is null.</exception>
/// <exception cref="ArgumentException"><paramref name="agentEndpoint"/> does not match the expected agent-endpoint shape.</exception>
/// <remarks>
/// This is the lightweight constructor for invoking an existing Foundry hosted agent when the
/// caller already has the per-agent endpoint URL. It populates <see cref="ChatClientAgentOptions.Id"/>
/// and <see cref="ChatClientAgentOptions.Name"/> from the agent name parsed out of the endpoint
/// path; <c>Description</c>, <c>Instructions</c>, <c>Temperature</c>, and <c>TopP</c> are not
/// populated. Callers that need those fields hydrated from server-side state should use
/// <c>AIProjectClient.AsAIAgent(ProjectsAgentVersion)</c> or
/// <c>AIProjectClient.AsAIAgent(ProjectsAgentRecord)</c> instead.
/// </remarks>
public FoundryAgent(
Uri agentEndpoint,
AuthenticationTokenProvider credential,
AIProjectClientOptions? clientOptions = null,
ProjectOpenAIClientOptions? clientOptions = null,
IList<AITool>? tools = null,
Func<IChatClient, IChatClient>? clientFactory = null,
IServiceProvider? services = null)
: base(CreateInnerAgentFromEndpoint(
CreateProjectClient(agentEndpoint, credential, clientOptions),
agentEndpoint, tools, clientFactory, services,
out var aiProjectClient))
: base(CreateInnerAgentFromAgentEndpoint(agentEndpoint, credential, clientOptions, tools, clientFactory, services))
{
this._aiProjectClient = aiProjectClient;
this._projectOpenAIClient = CreateProjectLevelOpenAIClientFromAgentEndpoint(agentEndpoint, credential, clientOptions);
}
/// <summary>
@@ -105,6 +146,7 @@ public sealed class FoundryAgent : DelegatingAIAgent
: base(WireClientHeaders(Throw.IfNull(innerAgent)))
{
this._aiProjectClient = Throw.IfNull(aiProjectClient);
this._projectOpenAIClient = aiProjectClient.GetProjectOpenAIClient();
}
#region Convenience methods
@@ -137,9 +179,7 @@ public sealed class FoundryAgent : DelegatingAIAgent
/// <returns>A <see cref="ChatClientAgentSession"/> linked to the newly created server-side conversation.</returns>
public async Task<ChatClientAgentSession> CreateConversationSessionAsync(CancellationToken cancellationToken = default)
{
var conversationsClient = this._aiProjectClient
.GetProjectOpenAIClient()
.GetProjectConversationsClient();
var conversationsClient = this._projectOpenAIClient.GetProjectConversationsClient();
var conversation = (await conversationsClient.CreateProjectConversationAsync(options: null, cancellationToken).ConfigureAwait(false)).Value;
@@ -161,6 +201,11 @@ public sealed class FoundryAgent : DelegatingAIAgent
return this._aiProjectClient;
}
if (serviceKey is null && serviceType == typeof(ProjectOpenAIClient))
{
return this._projectOpenAIClient;
}
return base.GetService(serviceType, serviceKey);
}
@@ -238,47 +283,181 @@ public sealed class FoundryAgent : DelegatingAIAgent
OpenAIRequestPoliciesReflection.AddPolicyIfMissing(
policies,
ClientHeadersPolicy.Instance,
System.ClientModel.Primitives.PipelinePosition.PerCall);
PipelinePosition.PerCall);
}
return new ClientHeadersAgent(innerAgent);
}
private static AIAgent CreateInnerAgentFromEndpoint(
AIProjectClient aiProjectClient,
/// <summary>
/// Builds the inner <see cref="ChatClientAgent"/> for the agent-endpoint constructor by
/// constructing a per-agent <see cref="ProjectOpenAIClient"/> via the
/// <c>ProjectOpenAIClient(AuthenticationPolicy, ProjectOpenAIClientOptions)</c>
/// constructor with <see cref="ProjectOpenAIClientOptions.AgentName"/> set. This routes the
/// outbound URL through the per-agent endpoint shape that the Foundry service expects for
/// hosted agents and lets the SDK auto-append the <c>api-version</c> query string.
/// Caller-supplied <paramref name="clientOptions"/> are passed through to the per-agent
/// client with <c>Endpoint</c> and
/// <see cref="ProjectOpenAIClientOptions.AgentName"/> overridden by values derived from
/// <paramref name="agentEndpoint"/>; any policies the caller added via <c>AddPolicy</c>
/// remain in effect on the per-agent pipeline. The MEAI user-agent policy is appended last.
/// </summary>
private static AIAgent CreateInnerAgentFromAgentEndpoint(
Uri agentEndpoint,
AuthenticationTokenProvider credential,
ProjectOpenAIClientOptions? clientOptions,
IList<AITool>? tools,
Func<IChatClient, IChatClient>? clientFactory,
IServiceProvider? services,
out AIProjectClient outClient)
IServiceProvider? services)
{
outClient = aiProjectClient;
Throw.IfNull(agentEndpoint);
Throw.IfNull(credential);
AgentReference agentReference = agentEndpoint.Segments[^1].TrimEnd('/');
var (agentName, _) = ParseAgentEndpoint(agentEndpoint);
ChatClientAgentOptions agentOptions = new()
{
Name = agentReference.Name,
ChatOptions = new() { Tools = tools },
};
var perAgentOptions = clientOptions ?? new ProjectOpenAIClientOptions();
perAgentOptions.Endpoint = agentEndpoint;
perAgentOptions.AgentName = agentName;
perAgentOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, PipelinePosition.PerCall);
IChatClient chatClient = new AzureAIProjectChatClient(aiProjectClient, agentReference, defaultModelId: null, agentOptions.ChatOptions);
var authPolicy = new BearerTokenPolicy(credential, AzureAiResourceScope);
var perAgentClient = new ProjectOpenAIClient(authPolicy, perAgentOptions);
IChatClient chatClient = perAgentClient.GetProjectResponsesClient().AsIChatClient();
if (clientFactory is not null)
{
chatClient = clientFactory(chatClient);
}
ChatClientAgentOptions agentOptions = new()
{
Id = agentName,
Name = agentName,
ChatOptions = new() { Tools = tools },
};
return WireClientHeaders(new ChatClientAgent(chatClient, agentOptions, services: services));
}
/// <summary>
/// Builds the project-scoped <see cref="ProjectOpenAIClient"/> for the agent-endpoint
/// constructor by deriving the project root from the supplied agent endpoint and constructing
/// a fresh client without <see cref="ProjectOpenAIClientOptions.AgentName"/> so the SDK
/// appends the standard <c>/openai/v1</c> suffix expected for project-level surfaces such as
/// conversations.
/// </summary>
/// <remarks>
/// Only the four observable primitive properties (<see cref="ClientPipelineOptions.RetryPolicy"/>,
/// <see cref="ClientPipelineOptions.NetworkTimeout"/>, <see cref="ClientPipelineOptions.Transport"/>,
/// and <c>UserAgentApplicationId</c>) are copied from the caller's options bag. Pipeline
/// policies added via <c>AddPolicy</c> on the caller bag do not propagate because
/// <see cref="ClientPipelineOptions"/> does not publicly enumerate its policies. The MEAI
/// user-agent policy is appended last.
/// </remarks>
private static ProjectOpenAIClient CreateProjectLevelOpenAIClientFromAgentEndpoint(
Uri agentEndpoint,
AuthenticationTokenProvider credential,
ProjectOpenAIClientOptions? clientOptions)
{
var (_, projectRoot) = ParseAgentEndpoint(agentEndpoint);
var projectOptions = new ProjectOpenAIClientOptions();
if (clientOptions is not null)
{
if (clientOptions.RetryPolicy is not null)
{
projectOptions.RetryPolicy = clientOptions.RetryPolicy;
}
if (clientOptions.NetworkTimeout is not null)
{
projectOptions.NetworkTimeout = clientOptions.NetworkTimeout;
}
if (clientOptions.Transport is not null)
{
projectOptions.Transport = clientOptions.Transport;
}
if (!string.IsNullOrEmpty(clientOptions.UserAgentApplicationId))
{
projectOptions.UserAgentApplicationId = clientOptions.UserAgentApplicationId;
}
}
projectOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, PipelinePosition.PerCall);
return new ProjectOpenAIClient(projectRoot, credential, projectOptions);
}
/// <summary>
/// Parses an agent endpoint URI of shape
/// <c>https://&lt;host&gt;/.../projects/&lt;project&gt;/agents/&lt;agentName&gt;/endpoint/protocols/openai</c>
/// and returns the agent name and the derived project-root URI.
/// </summary>
/// <remarks>
/// Single source of truth for both agent-name extraction and project-root derivation.
/// Tolerates trailing slash, casing variants on <c>/agents/</c> and the suffix segment, and
/// strips query string and fragment. Throws <see cref="ArgumentException"/> for inputs that
/// do not match the expected shape.
/// </remarks>
/// <exception cref="ArgumentException">
/// The endpoint is missing the <c>/agents/</c> segment, has an empty agent name, or has a
/// suffix other than <c>/endpoint/protocols/openai</c>.
/// </exception>
internal static (string AgentName, Uri ProjectRoot) ParseAgentEndpoint(Uri agentEndpoint)
{
Throw.IfNull(agentEndpoint);
const string AgentsSegment = "/agents/";
const string ExpectedSuffix = "/endpoint/protocols/openai";
var path = agentEndpoint.AbsolutePath.TrimEnd('/');
var idx = path.IndexOf(AgentsSegment, StringComparison.OrdinalIgnoreCase);
if (idx < 0)
{
throw new ArgumentException(
$"Expected an agent endpoint of shape 'https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai' but got '{agentEndpoint}'. " +
"If you want to construct a FoundryAgent against a project endpoint, use the (Uri projectEndpoint, AuthenticationTokenProvider credential, string model, string instructions, ...) constructor instead.",
nameof(agentEndpoint));
}
var afterAgents = path.Substring(idx + AgentsSegment.Length);
var nextSlash = afterAgents.IndexOf('/');
if (nextSlash <= 0)
{
throw new ArgumentException(
$"Agent endpoint '{agentEndpoint}' is missing the '<agentName>{ExpectedSuffix}' suffix.",
nameof(agentEndpoint));
}
var agentName = afterAgents.Substring(0, nextSlash);
var suffix = afterAgents.Substring(nextSlash);
if (!string.Equals(suffix, ExpectedSuffix, StringComparison.OrdinalIgnoreCase))
{
throw new ArgumentException(
$"Agent endpoint '{agentEndpoint}' has an unexpected suffix '{suffix}'. Expected '{ExpectedSuffix}'.",
nameof(agentEndpoint));
}
var rootPath = path.Substring(0, idx);
var projectRoot = new UriBuilder(agentEndpoint)
{
Path = rootPath,
Query = string.Empty,
Fragment = string.Empty,
}.Uri;
return (agentName, projectRoot);
}
private static AIProjectClient CreateProjectClient(Uri endpoint, AuthenticationTokenProvider credential, AIProjectClientOptions? clientOptions = null)
{
Throw.IfNull(endpoint);
Throw.IfNull(credential);
clientOptions ??= new AIProjectClientOptions();
clientOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, System.ClientModel.Primitives.PipelinePosition.PerCall);
clientOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, PipelinePosition.PerCall);
return new AIProjectClient(endpoint, credential, clientOptions);
}
@@ -1,7 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsReleased>true</IsReleased>
<!-- Preview while we depend on Azure.AI.Projects 2.1.0-beta.1 for hosted-agent routing
(ProjectOpenAIClientOptions.AgentName, the (AuthenticationPolicy, options) ctor, and
related per-agent endpoint surface). Flip back to IsReleased=true once Azure.AI.Projects
ships a stable 2.1.0. -->
<InjectSharedThrow>true</InjectSharedThrow>
<NoWarn>$(NoWarn);OPENAI001</NoWarn>
</PropertyGroup>
@@ -210,7 +210,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
string prompt = string.Join("\n", messages.Select(m => m.Text));
// Handle DataContent as attachments
(List<UserMessageDataAttachmentsItem>? attachments, tempDir) = await ProcessDataContentAttachmentsAsync(
(List<UserMessageAttachmentFile>? attachments, tempDir) = await ProcessDataContentAttachmentsAsync(
messages,
cancellationToken).ConfigureAwait(false);
@@ -443,11 +443,11 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
return new SessionConfig { Tools = mappedTools, SystemMessage = systemMessage };
}
private static async Task<(List<UserMessageDataAttachmentsItem>? Attachments, string? TempDir)> ProcessDataContentAttachmentsAsync(
private static async Task<(List<UserMessageAttachmentFile>? Attachments, string? TempDir)> ProcessDataContentAttachmentsAsync(
IEnumerable<ChatMessage> messages,
CancellationToken cancellationToken)
{
List<UserMessageDataAttachmentsItem>? attachments = null;
List<UserMessageAttachmentFile>? attachments = null;
string? tempDir = null;
foreach (ChatMessage message in messages)
{
@@ -461,7 +461,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
string tempFilePath = await dataContent.SaveToAsync(tempDir, cancellationToken).ConfigureAwait(false);
attachments ??= [];
attachments.Add(new UserMessageDataAttachmentsItemFile
attachments.Add(new UserMessageAttachmentFile
{
Path = tempFilePath,
DisplayName = Path.GetFileName(tempFilePath)
@@ -13,5 +13,5 @@ internal sealed class SequenceNumber
/// Gets the next sequence number.
/// </summary>
/// <returns>The next sequence number.</returns>
public int Increment() => this._sequenceNumber++;
public int Increment() => System.Threading.Interlocked.Increment(ref this._sequenceNumber) - 1;
}
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
using Microsoft.Agents.AI.Workflows.Specialized.Magentic;
namespace Microsoft.Agents.AI.Workflows;
@@ -10,6 +11,7 @@ namespace Microsoft.Agents.AI.Workflows;
[JsonDerivedType(typeof(ExecutorInvokedEvent))]
[JsonDerivedType(typeof(ExecutorCompletedEvent))]
[JsonDerivedType(typeof(ExecutorFailedEvent))]
[JsonDerivedType(typeof(MagenticOrchestratorEvent))]
public class ExecutorEvent(string executorId, object? data) : WorkflowEvent(data)
{
/// <summary>
@@ -54,6 +54,9 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
private bool _emitAgentResponseUpdateEvents;
private HandoffToolCallFilteringBehavior _toolCallFilteringBehavior = HandoffToolCallFilteringBehavior.HandoffOnly;
private bool _returnToPrevious;
private bool _autonomousMode;
private string? _autonomousModePrompt;
private int? _autonomousModeTurnLimit;
/// <summary>
/// Initializes a new instance of the <see cref="HandoffsWorkflowBuilder"/> class with no handoff relationships.
@@ -142,6 +145,34 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
return (TBuilder)this;
}
/// <summary>
/// Enables autonomous mode for all agents in the workflow.
/// </summary>
/// <remarks>
/// In autonomous mode, when an agent responds without requesting a handoff, it is immediately
/// re-invoked with a synthetic user message (the <paramref name="prompt"/>) rather than
/// returning control to the user. The agent continues iterating until it requests a handoff
/// or the <paramref name="turnLimit"/> is reached. After the turn limit is exceeded, control
/// is returned to the user as in the default human-in-the-loop behavior.
/// </remarks>
/// <param name="prompt">
/// The message to inject as a user turn when re-invoking an agent in autonomous mode.
/// If <see langword="null"/>, a default prompt is used.
/// </param>
/// <param name="turnLimit">
/// The maximum number of autonomous continuation turns per agent per incoming turn.
/// The counter resets at the beginning of each new turn (each incoming <see cref="HandoffState"/>).
/// If <see langword="null"/>, the default limit is used.
/// </param>
/// <returns>The updated builder instance.</returns>
public TBuilder EnableAutonomousMode(string? prompt = null, int? turnLimit = null)
{
this._autonomousMode = true;
this._autonomousModePrompt = prompt;
this._autonomousModeTurnLimit = turnLimit;
return (TBuilder)this;
}
/// <summary>
/// Adds handoff relationships from a source agent to one or more target agents.
/// </summary>
@@ -247,7 +278,10 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
HandoffAgentExecutorOptions options = new(this.HandoffInstructions,
this._emitAgentResponseEvents,
this._emitAgentResponseUpdateEvents,
this._toolCallFilteringBehavior);
this._toolCallFilteringBehavior,
autonomousMode: this._autonomousMode,
autonomousModePrompt: this._autonomousModePrompt,
autonomousModeTurnLimit: this._autonomousModeTurnLimit);
// There are two types of ids being used in this method, and it is critical that we are clear about
// which one we are using, and where.
@@ -0,0 +1,46 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Request for human review of a proposed plan.
/// </summary>
/// <param name="Plan">The proposed plan.</param>
/// <param name="CurrentProgress">The current progress ledger, if available. During the initial plan review,
/// this will be <see langword="null"/>. In subsequent reviews after replanning (due to stalls), this will
/// contain the latest progress ledger that determined that no progress has been made or the workflow was in
/// a loop.</param>
/// <param name="IsStalled">Whether the workflow is currently stalled.</param>
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
public record MagenticPlanReviewRequest(ChatMessage Plan, MagenticProgressLedger? CurrentProgress, bool IsStalled)
{
/// <summary>
/// Create an approving <see cref="MagenticPlanReviewResponse"/>.
/// </summary>
/// <returns></returns>
public MagenticPlanReviewResponse Approve() => new([]);
/// <summary>
/// Create a <see cref="MagenticPlanReviewResponse"/> with revisions.
/// </summary>
/// <returns></returns>
public MagenticPlanReviewResponse Revise(string message) => new([new(ChatRole.User, message)]);
/// <summary>
/// Create a <see cref="MagenticPlanReviewResponse"/> with revisions.
/// </summary>
/// <returns></returns>
public MagenticPlanReviewResponse Revise(ChatMessage message) => new([message]);
/// <summary>
/// Create a <see cref="MagenticPlanReviewResponse"/> with revisions.
/// </summary>
/// <returns></returns>
public MagenticPlanReviewResponse Revise(IEnumerable<ChatMessage> messages)
=> new(messages is List<ChatMessage> messageList ? messageList : messages.ToList());
}
@@ -0,0 +1,20 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Review feedback for a proposed plan, including any revisions if the plan is not approved as-is. An
/// empty list of review messages indicates approval of the proposed plan without any revisions.
/// </summary>
/// <param name="Review">
/// Review feedback for a generated plan. Empty if the plan is approved as-is and changes are requested.
/// </param>
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
public record MagenticPlanReviewResponse(List<ChatMessage> Review)
{
internal bool IsApproved => this.Review.Count == 0;
}
@@ -0,0 +1,270 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Maintains a ledger of progress made by the Magentic workflow.
/// </summary>
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
public class MagenticProgressLedger
{
internal static readonly BooleanProgressLedgerSlot IsRequestSatisfiedSlot = new("is_request_satisfied",
"Is the request fully satisfied? (True if complete, or False if the original request has yet to be SUCCESSFULLY and FULLY addressed)");
internal static readonly BooleanProgressLedgerSlot IsInLoopSlot = new("is_in_loop",
"Are we in a loop where we are repeating the same requests and or getting the same responses as before? " +
"Loops can span multiple turns, and can include repeated actions like scrolling up or down more than a handful of times.");
internal static readonly BooleanProgressLedgerSlot IsProgressBeingMadeSlot = new("is_progress_being_made",
"Are we making forward progress? (True if just starting, or recent messages are adding value. False if recent " +
"messages show evidence of being stuck in a loop or if there is evidence of significant barriers to success " +
"such as the inability to read from a required file)");
internal readonly StringProgressLedgerSlot NextSpeakerSlot;
internal static readonly StringProgressLedgerSlot InstructionOrQuestionSlot = new("instruction_or_question",
"What instruction or question would you give this team member? (Phrase as if speaking directly to them, and " +
"include any specific information they may need)");
internal MagenticProgressLedger(string teamNames, IEnumerable<ProgressLedgerSlot> additionalQuestions, JsonElement? state = null)
{
this.NextSpeakerSlot = new("next_speaker", $"Who should speak next? (select from: {teamNames})");
this.AdditionalQuestions = additionalQuestions as ProgressLedgerSlot[] ?? additionalQuestions.ToArray();
if (state != null)
{
this.TryUpdateState(state.Value);
}
}
internal ProgressLedgerSlot[] AdditionalQuestions { get; }
internal bool TryUpdateState(JsonElement element)
{
// In principle all of these should be inlineable, but the CodeAnalysis fails to properly chain through the and-chain to realize that
// all must be true for `requiredQuestionsAnswered` to be true, meaning all of the out parameters would be initialized properly.
bool isInLoop = false;
bool isProgressBeingMade = false;
string? nextSpeaker = string.Empty;
string? instructionOrQuestion = string.Empty;
bool requiredQuestionsAnswered =
IsRequestSatisfiedSlot.TryGetValueFrom(element, out bool isRequestSatisfied) &&
IsInLoopSlot.TryGetValueFrom(element, out isInLoop) &&
IsProgressBeingMadeSlot.TryGetValueFrom(element, out isProgressBeingMade) &&
this.NextSpeakerSlot.TryGetValueFrom(element, out nextSpeaker) &&
InstructionOrQuestionSlot.TryGetValueFrom(element, out instructionOrQuestion);
if (requiredQuestionsAnswered)
{
this.State = element;
this.IsRequestSatisfied = isRequestSatisfied;
this.IsInLoop = isInLoop;
this.IsProgressBeingMade = isProgressBeingMade;
this.NextSpeaker = nextSpeaker!;
this.InstructionOrQuestion = instructionOrQuestion!;
}
// TODO: To what extent do we want to enforce that the additional questions are also answered?
return requiredQuestionsAnswered;
}
[JsonInclude]
internal JsonElement? State;
/// <summary>
/// Specifies whether plan execution has started.
/// </summary>
[JsonIgnore]
public bool IsStarted => this.State != null;
/// <summary>
/// Specifies whether the task has been fully satisfied.
/// </summary>
[JsonIgnore]
public bool IsRequestSatisfied { get; private set; }
/// <summary>
/// Specifies whether the team is in a loop.
/// </summary>
[JsonIgnore]
public bool IsInLoop { get; private set; }
/// <summary>
/// Specifies whether the team is making progress on the task.
/// </summary>
[JsonIgnore]
public bool IsProgressBeingMade { get; private set; }
/// <summary>
/// Gets the next team member to take a turn.
/// </summary>
[JsonIgnore]
public string NextSpeaker { get; private set; } = string.Empty;
/// <summary>
/// Gets the instruction or question to send to the next team member.
/// </summary>
[JsonIgnore]
public string InstructionOrQuestion { get; private set; } = string.Empty;
[JsonIgnore]
internal IEnumerable<ProgressLedgerSlot> Slots =>
[
IsRequestSatisfiedSlot,
IsInLoopSlot,
IsProgressBeingMadeSlot,
this.NextSpeakerSlot,
InstructionOrQuestionSlot,
.. this.AdditionalQuestions
];
internal bool TryGetCurrentSlotValue<T>(ProgressLedgerSlot<T> slot, [NotNullWhen(true)] out T? value)
{
if (!this.State.HasValue)
{
value = default;
return false;
}
return slot.TryGetValueFrom(this.State.Value, out value);
}
private (string QuestionBlock, string AnswerSchema)? _questionFormatCache;
internal (string QuestionBlock, string AnswerSchema) FormatQuestions()
{
if (!this._questionFormatCache.HasValue)
{
StringBuilder questionBuilder = new(), schemaBuilder = new();
schemaBuilder.AppendLine("{");
foreach (ProgressLedgerSlot slot in this.Slots)
{
questionBuilder.AppendLine(slot.FormattedQuestion);
schemaBuilder.AppendLine($"\"{slot.Key}\": {{")
.AppendLine($" \"{ProgressLedgerSlot.ValueKey}\": {slot.SchemaType}{slot.SuffixString},")
.AppendLine($" \"{ProgressLedgerSlot.ReasonKey}\": string")
.AppendLine("}");
}
schemaBuilder.AppendLine("}");
this._questionFormatCache = (questionBuilder.ToString(), schemaBuilder.ToString());
}
return this._questionFormatCache.Value;
}
}
internal abstract record ProgressLedgerSlot(string Key, string Question, string? SchemaTypeSuffix = null)
{
public const string ValueKey = "answer";
public const string ReasonKey = "reason";
internal string SuffixString => this.SchemaTypeSuffix == null ? string.Empty : $"({this.SchemaTypeSuffix})";
protected internal abstract string SchemaType { get; }
public string FormattedQuestion
{
get
{
if (field == null)
{
IEnumerable<string> questionLines = this.Question.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)
.Select(line => line.TrimEnd());
field = $" - {string.Join("\n ", questionLines)}";
}
return field;
}
}
}
internal abstract record ProgressLedgerSlot<T>(string Key, string Question, string? SchemaTypeSuffix = null, JsonSerializerOptions? SerializerOptions = null)
: ProgressLedgerSlot(Key, Question, SchemaTypeSuffix)
{
protected internal virtual JsonTypeInfo<T> GetJsonTypeInfo() =>
((this.SerializerOptions ?? WorkflowsJsonUtilities.DefaultOptions).TryGetTypeInfo(typeof(T), out JsonTypeInfo? typeInfo)
? typeInfo as JsonTypeInfo<T> : null)
?? throw new InvalidOperationException($"Cannot get TypeInfo for {typeof(T)} from {(this.SerializerOptions == null ? "provided" : "default")} SerializationOptions.");
public bool TryGetValueFrom(JsonElement answers, [NotNullWhen(true)] out T? value)
{
if (answers.TryGetProperty(this.Key, out JsonElement slotElement) &&
slotElement.ValueKind != JsonValueKind.Null &&
slotElement.TryGetProperty(ValueKey, out JsonElement answerValue))
{
try
{
T? result = answerValue.Deserialize(this.GetJsonTypeInfo());
if (result != null)
{
value = result;
return true;
}
}
catch
{
}
}
value = default;
return false;
}
public bool TryGetReasonFrom(JsonElement answers, [NotNullWhen(true)] out string? value)
{
if (answers.TryGetProperty(this.Key, out JsonElement slotElement) &&
slotElement.ValueKind != JsonValueKind.Null &&
slotElement.TryGetProperty(ReasonKey, out JsonElement reasonValue))
{
try
{
string? result = reasonValue.Deserialize(WorkflowsJsonUtilities.JsonContext.Default.String);
if (result != null)
{
value = result;
return true;
}
}
catch
{
}
}
value = default;
return false;
}
}
internal sealed record BooleanProgressLedgerSlot(string Key, string Question, string? SchemaTypeSuffix = null) : ProgressLedgerSlot<bool>(Key, Question, SchemaTypeSuffix)
{
// Since we know the type statically, we can directly return the JsonTypeInfo for string from our JsonContext,
// which is more efficient than looking it up via the options.
protected internal override JsonTypeInfo<bool> GetJsonTypeInfo() => WorkflowsJsonUtilities.JsonContext.Default.Boolean;
protected internal override string SchemaType => "boolean";
}
internal sealed record StringProgressLedgerSlot(string Key, string Question, string? SchemaTypeSuffix = null) : ProgressLedgerSlot<string>(Key, Question, SchemaTypeSuffix)
{
// Since we know the type statically, we can directly return the JsonTypeInfo for string from our JsonContext,
// which is more efficient than looking it up via the options.
protected internal override JsonTypeInfo<string> GetJsonTypeInfo() => WorkflowsJsonUtilities.JsonContext.Default.String;
protected internal override string SchemaType => "string";
}
@@ -0,0 +1,160 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Specialized.Magentic;
using ExecutorFactoryFunc = System.Func<Microsoft.Agents.AI.Workflows.ExecutorConfig<Microsoft.Agents.AI.Workflows.ExecutorOptions>,
string,
System.Threading.Tasks.ValueTask<Microsoft.Agents.AI.Workflows.Specialized.Magentic.MagenticOrchestrator>>;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Fluent builder for creating Magentic One multi-agent orchestration workflows.
///
/// Magentic One workflows use an LLM-powered manager to coordinate multiple agents through dynamic task planning, progress tracking,
/// and adaptive replanning.The manager creates plans, selects agents, monitors progress, and determines when to replan or complete.
///
/// The builder provides a fluent API for configuring participants, the manager, optional plan review, checkpointing, and event
/// callbacks.
///
/// Human-in-the-loop Support: Magentic provides specialized HITL mechanisms via:
/// - `RequirePlanSignoff` - Review and approve/revise plans before execution
/// - Tool approval via `function_approval_request`: Approve individual tool calls on participating agents. Note that tool calls are
/// not supported on the ManagerAgent.
/// </summary>
/// <param name="managerAgent"></param>
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
public class MagenticWorkflowBuilder(AIAgent managerAgent)
{
private readonly List<AIAgent> _team = new();
private string? _name;
private string? _description;
private int _maxStalls = TaskLimits.DefaultMaxStallCount;
private int? _maxRounds;
private int? _maxResets;
private bool _requirePlanSignoff = true;
/// <inheritdoc cref="GroupChatWorkflowBuilder.AddParticipants(IEnumerable{AIAgent})"/>
public MagenticWorkflowBuilder AddParticipants(params IEnumerable<AIAgent> agents)
{
this._team.AddRange(agents);
return this;
}
/// <inheritdoc cref="WorkflowBuilder.WithName(string)"/>
public MagenticWorkflowBuilder WithName(string name)
{
this._name = name;
return this;
}
/// <inheritdoc cref="WorkflowBuilder.WithDescription(string)"/>
public MagenticWorkflowBuilder WithDescription(string description)
{
this._description = description;
return this;
}
/// <summary>
/// Set the maximum number of coordination rounds. <see langword="null"/> means unlimited.
/// </summary>
/// <returns></returns>
public MagenticWorkflowBuilder WithMaxRounds(int? maxRounds = null)
{
this._maxRounds = maxRounds;
return this;
}
/// <summary>
/// Set the maximum number ofnumber of resets allowed. <see langword="null"/> means unlimited.
/// </summary>
/// <returns></returns>
public MagenticWorkflowBuilder WithMaxResets(int? maxResets = null)
{
this._maxResets = maxResets;
return this;
}
/// <summary>
/// Set the maximum number of consecutive rounds without progress before replan (default 3).
/// </summary>
/// <returns></returns>
public MagenticWorkflowBuilder WithMaxStalls(int maxStalls = TaskLimits.DefaultMaxStallCount)
{
this._maxStalls = maxStalls;
return this;
}
/// <summary>
/// If <see langword="true"/>, requires human approval of the initial plan or any updates before proceeding. True by default.
/// </summary>
/// <param name="requirePlanSignoff"></param>
/// <returns></returns>
public MagenticWorkflowBuilder RequirePlanSignoff(bool requirePlanSignoff = true)
{
this._requirePlanSignoff = requirePlanSignoff;
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);
WorkflowBuilder result = new(orchestrator);
AIAgentHostOptions options = new()
{
ReassignOtherAgentsAsUsers = true,
ForwardIncomingMessages = false
};
List<ExecutorBinding> teamBindings = [];
foreach (AIAgent agent in team)
{
ExecutorBinding binding = agent.BindAsExecutor(options);
teamBindings.Add(binding);
result.AddEdge(binding, orchestrator);
}
result.AddFanOutEdge(orchestrator, teamBindings)
.WithOutputFrom(orchestrator);
if (!string.IsNullOrWhiteSpace(this._name))
{
result.WithName(this._name);
}
if (!string.IsNullOrWhiteSpace(this._description))
{
result.WithDescription(this._description);
}
return result;
}
/// <inheritdoc cref="WorkflowBuilder.Build"/>
public Workflow Build() => this.ReduceToWorkflowBuilder().Build();
private TaskLimits Limits => new(
MaxRoundCount: this._maxRounds,
MaxResetCount: this._maxResets,
MaxStallCount: this._maxStalls);
private static ExecutorBinding CreateOrchestratorBinding(AIAgent managerAgent, List<AIAgent> team, TaskLimits limits, bool requirePlanSignoff)
{
ExecutorFactoryFunc factory = CreateOrchestratorAsync;
return factory.BindExecutor(nameof(MagenticOrchestrator));
ValueTask<MagenticOrchestrator> CreateOrchestratorAsync(ExecutorConfig<ExecutorOptions> options, string sessionId)
{
return new(new MagenticOrchestrator(managerAgent, team, limits, requirePlanSignoff));
}
}
}
@@ -2,7 +2,7 @@
<PropertyGroup>
<IsReleased>true</IsReleased>
<NoWarn>$(NoWarn);MEAI001</NoWarn>
<NoWarn>$(NoWarn);MEAI001;MAAIW001</NoWarn>
</PropertyGroup>
<PropertyGroup>
@@ -0,0 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Notifies an AIAgent-hosting executor that it should reset its conversation state, and start a new session, if appropriate.
/// Note that for Agent Orchestrations, only Magentic makes use of this functionality.
/// </summary>
public sealed record ResetChatSignal();
@@ -24,7 +24,7 @@ internal static class TurnExtensions
=> handoffState.TurnToken.ShouldEmitStreamingEvents(agentSetting);
}
internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
internal class AIAgentHostExecutor : ChatProtocolExecutor
{
private readonly AIAgent _agent;
private readonly AIAgentHostOptions _options;
@@ -40,7 +40,9 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
StringMessageChatRole = ChatRole.User
};
public AIAgentHostExecutor(AIAgent agent, AIAgentHostOptions options) : base(id: agent.GetDescriptiveId(),
public static string IdFor(AIAgent agent) => agent.GetDescriptiveId();
public AIAgentHostExecutor(AIAgent agent, AIAgentHostOptions options) : base(id: IdFor(agent),
s_defaultChatProtocolOptions,
declareCrossRunShareable: false) // Explicitly false, because we maintain turn state on the instance
{
@@ -67,7 +69,14 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
return this.ConfigureUserInputHandling(base.ConfigureProtocol(protocolBuilder));
return this.ConfigureUserInputHandling(base.ConfigureProtocol(protocolBuilder))
.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler<ResetChatSignal>(this.ResetChat));
}
internal void ResetChat(ResetChatSignal signal, IWorkflowContext context)
{
this._session = null;
this._currentTurnEmitEvents = null;
}
private ValueTask HandleUserInputResponseAsync(
@@ -15,12 +15,22 @@ namespace Microsoft.Agents.AI.Workflows.Specialized;
internal sealed class HandoffAgentExecutorOptions
{
public HandoffAgentExecutorOptions(string? handoffInstructions, bool emitAgentResponseEvents, bool? emitAgentResponseUpdateEvents, HandoffToolCallFilteringBehavior toolCallFilteringBehavior)
public HandoffAgentExecutorOptions(
string? handoffInstructions,
bool emitAgentResponseEvents,
bool? emitAgentResponseUpdateEvents,
HandoffToolCallFilteringBehavior toolCallFilteringBehavior,
bool autonomousMode = false,
string? autonomousModePrompt = null,
int? autonomousModeTurnLimit = null)
{
this.HandoffInstructions = handoffInstructions;
this.EmitAgentResponseEvents = emitAgentResponseEvents;
this.EmitAgentResponseUpdateEvents = emitAgentResponseUpdateEvents;
this.ToolCallFilteringBehavior = toolCallFilteringBehavior;
this.AutonomousMode = autonomousMode;
this.AutonomousModePrompt = autonomousModePrompt ?? HandoffAgentExecutor.DefaultAutonomousModePrompt;
this.AutonomousModeTurnLimit = autonomousModeTurnLimit ?? HandoffAgentExecutor.DefaultAutonomousModeTurnLimit;
}
public string? HandoffInstructions { get; set; }
@@ -30,6 +40,23 @@ internal sealed class HandoffAgentExecutorOptions
public bool? EmitAgentResponseUpdateEvents { get; set; }
public HandoffToolCallFilteringBehavior ToolCallFilteringBehavior { get; set; } = HandoffToolCallFilteringBehavior.HandoffOnly;
/// <summary>
/// Gets or sets a value indicating whether the agent operates in autonomous mode.
/// In autonomous mode, the agent continues responding without user input until a handoff is requested or the turn limit is reached.
/// </summary>
public bool AutonomousMode { get; set; }
/// <summary>
/// Gets or sets the prompt to inject as a user message when continuing in autonomous mode.
/// </summary>
public string AutonomousModePrompt { get; set; }
/// <summary>
/// Gets or sets the maximum number of autonomous turns per incoming turn.
/// The counter is reset at the start of every new <see cref="HandoffState"/> turn.
/// </summary>
public int AutonomousModeTurnLimit { get; set; }
}
internal struct AgentInvocationResult(AgentResponse agentResponse, string? handoffTargetId)
@@ -74,6 +101,12 @@ internal sealed record StateRef<TState>(string Key, string? ScopeName)
internal sealed class HandoffAgentExecutor :
StatefulExecutor<HandoffAgentHostState, HandoffState>
{
/// <summary>The default prompt injected as a user message when operating in autonomous mode and no handoff has been requested.</summary>
internal const string DefaultAutonomousModePrompt = "User did not respond. Continue assisting autonomously.";
/// <summary>The default maximum number of autonomous turns before control is returned to the user.</summary>
internal const int DefaultAutonomousModeTurnLimit = 50;
private static readonly JsonElement s_handoffSchema = AIFunctionFactory.Create(
([Description("The reason for the handoff")] string? reasonForHandoff) => { }).JsonSchema;
@@ -87,6 +120,8 @@ internal sealed class HandoffAgentExecutor :
private readonly HashSet<string> _handoffFunctionNames = [];
private readonly Dictionary<string, string> _handoffFunctionToAgentId = [];
private int _autonomousModeTurnCount;
private readonly StateRef<HandoffSharedState> _sharedStateRef = new(HandoffConstants.HandoffSharedStateKey,
HandoffConstants.HandoffSharedStateScope);
@@ -277,6 +312,38 @@ internal sealed class HandoffAgentExecutor :
// happens if we have no outstanding requests.
if (!this.HasOutstandingRequests)
{
// In autonomous mode, if no handoff was requested and we haven't hit the turn limit, continue the agent's
// turn by injecting a synthetic user message instead of returning control to the user.
if (this._options.AutonomousMode && !result.IsHandoffRequested && this._autonomousModeTurnCount < this._options.AutonomousModeTurnLimit)
{
ChatMessage autonomousMessage = new(ChatRole.User, this._options.AutonomousModePrompt)
{
CreatedAt = DateTimeOffset.UtcNow,
MessageId = Guid.NewGuid().ToString("N"),
};
int autonomousBookmark = newConversationBookmark;
await this._sharedStateRef.InvokeWithStateAsync(
(sharedState, ctx, ct) =>
{
autonomousBookmark = sharedState!.Conversation.AddMessage(autonomousMessage);
return new ValueTask();
},
context,
cancellationToken).ConfigureAwait(false);
// Increment only after successfully adding the autonomous message to shared state.
// This ensures the counter remains accurate if the state write throws an exception.
this._autonomousModeTurnCount++;
return await this.ContinueTurnAsync(
state with { ConversationBookmark = autonomousBookmark },
[autonomousMessage],
context,
cancellationToken,
skipAddIncoming: true).ConfigureAwait(false);
}
HandoffState outgoingState = new(state.IncomingState.TurnToken, result.HandoffTargetId, this._agent.Id);
await context.SendMessageAsync(outgoingState, cancellationToken).ConfigureAwait(false);
@@ -321,6 +388,11 @@ internal sealed class HandoffAgentExecutor :
state = state with { IncomingState = message, ConversationBookmark = newConversationBookmark };
// Reset the autonomous turn counter at the start of each new HandoffState turn so that
// the limit is applied fresh for every incoming message, regardless of how the previous
// turn ended (e.g. outstanding external requests that prevented an earlier reset).
this._autonomousModeTurnCount = 0;
return await this.ContinueTurnAsync(state, newConversationMessages.ToList(), context, cancellationToken, skipAddIncoming: true)
.ConfigureAwait(false);
}
@@ -0,0 +1,175 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized.Magentic;
internal static partial class ChatMessageExtensions
{
private static void ProcessAIContents(StringBuilder resultBuilder, IEnumerable<AIContent> contents, StreamingToolCallResultPairMatcher? pairMatcher = null)
{
pairMatcher ??= new();
foreach (AIContent content in contents)
{
switch (content)
{
case TextContent textContent:
resultBuilder.AppendLine(textContent.Text);
break;
//case DataContent dataContent:
// // We really do not know how to deal with anything other than image data with descriptions, which is not
// // a well-defined concept in MEAI (as contrasted with AutoGen's ImageContent type)
// break;
case ErrorContent errorContent:
resultBuilder.AppendLine($"[ERROR{(errorContent.ErrorCode != null ? $"(Code={errorContent.ErrorCode})" : string.Empty)}]");
resultBuilder.AppendLine(errorContent.Message);
if (errorContent.Details != null)
{
resultBuilder.Append("Details:").AppendLine(errorContent.Details);
}
break;
case FunctionCallContent functionCallContent:
pairMatcher.CollectFunctionCall(functionCallContent);
break;
case FunctionResultContent functionResultContent:
pairMatcher.TryResolveFunctionCall(functionResultContent, out string? functionName);
string result = functionResultContent.Result?.ToString() ?? string.Empty;
resultBuilder.AppendLine($"[Tool Call '{functionName ?? functionResultContent.CallId}' Result]")
.AppendLine(result);
break;
case McpServerToolCallContent mstContent:
pairMatcher.CollectMcpServerToolCall(mstContent);
break;
case McpServerToolResultContent mstResultContent:
if (mstResultContent.Outputs?.Any() is true)
{
pairMatcher.TryResolveMcpServerToolCall(mstResultContent, out string? mcpServerToolName);
resultBuilder.AppendLine($"[Start MCP Server Tool Call '{mcpServerToolName ?? mstResultContent.CallId}' Results]");
ProcessAIContents(resultBuilder, mstResultContent.Outputs!);
resultBuilder.AppendLine($"[End MCP Server Tool Call '{mcpServerToolName ?? mstResultContent.CallId}']");
}
break;
case TextReasoningContent reasoningContent:
if (!string.IsNullOrWhiteSpace(reasoningContent.Text))
{
resultBuilder.Append("[Reasoning] ")
.AppendLine(reasoningContent.Text);
}
break;
case UriContent uriContent:
resultBuilder.AppendLine(uriContent.Uri.ToString());
break;
}
}
}
public static string GetText(this List<ChatMessage> messages)
{
if (messages.Count == 0)
{
return string.Empty;
}
StringBuilder builder = new();
StreamingToolCallResultPairMatcher pairMatcher = new();
foreach (ChatMessage message in messages)
{
ProcessAIContents(builder, message.Contents, pairMatcher);
}
return builder.ToString();
}
private const string FencedJsonRegexPattern = @"```(?<lang>[a-z]+)?\s*(?<json>\{[\s\S]*?\})\s*```";
#if NET
[GeneratedRegex(FencedJsonRegexPattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture)]
public static partial Regex FencedJsonRegex();
#else
public static Regex FencedJsonRegex() => s_fencedJsonRegex;
private static readonly Regex s_fencedJsonRegex =
new(FencedJsonRegexPattern, RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture);
#endif
internal static JsonElement ExtractJson(string messageText)
{
Match match = FencedJsonRegex().Match(messageText);
if (match.Success)
{
return JsonElement.Parse(match.Groups["json"].Value);
}
int start = messageText.IndexOf('{'), scanHead = start;
int? end = null;
if (scanHead < 0)
{
throw new InvalidOperationException("No JSON object found.");
}
int depth = 0;
bool inQuotes = false, inEscape = false;
for (; scanHead < messageText.Length && end is null; scanHead++)
{
if (inEscape)
{
inEscape = false;
continue;
}
switch (messageText[scanHead])
{
case '{' when !inQuotes:
depth++;
break;
case '}' when !inQuotes:
depth--;
if (depth == 0)
{
end = scanHead;
}
break;
case '\"':
// We already handled inEscape, so we can always flip inQuotes here
inQuotes = !inQuotes;
break;
case '\\':
Debug.Assert(!inEscape);
inEscape = true;
break;
}
}
if (end is null)
{
throw new InvalidOperationException("Unbalanced JSON braces.");
}
return JsonElement.Parse(messageText.Substring(start, end.Value - start + 1));
}
public static JsonElement ExtractJson(this ChatMessage message) => ExtractJson(message.Text);
}
@@ -0,0 +1,72 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized.Magentic;
internal sealed class ExecutorAgentHarness(AIAgent agent, AIAgentUnservicedRequestsCollector collector)
{
internal const string AgentSessionKey = nameof(AgentSession);
private AgentSession? _session;
private async ValueTask<AgentSession> EnsureSessionAsync(IWorkflowContext context, CancellationToken cancellationToken) =>
this._session ??= await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
public async ValueTask<AgentResponse> InvokeAgentAsync(IEnumerable<ChatMessage> messages, IWorkflowContext context, bool emitUpdateEvents, CancellationToken cancellationToken = default)
{
AgentResponse response;
if (emitUpdateEvents)
{
// Run the agent in streaming mode only when agent run update events are to be emitted.
IAsyncEnumerable<AgentResponseUpdate> agentStream = agent.RunStreamingAsync(
messages,
await this.EnsureSessionAsync(context, cancellationToken).ConfigureAwait(false),
cancellationToken: cancellationToken);
List<AgentResponseUpdate> updates = [];
await foreach (AgentResponseUpdate update in agentStream.ConfigureAwait(false))
{
await context.YieldOutputAsync(update, cancellationToken).ConfigureAwait(false);
collector.ProcessAgentResponseUpdate(update);
updates.Add(update);
}
response = updates.ToAgentResponse();
}
else
{
// Otherwise, run the agent in non-streaming mode.
response = await agent.RunAsync(messages,
await this.EnsureSessionAsync(context, cancellationToken).ConfigureAwait(false),
cancellationToken: cancellationToken)
.ConfigureAwait(false);
collector.ProcessAgentResponse(response);
}
return response;
}
public async ValueTask<JsonElement?> SerializeSessionAsync(CancellationToken cancellationToken)
=> this._session == null
? null
: await agent.SerializeSessionAsync(this._session, cancellationToken: cancellationToken).ConfigureAwait(false);
public async ValueTask DeserializeSessionAsync(JsonElement? serializedSession, CancellationToken cancellationToken)
{
this._session = serializedSession == null
? null
: await agent.DeserializeSessionAsync(serializedSession.Value, cancellationToken: cancellationToken)
.ConfigureAwait(false);
}
public void ResetSession()
{
this._session = null;
}
}
@@ -0,0 +1,8 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Workflows.Specialized.Magentic;
internal static class MagenticConstants
{
public const string MagenticTaskContextKey = nameof(MagenticTaskContextKey);
}
@@ -0,0 +1,122 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Runtime.ExceptionServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized.Magentic;
internal class MagenticManager(AIAgent managerAgent)
{
private static async ValueTask<ChatMessage> CheckResponseAsync(Task<AgentResponse> responseTask, IWorkflowContext context, CancellationToken cancellationToken)
{
AgentResponse response = await responseTask.ConfigureAwait(false);
if (response.Messages.Count == 0)
{
throw new InvalidOperationException("Planner Agent did not return any messages.");
}
if (response.Messages.Count > 1)
{
await context.AddEventAsync(new WorkflowWarningEvent("Planner Agent returned multiple messages; using the last one."), cancellationToken)
.ConfigureAwait(false);
}
return response.Messages[response.Messages.Count - 1];
}
private ValueTask<ChatMessage> InvokeAgentAsync(IEnumerable<ChatMessage> messages, IWorkflowContext context, CancellationToken cancellationToken, AgentSession? session = null)
=> CheckResponseAsync(managerAgent.RunAsync(messages, session, cancellationToken: cancellationToken), context, cancellationToken);
public async ValueTask<TaskLedger> UpdatePlanAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
{
// If we already have a TaskLedger, we need to update the facts based on the existing factset; otherwise, we use the initial facts construction
bool isReplan = taskContext.TaskLedger != null;
AgentSession localSession = await managerAgent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
ChatMessage factsRequest = new(ChatRole.User, isReplan ? taskContext.ToTaskLedgerFactsUpdatePrompt() : taskContext.ToTaskLedgerFactsPrompt());
ChatMessage updatedFacts = await this.InvokeAgentAsync(
messages: [.. taskContext.ChatHistory, factsRequest],
context,
cancellationToken,
localSession)
.ConfigureAwait(false);
ChatMessage planRequest = new(ChatRole.User, isReplan ? taskContext.ToTaskLedgerPlanUpdatePrompt() : taskContext.ToTaskLedgerPlanPrompt());
ChatMessage updatedPlan = await this.InvokeAgentAsync(
// We rely on the AgentSession to maintain the context of the conversation, so we don't include the
// history, facts request, or updated facts in the messages list.
messages: [planRequest],
context,
cancellationToken,
localSession)
.ConfigureAwait(false);
taskContext.ChatHistory.AddRange([factsRequest, updatedFacts, planRequest, updatedPlan]);
return new(updatedFacts, updatedPlan);
}
public async ValueTask UpdateProgressLedgerAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
{
ChatMessage progressRequest = new(ChatRole.User, taskContext.ToProgressLedgerPrompt());
ExceptionDispatchInfo? lastException = null;
int maxRetryCount = taskContext.TaskLimits.MaxProgressLedgerRetryCount;
for (int attempts = 0; attempts < maxRetryCount; attempts++)
{
ChatMessage progressUpdateMessage = await this.InvokeAgentAsync(
messages: [.. taskContext.ChatHistory, progressRequest],
context,
cancellationToken)
.ConfigureAwait(false);
try
{
lastException = null;
JsonElement stateUpdateJson = progressUpdateMessage.ExtractJson();
if (!taskContext.ProgressLedger.TryUpdateState(stateUpdateJson))
{
throw new InvalidOperationException("Could not answer progress ledger questions with provided JSON.");
}
break;
}
catch (Exception e)
{
lastException = ExceptionDispatchInfo.Capture(e);
string warnString = $"Progress ledger JSON parse failed (attempt {attempts}/{maxRetryCount}): {e}";
await context.AddEventAsync(new WorkflowWarningEvent(warnString), cancellationToken).ConfigureAwait(false);
if (attempts < maxRetryCount)
{
await Task.Delay(250 * attempts, cancellationToken).ConfigureAwait(false);
}
}
}
lastException?.Throw();
}
public async ValueTask<ChatMessage> PrepareFinalAnswerAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
{
ChatMessage finalAnswerRequest = new(ChatRole.User, taskContext.ToFinalAnswerPrompt());
ChatMessage finalAnswer = await this.InvokeAgentAsync([.. taskContext.ChatHistory, finalAnswerRequest], context, cancellationToken)
.ConfigureAwait(false);
return new(ChatRole.Assistant, finalAnswer.Text)
{
AuthorName = finalAnswer.AuthorName ?? nameof(MagenticManager),
MessageId = finalAnswer.MessageId ?? Guid.NewGuid().ToString("N"),
CreatedAt = finalAnswer.CreatedAt ?? DateTimeOffset.UtcNow,
RawRepresentation = finalAnswer.RawRepresentation,
};
}
}
@@ -0,0 +1,331 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized.Magentic;
/// <summary>
/// Base type for Magentic Orchestration Events
/// </summary>
/// <param name="data"></param>
[JsonDerivedType(typeof(MagenticPlanCreatedEvent))]
[JsonDerivedType(typeof(MagenticReplannedEvent))]
[JsonDerivedType(typeof(MagenticProgressLedgerUpdatedEvent))]
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
public abstract class MagenticOrchestratorEvent(object? data) : WorkflowEvent(data)
{
}
/// <summary>
/// Represents the creation of the initial plan
/// </summary>
/// <param name="fullTaskLeger"></param>
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
public sealed class MagenticPlanCreatedEvent(ChatMessage fullTaskLeger) : MagenticOrchestratorEvent(fullTaskLeger)
{
/// <summary>
/// A <see cref="ChatMessage"/> containing the initial plan.
/// </summary>
public ChatMessage FullTaskLedger { get; } = fullTaskLeger;
}
/// <summary>
/// Represents the creation of a new plan in response to a stall.
/// </summary>
/// <param name="fullTaskLeger"></param>
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
public sealed class MagenticReplannedEvent(ChatMessage fullTaskLeger) : MagenticOrchestratorEvent(fullTaskLeger)
{
/// <summary>
/// A <see cref="ChatMessage"/> containing the new plan.
/// </summary>
public ChatMessage FullTaskLedger { get; } = fullTaskLeger;
}
/// <summary>
/// Represents an update to the <see cref="MagenticProgressLedger"/> when running a coordination round.
/// </summary>
/// <param name="progressLedger"></param>
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
public sealed class MagenticProgressLedgerUpdatedEvent(MagenticProgressLedger progressLedger) : MagenticOrchestratorEvent(progressLedger)
{
/// <summary>
/// The new state of the <see cref="MagenticProgressLedger"/>
/// </summary>
public MagenticProgressLedger ProgressLedger { get; } = progressLedger;
}
/// <summary>
/// Magentic orchestrator that defines the workflow structure.
///
/// This orchestrator manages the overall Magentic workflow in the following structure:
///
/// 1. Upon receiving the task(a list of messages), it creates the plan using the manager then runs the inner loop.
/// 2. The inner loop is distributed and implementation is decentralized. In the orchestrator, it is responsible for:
/// - Creating the progress ledger using the manager.
/// - Checking for task completion.
/// - Detecting stalling or looping and triggering replanning if needed.
/// - Sending requests to participants based on the progress ledger's next speaker.
/// - Issue requests for human intervention if enabled and needed.
/// 3. The inner loop waits for responses from the selected participant, then continues the loop.
/// 4. The orchestrator breaks out of the inner loop when the replanning or final answer conditions are met.
/// 5. The outer loop handles replanning and reenters the inner loop.
/// </summary>
/// <param name="managerAgent"></param>
/// <param name="team"></param>
/// <param name="limits"></param>
/// <param name="requirePlanSignoff"></param>
internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, TaskLimits limits, bool requirePlanSignoff)
: ChatProtocolExecutor(nameof(MagenticOrchestrator), s_options, declareCrossRunShareable: false)
{
private readonly MagenticManager _manager = new(managerAgent);
private static readonly ChatProtocolExecutorOptions s_options = new()
{
StringMessageChatRole = ChatRole.User,
AutoSendTurnToken = false
};
private MagenticTaskContext? _taskContext;
private PortBinding? _planReviewPort;
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
return base.ConfigureProtocol(protocolBuilder).ConfigureRoutes(ConfigureRoutes);
void ConfigureRoutes(RouteBuilder routeBuilder) => routeBuilder.AddPortHandler<MagenticPlanReviewRequest, MagenticPlanReviewResponse>(
"RequestPlanReview",
this.ProcessPlanReviewAsync,
out this._planReviewPort);
}
private ValueTask SubmitPlanReviewRequestAsync(MagenticTaskContext taskContext, IWorkflowContext workflowContext)
{
MagenticProgressLedger? progressLedger = taskContext.ProgressLedger;
if (progressLedger?.IsStarted is not true)
{
progressLedger = null;
}
MagenticPlanReviewRequest request = new(taskContext.TaskLedger!.CurrentPlan, progressLedger, taskContext.IsStalled);
return this._planReviewPort!.PostRequestAsync(request);
}
private async ValueTask ProcessPlanReviewAsync(MagenticPlanReviewResponse response, IWorkflowContext context, CancellationToken cancellationToken)
{
/*
Handle the human response to the plan review request.
Logic:
There are code paths which will trigger a plan review request to the human:
- Initial plan creation if `require_plan_signoff` is True.
- Potentially during the inner loop if stalling is detected (resetting and replanning).
The human can either approve the plan or request revisions with comments.
- If approved, proceed to run the outer loop, which simply adds the task ledger
to the conversation and enters the inner loop.
- If revision requested, append the review comments to the chat history,
trigger replanning via the manager, emit a REPLANNED event, then run the outer loop.
*/
if (this._taskContext == null || this._taskContext.TaskLedger == null)
{
throw new InvalidOperationException("Magentic Orchestration was not initialized correctly.");
}
if (this._taskContext.IsTerminated)
{
throw new InvalidOperationException("Magentic Orchestration has already been terminated and cannot process new messages. Please start a new session.");
}
if (response.IsApproved)
{
await this.DelegateToTeamAsync(this._taskContext, context, cancellationToken).ConfigureAwait(false);
}
else
{
this._taskContext.ChatHistory.AddRange(response.Review);
await this.UpdatePlanAndDelegateAsync(this._taskContext, context, cancellationToken).ConfigureAwait(false);
}
}
private async ValueTask UpdatePlanAndDelegateAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
{
bool isReplan = taskContext.TaskLedger != null;
taskContext.TaskLedger = await this._manager.UpdatePlanAsync(taskContext, context, cancellationToken)
.ConfigureAwait(false);
this._fullTaskLedgerMessage = new(ChatRole.User, taskContext.ToTaskLedgerFullPrompt());
taskContext.ChatHistory.Add(this._fullTaskLedgerMessage);
await context.AddEventAsync(isReplan
? new MagenticReplannedEvent(this._fullTaskLedgerMessage)
: new MagenticPlanCreatedEvent(this._fullTaskLedgerMessage), cancellationToken).ConfigureAwait(false);
if (requirePlanSignoff)
{
await this.SubmitPlanReviewRequestAsync(taskContext, context).ConfigureAwait(false);
}
else
{
await this.DelegateToTeamAsync(taskContext, context, cancellationToken).ConfigureAwait(false);
}
}
protected override async ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
{
// First Turn: Initialize the task context and send the initial messages to the planner agent
this._taskContext ??= new(messages, team, limits, emitEvents, []);
await this.UpdatePlanAndDelegateAsync(this._taskContext, context, cancellationToken).ConfigureAwait(false);
}
private ChatMessage? _fullTaskLedgerMessage;
private ValueTask DelegateToTeamAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
{
return this.RunCoordinationRoundAsync(taskContext, context, cancellationToken);
}
private async ValueTask RunCoordinationRoundAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
{
(bool hitRoundLimit, bool hitResetLimit) = taskContext.CheckLimits();
if (hitRoundLimit || hitResetLimit)
{
string limitType = hitRoundLimit ? "round" : "reset";
List<ChatMessage> messages = [new(ChatRole.Assistant, $"Task execution stopped due to hitting the maximum {limitType} count limit.")];
await context.YieldOutputAsync(messages, cancellationToken).ConfigureAwait(false);
taskContext.IsTerminated = true;
return;
}
taskContext.TaskCounters.RoundCount++;
// Update the Progress Ledger
try
{
await this._manager.UpdateProgressLedgerAsync(taskContext, context, cancellationToken).ConfigureAwait(false);
await context.AddEventAsync(new MagenticProgressLedgerUpdatedEvent(taskContext.ProgressLedger), cancellationToken)
.ConfigureAwait(false);
}
// Retry on exception to max retry count, unless it is OperationCancelledException - in that case exit the loop right away
catch (Exception ex) when (ex is not OperationCanceledException)
{
await context.AddEventAsync(new WorkflowWarningEvent($"Magentic Orchestrator: Progress ledger creation failed, triggering reset: {ex}"), cancellationToken)
.ConfigureAwait(false);
await this.ResetAndReplanAsync(taskContext, context, cancellationToken).ConfigureAwait(false);
return;
}
// Check and handle finish condition
if (taskContext.ProgressLedger.IsRequestSatisfied)
{
await this.PrepareFinalAnswerAsync(taskContext, context, cancellationToken).ConfigureAwait(false);
return;
}
// Check and handle stalls
if (taskContext.ProgressLedger.IsInLoop || !taskContext.ProgressLedger.IsProgressBeingMade)
{
taskContext.TaskCounters.StallCount++;
}
else
{
taskContext.TaskCounters.StallCount = Math.Max(0, taskContext.TaskCounters.StallCount - 1);
}
if (taskContext.IsStalled)
{
await this.ResetAndReplanAsync(taskContext, context, cancellationToken).ConfigureAwait(false);
return;
}
// Prepare to delegate to the next speaker
string nextSpeaker = taskContext.ProgressLedger.NextSpeaker;
if (string.IsNullOrEmpty(nextSpeaker))
{
await context.AddEventAsync(new WorkflowWarningEvent("Next speaker answer empty; selecting first participant as fallback"), cancellationToken)
.ConfigureAwait(false);
nextSpeaker = team.First().Name!;
}
AIAgent? nextAgent = team.FirstOrDefault(agent => agent.Name == nextSpeaker);
if (nextAgent == null)
{
await context.AddEventAsync(new WorkflowWarningEvent($"Invalid next speaker: {nextSpeaker}"), cancellationToken)
.ConfigureAwait(false);
await this.PrepareFinalAnswerAsync(taskContext, context, cancellationToken).ConfigureAwait(false);
return;
}
if (!string.IsNullOrWhiteSpace(taskContext.ProgressLedger.InstructionOrQuestion))
{
ChatMessage instruction = new(ChatRole.Assistant, taskContext.ProgressLedger.InstructionOrQuestion);
taskContext.ChatHistory.Add(instruction);
await context.SendMessageAsync(instruction, cancellationToken).ConfigureAwait(false);
}
string nextExecutorId = AIAgentHostExecutor.IdFor(nextAgent);
await context.SendMessageAsync(new TurnToken(taskContext.EmitUpdateEvents), nextExecutorId, cancellationToken).ConfigureAwait(false);
}
private async ValueTask ResetAndReplanAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
{
taskContext.Reset();
await context.SendMessageAsync(new ResetChatSignal(), cancellationToken: cancellationToken).ConfigureAwait(false);
await this.UpdatePlanAndDelegateAsync(taskContext, context, cancellationToken).ConfigureAwait(false);
}
private async ValueTask PrepareFinalAnswerAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
{
List<ChatMessage> messages = [await this._manager.PrepareFinalAnswerAsync(taskContext, context, cancellationToken).ConfigureAwait(false)];
await context.YieldOutputAsync(messages, cancellationToken).ConfigureAwait(false);
taskContext.IsTerminated = true;
}
private const string CurrentTurnEmitUpdateEventsKey = nameof(CurrentTurnEmitUpdateEventsKey);
protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
Task contextStateTask = this._taskContext == null
? Task.CompletedTask
: context.QueueStateUpdateAsync(MagenticConstants.MagenticTaskContextKey,
this._taskContext.ExportState(),
cancellationToken: cancellationToken)
.AsTask();
await Task.WhenAll(base.OnCheckpointingAsync(context, cancellationToken).AsTask(),
contextStateTask).ConfigureAwait(false);
}
protected internal override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
await Task.WhenAll(base.OnCheckpointRestoredAsync(context, cancellationToken).AsTask(), LoadContextStateAsync())
.ConfigureAwait(false);
async Task LoadContextStateAsync()
{
MagenticTaskState? state = await context.ReadStateAsync<MagenticTaskState>(MagenticConstants.MagenticTaskContextKey, cancellationToken: cancellationToken)
.ConfigureAwait(false);
if (state != null)
{
this._taskContext = new MagenticTaskContext(state, team, limits, []);
}
}
}
}
@@ -0,0 +1,95 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized.Magentic;
internal record TaskLimits(int MaxStallCount = TaskLimits.DefaultMaxStallCount,
int? MaxRoundCount = null,
int? MaxResetCount = null,
int MaxProgressLedgerRetryCount = TaskLimits.DefaultMaxProgressLedgerRetryCount)
{
public const int DefaultMaxStallCount = 3;
public const int DefaultMaxProgressLedgerRetryCount = 3;
}
internal record TaskLedger(ChatMessage CurrentFacts, ChatMessage CurrentPlan);
internal class TaskCounters
{
public int RoundCount { get; set; }
public int StallCount { get; set; }
public int ResetCount { get; set; }
}
internal record MagenticTaskState(List<ChatMessage> TaskDefinition, List<ChatMessage> ChatHistory, TaskLedger? TaskLedger, JsonElement? ProgressLedgerState, TaskCounters Counters, bool Terminated, bool? EmitUpdateEvents)
{
}
internal class MagenticTaskContext(List<ChatMessage> taskDefinition, List<AIAgent> team, TaskLimits limits, bool? emitUpdateEvents, IEnumerable<ProgressLedgerSlot> additionalProgressQuestions)
{
internal MagenticTaskContext(MagenticTaskState state, List<AIAgent> team, TaskLimits limits, IEnumerable<ProgressLedgerSlot> additionalProgressQuestions)
: this(state.TaskDefinition, team, limits, state.EmitUpdateEvents, additionalProgressQuestions)
{
this.TaskLedger = state.TaskLedger;
this.TaskCounters = state.Counters;
this.ChatHistory = state.ChatHistory;
this.IsTerminated = state.Terminated;
if (state.ProgressLedgerState.HasValue && !this.ProgressLedger.TryUpdateState(state.ProgressLedgerState.Value))
{
throw new InvalidOperationException("Could not load progress ledger state value");
}
}
public string Task { get; } = taskDefinition.GetText();
public string TeamDescription { get; } = GetTeamDescription(team);
public List<ChatMessage> ChatHistory { get; internal set; } = new();
public TaskLedger? TaskLedger { get; internal set; }
public TaskLimits TaskLimits => limits;
public bool IsTerminated { get; internal set; }
public bool IsStalled => this.TaskCounters.StallCount >= this.TaskLimits.MaxStallCount;
public (bool HitRoundLimit, bool HitResetLimit) CheckLimits()
{
return (this.TaskLimits.MaxRoundCount.HasValue && this.TaskLimits.MaxRoundCount.Value <= this.TaskCounters.RoundCount,
this.TaskLimits.MaxResetCount.HasValue && this.TaskLimits.MaxResetCount.Value <= this.TaskCounters.ResetCount);
}
public TaskCounters TaskCounters { get; internal set; } = new();
public MagenticProgressLedger ProgressLedger { get; } = new(GetTeamNames(team), additionalProgressQuestions);
public bool? EmitUpdateEvents => emitUpdateEvents;
public static string GetTeamDescription(IEnumerable<AIAgent> team)
{
return string.Join("\n", team.Select(agent => $"- {agent.Name}: {agent.Description}"));
}
public static string GetTeamNames(IEnumerable<AIAgent> team)
{
return string.Join(", ", team.Select(agent => agent.Name));
}
public MagenticTaskState ExportState()
{
return new(taskDefinition, this.ChatHistory, this.TaskLedger, this.ProgressLedger.State, this.TaskCounters, this.IsTerminated, this.EmitUpdateEvents);
}
internal void Reset()
{
this.ChatHistory.Clear();
this.TaskCounters.ResetCount++;
this.TaskCounters.StallCount = 0;
}
}
@@ -0,0 +1,151 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized.Magentic;
internal static class PromptTemplateExtensions
{
public static string ToTaskLedgerFactsPrompt(this MagenticTaskContext taskContext)
{
return $"""
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:
{taskContext.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.
""";
}
public static string ToTaskLedgerFactsUpdatePrompt(this MagenticTaskContext taskContext)
{
return $"""
As a reminder, we are working to solve the following task:
{taskContext.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:
{taskContext.TaskLedger?.CurrentFacts ?? new(ChatRole.Assistant, string.Empty)}
""";
}
public static string ToTaskLedgerPlanPrompt(this MagenticTaskContext taskContext)
{
return $"""
Fantastic. To address this request we have assembled the following team:
{taskContext.TeamDescription}
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.
""";
}
public static string ToTaskLedgerPlanUpdatePrompt(this MagenticTaskContext taskContext)
{
return $"""
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:
{taskContext.TeamDescription}
""";
}
public static string ToTaskLedgerFullPrompt(this MagenticTaskContext taskContext)
{
return $"""
We are working to address the following user request:
{taskContext.Task}
To answer this request we have assembled the following team:
{taskContext.TeamDescription}
Here is an initial fact sheet to consider:
{taskContext.TaskLedger!.CurrentFacts ?? new(ChatRole.Assistant, string.Empty)}
Here is the plan to follow as best as possible:
{taskContext.TaskLedger!.CurrentPlan}
""";
}
public static string ToProgressLedgerPrompt(this MagenticTaskContext taskContext)
{
(string questions, string schema) = taskContext.ProgressLedger.FormatQuestions();
return $"""
Recall we are working on the following request:
{taskContext.Task}
And we have assembled the following team:
{taskContext.TeamDescription}
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}
""";
}
public static string ToFinalAnswerPrompt(this MagenticTaskContext taskContext)
{
return $"""
We are working on the following task:
{taskContext.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,77 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized.Magentic;
internal sealed class StreamingToolCallResultPairMatcher
{
private enum CallType
{
Function,
McpServerTool
}
private record CallSummaryKey(CallType Type, string CallId);
private struct ToolCallSummary(CallType callType, string callId, string name)
{
public CallType CallType => callType;
public string? CallId => callId;
public string Name => name;
}
private readonly Dictionary<CallSummaryKey, ToolCallSummary> _callSummaries = new();
private void Collect(CallType callType, string callId, string name, string callContentTypeName, string resultContentTypeName)
{
CallSummaryKey key = new(callType, callId);
if (this._callSummaries.ContainsKey(key))
{
throw new InvalidOperationException($"Duplicate {callContentTypeName} with CallId '{callId}' without corresponding {resultContentTypeName}.");
}
this._callSummaries[key] = new ToolCallSummary(callType, callId, name);
}
public void CollectFunctionCall(FunctionCallContent callContent)
{
const string FunctionCallContentTypeName = nameof(FunctionCallContent);
const string FunctionResultContentTypeName = nameof(FunctionResultContent);
this.Collect(CallType.Function, callContent.CallId, callContent.Name, FunctionCallContentTypeName, FunctionResultContentTypeName);
}
public void CollectMcpServerToolCall(McpServerToolCallContent callContent)
{
const string McpServerToolCallContentTypeName = nameof(McpServerToolCallContent);
const string McpServerToolResultContentTypeName = nameof(McpServerToolResultContent);
this.Collect(CallType.McpServerTool, callContent.CallId, callContent.Name, McpServerToolCallContentTypeName, McpServerToolResultContentTypeName);
}
private bool TryResolve(CallType callType, string callId, [NotNullWhen(true)] out string? name)
{
CallSummaryKey key = new(callType, callId);
bool hasMatchingCall = this._callSummaries.TryGetValue(key, out ToolCallSummary callSummary);
if (hasMatchingCall)
{
this._callSummaries.Remove(key);
}
name = hasMatchingCall ? callSummary.Name : null;
return hasMatchingCall;
}
public bool TryResolveFunctionCall(FunctionResultContent resultContent, [NotNullWhen(true)] out string? name)
=> this.TryResolve(CallType.Function, resultContent.CallId, out name);
public bool TryResolveMcpServerToolCall(McpServerToolResultContent resultContent, [NotNullWhen(true)] out string? name)
=> this.TryResolve(CallType.McpServerTool, resultContent.CallId, out name);
}
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
using Microsoft.Agents.AI.Workflows.Specialized.Magentic;
namespace Microsoft.Agents.AI.Workflows;
@@ -14,6 +15,8 @@ namespace Microsoft.Agents.AI.Workflows;
[JsonDerivedType(typeof(WorkflowWarningEvent))]
[JsonDerivedType(typeof(WorkflowOutputEvent))]
[JsonDerivedType(typeof(RequestInfoEvent))]
[JsonDerivedType(typeof(MagenticOrchestratorEvent))]
public class WorkflowEvent(object? data = null)
{
/// <summary>
@@ -7,6 +7,7 @@ using System.Text.Json.Serialization;
using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.Agents.AI.Workflows.Execution;
using Microsoft.Agents.AI.Workflows.Specialized;
using Microsoft.Agents.AI.Workflows.Specialized.Magentic;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows;
@@ -97,6 +98,10 @@ internal static partial class WorkflowsJsonUtilities
[JsonSerializable(typeof(AIAgentHostState))]
[JsonSerializable(typeof(HandoffSharedState))]
[JsonSerializable(typeof(HandoffAgentHostState))]
[JsonSerializable(typeof(MagenticPlanReviewRequest))]
[JsonSerializable(typeof(MagenticPlanReviewResponse))]
[JsonSerializable(typeof(MagenticTaskState))]
[JsonSerializable(typeof(ResetChatSignal))]
// Event Types
//[JsonSerializable(typeof(WorkflowEvent))]
@@ -329,40 +329,18 @@ public sealed partial class ChatClientAgent : AIAgent
this._logger.LogAgentChatClientInvokedStreamingAgent(nameof(RunStreamingAsync), this.Id, loggingAgentName, this._chatClientType);
bool hasUpdates;
// Ensure the inner enumerator is always disposed, even if the consumer breaks out early
// (e.g. ToolApprovalAgent does `yield break` after emitting an approval request). Without
// this, downstream decorators like PerServiceCallChatHistoryPersistingChatClient would be
// left suspended at `yield return`, never running their finally blocks, and any in-flight
// FunctionResultContent / FunctionCallContent state would not be persisted before the next
// turn, leaving the next request to the model with dangling tool calls.
try
{
// Ensure we start the streaming request
hasUpdates = await responseUpdatesEnumerator.MoveNextAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
await this.NotifyProvidersOfFailureAtEndOfRunAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), chatOptions, cancellationToken).ConfigureAwait(false);
throw;
}
while (hasUpdates)
{
var update = responseUpdatesEnumerator.Current;
if (update is not null)
{
update.AuthorName ??= this.Name;
responseUpdates.Add(update);
yield return new(update)
{
AgentId = this.Id,
ContinuationToken = WrapContinuationToken(update.ContinuationToken, GetInputMessages(inputMessages, continuationToken), responseUpdates)
};
}
bool hasUpdates;
try
{
// Re-ensure the run context has the resolved session before each MoveNextAsync.
// The base class RunStreamingAsync restores the original context (potentially with
// null session) after each yield, so we must re-establish it for the decorator.
EnsureRunContextHasSession(safeSession);
// Ensure we start the streaming request
hasUpdates = await responseUpdatesEnumerator.MoveNextAsync().ConfigureAwait(false);
}
catch (Exception ex)
@@ -370,20 +348,55 @@ public sealed partial class ChatClientAgent : AIAgent
await this.NotifyProvidersOfFailureAtEndOfRunAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), chatOptions, cancellationToken).ConfigureAwait(false);
throw;
}
while (hasUpdates)
{
var update = responseUpdatesEnumerator.Current;
if (update is not null)
{
update.AuthorName ??= this.Name;
responseUpdates.Add(update);
yield return new(update)
{
AgentId = this.Id,
ContinuationToken = WrapContinuationToken(update.ContinuationToken, GetInputMessages(inputMessages, continuationToken), responseUpdates)
};
}
try
{
// Re-ensure the run context has the resolved session before each MoveNextAsync.
// The base class RunStreamingAsync restores the original context (potentially with
// null session) after each yield, so we must re-establish it for the decorator.
EnsureRunContextHasSession(safeSession);
hasUpdates = await responseUpdatesEnumerator.MoveNextAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
await this.NotifyProvidersOfFailureAtEndOfRunAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), chatOptions, cancellationToken).ConfigureAwait(false);
throw;
}
}
var chatResponse = responseUpdates.ToChatResponse();
var forceEndOfRunPersistence = continuationToken is not null || chatOptions?.AllowBackgroundResponses is true;
// We can derive the type of supported session from whether we have a conversation id,
// so let's update it and set the conversation id for the service session case.
this.UpdateSessionConversationIdAtEndOfRun(safeSession, chatResponse.ConversationId, cancellationToken, forceUpdate: forceEndOfRunPersistence);
// Notify providers of all new messages unless persistence is handled per-service-call by the decorator.
// When resuming from a continuation token or using background responses, force notification
// to send the combined data (per-service-call persistence is unreliable for these scenarios).
await this.NotifyProvidersOfNewMessagesAtEndOfRunAsync(safeSession, GetInputMessages(inputMessagesForChatClient, continuationToken), chatResponse.Messages, chatOptions, cancellationToken, forceNotify: forceEndOfRunPersistence).ConfigureAwait(false);
}
finally
{
await responseUpdatesEnumerator.DisposeAsync().ConfigureAwait(false);
}
var chatResponse = responseUpdates.ToChatResponse();
var forceEndOfRunPersistence = continuationToken is not null || chatOptions?.AllowBackgroundResponses is true;
// We can derive the type of supported session from whether we have a conversation id,
// so let's update it and set the conversation id for the service session case.
this.UpdateSessionConversationIdAtEndOfRun(safeSession, chatResponse.ConversationId, cancellationToken, forceUpdate: forceEndOfRunPersistence);
// Notify providers of all new messages unless persistence is handled per-service-call by the decorator.
// When resuming from a continuation token or using background responses, force notification
// to send the combined data (per-service-call persistence is unreliable for these scenarios).
await this.NotifyProvidersOfNewMessagesAtEndOfRunAsync(safeSession, GetInputMessages(inputMessagesForChatClient, continuationToken), chatResponse.Messages, chatOptions, cancellationToken, forceNotify: forceEndOfRunPersistence).ConfigureAwait(false);
}
/// <inheritdoc/>
@@ -151,6 +151,36 @@ public sealed class ChatClientAgentOptions
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public bool RequirePerServiceCallChatHistoryPersistence { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to include a <see cref="MessageInjectingChatClient"/>
/// in the chat client pipeline.
/// </summary>
/// <remarks>
/// <para>
/// When set to <see langword="true"/>, a <see cref="MessageInjectingChatClient"/> is added to the pipeline
/// between the <see cref="FunctionInvokingChatClient"/> and the inner client. This enables external code
/// (such as tool delegates) to inject messages into the function execution loop via the
/// <see cref="MessageInjectingChatClient"/> class, which can be resolved from the chat client using
/// <c>GetService&lt;MessageInjectingChatClient&gt;()</c>.
/// </para>
/// <para>
/// This setting can be used independently of <see cref="RequirePerServiceCallChatHistoryPersistence"/>,
/// however it is recommended to also enable per-service-call persistence when using message injection
/// so that injected messages are persisted to chat history between service calls.
/// </para>
/// <para>
/// When setting the <see cref="UseProvidedChatClientAsIs"/> setting to <see langword="true"/> and
/// <see cref="EnableMessageInjection"/> to <see langword="true"/>, ensure that your custom chat client stack
/// includes a <see cref="MessageInjectingChatClient"/>. You can add one manually via the
/// <see cref="ChatClientBuilderExtensions.UseMessageInjection"/> extension method.
/// </para>
/// </remarks>
/// <value>
/// Default is <see langword="false"/>.
/// </value>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public bool EnableMessageInjection { get; set; }
/// <summary>
/// Creates a new instance of <see cref="ChatClientAgentOptions"/> with the same values as this instance.
/// </summary>
@@ -168,5 +198,6 @@ public sealed class ChatClientAgentOptions
WarnOnChatHistoryProviderConflict = this.WarnOnChatHistoryProviderConflict,
ThrowOnChatHistoryProviderConflict = this.ThrowOnChatHistoryProviderConflict,
RequirePerServiceCallChatHistoryPersistence = this.RequirePerServiceCallChatHistoryPersistence,
EnableMessageInjection = this.EnableMessageInjection,
};
}
@@ -114,4 +114,38 @@ public static class ChatClientBuilderExtensions
{
return builder.Use(innerClient => new PerServiceCallChatHistoryPersistingChatClient(innerClient));
}
/// <summary>
/// Adds a <see cref="MessageInjectingChatClient"/> to the chat client pipeline.
/// </summary>
/// <remarks>
/// <para>
/// This decorator enables external code (such as tool delegates) to inject messages into the function
/// execution loop. It should be positioned between the <see cref="FunctionInvokingChatClient"/> and
/// the <see cref="PerServiceCallChatHistoryPersistingChatClient"/> (or the leaf <see cref="IChatClient"/>)
/// in the pipeline.
/// </para>
/// <para>
/// The <see cref="MessageInjectingChatClient"/> can be retrieved from the chat client via
/// <c>GetService&lt;MessageInjectingChatClient&gt;</c> to enqueue messages from tool delegates or other code.
/// </para>
/// <para>
/// This extension method is intended for use with custom chat client stacks when
/// <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> is <see langword="true"/>.
/// When <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> is <see langword="false"/> (the default),
/// the <see cref="ChatClientAgent"/> automatically includes this decorator in the pipeline when
/// <see cref="ChatClientAgentOptions.RequirePerServiceCallChatHistoryPersistence"/> is <see langword="true"/>.
/// </para>
/// <para>
/// This decorator only works within the context of a running <see cref="ChatClientAgent"/> and will throw an
/// exception if used in any other stack.
/// </para>
/// </remarks>
/// <param name="builder">The <see cref="ChatClientBuilder"/> to add the decorator to.</param>
/// <returns>The <paramref name="builder"/> for chaining.</returns>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public static ChatClientBuilder UseMessageInjection(this ChatClientBuilder builder)
{
return builder.Use(innerClient => new MessageInjectingChatClient(innerClient));
}
}
@@ -63,13 +63,21 @@ public static class ChatClientExtensions
});
}
// PerServiceCallChatHistoryPersistingChatClient is only injected when RequirePerServiceCallChatHistoryPersistence is enabled.
// It is registered after FunctionInvokingChatClient so that it sits between FIC and the leaf client.
// MessageInjectingChatClient is injected when EnableMessageInjection is enabled.
// It is registered after FunctionInvokingChatClient so that it sits between FIC and the inner client.
// ChatClientBuilder.Build applies factories in reverse order, making the first Use() call outermost.
// By adding our decorator second, the resulting pipeline is:
// FunctionInvokingChatClient → PerServiceCallChatHistoryPersistingChatClient → leaf IChatClient
// This allows the decorator to simulate service-stored chat history by loading history before
// each service call, persisting after each call, and returning a sentinel ConversationId.
// MessageInjectingChatClient enables injecting messages during the function loop and looping when needed.
if (options?.EnableMessageInjection is true)
{
chatBuilder.Use(innerClient => new MessageInjectingChatClient(innerClient));
}
// PerServiceCallChatHistoryPersistingChatClient is injected when RequirePerServiceCallChatHistoryPersistence is enabled.
// It is registered after MessageInjectingChatClient (if present) so it sits closest to the leaf client.
// The resulting pipeline is:
// FunctionInvokingChatClient → [MessageInjectingChatClient] → [PerServiceCallChatHistoryPersistingChatClient] → leaf IChatClient
// PerServiceCallChatHistoryPersistingChatClient simulates service-stored chat history by loading history
// before each service call, persisting after each call, and returning a sentinel ConversationId.
if (options?.RequirePerServiceCallChatHistoryPersistence is true)
{
chatBuilder.Use(innerClient => new PerServiceCallChatHistoryPersistingChatClient(innerClient));
@@ -0,0 +1,320 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// A delegating chat client that supports injecting messages into the function execution loop.
/// </summary>
/// <remarks>
/// <para>
/// This decorator enables external code (such as tool delegates) to enqueue messages that will be
/// sent to the underlying model at the next opportunity. It sits between the <see cref="FunctionInvokingChatClient"/>
/// and the <see cref="PerServiceCallChatHistoryPersistingChatClient"/> (or the leaf <see cref="IChatClient"/>)
/// in a <see cref="ChatClientAgent"/> pipeline.
/// </para>
/// <para>
/// The injected messages queue is stored per-session in the <see cref="AgentSession.StateBag"/>, ensuring
/// isolation between concurrent sessions.
/// </para>
/// <para>
/// After each service call, if no actionable <see cref="FunctionCallContent"/> is returned but injected
/// messages are pending, the decorator loops internally and calls the inner client again with the new
/// messages. When actionable function calls are present, control returns to the parent
/// <see cref="FunctionInvokingChatClient"/> loop.
/// </para>
/// <para>
/// This chat client must be used within the context of a running <see cref="ChatClientAgent"/>. It retrieves the
/// current session from <see cref="AIAgent.CurrentRunContext"/>, which is set automatically when an agent's
/// <see cref="AIAgent.RunAsync(IEnumerable{ChatMessage}, AgentSession?, AgentRunOptions?, CancellationToken)"/> or
/// <see cref="AIAgent.RunStreamingAsync(IEnumerable{ChatMessage}, AgentSession?, AgentRunOptions?, CancellationToken)"/>
/// method is called.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class MessageInjectingChatClient : DelegatingChatClient
{
/// <summary>
/// The key used to store the pending injected messages queue in the session's <see cref="AgentSessionStateBag"/>.
/// </summary>
internal const string PendingMessagesStateKey = "MessageInjectingChatClient.PendingInjectedMessages";
/// <summary>
/// Initializes a new instance of the <see cref="MessageInjectingChatClient"/> class.
/// </summary>
/// <param name="innerClient">The underlying chat client that will handle the core operations.</param>
public MessageInjectingChatClient(IChatClient innerClient)
: base(innerClient)
{
}
/// <inheritdoc/>
public override async Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
CancellationToken cancellationToken = default)
{
var session = GetRequiredSession();
var queue = GetOrCreateQueue(session);
var newMessages = DrainInjectedMessages(queue, messages as IList<ChatMessage> ?? messages.ToList());
// Loop to process injected messages: after each service call, if no actionable function calls
// are pending but new messages have been injected into the queue, we call the service again
// so the model can process them. The loop exits when the response contains actionable
// function calls (handed off to the parent FunctionInvokingChatClient) or the queue is empty.
while (true)
{
var response = await base.GetResponseAsync(newMessages, options, cancellationToken).ConfigureAwait(false);
// If the response contains actionable function calls, the parent FunctionInvokingChatClient
// loop will iterate — return immediately so it can process them.
if (HasActionableFunctionCalls(response.Messages))
{
return response;
}
// No actionable function calls. If there are pending injected messages, loop again
// to send them to the service. Otherwise, we're done.
bool queueEmpty;
lock (queue)
{
queueEmpty = queue.Count == 0;
}
if (queueEmpty)
{
return response;
}
// Propagate any ConversationId returned by the service so subsequent iterations
// continue within the same conversation.
UpdateOptionsForNextIteration(ref options, response.ConversationId);
newMessages = DrainInjectedMessages(queue, Array.Empty<ChatMessage>());
}
}
/// <inheritdoc/>
public override async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var session = GetRequiredSession();
var queue = GetOrCreateQueue(session);
var newMessages = DrainInjectedMessages(queue, messages as IList<ChatMessage> ?? messages.ToList());
// Loop to process injected messages: after each service call, if no actionable function calls
// are pending but new messages have been injected into the queue, we call the service again
// so the model can process them. The loop exits when the response contains actionable
// function calls (handed off to the parent FunctionInvokingChatClient) or the queue is empty.
while (true)
{
bool hasActionableFunctionCalls = false;
string? lastConversationId = null;
var enumerator = base.GetStreamingResponseAsync(newMessages, options, cancellationToken).GetAsyncEnumerator(cancellationToken);
try
{
while (await enumerator.MoveNextAsync().ConfigureAwait(false))
{
var update = enumerator.Current;
// Check each update for actionable function call content as it streams through.
if (!hasActionableFunctionCalls && HasActionableFunctionCalls(update))
{
hasActionableFunctionCalls = true;
}
// Track the latest ConversationId from the stream.
if (update.ConversationId is not null)
{
lastConversationId = update.ConversationId;
}
yield return update;
}
}
finally
{
await enumerator.DisposeAsync().ConfigureAwait(false);
}
// If the response contains actionable function calls, the parent FunctionInvokingChatClient
// loop will iterate — return immediately so it can process them.
if (hasActionableFunctionCalls)
{
yield break;
}
// No actionable function calls. If there are pending injected messages, loop again
// to send them to the service. Otherwise, we're done.
bool queueEmpty;
lock (queue)
{
queueEmpty = queue.Count == 0;
}
if (queueEmpty)
{
yield break;
}
// Propagate any ConversationId returned by the service so subsequent iterations
// continue within the same conversation.
UpdateOptionsForNextIteration(ref options, lastConversationId);
newMessages = DrainInjectedMessages(queue, Array.Empty<ChatMessage>());
}
}
/// <summary>
/// Enqueues one or more messages to be used at the next opportunity.
/// </summary>
/// <remarks>
/// This method is thread-safe and can be called concurrently from tool delegates or other code
/// while the function execution loop is in progress. The enqueued messages will be picked up
/// at the next opportunity.
/// </remarks>
/// <param name="session">The agent session to enqueue messages for.</param>
/// <param name="messages">The messages to enqueue.</param>
public void EnqueueMessages(AgentSession session, IEnumerable<ChatMessage> messages)
{
Throw.IfNull(session);
Throw.IfNull(messages);
var queue = GetOrCreateQueue(session);
lock (queue)
{
foreach (var message in messages)
{
queue.Add(message);
}
}
}
/// <summary>
/// Gets or creates the pending injected messages queue from the session's <see cref="AgentSessionStateBag"/>.
/// </summary>
private static List<ChatMessage> GetOrCreateQueue(AgentSession session)
{
if (session.StateBag.TryGetValue<List<ChatMessage>>(PendingMessagesStateKey, out var queue))
{
return queue!;
}
var newQueue = new List<ChatMessage>();
session.StateBag.SetValue(PendingMessagesStateKey, newQueue);
return newQueue;
}
/// <summary>
/// Gets the current <see cref="AgentSession"/> from the run context.
/// </summary>
private static AgentSession GetRequiredSession()
{
var runContext = AIAgent.CurrentRunContext
?? throw new InvalidOperationException(
$"{nameof(MessageInjectingChatClient)} can only be used within the context of a running AIAgent. " +
"Ensure that the chat client is being invoked as part of an AIAgent.RunAsync or AIAgent.RunStreamingAsync call.");
return runContext.Session
?? throw new InvalidOperationException(
$"{nameof(MessageInjectingChatClient)} requires a session. " +
"The current run context does not have a session.");
}
/// <summary>
/// Drains all pending injected messages from the queue and returns a new list combining
/// the original messages with the drained messages. The original list is never modified.
/// </summary>
private static IList<ChatMessage> DrainInjectedMessages(List<ChatMessage> queue, IList<ChatMessage> newMessages)
{
lock (queue)
{
if (queue.Count == 0)
{
return newMessages;
}
var combined = new List<ChatMessage>(newMessages);
combined.AddRange(queue);
queue.Clear();
return combined;
}
}
/// <summary>
/// Determines whether any message in the list contains a <see cref="FunctionCallContent"/>
/// that is not marked as <see cref="FunctionCallContent.InformationalOnly"/>.
/// </summary>
private static bool HasActionableFunctionCalls(IList<ChatMessage> responseMessages)
{
for (int i = 0; i < responseMessages.Count; i++)
{
var contents = responseMessages[i].Contents;
for (int j = 0; j < contents.Count; j++)
{
if (contents[j] is FunctionCallContent fcc && !fcc.InformationalOnly)
{
return true;
}
}
}
return false;
}
/// <summary>
/// Determines whether a streaming update contains a <see cref="FunctionCallContent"/>
/// that is not marked as <see cref="FunctionCallContent.InformationalOnly"/>.
/// </summary>
private static bool HasActionableFunctionCalls(ChatResponseUpdate update)
{
var contents = update.Contents;
for (int i = 0; i < contents.Count; i++)
{
if (contents[i] is FunctionCallContent fcc && !fcc.InformationalOnly)
{
return true;
}
}
return false;
}
/// <summary>
/// Propagates the <paramref name="conversationId"/> from the service response into
/// <paramref name="options"/> so that subsequent loop iterations continue within the
/// same conversation. Clones <paramref name="options"/> before mutating to avoid
/// affecting the caller's instance.
/// </summary>
private static void UpdateOptionsForNextIteration(ref ChatOptions? options, string? conversationId)
{
if (options is null)
{
if (conversationId is not null)
{
options = new() { ConversationId = conversationId };
}
}
else if (options.ConversationId != conversationId)
{
options = options.Clone();
options.ConversationId = conversationId;
}
}
}
@@ -152,7 +152,14 @@ internal sealed class PerServiceCallChatHistoryPersistingChatClient : Delegating
|| options?.AllowBackgroundResponses is true;
bool skipSimulation = isServiceManaged || isContinuationOrBackground;
var newMessages = messages as IList<ChatMessage> ?? messages.ToList();
// Snapshot the input messages into a private list. The caller (typically
// FunctionInvokingChatClient) reuses a single mutable buffer across iterations,
// and the streaming path can defer persistence until after the caller has already
// mutated that buffer for the next iteration (e.g. on the cooperative early-exit
// path NotifyProvidersOfEarlyExitInputAsync). Aliasing the caller's list would
// then cause us to persist the wrong messages — losing FunctionResultContent and
// corrupting history with dangling FunctionCallContent.
var newMessages = messages.ToList();
// When simulating, load history and prepend it. When the service manages
// history (real ConversationId) or this is a continuation/background run,
@@ -174,45 +181,83 @@ internal sealed class PerServiceCallChatHistoryPersistingChatClient : Delegating
throw;
}
bool hasUpdates;
bool loopExitedNormally = false;
bool serviceErrorOccurred = false;
try
{
hasUpdates = await enumerator.MoveNextAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
await agent.NotifyProvidersOfFailureAsync(session, ex, newMessages, options, cancellationToken).ConfigureAwait(false);
throw;
}
while (hasUpdates)
{
var update = enumerator.Current;
responseUpdates.Add(update.Clone());
// If the service returned a real ConversationId on any update, remember that.
// Otherwise stamp our sentinel so FICC treats this as service-managed —
// unless this is a continuation/background run where the agent handles everything.
if (!string.IsNullOrEmpty(update.ConversationId))
{
isServiceManaged = true;
}
else if (!skipSimulation)
{
update.ConversationId = LocalHistoryConversationId;
}
yield return update;
bool hasUpdates;
try
{
hasUpdates = await enumerator.MoveNextAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
serviceErrorOccurred = true;
await agent.NotifyProvidersOfFailureAsync(session, ex, newMessages, options, cancellationToken).ConfigureAwait(false);
throw;
}
while (hasUpdates)
{
var update = enumerator.Current;
responseUpdates.Add(update.Clone());
// If the service returned a real ConversationId on any update, remember that.
// Otherwise stamp our sentinel so FICC treats this as service-managed —
// unless this is a continuation/background run where the agent handles everything.
if (!string.IsNullOrEmpty(update.ConversationId))
{
isServiceManaged = true;
}
else if (!skipSimulation)
{
update.ConversationId = LocalHistoryConversationId;
}
yield return update;
try
{
hasUpdates = await enumerator.MoveNextAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
serviceErrorOccurred = true;
await agent.NotifyProvidersOfFailureAsync(session, ex, newMessages, options, cancellationToken).ConfigureAwait(false);
throw;
}
}
loopExitedNormally = true;
}
finally
{
// If the iterator was disposed by the consumer before completing — e.g.
// ToolApprovalAgent does `yield break` after emitting an approval request — persist
// the input messages so that any in-flight FunctionResultContent paired with
// previously-persisted FunctionCallContent is not lost between turns. We only do
// this on the cooperative-pause path; service errors deliberately do NOT persist
// input messages (history of failed calls is the caller's responsibility, e.g.
// by retrying or starting from an earlier point).
if (!loopExitedNormally && !serviceErrorOccurred)
{
// Prefer the original cancellation token so cleanup remains responsive; fall
// back to None only if the caller's token has already been canceled (otherwise
// the notify call would observe the cancellation, throw, and mask the
// original early-exit reason).
var persistToken = cancellationToken.IsCancellationRequested ? CancellationToken.None : cancellationToken;
try
{
await NotifyProvidersOfEarlyExitInputAsync(agent, session, newMessages, options, persistToken).ConfigureAwait(false);
}
catch
{
// Best-effort persistence; swallow to avoid masking the original exit reason.
}
}
// Always dispose the underlying enumerator on every exit path (normal completion,
// exception, or early consumer disposal) to release the underlying HTTP/stream.
await enumerator.DisposeAsync().ConfigureAwait(false);
}
var chatResponse = responseUpdates.ToChatResponse();
@@ -236,6 +281,30 @@ internal sealed class PerServiceCallChatHistoryPersistingChatClient : Delegating
}
}
/// <summary>
/// Notifies <see cref="ChatHistoryProvider"/>s of the input messages only (no response
/// messages) on the cooperative early-exit path — e.g. when <c>ToolApprovalAgent</c>
/// does <c>yield break</c> after emitting an approval request. This ensures any
/// in-flight <see cref="FunctionResultContent"/> paired with previously-persisted
/// <see cref="FunctionCallContent"/> is not orphaned in the persisted chat history.
/// The notification is routed through the same success channel used at the end of a
/// normal run; the providers themselves decide how (or whether) to persist.
/// </summary>
private static async Task NotifyProvidersOfEarlyExitInputAsync(
ChatClientAgent agent,
ChatClientAgentSession session,
List<ChatMessage> newMessages,
ChatOptions? options,
CancellationToken cancellationToken)
{
if (newMessages.Count == 0)
{
return;
}
await agent.NotifyProvidersOfNewMessagesAsync(session, newMessages, [], options, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Sets the sentinel <see cref="LocalHistoryConversationId"/> on the response and session
/// so that <see cref="FunctionInvokingChatClient"/> treats the conversation as service-managed.
@@ -21,6 +21,10 @@ internal static class TestSettings
public const string AzureAIModelDeploymentName = "AZURE_AI_MODEL_DEPLOYMENT_NAME";
public const string AzureAIProjectEndpoint = "AZURE_AI_PROJECT_ENDPOINT";
// Azure AI Search (Foundry.Hosting integration tests, RAG scenario)
public const string AzureSearchEndpoint = "AZURE_SEARCH_ENDPOINT";
public const string AzureSearchIndexName = "AZURE_SEARCH_INDEX_NAME";
// Foundry Hosted Agents (Foundry.Hosting integration tests)
public const string FoundryHostingItImage = "IT_HOSTED_AGENT_IMAGE";
@@ -17,9 +17,6 @@ namespace AnthropicChatCompletion.IntegrationTests;
public class AnthropicChatCompletionFixture : IChatClientAgentFixture
{
// All tests for Anthropic are intended to be ran locally as the CI pipeline for Anthropic is not setup.
internal const string SkipReason = "Integrations tests for local execution only";
private readonly bool _useReasoningModel;
private readonly bool _useBeta;
@@ -105,7 +102,22 @@ public class AnthropicChatCompletionFixture : IChatClientAgentFixture
public async ValueTask InitializeAsync()
{
Assert.SkipWhen(SkipReason is not null, SkipReason ?? string.Empty);
// Temporarily disabled: Anthropic SDK has a binary incompatibility with the current
// Microsoft.Extensions.AI version (WebSearchToolResultContent.Results method not found).
// See: https://github.com/microsoft/agent-framework/pull/5515
Assert.Skip("Anthropic integration tests temporarily disabled due to SDK incompatibility with Microsoft.Extensions.AI");
try
{
_ = TestConfiguration.GetRequiredValue(TestSettings.AnthropicApiKey);
_ = TestConfiguration.GetRequiredValue(TestSettings.AnthropicChatModelName);
_ = TestConfiguration.GetRequiredValue(TestSettings.AnthropicReasoningModelName);
}
catch (InvalidOperationException ex)
{
Assert.Skip("Anthropic configuration could not be loaded. Error:" + ex.Message);
}
this._agent = await this.CreateChatClientAgentAsync();
}
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using AgentConformance.IntegrationTests.Support;
using Anthropic;
@@ -17,19 +18,28 @@ namespace AnthropicChatCompletion.IntegrationTests;
/// Integration tests for Anthropic Skills functionality.
/// These tests are designed to be run locally with a valid Anthropic API key.
/// </summary>
/// <remarks>
/// Temporarily disabled due to Anthropic SDK binary incompatibility with
/// the current Microsoft.Extensions.AI version (WebSearchToolResultContent.Results).
/// </remarks>
[Trait("Category", "IntegrationDisabled")]
public sealed class AnthropicSkillsIntegrationTests
{
// All tests for Anthropic are intended to be ran locally as the CI pipeline for Anthropic is not setup.
private const string SkipReason = "Integrations tests for local execution only";
[Fact]
public async Task CreateAgentWithPptxSkillAsync()
{
Assert.SkipWhen(SkipReason is not null, SkipReason ?? string.Empty);
// Arrange
AnthropicClient anthropicClient = new() { ApiKey = TestConfiguration.GetRequiredValue(TestSettings.AnthropicApiKey) };
string model = TestConfiguration.GetRequiredValue(TestSettings.AnthropicChatModelName);
AnthropicClient? anthropicClient;
string? model;
try
{
anthropicClient = new() { ApiKey = TestConfiguration.GetRequiredValue(TestSettings.AnthropicApiKey) };
model = TestConfiguration.GetRequiredValue(TestSettings.AnthropicChatModelName);
}
catch (InvalidOperationException ex)
{
Assert.Skip("Anthropic configuration could not be loaded. Error:" + ex.Message);
return;
}
BetaSkillParams pptxSkill = new()
{
@@ -56,10 +66,16 @@ public sealed class AnthropicSkillsIntegrationTests
[Fact]
public async Task ListAnthropicManagedSkillsAsync()
{
Assert.SkipWhen(SkipReason is not null, SkipReason ?? string.Empty);
// Arrange
AnthropicClient anthropicClient = new() { ApiKey = TestConfiguration.GetRequiredValue(TestSettings.AnthropicApiKey) };
AnthropicClient? anthropicClient;
try
{
anthropicClient = new() { ApiKey = TestConfiguration.GetRequiredValue(TestSettings.AnthropicApiKey) };
}
catch (InvalidOperationException ex)
{
Assert.Skip("Anthropic configuration could not be loaded. Error:" + ex.Message);
return;
}
// Act
SkillListPage skills = await anthropicClient.Beta.Skills.List(
@@ -33,6 +33,7 @@
<ItemGroup>
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Azure.Search.Documents" />
<PackageReference Include="Microsoft.Extensions.AI" />
</ItemGroup>
@@ -1,8 +1,11 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ComponentModel;
using Azure;
using Azure.AI.Projects;
using Azure.Identity;
using Azure.Search.Documents;
using Azure.Search.Documents.Models;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Extensions.AI;
@@ -29,9 +32,10 @@ AIAgent agent = scenario switch
"happy-path" => CreateHappyPathAgent(projectClient, deployment),
"tool-calling" => CreateToolCallingAgent(projectClient, deployment),
"tool-calling-approval" => CreateToolCallingApprovalAgent(projectClient, deployment),
"toolbox" => CreateToolboxAgent(projectClient, deployment),
"mcp-toolbox" => CreateMcpToolboxAgent(projectClient, deployment),
"custom-storage" => CreateCustomStorageAgent(projectClient, deployment),
"azure-search-rag" => CreateAzureSearchRagAgent(projectClient, deployment),
"session-files" => CreateSessionFilesAgent(projectClient, deployment),
_ => throw new InvalidOperationException($"Unknown IT_SCENARIO '{scenario}'.")
};
@@ -79,17 +83,6 @@ static AIAgent CreateToolCallingApprovalAgent(AIProjectClient client, string dep
AIFunctionFactory.Create(SendEmail)
]);
static AIAgent CreateToolboxAgent(AIProjectClient client, string deployment) =>
// TODO: wire Foundry toolbox host once API surface is finalized for hosted agents.
client.AsAIAgent(
model: deployment,
instructions: "You are a toolbox enabled assistant. Use GetEnvironmentName when asked.",
name: "toolbox-agent",
description: "Toolbox test agent (placeholder).",
tools: [
AIFunctionFactory.Create(GetEnvironmentName)
]);
static AIAgent CreateMcpToolboxAgent(AIProjectClient client, string deployment) =>
// TODO: wire MCP toolbox client to https://learn.microsoft.com/api/mcp.
client.AsAIAgent(
@@ -106,6 +99,86 @@ static AIAgent CreateCustomStorageAgent(AIProjectClient client, string deploymen
name: "custom-storage-agent",
description: "Custom storage test agent (placeholder).");
static AIAgent CreateAzureSearchRagAgent(AIProjectClient client, string deployment)
{
// The fixture (AzureSearchRagHostedAgentFixture) injects AZURE_SEARCH_ENDPOINT and
// AZURE_SEARCH_INDEX_NAME into the hosted agent definition. The index is provisioned
// out of band (see dotnet/tests/Foundry.Hosting.IntegrationTests/README.md for the
// required schema and seed content); the container only needs read access. The
// agent's managed identity must hold 'Search Index Data Reader' on the search service
// scope.
var searchEndpoint = new Uri(Environment.GetEnvironmentVariable("AZURE_SEARCH_ENDPOINT")
?? throw new InvalidOperationException("AZURE_SEARCH_ENDPOINT is not set for IT_SCENARIO=azure-search-rag."));
var indexName = Environment.GetEnvironmentVariable("AZURE_SEARCH_INDEX_NAME")
?? throw new InvalidOperationException("AZURE_SEARCH_INDEX_NAME is not set for IT_SCENARIO=azure-search-rag.");
var searchClient = new SearchClient(searchEndpoint, indexName, new DefaultAzureCredential());
var options = new TextSearchProviderOptions
{
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
RecentMessageMemoryLimit = 6,
};
return client.AsAIAgent(new ChatClientAgentOptions
{
Name = "azure-search-rag-agent",
ChatOptions = new ChatOptions
{
ModelId = deployment,
Instructions = "You are a helpful support specialist for Contoso Outdoors. " +
"Answer questions using the provided context and cite the source document when available.",
},
AIContextProviders = [new TextSearchProvider(CreateAzureSearchAdapter(searchClient), options)]
});
}
static Func<string, CancellationToken, Task<IEnumerable<TextSearchProvider.TextSearchResult>>>
CreateAzureSearchAdapter(SearchClient client, int top = 3) =>
async (query, cancellationToken) =>
{
var searchOptions = new SearchOptions { Size = top };
Response<SearchResults<SearchDocument>> response =
await client.SearchAsync<SearchDocument>(query, searchOptions, cancellationToken).ConfigureAwait(false);
var results = new List<TextSearchProvider.TextSearchResult>();
await foreach (SearchResult<SearchDocument> hit in response.Value.GetResultsAsync().WithCancellation(cancellationToken).ConfigureAwait(false))
{
results.Add(new TextSearchProvider.TextSearchResult
{
SourceName = hit.Document.TryGetValue("sourceName", out var name) ? name?.ToString() ?? string.Empty : string.Empty,
SourceLink = hit.Document.TryGetValue("sourceLink", out var link) ? link?.ToString() ?? string.Empty : string.Empty,
Text = hit.Document.TryGetValue("content", out var content) ? content?.ToString() ?? string.Empty : string.Empty,
RawRepresentation = hit
});
}
return results;
};
// session-files scenario: agent reads files from $HOME inside the per-session sandbox volume.
// Mirrors the dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files sample.
static AIAgent CreateSessionFilesAgent(AIProjectClient client, string deployment) =>
client.AsAIAgent(
model: deployment,
instructions: """
You are a friendly assistant that helps users inspect and summarise
files stored in the session sandbox at $HOME.
Always answer file-related questions by calling the available tools
(GetHomeDirectory, ListFiles, ReadFile). Do not guess file paths or
contents read the file before answering.
Quote numbers and figures verbatim from the file rather than
paraphrasing them.
""",
name: "session-files-agent",
description: "Reads files from the per-session $HOME volume.",
tools: [
AIFunctionFactory.Create(GetHomeDirectory),
AIFunctionFactory.Create(ListFiles),
AIFunctionFactory.Create(ReadFile)
]);
[Description("Returns the current UTC date and time as an ISO 8601 string.")]
static string GetUtcNow() => DateTime.UtcNow.ToString("o");
@@ -118,5 +191,73 @@ static string SendEmail(
[Description("Email subject")] string subject) =>
$"Email sent to {to} with subject '{subject}'.";
[Description("Returns the deployment environment name.")]
static string GetEnvironmentName() => "integration-test";
// session-files tools: resolve paths against $HOME (the per-session sandbox volume).
[Description("Get the absolute path of the session home directory ($HOME).")]
static string GetHomeDirectory() => SessionHome();
[Description("List files and directories under the given path inside the session sandbox. Pass an empty string to list $HOME.")]
static string[] ListFiles(
[Description("Path relative to $HOME. Absolute paths and traversals (..) are rejected.")] string path)
{
try
{
return Directory.EnumerateFileSystemEntries(ResolveSessionPath(path)).ToArray();
}
catch (Exception ex)
{
return [$"Error listing '{path}': {ex.Message}"];
}
}
[Description("Read the full text contents of a file inside the session sandbox.")]
static string ReadFile(
[Description("Path relative to $HOME. Absolute paths and traversals (..) are rejected.")] string path)
{
try
{
return File.ReadAllText(ResolveSessionPath(path));
}
catch (Exception ex)
{
return $"Error reading '{path}': {ex.Message}";
}
}
static string SessionHome() =>
Environment.GetEnvironmentVariable("HOME")
?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
// Resolve a caller-supplied path against $HOME, rejecting absolute paths and traversal segments
// so that the model cannot read or list arbitrary container files via the ReadFile/ListFiles
// tools (defense-in-depth against indirect prompt injection). Mirrors the canonicalize +
// startsWith($HOME) pattern used by FileSystemAgentFileStore.ResolveSafePath.
static string ResolveSessionPath(string path)
{
string home = SessionHome();
string homeFull = Path.GetFullPath(home);
string homePrefix = homeFull.EndsWith(Path.DirectorySeparatorChar)
? homeFull
: homeFull + Path.DirectorySeparatorChar;
if (string.IsNullOrWhiteSpace(path))
{
return homeFull;
}
if (Path.IsPathRooted(path))
{
throw new ArgumentException($"Absolute paths are not allowed: '{path}'.", nameof(path));
}
string combined = Path.Combine(homeFull, path);
string fullPath = Path.GetFullPath(combined);
if (!fullPath.Equals(homeFull, StringComparison.Ordinal) &&
!fullPath.StartsWith(homePrefix, StringComparison.Ordinal))
{
throw new ArgumentException(
$"Path '{path}' resolves outside the session sandbox.", nameof(path));
}
return fullPath;
}
@@ -0,0 +1,79 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using Foundry.Hosting.IntegrationTests.Fixtures;
using Microsoft.Agents.AI;
namespace Foundry.Hosting.IntegrationTests;
/// <summary>
/// End to end RAG integration tests against a hosted agent backed by Azure AI Search.
/// The hosted agent runs the test container with <c>IT_SCENARIO=azure-search-rag</c>, which
/// wires <see cref="TextSearchProvider"/> over a real <c>SearchClient</c> against the
/// pre-seeded Contoso Outdoors index.
/// </summary>
/// <remarks>
/// Each test asks for a unique <c>*-CANARY-*</c> token that exists ONLY in the seeded
/// document. The model cannot fabricate these tokens from its training data, so a passing
/// assertion is proof the agent retrieved the seeded document via Azure AI Search rather
/// than answering from general knowledge.
/// </remarks>
[Trait("Category", "FoundryHostedAgents")]
public sealed class AzureSearchRagHostedAgentTests(AzureSearchRagHostedAgentFixture fixture)
: IClassFixture<AzureSearchRagHostedAgentFixture>
{
private readonly AzureSearchRagHostedAgentFixture _fixture = fixture;
[Fact]
public async Task RagAnswer_CitesSeededReturnPolicy_WhenAskedAboutReturnsAsync()
{
// Arrange
var agent = this._fixture.Agent;
// Act: ask about the canary SKU embedded in the seeded Return Policy doc. The
// canary token (TR-CANARY-7821) is unfakeable - it does not exist in any model
// training data, so its presence in the answer is proof the agent retrieved
// the seeded document via the Azure AI Search adapter.
var response = await agent.RunAsync(
"What item code do I get with my return? Cite the source.");
// Assert
Assert.False(string.IsNullOrWhiteSpace(response.Text));
Assert.Contains("TR-CANARY-7821", response.Text, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task RagAnswer_CitesShippingGuide_WhenAskedAboutShippingAsync()
{
// Arrange
var agent = this._fixture.Agent;
// Act: canary promo code (SHIP-CANARY-4493) is unique to the seeded Shipping
// Guide doc. Its presence proves the answer was grounded in retrieved content.
var response = await agent.RunAsync(
"What promo code can I use for free overnight shipping? Cite the source.");
// Assert
Assert.False(string.IsNullOrWhiteSpace(response.Text));
Assert.Contains("SHIP-CANARY-4493", response.Text, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task RagAnswer_StaysGroundedWithoutContext_WhenAskedUnrelatedQuestionAsync()
{
// Arrange: ask something that is NOT covered by the three seeded Contoso documents.
var agent = this._fixture.Agent;
// Act
var response = await agent.RunAsync(
"What is the boiling point of liquid nitrogen in degrees Celsius? " +
"Just give the number with units, no other context.");
// Assert: response is non empty AND does NOT fabricate a Contoso source citation.
// The agent may either answer from its general knowledge or admit uncertainty; either
// is acceptable. The key assertion is that we do not see a fake Contoso link.
Assert.False(string.IsNullOrWhiteSpace(response.Text));
Assert.DoesNotContain("contoso.com", response.Text, StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,41 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using AgentConformance.IntegrationTests.Support;
using Shared.IntegrationTests;
namespace Foundry.Hosting.IntegrationTests.Fixtures;
/// <summary>
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=azure-search-rag</c> mode.
/// Wires the container up with an Azure AI Search backed <see cref="Microsoft.Agents.AI.TextSearchProvider"/>
/// adapter that retrieves Contoso Outdoors documents from a pre-provisioned search index before each
/// model invocation.
/// </summary>
/// <remarks>
/// Prerequisites managed out of band:
/// <list type="bullet">
/// <item><description>The <c>it-azure-search-rag</c> agent's managed identity must hold
/// <c>Search Index Data Reader</c> on the search service scope. Granted manually after
/// the first <c>scripts/it-bootstrap-agents.ps1</c> run; see the IT README.</description></item>
/// <item><description>The search index referenced by <c>AZURE_SEARCH_INDEX_NAME</c> must
/// already exist with the documented schema and Contoso Outdoors content. The search
/// service is shared with <c>python-sample-validation.yml</c>; no .NET-side provisioning
/// script ships with this repository.</description></item>
/// </list>
/// </remarks>
public sealed class AzureSearchRagHostedAgentFixture : HostedAgentFixture
{
protected override string ScenarioName => "azure-search-rag";
/// <summary>
/// Inject the AZURE_SEARCH_* env vars onto the hosted agent definition so the test container
/// scenario branch can construct its <c>SearchClient</c>. These names are NOT in the platform
/// reserved <c>FOUNDRY_*</c> / <c>AGENT_*</c> namespace so they are safe to set.
/// </summary>
protected override void ConfigureEnvironment(IDictionary<string, string> environment)
{
environment[TestSettings.AzureSearchEndpoint] = TestConfiguration.GetRequiredValue(TestSettings.AzureSearchEndpoint);
environment[TestSettings.AzureSearchIndexName] = TestConfiguration.GetRequiredValue(TestSettings.AzureSearchIndexName);
}
}
@@ -0,0 +1,17 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Foundry.Hosting.IntegrationTests.Fixtures;
/// <summary>
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=session-files</c> mode.
/// The container exposes three local function tools (<c>GetHomeDirectory</c>, <c>ListFiles</c>,
/// <c>ReadFile</c>) that read from the per-session <c>$HOME</c> sandbox volume — mirroring the
/// <c>Hosted-Files</c> sample. Tests use the alpha
/// <see cref="Azure.AI.Projects.Agents.AgentSessionFiles"/> API to upload a file into the session
/// sandbox, then invoke the agent (pinned to the same <c>agent_session_id</c>) and assert that the
/// agent's tools observed the uploaded file.
/// </summary>
public sealed class SessionFilesHostedAgentFixture : HostedAgentFixture
{
protected override string ScenarioName => "session-files";
}
@@ -1,14 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Foundry.Hosting.IntegrationTests.Fixtures;
/// <summary>
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=toolbox</c> mode.
/// The container hosts a Foundry toolbox with at least one server registered tool. Tests verify
/// that the model can invoke those tools and that client side toolbox additions surface alongside
/// server side registrations when listed.
/// </summary>
public sealed class ToolboxHostedAgentFixture : HostedAgentFixture
{
protected override string ScenarioName => "toolbox";
}
@@ -20,8 +20,17 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Azure.Search.Documents" />
<PackageReference Include="Microsoft.Extensions.AI" />
</ItemGroup>
<ItemGroup>
<!-- Linked from the Hosted-Files sample so the demo testdata file has a single source of truth. -->
<Content Include="..\..\samples\04-hosting\FoundryHostedAgents\responses\Hosted-Files\resources\contoso_q1_2026_report.txt"
Link="TestData\contoso_q1_2026_report.txt"
CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
@@ -38,6 +38,8 @@ etc.).
| `AZURE_AI_PROJECT_ENDPOINT` | Foundry project | Where to provision the agent. Must be in a region that has the Hosted Agents preview enabled (e.g. East US 2). |
| `AZURE_AI_MODEL_DEPLOYMENT_NAME` | Foundry project | Model the agent uses. Defaults to `gpt-4o` inside the container. |
| `IT_HOSTED_AGENT_IMAGE` | `scripts/it-build-image.ps1` | ACR image reference the agent points at. |
| `AZURE_SEARCH_ENDPOINT` | Pre-provisioned Azure AI Search service | Endpoint for the `azure-search-rag` scenario. The index it points at must already exist with the schema and content described under **Azure AI Search index prerequisite** below. |
| `AZURE_SEARCH_INDEX_NAME` | Pre-provisioned Azure AI Search service | Name of the pre-seeded index for the `azure-search-rag` scenario. |
## One-time bootstrap (per Foundry project)
@@ -57,6 +59,58 @@ The script is idempotent. It requires Owner or User Access Administrator on the
scope (RBAC writes). Wait ~3 minutes after first-time grants for AAD propagation before
running the tests.
### Per-scenario data-plane RBAC (manual, one time per agent)
The bootstrap script grants only `Azure AI User` on the Foundry project scope, which is what
every hosted agent needs to receive inbound inference traffic. Scenarios that read from
external data services need an additional grant on that service to the agent's managed
identity. Today only the `azure-search-rag` scenario falls into this category.
For `it-azure-search-rag`, after the first bootstrap run, grant `Search Index Data Reader`
on the Azure AI Search service to the agent's managed identity:
```powershell
# 1. Get the agent MI principal id
$tok = az account get-access-token --resource "https://ai.azure.com" --query accessToken -o tsv
$agent = Invoke-RestMethod `
-Headers @{Authorization="Bearer $tok"; "Foundry-Features"="HostedAgents=V1Preview"} `
-Uri "<project-endpoint>/agents/it-azure-search-rag?api-version=v1"
$mi = $agent.versions.latest.instance_identity.principal_id
# 2. Grant Search Index Data Reader on the search service
az role assignment create `
--assignee-object-id $mi `
--assignee-principal-type ServicePrincipal `
--role "Search Index Data Reader" `
--scope "/subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.Search/searchServices/<search-service>"
```
Wait ~3 minutes after the grant for RBAC propagation before running the tests.
If the search service has `authOptions = apiKeyOnly` (default for older deployments), Entra
auth will return 403 regardless of role assignments. Flip it to `aadOrApiKey` first:
```powershell
az search service update -g <rg> -n <search-service> --auth-options aadOrApiKey --aad-auth-failure-mode http403
```
### Azure AI Search index prerequisite (one time, out of band)
The `azure-search-rag` scenario assumes the index pointed at by `AZURE_SEARCH_INDEX_NAME` already
exists with the schema and Contoso Outdoors content the test asserts against. See
`dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/README.md` for
the schema and copy-pasteable provisioning snippet. Provisioning the index from your user
identity needs `Search Index Data Contributor` on the search service scope. The search service
itself is treated as pre-existing infrastructure shared with `python-sample-validation.yml`;
no automated provisioning script ships in this repository.
### Required user/SP roles for delegating data-plane grants
To self-serve the `Search Index Data Reader` grant above, you need `User Access Administrator`
(or `Owner`) on the search service scope. To create/seed the index from your own identity, you
need `Search Index Data Contributor`. These are typically granted once per onboarded engineer
and reused for every new IT scenario that needs Search.
## Building and pushing the test container image
The test container source lives at `dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer`.
@@ -115,6 +169,8 @@ container, the test fixture, or their tooling changed:
| `IT_HOSTED_AGENT_PROJECT_ENDPOINT` | `AZURE_AI_PROJECT_ENDPOINT` |
| `IT_HOSTED_AGENT_MODEL_DEPLOYMENT_NAME` | `AZURE_AI_MODEL_DEPLOYMENT_NAME` |
| `IT_HOSTED_AGENT_REGISTRY` | (consumed by `it-build-image.ps1`; not passed to tests) |
| `secrets.AZURE_SEARCH_ENDPOINT` | `AZURE_SEARCH_ENDPOINT` (shared with `python-sample-validation.yml`) |
| `secrets.AZURE_SEARCH_INDEX_NAME` | `AZURE_SEARCH_INDEX_NAME` (shared with `python-sample-validation.yml`) |
Like all integration tests in this workflow, the steps run only on `push` and merge-queue
events, never on plain `pull_request`. The path-filter list lives in the `paths-filter`
@@ -125,6 +181,10 @@ The CI service principal that backs `secrets.AZURE_CLIENT_ID` needs:
- `Azure AI User` on the hosted-agents Foundry project (to add/delete agent versions).
- `AcrPush` on the registry referenced by `IT_HOSTED_AGENT_REGISTRY` (to push the image).
The Azure AI Search index referenced by `secrets.AZURE_SEARCH_ENDPOINT` and
`secrets.AZURE_SEARCH_INDEX_NAME` is provisioned out of band (shared with
`python-sample-validation.yml`); CI does not need write access to the search service.
The bootstrap script (and one-time `AcrPull` grants for the Foundry project's MIs) is a
human-only operation; CI only adds and deletes versions under existing agents.
@@ -135,9 +195,10 @@ human-only operation; CI only adds and deletes versions under existing agents.
| `HappyPathHostedAgentFixture` | `happy-path` | `it-happy-path` | Round trip, streaming, multi turn (`previous_response_id` and `conversation_id`), `stored=false` flag in three combinations, instructions obeyed. |
| `ToolCallingHostedAgentFixture` | `tool-calling` | `it-tool-calling` | Server side AIFunction invocation; arguments; multi turn referencing prior tool result. |
| `ToolCallingApprovalHostedAgentFixture` | `tool-calling-approval` | `it-tool-calling-approval` | Approval requests raised, approved, denied. |
| `ToolboxHostedAgentFixture` | `toolbox` | `it-toolbox` | Server registered toolbox tool callable; client side additions visible (placeholder). |
| `McpToolboxHostedAgentFixture` | `mcp-toolbox` | `it-mcp-toolbox` | MCP backed tool invocation against `https://learn.microsoft.com/api/mcp` (placeholder). |
| `CustomStorageHostedAgentFixture` | `custom-storage` | `it-custom-storage` | Round trip with custom `IResponsesStorageProvider`; multi turn reads from the custom store (placeholder). |
| `AzureSearchRagHostedAgentFixture` | `azure-search-rag` | `it-azure-search-rag` | RAG against a real Azure AI Search index seeded with Contoso Outdoors documents; verifies the model cites the retrieved sources. |
| `SessionFilesHostedAgentFixture` | `session-files` | `it-session-files` | End-to-end: upload via `AgentSessionFiles` (alpha) into a pinned `agent_session_id`, invoke the agent, assert it reads the file via the container's `ReadFile` tool. |
The placeholder scenarios will be wired up in the test container `Program.cs` once the
relevant `Microsoft.Agents.AI.Foundry.Hosting` API surfaces stabilize.
@@ -0,0 +1,238 @@
// Copyright (c) Microsoft. All rights reserved.
#pragma warning disable AAIP001 // AgentSessionFiles is experimental
#pragma warning disable OPENAI001 // CreateResponseOptions is experimental
using System;
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using AgentConformance.IntegrationTests.Support;
using Azure.AI.Extensions.OpenAI;
using Azure.AI.Projects;
using Azure.AI.Projects.Agents;
using Foundry.Hosting.IntegrationTests.Fixtures;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI.Responses;
using Shared.IntegrationTests;
namespace Foundry.Hosting.IntegrationTests;
/// <summary>
/// End-to-end integration test for the Hosted-Files style scenario: a file uploaded by the client
/// via the alpha <see cref="AgentSessionFiles"/> SDK is read by the deployed hosted agent's
/// container-side <c>ReadFile</c> tool and surfaces in <see cref="AIAgent.RunAsync(string, AgentSession, AgentRunOptions, CancellationToken)"/>.
/// </summary>
/// <remarks>
/// <para>
/// Routing both invocations to the same per-session container requires two clients on the same
/// agent-scoped <see cref="ProjectOpenAIClient"/>: a <see cref="ProjectConversationsClient"/> to
/// pre-create a conversation bound to the agent endpoint, and a <see cref="ProjectResponsesClient"/>
/// for invocation. The session id resolved by the platform on the first call is captured from the
/// <c>x-agent-session-id</c> response header and used to target the
/// <see cref="AgentSessionFiles"/> upload at the same session's <c>$HOME</c>. The second call
/// carries the same conversation_id so it lands in the same container and the agent's
/// <c>ReadFile</c> tool sees the upload.
/// </para>
/// </remarks>
[Trait("Category", "FoundryHostedAgents")]
public sealed class SessionFilesHostedAgentTests(SessionFilesHostedAgentFixture fixture) : IClassFixture<SessionFilesHostedAgentFixture>
{
private const string FoundryFeaturesHeader = "Foundry-Features";
private const string HostedAgentsFeatureValue = "HostedAgents=V1Preview,AgentEndpoints=V1Preview";
private const string SessionIdHeader = "x-agent-session-id";
private const string TestDataFileName = "contoso_q1_2026_report.txt";
/// <summary>Token that appears verbatim in the test data file. Proof the agent read what we uploaded.</summary>
private const string ExpectedTokenInFile = "1,482.6";
private readonly SessionFilesHostedAgentFixture _fixture = fixture;
[Fact]
public async Task UploadedFile_IsReadByHostedAgentAsync()
{
// Arrange
string localPath = Path.Combine(AppContext.BaseDirectory, "TestData", TestDataFileName);
Assert.True(
File.Exists(localPath),
$"Test data file not found at '{localPath}'. Confirm the linked Content entry in the csproj.");
var endpoint = new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint));
var credential = TestAzureCliCredentials.CreateAzureCliCredential();
// Admin client + AgentSessionFiles for upload/list/delete (alpha SDK).
var adminOptions = new AgentAdministrationClientOptions();
adminOptions.AddPolicy(new FoundryFeaturesPolicy(HostedAgentsFeatureValue), PipelinePosition.PerCall);
var adminClient = new AgentAdministrationClient(endpoint, credential, adminOptions);
var sessionFiles = adminClient.GetAgentSessionFiles();
// Build the per-agent OpenAI client. The conversation is created on this client so it is
// bound to the agent endpoint URL (`/agents/{name}/endpoint/protocols/openai/conversations`).
// A header-capture policy reads the `x-agent-session-id` the platform stamps on every reply.
var headerCapture = new ResponseHeaderCapturePolicy(SessionIdHeader);
var openAIOptions = new ProjectOpenAIClientOptions { AgentName = this._fixture.AgentName };
openAIOptions.AddPolicy(new FoundryFeaturesPolicy(HostedAgentsFeatureValue), PipelinePosition.PerCall);
openAIOptions.AddPolicy(headerCapture, PipelinePosition.PerCall);
var openAIClient = new ProjectOpenAIClient(endpoint, credential, openAIOptions);
var conversations = openAIClient.GetProjectConversationsClient();
var responses = openAIClient.GetProjectResponsesClient();
// Step 1 — create a conversation bound to the agent endpoint. Subsequent /responses calls
// tagged with this conversation_id route to the same per-session container.
var conversation = await conversations.CreateProjectConversationAsync();
string conversationId = conversation.Value.Id;
try
{
// Step 2 — warm-up call. Provisions the per-session container under the conversation and
// lets us read back the resolved agent_session_id from the response header.
var agent = responses.AsIChatClient().AsAIAgent(name: this._fixture.AgentName);
var convOptions = new ChatClientAgentRunOptions(new ChatOptions { ConversationId = conversationId });
var warmup = await agent.RunAsync(
"Reply with the single word 'ready' and nothing else.",
options: convOptions);
Assert.False(string.IsNullOrWhiteSpace(warmup.Text));
string agentSessionId = headerCapture.LastValue
?? throw new InvalidOperationException(
$"Expected '{SessionIdHeader}' response header on warm-up but got none.");
try
{
// Step 3 — upload the file via the alpha AgentSessionFiles SDK to that exact session's $HOME.
SessionFileWriteResponse writeResponse = await sessionFiles.UploadSessionFileAsync(
agentName: this._fixture.AgentName,
sessionId: agentSessionId,
sessionStoragePath: TestDataFileName,
localPath: localPath);
long expectedBytes = new FileInfo(localPath).Length;
Assert.Equal(expectedBytes, writeResponse.BytesWritten);
SessionDirectoryListResponse listing = await sessionFiles.GetSessionFilesAsync(
agentName: this._fixture.AgentName,
sessionId: agentSessionId,
sessionStoragePath: ".");
Assert.Contains(
listing.Entries,
e => e.Name == TestDataFileName && !e.IsDirectory && e.Size == expectedBytes);
// Step 4 — invoke the agent again on the SAME conversation. The platform routes back to
// the same agent_session_id container, so the agent's ReadFile tool sees the upload.
// The platform mutates session/conversation revision when AgentSessionFiles uploads land,
// so an immediate /responses follow-up races and 400's with "modified concurrently. Please
// retry." — the response message literally tells us to retry. Bounded retry handles it.
var readOptions = new CreateResponseOptions { AgentConversationId = conversationId };
readOptions.InputItems.Add(ResponseItem.CreateUserMessageItem(
$"Read {TestDataFileName} from $HOME and quote the headline total revenue figure verbatim, no commentary."));
ClientResult<ResponseResult> rawResponse = null!;
const int MaxAttempts = 5;
for (int attempt = 1; attempt <= MaxAttempts; attempt++)
{
try
{
rawResponse = await responses.CreateResponseAsync(readOptions);
break;
}
catch (ClientResultException ex) when (
ex.Status == 400 &&
ex.Message.Contains("modified concurrently", StringComparison.OrdinalIgnoreCase) &&
attempt < MaxAttempts)
{
await Task.Delay(TimeSpan.FromSeconds(2 * attempt));
}
}
string responseText = rawResponse.Value.GetOutputText() ?? string.Empty;
Assert.Equal(agentSessionId, headerCapture.LastValue);
// Assert: the response contains the deterministic token from the file.
Assert.False(string.IsNullOrWhiteSpace(responseText));
Assert.Contains(ExpectedTokenInFile, responseText);
}
finally
{
// Best-effort cleanup of the uploaded file. The session itself is left for TTL expiry —
// the platform owns its lifecycle (no isolation key in our hands).
try
{
await sessionFiles.DeleteSessionFileAsync(
agentName: this._fixture.AgentName,
sessionId: agentSessionId,
path: TestDataFileName);
}
catch
{
// Ignore.
}
}
}
finally
{
await this._fixture.DeleteConversationAsync(conversationId);
}
}
/// <summary>
/// Captures a response header value on every pipeline call. Latest value is read after the
/// response completes. Used to grab the platform's <c>x-agent-session-id</c> stamp.
/// </summary>
private sealed class ResponseHeaderCapturePolicy(string headerName) : PipelinePolicy
{
private readonly string _headerName = headerName;
private string? _lastValue;
public string? LastValue => Volatile.Read(ref this._lastValue);
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
ProcessNext(message, pipeline, currentIndex);
this.Capture(message);
}
public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false);
this.Capture(message);
}
private void Capture(PipelineMessage message)
{
if (message.Response is not null &&
message.Response.Headers.TryGetValue(this._headerName, out var value) &&
!string.IsNullOrEmpty(value))
{
Volatile.Write(ref this._lastValue, value);
}
}
}
private sealed class FoundryFeaturesPolicy(string features) : PipelinePolicy
{
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
this.SetHeader(message);
ProcessNext(message, pipeline, currentIndex);
}
public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
this.SetHeader(message);
await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false);
}
private void SetHeader(PipelineMessage message)
{
message.Request.Headers.Remove(FoundryFeaturesHeader);
message.Request.Headers.Add(FoundryFeaturesHeader, features);
}
}
}
@@ -1,49 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using Foundry.Hosting.IntegrationTests.Fixtures;
namespace Foundry.Hosting.IntegrationTests;
/// <summary>
/// Tests for the Foundry toolbox: the hosted container registers tools via the toolbox API
/// (server side), and tests can also add tools client side. The model should be able to
/// invoke tools from both sources.
/// </summary>
[Trait("Category", "FoundryHostedAgents")]
public sealed class ToolboxHostedAgentTests(ToolboxHostedAgentFixture fixture) : IClassFixture<ToolboxHostedAgentFixture>
{
private readonly ToolboxHostedAgentFixture _fixture = fixture;
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
public async Task ServerRegisteredToolboxTool_IsCallableAsync()
{
// Arrange: the container side toolbox registers GetEnvironmentName which returns a constant.
var agent = this._fixture.Agent;
// Act
var response = await agent.RunAsync("Call GetEnvironmentName via the toolbox and reply with just the value.");
// Assert
Assert.False(string.IsNullOrWhiteSpace(response.Text));
Assert.Contains("integration-test", response.Text, System.StringComparison.OrdinalIgnoreCase);
}
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
public async Task ClientSideAddedToolboxTool_IsListedAndCallableAsync()
{
// TODO: requires AgentToolboxes API surface. Placeholder asserting the test runs.
var agent = this._fixture.Agent;
var response = await agent.RunAsync("List all tools you have access to.");
Assert.False(string.IsNullOrWhiteSpace(response.Text));
}
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
public async Task ListingTools_ReturnsBothServerAndClientSideEntriesAsync()
{
// TODO: requires AgentAdministrationClient toolbox listing. Placeholder.
var agent = this._fixture.Agent;
var response = await agent.RunAsync("Briefly describe what tools are available.");
Assert.False(string.IsNullOrWhiteSpace(response.Text));
}
}
@@ -20,6 +20,13 @@
Container image reference for the placeholder version (e.g. <acr>.azurecr.io/foundry-hosting-it:<tag>).
Use the value emitted by scripts/it-build-image.ps1.
.NOTES
Per-scenario data-plane RBAC (e.g. `Search Index Data Reader` on the Azure AI Search service
for the `azure-search-rag` scenario) is intentionally NOT performed by this script. Search,
Cosmos, and other backing services are treated as pre-existing infrastructure. Grant the
scenario-specific data role to the agent's managed identity manually after the first run
(see dotnet/tests/Foundry.Hosting.IntegrationTests/README.md).
.EXAMPLE
./it-bootstrap-agents.ps1 `
-ProjectEndpoint "https://my-acct.services.ai.azure.com/api/projects/my-proj" `
@@ -36,9 +43,10 @@ $Scenarios = @(
'happy-path',
'tool-calling',
'tool-calling-approval',
'toolbox',
'mcp-toolbox',
'custom-storage'
'custom-storage',
'azure-search-rag',
'session-files'
)
# Resolve project ARM scope from the endpoint.

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