Compare commits

...
Author SHA1 Message Date
Jacob AlberandGitHub d52cbcf1bc Merge branch 'main' into dev/dotnet_workflow/fix_flaky_checkpoint_restore_test 2026-05-12 10:35:53 -04:00
dfc3079d68 .NET: Add A2A input-request content for human-in-the-loop scenarios (#5743)
* .NET: Add A2A input-request content for human-in-the-loop scenarios

Adds first-class support for handling user input requests from A2A agents
when they return an `input-required` task state.

- Add `A2AInputRequestContent` (wraps the requested `AIContent`) and
  `A2AInputResponseContent` (wraps the user's `AIContent` reply), with
  `CreateResponse` helper overloads on the request type.
- Surface input requests on `AgentResponse` / `AgentResponseUpdate` via
  `AgentTask` and `TaskStatusUpdateEvent` mappings.
- Link follow-up messages containing `A2AInputResponseContent` to the
  existing task via `TaskId` instead of `ReferenceTaskIds`.
- Add `A2AAgent_HumanInTheLoop` sample and register it in the solution
  and parent README.
- Add unit tests for the new types, extensions, and `A2AAgent` paths.

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

* Remove unnecessary using directive flagged by CI format check

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

* address feedback

* Guard against null TaskId when sending A2AInputResponseContent

Throw InvalidOperationException if TaskId is missing when the message
contains A2AInputResponseContent, preventing silent no-op responses.
Also adds tests for both RunAsync and RunStreamingAsync paths.

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

* Leave Contents null for non-InputRequired status updates

Remove unnecessary '?? []' fallback so Contents stays null when there
are no input requests, matching the other update mapping patterns.

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

* Use consistent GUID format for request IDs

Use ToString("N") to match message ID format used elsewhere in
the A2A component.

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

* Remove Debug build exclusion for the HumanInTheLoop sample so it                                                                                                                                                                                                               participates in normal solution validation.

* Add missing using Microsoft.Extensions.AI to A2AAgent_HumanInTheLoop

The sample uses ChatMessage, TextContent, and ChatRole types from
Microsoft.Extensions.AI but was missing the using directive, causing
CS0246 build errors on all CI jobs.

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

* change the way user input requests are handled based on pr review comments

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-12 13:10:18 +00:00
Evan MattsonandGitHub 939d4d0153 propagate token (#5768) 2026-05-12 15:27:13 +09:00
Evan MattsonandGitHub fe09f13adb Trigger issue triage on bug-labeled issues (#5763)
* Trigger issue triage on bug-labeled issues instead of manual dispatch

* Address PR feedback: scope concurrency cancellation to bug-label events
2026-05-12 13:07:17 +09: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
Jacob AlberandGitHub 7d80d7260d Merge branch 'main' into dev/dotnet_workflow/fix_flaky_checkpoint_restore_test 2026-04-20 11:33:52 -04:00
Jacob AlberandGitHub bcf86f78b4 Merge branch 'main' into dev/dotnet_workflow/fix_flaky_checkpoint_restore_test 2026-04-07 11:04:00 -04:00
Jacob AlberandGitHub 4e1e590af5 Merge branch 'main' into dev/dotnet_workflow/fix_flaky_checkpoint_restore_test 2026-04-03 17:55:07 -04:00
Jacob Alber 812550bf25 fix: Sporadic failure of Checkpoint Restore with PendingRequests tests 2026-04-03 17:36:28 -04:00
90 changed files with 3613 additions and 1642 deletions
+22 -34
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'
@@ -340,7 +341,6 @@ jobs:
runs-on: ubuntu-latest
environment: integration
env:
targetFramework: net10.0
configuration: Release
steps:
- uses: actions/checkout@v6
@@ -357,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
@@ -394,13 +378,12 @@ jobs:
# 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.
#
# `-UsePrebuiltProjectReferences` opts into the no-rebuild fast path: publish skips
# rebuilding ProjectReferences and consumes the DLLs the prior "Build Foundry hosted IT
# (and its deps)" step already produced. This avoids MSB3026 ("file is being used by
# another process") collisions caused by the previous build's shared-compilation server
# still holding file handles to those DLLs. Safe in CI because the prebuild step ran in
# the same job against the same source. Do not remove the prebuild step (the subsequent
# `dotnet test --no-build` step depends on it too).
# 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
@@ -410,14 +393,13 @@ jobs:
if ([string]::IsNullOrWhiteSpace($registry)) {
throw "IT_HOSTED_AGENT_REGISTRY not set in the integration environment."
}
& "${{ github.workspace }}/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-build-image.ps1" -Registry $registry -UsePrebuiltProjectReferences | Tee-Object -FilePath $env:GITHUB_ENV -Append
& "${{ github.workspace }}/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-build-image.ps1" -Registry $registry | Tee-Object -FilePath $env:GITHUB_ENV -Append
- name: Run Foundry Hosted Agents Integration Tests
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 `
@@ -426,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
+15 -14
View File
@@ -1,12 +1,8 @@
name: Issue Triage
on:
workflow_dispatch:
inputs:
issue_number:
description: Issue number to triage
required: true
type: string
issues:
types: [opened, labeled]
permissions:
contents: read
@@ -14,7 +10,13 @@ permissions:
id-token: write
concurrency:
group: issue-triage-${{ github.repository }}-${{ github.event.issue.number || inputs.issue_number || github.run_id }}
group: >-
issue-triage-${{ github.repository }}-${{
((github.event.action == 'opened' && contains(github.event.issue.labels.*.name, 'bug'))
|| (github.event.action == 'labeled' && github.event.label.name == 'bug'))
&& github.event.issue.number
|| github.run_id
}}
cancel-in-progress: true
env:
@@ -26,6 +28,7 @@ env:
jobs:
team_check:
runs-on: ubuntu-latest
if: ${{ (github.event.action == 'opened' && contains(github.event.issue.labels.*.name, 'bug')) || (github.event.action == 'labeled' && github.event.label.name == 'bug') }}
outputs:
is_team_member: ${{ steps.check.outputs.is_team_member }}
issue_number: ${{ steps.issue.outputs.issue_number }}
@@ -36,18 +39,13 @@ jobs:
shell: bash
env:
ISSUE_NUMBER_EVENT: ${{ github.event.issue.number }}
ISSUE_NUMBER_INPUT: ${{ inputs.issue_number }}
run: |
set -euo pipefail
if [[ "${GITHUB_EVENT_NAME}" == "issues" ]]; then
issue_number="${ISSUE_NUMBER_EVENT}"
else
issue_number="${ISSUE_NUMBER_INPUT}"
fi
issue_number="${ISSUE_NUMBER_EVENT}"
if [[ ! "$issue_number" =~ ^[1-9][0-9]*$ ]]; then
echo "Could not determine issue number; for workflow_dispatch runs, the 'issue_number' input is required." >&2
echo "Could not determine issue number from event payload." >&2
exit 1
fi
@@ -166,6 +164,9 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_COPILOT_TOKEN: ${{ secrets.GH_COPILOT_TOKEN }}
# Not seen by the agent prompt; used only to push a paper-trail
# branch back to maf-dashboard at run end.
DEVFLOW_TOKEN: ${{ secrets.DEVFLOW_TOKEN }}
SK_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
AGENT_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
ISSUE_REPO: ${{ needs.team_check.outputs.repo }}
+1
View File
@@ -25,6 +25,7 @@
<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.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" />
+10 -3
View File
@@ -1,4 +1,4 @@
<Solution>
<Solution>
<Configurations>
<BuildType Name="Debug" />
<BuildType Name="Publish" />
@@ -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" />
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
@@ -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" />
<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.
@@ -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>
+29 -14
View File
@@ -93,26 +93,26 @@ public sealed class A2AAgent : AIAgent
/// <inheritdoc/>
protected override async Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(messages);
var inputMessages = Throw.IfNull(messages) as IReadOnlyCollection<ChatMessage> ?? messages.ToList();
A2AAgentSession typedSession = await this.GetA2ASessionAsync(session, options, cancellationToken).ConfigureAwait(false);
this._logger.LogA2AAgentInvokingAgent(nameof(RunAsync), this.Id, this.Name);
if (GetContinuationToken(messages, options) is { } token)
if (GetContinuationToken(inputMessages, options) is { } token)
{
AgentTask agentTask = await this._a2aClient.GetTaskAsync(new GetTaskRequest { Id = token.TaskId }, cancellationToken).ConfigureAwait(false);
this._logger.LogAgentChatClientInvokedAgent(nameof(RunAsync), this.Id, this.Name);
UpdateSession(typedSession, agentTask.ContextId, agentTask.Id);
UpdateSession(typedSession, agentTask.ContextId, agentTask.Id, agentTask.Status.State);
return this.ConvertToAgentResponse(agentTask);
}
SendMessageRequest sendParams = new()
{
Message = CreateA2AMessage(typedSession, messages),
Message = CreateA2AMessage(typedSession, inputMessages),
Metadata = options?.AdditionalProperties?.ToA2AMetadata(),
Configuration = new SendMessageConfiguration { ReturnImmediately = options?.AllowBackgroundResponses is true }
};
@@ -134,7 +134,7 @@ public sealed class A2AAgent : AIAgent
{
var agentTask = a2aResponse.Task!;
UpdateSession(typedSession, agentTask.ContextId, agentTask.Id);
UpdateSession(typedSession, agentTask.ContextId, agentTask.Id, agentTask.Status.State);
return this.ConvertToAgentResponse(agentTask);
}
@@ -145,7 +145,7 @@ public sealed class A2AAgent : AIAgent
/// <inheritdoc/>
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(messages);
var inputMessages = Throw.IfNull(messages) as IReadOnlyCollection<ChatMessage> ?? messages.ToList();
A2AAgentSession typedSession = await this.GetA2ASessionAsync(session, options, cancellationToken).ConfigureAwait(false);
@@ -153,7 +153,7 @@ public sealed class A2AAgent : AIAgent
ConfiguredCancelableAsyncEnumerable<StreamResponse> streamEvents;
if (GetContinuationToken(messages, options) is { } token)
if (GetContinuationToken(inputMessages, options) is { } token)
{
streamEvents = this.SubscribeToTaskWithFallbackAsync(token.TaskId, cancellationToken).ConfigureAwait(false);
}
@@ -161,7 +161,7 @@ public sealed class A2AAgent : AIAgent
{
SendMessageRequest sendParams = new()
{
Message = CreateA2AMessage(typedSession, messages),
Message = CreateA2AMessage(typedSession, inputMessages),
Metadata = options?.AdditionalProperties?.ToA2AMetadata()
};
@@ -172,6 +172,7 @@ public sealed class A2AAgent : AIAgent
string? contextId = null;
string? taskId = null;
TaskState? taskState = null;
await foreach (var streamResponse in streamEvents)
{
@@ -187,6 +188,7 @@ public sealed class A2AAgent : AIAgent
var task = streamResponse.Task!;
contextId = task.ContextId;
taskId = task.Id;
taskState = task.Status.State;
yield return this.ConvertToAgentResponseUpdate(task);
break;
@@ -194,6 +196,7 @@ public sealed class A2AAgent : AIAgent
var statusUpdate = streamResponse.StatusUpdate!;
contextId = statusUpdate.ContextId;
taskId = statusUpdate.TaskId;
taskState = statusUpdate.Status.State;
yield return this.ConvertToAgentResponseUpdate(statusUpdate);
break;
@@ -209,7 +212,7 @@ public sealed class A2AAgent : AIAgent
}
}
UpdateSession(typedSession, contextId, taskId);
UpdateSession(typedSession, contextId, taskId, taskState);
}
/// <inheritdoc/>
@@ -317,7 +320,7 @@ public sealed class A2AAgent : AIAgent
}
}
private static void UpdateSession(A2AAgentSession? session, string? contextId, string? taskId = null)
private static void UpdateSession(A2AAgentSession? session, string? contextId, string? taskId = null, TaskState? taskState = null)
{
if (session is null)
{
@@ -335,9 +338,10 @@ public sealed class A2AAgent : AIAgent
// Assign a server-generated context Id to the session if it's not already set.
session.ContextId ??= contextId;
session.TaskId = taskId;
session.TaskState = taskState;
}
private static Message CreateA2AMessage(A2AAgentSession typedSession, IEnumerable<ChatMessage> messages)
private static Message CreateA2AMessage(A2AAgentSession typedSession, IReadOnlyCollection<ChatMessage> messages)
{
var a2aMessage = messages.ToA2AMessage();
@@ -345,9 +349,19 @@ public sealed class A2AAgent : AIAgent
// See: https://github.com/a2aproject/A2A/blob/main/docs/topics/life-of-a-task.md#group-related-interactions
a2aMessage.ContextId = typedSession.ContextId;
// Link the message as a follow-up to an existing task, if any.
// See: https://github.com/a2aproject/A2A/blob/main/docs/topics/life-of-a-task.md#task-refinements
a2aMessage.ReferenceTaskIds = typedSession.TaskId is null ? null : [typedSession.TaskId];
if (typedSession.TaskState == TaskState.InputRequired)
{
// If the session indicates the task is waiting for user input,
// link the response to the existing task so it is treated as input
// for that task.
a2aMessage.TaskId = typedSession.TaskId;
}
else
{
// Link the message as a follow-up to an existing task, if any.
// See: https://github.com/a2aproject/A2A/blob/main/docs/topics/life-of-a-task.md#task-refinements
a2aMessage.ReferenceTaskIds = typedSession.TaskId is not null ? [typedSession.TaskId] : null;
}
return a2aMessage;
}
@@ -444,6 +458,7 @@ public sealed class A2AAgent : AIAgent
Role = ChatRole.Assistant,
FinishReason = MapTaskStateToFinishReason(statusUpdateEvent.Status.State),
AdditionalProperties = statusUpdateEvent.Metadata?.ToAdditionalProperties() ?? [],
Contents = statusUpdateEvent.Status.GetUserInputRequests(),
};
}
@@ -5,6 +5,8 @@ using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Serialization;
using TaskState = A2A.TaskState;
namespace Microsoft.Agents.AI.A2A;
/// <summary>
@@ -18,10 +20,11 @@ public sealed class A2AAgentSession : AgentSession
}
[JsonConstructor]
internal A2AAgentSession(string? contextId, string? taskId, AgentSessionStateBag? stateBag) : base(stateBag ?? new())
internal A2AAgentSession(string? contextId, string? taskId, TaskState? taskState, AgentSessionStateBag? stateBag) : base(stateBag ?? new())
{
this.ContextId = contextId;
this.TaskId = taskId;
this.TaskState = taskState;
}
/// <summary>
@@ -36,6 +39,12 @@ public sealed class A2AAgentSession : AgentSession
[JsonPropertyName("taskId")]
public string? TaskId { get; internal set; }
/// <summary>
/// Gets the state of the task the agent is currently working on.
/// </summary>
[JsonPropertyName("taskState")]
public TaskState? TaskState { get; internal set; }
/// <inheritdoc/>
internal JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
{
@@ -57,5 +66,5 @@ public sealed class A2AAgentSession : AgentSession
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
private string DebuggerDisplay =>
$"ContextId = {this.ContextId}, TaskId = {this.TaskId}, StateBag Count = {this.StateBag.Count}";
$"ContextId = {this.ContextId}, TaskId = {this.TaskId}, TaskState = {this.TaskState}, StateBag Count = {this.StateBag.Count}";
}
@@ -13,7 +13,7 @@ internal static class A2AAIContentExtensions
/// <summary>
/// Converts a collection of <see cref="AIContent"/> to a list of <see cref="Part"/> objects.
/// </summary>
/// <param name="contents">The collection of AI contents to convert.</param>"
/// <param name="contents">The collection of AI contents to convert.</param>
/// <returns>The list of A2A <see cref="Part"/> objects.</returns>
internal static List<Part>? ToParts(this IEnumerable<AIContent> contents)
{
@@ -21,8 +21,7 @@ internal static class A2AAIContentExtensions
foreach (var content in contents)
{
var part = content.ToPart();
if (part is not null)
if (content.ToPart() is { } part)
{
(parts ??= []).Add(part);
}
@@ -17,7 +17,7 @@ internal static class A2AAgentTaskExtensions
List<ChatMessage>? messages = null;
if (agentTask?.Artifacts is { Count: > 0 })
if (agentTask.Artifacts is { Count: > 0 })
{
foreach (var artifact in agentTask.Artifacts)
{
@@ -25,6 +25,14 @@ internal static class A2AAgentTaskExtensions
}
}
if (agentTask.Status?.GetUserInputRequests() is { } userInputRequests)
{
(messages ??= []).Add(new(ChatRole.Assistant, userInputRequests)
{
RawRepresentation = agentTask.Status,
});
}
return messages;
}
@@ -42,6 +50,11 @@ internal static class A2AAgentTaskExtensions
}
}
if (agentTask.Status?.GetUserInputRequests() is { } userInputRequests)
{
(aiContents ??= []).AddRange(userInputRequests);
}
return aiContents;
}
}
@@ -0,0 +1,35 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace A2A;
/// <summary>
/// Extension methods for the <see cref="TaskStatus"/> class.
/// </summary>
internal static class AgentTaskStatusExtensions
{
internal static IList<AIContent>? GetUserInputRequests(this TaskStatus status)
{
_ = Throw.IfNull(status);
List<AIContent>? contents = null;
if (status.Message is null || status.State is not TaskState.InputRequired)
{
return contents;
}
foreach (var part in status.Message.Parts)
{
var aiContent = part.ToAIContent();
aiContent.RawRepresentation = part;
aiContent.AdditionalProperties = part.Metadata.ToAdditionalProperties();
(contents ??= []).Add(aiContent);
}
return contents;
}
}
@@ -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
}
@@ -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;
}
}
@@ -186,6 +186,8 @@ internal sealed class AsyncRunHandle : ICheckpointingHandle, IAsyncDisposable
public async ValueTask RestoreCheckpointAsync(CheckpointInfo checkpointInfo, CancellationToken cancellationToken = default)
{
LockstepRunEventStream? lockstepEventStream = null;
// Clear buffered events from the channel BEFORE restoring to discard stale events from supersteps
// that occurred after the checkpoint we're restoring to
// This must happen BEFORE the restore so that events republished during restore aren't cleared
@@ -193,8 +195,9 @@ internal sealed class AsyncRunHandle : ICheckpointingHandle, IAsyncDisposable
{
streamingEventStream.ClearBufferedEvents();
}
else if (this._eventStream is LockstepRunEventStream lockstepEventStream)
else if (this._eventStream is LockstepRunEventStream lES)
{
lockstepEventStream = lES;
lockstepEventStream.ClearBufferedEvents();
}
@@ -202,6 +205,8 @@ internal sealed class AsyncRunHandle : ICheckpointingHandle, IAsyncDisposable
// This can re-emit pending requests into the already-active event stream.
await this._checkpointingHandle.RestoreCheckpointAsync(checkpointInfo, cancellationToken).ConfigureAwait(false);
lockstepEventStream?.UpdateStatus();
// After restore, signal the run loop to process any restored messages. Initial resume
// paths handle this separately when they create the event stream after restoring state.
this.SignalInputToRunLoop();
@@ -46,6 +46,17 @@ internal sealed class LockstepRunEventStream : IRunEventStream
Activity.Current = previousActivity;
}
internal void UpdateStatus()
{
switch (this.RunStatus)
{
case RunStatus.Idle:
case RunStatus.PendingRequests:
this.RunStatus = this._stepRunner.HasUnservicedRequests ? RunStatus.PendingRequests : RunStatus.Idle;
break;
}
}
public async IAsyncEnumerable<WorkflowEvent> TakeEventStreamAsync(bool blockOnPendingRequest, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
#if NET
@@ -217,7 +217,11 @@ internal sealed class StreamingRunEventStream : IRunEventStream
// Get the current epoch - we'll only respond to completion signals from this epoch or later
int currentEpoch = Volatile.Read(ref this._completionEpoch);
bool expectingFreshWork = this._stepRunner.HasUnprocessedMessages || this._runStatus == RunStatus.Running;
bool expectingFreshWork = this._stepRunner.HasUnprocessedMessages ||
this._stepRunner.HasUnservicedRequests ||
this._runStatus == RunStatus.Running ||
this._runStatus == RunStatus.PendingRequests;
int myEpoch = expectingFreshWork ? currentEpoch + 1 : currentEpoch;
// Use custom async enumerable to avoid exceptions on cancellation.
@@ -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/>
@@ -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";
@@ -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.
@@ -41,14 +41,7 @@ param(
[string] $Repository = "foundry-hosting-it",
[string] $TestContainerProject = "dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer",
# Explicit opt-in for the no-rebuild fast path. CI sets this after running the
# "Build Foundry hosted IT (and its deps)" step, which guarantees the prebuilt
# library DLLs match current source. Off by default so local invocations always
# let publish rebuild ProjectReferences and never produce an image whose tag is
# computed from current source while the contents come from a stale build.
[switch] $UsePrebuiltProjectReferences
[string] $TestContainerProject = "dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer"
)
$ErrorActionPreference = "Stop"
@@ -107,60 +100,35 @@ if (Test-Path $out) {
Remove-Item -Recurse -Force $out
}
# Conditionally tell publish to skip rebuilding ProjectReferences and consume the
# prebuilt library DLLs in place. This avoids two failure modes that arise when
# the CI workflow runs a `dotnet build` of the same library projects immediately
# before this script:
# 1) MSB3026 "file is being used by another process" when publish's MSBuild
# tries to overwrite src/<lib>/bin/Release/net10.0/<lib>.dll while the
# previous build's shared-compilation server still holds a file handle.
# 2) Publish needlessly rebuilding identical managed (RID-agnostic) library
# DLLs that prebuild already produced.
# Gated on -UsePrebuiltProjectReferences (a strict opt-in) instead of marker
# detection, because a developer machine may have a stale Release build of the
# libraries from days ago; using those would silently produce an image whose
# content is older than the source the tag is computed from.
$publishExtraArgs = @()
if ($UsePrebuiltProjectReferences) {
Write-Host "-UsePrebuiltProjectReferences: skipping ProjectReference rebuild." -ForegroundColor DarkGray
$publishExtraArgs += "-p:BuildProjectReferences=false"
} else {
# Preflight: in default (rebuild) mode, publish propagates RuntimeIdentifier=linux-musl-x64
# to library ProjectReferences and writes their intermediates to a RID-suffixed obj path
# (e.g. obj/Release/net10.0/linux-musl-x64/). DefaultItemExcludes follows the new
# IntermediateOutputPath, so any *.AssemblyInfo.cs left in obj/Release/net10.0/ from a
# prior `dotnet build` is no longer excluded and gets picked up by the **/*.cs Compile
# glob, producing CS0579 "duplicate attribute" errors. Detect that state up front and
# tell the user exactly how to recover.
$staleObjProbes = @(
"dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/obj/Release/net10.0",
"dotnet/src/Microsoft.Agents.AI.Foundry/obj/Release/net10.0",
"dotnet/src/Microsoft.Agents.AI/obj/Release/net10.0",
"dotnet/src/Microsoft.Agents.AI.Abstractions/obj/Release/net10.0"
)
$stale = @($staleObjProbes | Where-Object { Test-Path (Join-Path $_ "*.AssemblyInfo.cs") })
if ($stale.Count -gt 0) {
$msg = @(
"Detected prior Release/net10.0 build outputs in:"
($stale | ForEach-Object { " - $_" })
""
"Publish would propagate -r linux-musl-x64 to those ProjectReferences and the"
"leftover obj/Release/net10.0/*.AssemblyInfo.cs files would cause CS0579 duplicate"
"attribute errors. Pick one:"
" (a) Pass -UsePrebuiltProjectReferences (skips ProjectReference rebuild and"
" uses the existing src/<lib>/bin/Release/net10.0/*.dll outputs in place)."
" Only safe when you know those DLLs match current source - this is the path"
" CI uses immediately after its 'Build Foundry hosted IT (and its deps)' step."
" (b) Remove the stale obj/Release trees, e.g.:"
" Remove-Item -Recurse -Force dotnet/src/Microsoft.Agents.AI*/obj/Release"
" and re-run."
) -join "`n"
throw $msg
}
Write-Host "Letting publish build ProjectReferences (pass -UsePrebuiltProjectReferences in CI to skip)." -ForegroundColor DarkGray
# Always tell publish to skip ProjectReference rebuilds via --no-dependencies. Publish
# resolves TestContainer's framework lib references (Foundry, Foundry.Hosting and their
# transitive deps) by reading the prebuilt DLLs at src/<lib>/bin/Release/net10.0/*.dll.
# This:
# 1) Structurally avoids the MSB3026 "file is being used by another process" race that
# occurs when publish overwrites the same DLL paths a prior `dotnet build` produced
# while VBCSCompiler from that build still holds file handles.
# 2) Avoids needlessly rebuilding identical managed (RID-agnostic) library DLLs.
# Callers MUST run `dotnet build dotnet/tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj -c Release`
# (or equivalent) first so those prebuilt DLLs exist. The CI workflow does this in the
# preceding "Build Foundry hosted IT (and its deps)" step.
$prebuildProbes = @(
"dotnet/src/Microsoft.Agents.AI.Foundry/bin/Release/net10.0/Microsoft.Agents.AI.Foundry.dll",
"dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/bin/Release/net10.0/Microsoft.Agents.AI.Foundry.Hosting.dll"
)
$missingPrebuilds = @($prebuildProbes | Where-Object { -not (Test-Path $_) })
if ($missingPrebuilds.Count -gt 0) {
$msg = @(
"Required prebuilt outputs not found:"
($missingPrebuilds | ForEach-Object { " - $_" })
""
"Publish runs with --no-dependencies and consumes prebuilt DLLs in place. Build the"
"test project first so its ProjectReference closure populates src/<lib>/bin/Release/net10.0/:"
" dotnet build dotnet/tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj -c Release"
) -join "`n"
throw $msg
}
dotnet publish $TestContainerProject -c Release -f net10.0 -r linux-musl-x64 --self-contained false -o $out @publishExtraArgs --tl:off | Out-Host
dotnet publish $TestContainerProject -c Release -f net10.0 -r linux-musl-x64 --self-contained false --no-dependencies -o $out --tl:off | Out-Host
if ($LASTEXITCODE -ne 0) {
throw "dotnet publish failed with exit code $LASTEXITCODE."
}
@@ -493,6 +493,35 @@ public sealed class A2AAgentTests : IDisposable
Assert.Contains("task-123", message.ReferenceTaskIds);
}
[Fact]
public async Task RunAsync_WithInputRequiredTaskState_SetsTaskIdOnMessageAsync()
{
// Arrange
this._handler.ResponseToReturn = new SendMessageResponse
{
Message = new Message
{
MessageId = "response-456",
Role = Role.Agent,
Parts = [new Part { Text = "Booking confirmed" }]
}
};
var session = (A2AAgentSession)await this._agent.CreateSessionAsync();
session.TaskId = "task-123";
session.TaskState = TaskState.InputRequired;
var inputMessage = new ChatMessage(ChatRole.User, [new TextContent("New York to London")]);
// Act
await this._agent.RunAsync(inputMessage, session);
// Assert
var message = this._handler.CapturedSendMessageRequest?.Message;
Assert.Equal("task-123", message?.TaskId);
Assert.Null(message?.ReferenceTaskIds);
}
[Fact]
public async Task RunAsync_WithAgentTask_UpdatesSessionTaskIdAsync()
{
@@ -573,6 +602,7 @@ public sealed class A2AAgentTests : IDisposable
[InlineData(TaskState.Completed)]
[InlineData(TaskState.Failed)]
[InlineData(TaskState.Canceled)]
[InlineData(TaskState.InputRequired)]
public async Task RunAsync_WithVariousTaskStates_ReturnsCorrectTokenAsync(TaskState taskState)
{
// Arrange
@@ -842,6 +872,38 @@ public sealed class A2AAgentTests : IDisposable
Assert.Contains("task-123", message.ReferenceTaskIds);
}
[Fact]
public async Task RunStreamingAsync_WithInputRequiredTaskState_SetsTaskIdOnMessageAsync()
{
// Arrange
this._handler.StreamingResponseToReturn = new StreamResponse
{
Message = new Message
{
MessageId = "response-456",
Role = Role.Agent,
Parts = [new Part { Text = "Booking confirmed" }]
}
};
var session = (A2AAgentSession)await this._agent.CreateSessionAsync();
session.TaskId = "task-123";
session.TaskState = TaskState.InputRequired;
var inputMessage = new ChatMessage(ChatRole.User, [new TextContent("New York to London")]);
// Act
await foreach (var _ in this._agent.RunStreamingAsync([inputMessage], session))
{
// Just iterate through to trigger the logic
}
// Assert
var message = this._handler.CapturedSendMessageRequest?.Message;
Assert.Equal("task-123", message?.TaskId);
Assert.Null(message?.ReferenceTaskIds);
}
[Fact]
public async Task RunStreamingAsync_WithAgentTask_UpdatesSessionTaskIdAsync()
{
@@ -1004,6 +1066,50 @@ public sealed class A2AAgentTests : IDisposable
Assert.Equal(TaskId, a2aSession.TaskId);
}
[Fact]
public async Task RunStreamingAsync_WithInputRequiredStatusUpdate_YieldsStatusContentsAsync()
{
// Arrange
const string TaskId = "task-input-123";
const string ContextId = "ctx-input-456";
this._handler.StreamingResponseToReturn = new StreamResponse
{
StatusUpdate = new TaskStatusUpdateEvent
{
TaskId = TaskId,
ContextId = ContextId,
Status = new()
{
State = TaskState.InputRequired,
Message = new Message
{
Parts = [Part.FromText("Where would you like to fly?")]
}
}
}
};
var session = await this._agent.CreateSessionAsync();
// Act
var updates = new List<AgentResponseUpdate>();
await foreach (var update in this._agent.RunStreamingAsync("I'd like to book a flight.", session))
{
updates.Add(update);
}
// Assert
Assert.Single(updates);
var update0 = updates[0];
Assert.Equal(TaskId, update0.ResponseId);
Assert.Null(update0.FinishReason);
var textContent = Assert.Single(update0.Contents.OfType<TextContent>());
Assert.Equal("Where would you like to fly?", textContent.Text);
}
[Fact]
public async Task RunStreamingAsync_WithTaskArtifactUpdateEvent_YieldsResponseUpdateAsync()
{
@@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using A2A;
using Microsoft.Extensions.AI;
@@ -166,4 +167,79 @@ public sealed class A2AAgentTaskExtensionsTests
Assert.Equal("content2", result[1].ToString());
Assert.Equal("content3", result[2].ToString());
}
[Fact]
public void ToChatMessages_WithInputRequiredStatus_IncludesStatusContents()
{
// Arrange
var agentTask = new AgentTask
{
Id = "task1",
Artifacts = null,
Status = new TaskStatus
{
State = TaskState.InputRequired,
Message = new Message { Parts = [Part.FromText("What is your destination?")] },
},
};
// Act
IList<ChatMessage>? result = agentTask.ToChatMessages();
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.Equal(ChatRole.Assistant, result[0].Role);
var textContent = Assert.Single(result[0].Contents.OfType<TextContent>());
Assert.Equal("What is your destination?", textContent.Text);
}
[Fact]
public void ToAIContents_WithInputRequiredStatus_IncludesStatusContents()
{
// Arrange
var agentTask = new AgentTask
{
Id = "task1",
Artifacts = null,
Status = new TaskStatus
{
State = TaskState.InputRequired,
Message = new Message { Parts = [Part.FromText("What is your destination?")] },
},
};
// Act
IList<AIContent>? result = agentTask.ToAIContents();
// Assert
Assert.NotNull(result);
var textContent = Assert.Single(result.OfType<TextContent>());
Assert.Equal("What is your destination?", textContent.Text);
}
[Fact]
public void ToChatMessages_WithArtifactsAndInputRequired_IncludesBoth()
{
// Arrange
var agentTask = new AgentTask
{
Id = "task1",
Artifacts = [new Artifact { Parts = [Part.FromText("partial result")] }],
Status = new TaskStatus
{
State = TaskState.InputRequired,
Message = new Message { Parts = [Part.FromText("Need more info")] },
},
};
// Act
IList<ChatMessage>? result = agentTask.ToChatMessages();
// Assert
Assert.NotNull(result);
Assert.Equal(2, result.Count);
Assert.Equal("partial result", result[0].Text);
Assert.Single(result[1].Contents.OfType<TextContent>());
}
}
@@ -0,0 +1,121 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using A2A;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.A2A.UnitTests;
/// <summary>
/// Unit tests for the <see cref="AgentTaskStatusExtensions"/> class.
/// </summary>
public sealed class AgentTaskStatusExtensionsTests
{
[Fact]
public void GetUserInputRequests_WithNullMessage_ReturnsNull()
{
// Arrange
var status = new TaskStatus
{
State = TaskState.InputRequired,
Message = null,
};
// Act
IList<AIContent>? result = status.GetUserInputRequests();
// Assert
Assert.Null(result);
}
[Fact]
public void GetUserInputRequests_WithNotInputRequiredState_ReturnsNull()
{
// Arrange
var status = new TaskStatus
{
State = TaskState.Completed,
Message = new Message { Parts = [Part.FromText("Some text")] },
};
// Act
IList<AIContent>? result = status.GetUserInputRequests();
// Assert
Assert.Null(result);
}
[Fact]
public void GetUserInputRequests_WithInputRequiredStateAndMultipleRequests_ReturnsAIContentList()
{
// Arrange
var status = new TaskStatus
{
State = TaskState.InputRequired,
Message = new Message
{
Parts =
[
Part.FromText("First request"),
Part.FromText("Second request"),
Part.FromText("Third request")
],
},
};
// Act
IList<AIContent>? result = status.GetUserInputRequests();
// Assert
Assert.NotNull(result);
Assert.Equal(3, result.Count);
Assert.Equal("First request", Assert.IsType<TextContent>(result[0]).Text);
Assert.Equal("Second request", Assert.IsType<TextContent>(result[1]).Text);
Assert.Equal("Third request", Assert.IsType<TextContent>(result[2]).Text);
}
[Fact]
public void GetUserInputRequests_WithTextParts_SetsRawRepresentationAndAdditionalPropertiesCorrectly()
{
// Arrange
var textPart = Part.FromText("Input request");
textPart.Metadata = new Dictionary<string, System.Text.Json.JsonElement>
{
{ "key1", System.Text.Json.JsonSerializer.SerializeToElement("value1") },
{ "key2", System.Text.Json.JsonSerializer.SerializeToElement("value2") }
};
var status = new TaskStatus
{
State = TaskState.InputRequired,
Message = new Message { Parts = [textPart] },
};
// Act
IList<AIContent>? result = status.GetUserInputRequests();
// Assert
Assert.NotNull(result);
var content = Assert.IsType<TextContent>(result[0]);
Assert.Equal(textPart, content.RawRepresentation);
Assert.NotNull(content.AdditionalProperties);
Assert.True(content.AdditionalProperties.ContainsKey("key1"));
Assert.True(content.AdditionalProperties.ContainsKey("key2"));
}
[Fact]
public void GetUserInputRequests_WithEmptyMessageParts_ReturnsNull()
{
// Arrange
var status = new TaskStatus
{
State = TaskState.InputRequired,
Message = new Message { Parts = [] },
};
// Act
IList<AIContent>? result = status.GetUserInputRequests();
// Assert
Assert.Null(result);
}
}
@@ -0,0 +1,183 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Moq;
namespace Microsoft.Agents.AI.DevUI.UnitTests;
public class DevUIAccessControlTests
{
private static WebApplicationBuilder NewBuilder()
{
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
var mockChatClient = new Mock<IChatClient>();
var agent = new ChatClientAgent(mockChatClient.Object, "Test", "agent-name");
builder.Services.AddKeyedSingleton<AIAgent>("agent-name", agent);
return builder;
}
private static void SimulateRemoteIp(WebApplication app, IPAddress remoteIp)
{
app.Use(async (HttpContext ctx, RequestDelegate next) =>
{
ctx.Connection.RemoteIpAddress = remoteIp;
await next(ctx);
});
}
[Fact]
public async Task NonLoopbackRequest_ReturnsForbiddenByDefaultAsync()
{
var builder = NewBuilder();
builder.Services.AddDevUI();
using var app = builder.Build();
SimulateRemoteIp(app, IPAddress.Parse("192.0.2.1"));
app.MapDevUI();
await app.StartAsync();
var response = await app.GetTestClient().GetAsync(new Uri("/v1/entities", UriKind.Relative));
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task NonLoopbackRequest_IsAllowedWhenAllowRemoteAccessAsync()
{
var builder = NewBuilder();
builder.Services.AddDevUI(o => o.AllowRemoteAccess = true);
using var app = builder.Build();
SimulateRemoteIp(app, IPAddress.Parse("192.0.2.1"));
app.MapDevUI();
await app.StartAsync();
var response = await app.GetTestClient().GetAsync(new Uri("/v1/entities", UriKind.Relative));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task LoopbackRequest_WithAuthTokenSet_RequiresBearerHeaderAsync()
{
var builder = NewBuilder();
builder.Services.AddDevUI(o => o.AuthToken = "secret-token");
using var app = builder.Build();
SimulateRemoteIp(app, IPAddress.Loopback);
app.MapDevUI();
await app.StartAsync();
var response = await app.GetTestClient().GetAsync(new Uri("/v1/entities", UriKind.Relative));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task LoopbackRequest_WithCorrectBearerToken_SucceedsAsync()
{
var builder = NewBuilder();
builder.Services.AddDevUI(o => o.AuthToken = "secret-token");
using var app = builder.Build();
SimulateRemoteIp(app, IPAddress.Loopback);
app.MapDevUI();
await app.StartAsync();
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri("/v1/entities", UriKind.Relative));
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "secret-token");
var response = await app.GetTestClient().SendAsync(request);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task EnvironmentVariableToken_IsEnforcedWhenAuthTokenNotConfiguredAsync()
{
const string EnvVar = "DEVUI_AUTH_TOKEN";
const string EnvToken = "env-token";
var previous = Environment.GetEnvironmentVariable(EnvVar);
Environment.SetEnvironmentVariable(EnvVar, EnvToken);
WebApplication? app = null;
try
{
var builder = NewBuilder();
builder.Services.AddDevUI();
app = builder.Build();
// Force singleton construction so the env var is captured before we
// restore it; otherwise tests running in parallel can pick up the
// leaked DEVUI_AUTH_TOKEN.
_ = app.Services.GetRequiredService<DevUIAuthFilter>();
}
finally
{
Environment.SetEnvironmentVariable(EnvVar, previous);
}
await using (app)
{
SimulateRemoteIp(app, IPAddress.Loopback);
app.MapDevUI();
await app.StartAsync();
var missing = await app.GetTestClient().GetAsync(new Uri("/v1/entities", UriKind.Relative));
Assert.Equal(HttpStatusCode.Unauthorized, missing.StatusCode);
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri("/v1/entities", UriKind.Relative));
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", EnvToken);
var accepted = await app.GetTestClient().SendAsync(request);
Assert.Equal(HttpStatusCode.OK, accepted.StatusCode);
}
}
[Fact]
public async Task MetaEndpoint_IsReachableWithoutAuthenticationAsync()
{
var builder = NewBuilder();
builder.Services.AddDevUI(o => o.AuthToken = "secret-token");
using var app = builder.Build();
SimulateRemoteIp(app, IPAddress.Loopback);
app.MapDevUI();
await app.StartAsync();
var response = await app.GetTestClient().GetAsync(new Uri("/meta", UriKind.Relative));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadAsStringAsync();
Assert.Contains("\"auth_required\":true", body);
}
[Fact]
public async Task LoopbackRequest_WithWrongBearerToken_ReturnsUnauthorizedAsync()
{
var builder = NewBuilder();
builder.Services.AddDevUI(o => o.AuthToken = "secret-token");
using var app = builder.Build();
SimulateRemoteIp(app, IPAddress.Loopback);
app.MapDevUI();
await app.StartAsync();
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri("/v1/entities", UriKind.Relative));
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "not-the-token");
var response = await app.GetTestClient().SendAsync(request);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
}
@@ -33,7 +33,7 @@ public class DevUIIntegrationTests
var agent = new ChatClientAgent(mockChatClient.Object, "Test", "agent-name");
builder.Services.AddKeyedSingleton<AIAgent>("registration-key", agent);
builder.Services.AddDevUI();
builder.Services.AddDevUI(o => o.AllowRemoteAccess = true);
using WebApplication app = builder.Build();
app.MapDevUI();
@@ -66,7 +66,7 @@ public class DevUIIntegrationTests
builder.Services.AddKeyedSingleton<AIAgent>("key-1", agent1);
builder.Services.AddKeyedSingleton<AIAgent>("key-2", agent2);
builder.Services.AddKeyedSingleton<AIAgent>("key-3", agent3);
builder.Services.AddDevUI();
builder.Services.AddDevUI(o => o.AllowRemoteAccess = true);
using WebApplication app = builder.Build();
app.MapDevUI();
@@ -102,7 +102,7 @@ public class DevUIIntegrationTests
builder.Services.AddKeyedSingleton<AIAgent>("key-1", agentKeyed1);
builder.Services.AddKeyedSingleton<AIAgent>("key-2", agentKeyed2);
builder.Services.AddSingleton<AIAgent>(agentDefault);
builder.Services.AddDevUI();
builder.Services.AddDevUI(o => o.AllowRemoteAccess = true);
using WebApplication app = builder.Build();
app.MapDevUI();
@@ -151,7 +151,7 @@ public class DevUIIntegrationTests
builder.Services.AddKeyedSingleton("key-1", workflow1);
builder.Services.AddKeyedSingleton("key-2", workflow2);
builder.Services.AddKeyedSingleton("key-3", workflow3);
builder.Services.AddDevUI();
builder.Services.AddDevUI(o => o.AllowRemoteAccess = true);
using WebApplication app = builder.Build();
app.MapDevUI();
@@ -197,7 +197,7 @@ public class DevUIIntegrationTests
builder.Services.AddKeyedSingleton("key-1", workflowKeyed1);
builder.Services.AddKeyedSingleton("key-2", workflowKeyed2);
builder.Services.AddSingleton(workflowDefault);
builder.Services.AddDevUI();
builder.Services.AddDevUI(o => o.AllowRemoteAccess = true);
using WebApplication app = builder.Build();
app.MapDevUI();
@@ -255,7 +255,7 @@ public class DevUIIntegrationTests
builder.Services.AddKeyedSingleton("workflow-key-1", workflow1);
builder.Services.AddKeyedSingleton("workflow-key-2", workflow2);
builder.Services.AddSingleton(workflowDefault);
builder.Services.AddDevUI();
builder.Services.AddDevUI(o => o.AllowRemoteAccess = true);
using WebApplication app = builder.Build();
app.MapDevUI();
@@ -1,328 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Azure.AI.Projects;
using Azure.AI.Projects.Agents;
using Microsoft.Extensions.AI;
#pragma warning disable OPENAI001
#pragma warning disable AAIP001
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
/// <summary>
/// Unit tests for the <see cref="FoundryToolbox"/> class.
/// </summary>
public class FoundryToolboxTests
{
private static readonly Uri s_testEndpoint = new("https://test.services.ai.azure.com/api/projects/test-project");
#region Parameter validation tests
[Fact]
public async Task GetToolboxVersionAsync_NullEndpoint_ThrowsAsync()
{
await Assert.ThrowsAsync<ArgumentNullException>(() =>
FoundryToolbox.GetToolboxVersionAsync(
projectEndpoint: null!,
credential: new FakeAuthenticationTokenProvider(),
name: "test-toolbox"));
}
[Fact]
public async Task GetToolboxVersionAsync_NullCredential_ThrowsAsync()
{
await Assert.ThrowsAsync<ArgumentNullException>(() =>
FoundryToolbox.GetToolboxVersionAsync(
projectEndpoint: s_testEndpoint,
credential: null!,
name: "test-toolbox"));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task GetToolboxVersionAsync_InvalidName_ThrowsAsync(string? name)
{
await Assert.ThrowsAnyAsync<ArgumentException>(() =>
FoundryToolbox.GetToolboxVersionAsync(
projectEndpoint: s_testEndpoint,
credential: new FakeAuthenticationTokenProvider(),
name: name!));
}
[Fact]
public async Task GetToolsAsync_NullEndpoint_ThrowsAsync()
{
await Assert.ThrowsAsync<ArgumentNullException>(() =>
FoundryToolbox.GetToolsAsync(
projectEndpoint: null!,
credential: new FakeAuthenticationTokenProvider(),
name: "test-toolbox"));
}
[Fact]
public void ToAITools_NullToolboxVersion_Throws()
{
Assert.Throws<ArgumentNullException>(() =>
FoundryToolbox.ToAITools(null!));
}
#endregion
#region ToAITools conversion tests
[Fact]
public void ToAITools_EmptyTools_ReturnsEmptyList()
{
var version = ProjectsAgentsModelFactory.ToolboxVersion(
metadata: null,
id: "ver-1",
name: "empty-toolbox",
version: "v1",
description: "Empty",
createdAt: DateTimeOffset.UtcNow,
tools: Array.Empty<ProjectsAgentTool>(),
policies: null);
var tools = version.ToAITools();
Assert.Empty(tools);
}
[Fact]
public void ToAITools_NullTools_ReturnsEmptyList()
{
var version = ProjectsAgentsModelFactory.ToolboxVersion(
metadata: null,
id: "ver-1",
name: "null-tools-toolbox",
version: "v1",
description: "Null tools",
createdAt: DateTimeOffset.UtcNow,
tools: null,
policies: null);
var tools = version.ToAITools();
Assert.Empty(tools);
}
[Fact]
public void ToAITools_WithCodeInterpreterTool_ReturnsAITool()
{
var json = TestDataUtil.GetToolboxVersionResponseJson();
var version = ModelReaderWriter.Read<ToolboxVersion>(BinaryData.FromString(json))!;
var tools = version.ToAITools();
Assert.Single(tools);
Assert.IsAssignableFrom<AITool>(tools[0]);
}
[Fact]
public void ToAITools_SanitizesDecorationFieldsOnNonFunctionTools()
{
var json = TestDataUtil.GetToolboxVersionWithDecorationFieldsJson();
var version = ModelReaderWriter.Read<ToolboxVersion>(BinaryData.FromString(json))!;
var tools = version.ToAITools();
Assert.Single(tools);
Assert.IsAssignableFrom<AITool>(tools[0]);
}
[Fact]
public void SanitizeAndConvert_FunctionTool_PreservesNameAndDescription()
{
const string ToolJson = @"{""type"":""function"",""name"":""get_weather"",""description"":""Get weather"",""parameters"":{""type"":""object"",""properties"":{}}}";
var tool = ModelReaderWriter.Read<ProjectsAgentTool>(BinaryData.FromString(ToolJson))!;
var aiTool = FoundryToolbox.SanitizeAndConvert(tool);
Assert.NotNull(aiTool);
Assert.IsAssignableFrom<AITool>(aiTool);
}
[Fact]
public void SanitizeAndConvert_CodeInterpreterWithExtraFields_StripsDecorationFields()
{
const string ToolJson = @"{""type"":""code_interpreter"",""name"":""code_interpreter"",""description"":""Execute code""}";
var tool = ModelReaderWriter.Read<ProjectsAgentTool>(BinaryData.FromString(ToolJson))!;
var aiTool = FoundryToolbox.SanitizeAndConvert(tool);
Assert.NotNull(aiTool);
}
#endregion
#region Integration tests with mock HTTP
[Fact]
public async Task GetToolboxVersionAsync_WithExplicitVersion_FetchesVersionDirectlyAsync()
{
var versionJson = TestDataUtil.GetToolboxVersionResponseJson();
using var httpHandler = new HttpHandlerAssert((request) =>
{
Assert.Contains("/toolboxes/research_tools/versions/v5", request.RequestUri!.PathAndQuery);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(versionJson, Encoding.UTF8, "application/json")
};
});
#pragma warning disable CA5399
using var httpClient = new HttpClient(httpHandler);
#pragma warning restore CA5399
var clientOptions = new AgentAdministrationClientOptions { Transport = new HttpClientPipelineTransport(httpClient) };
var result = await FoundryToolbox.GetToolboxVersionAsync(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
"research_tools",
version: "v5",
clientOptions: clientOptions,
cancellationToken: default);
Assert.Equal("research_tools", result.Name);
Assert.Equal("v5", result.Version);
Assert.Single(result.Tools);
}
[Fact]
public async Task GetToolboxVersionAsync_WithoutVersion_ResolvesDefaultThenFetchesAsync()
{
var recordJson = TestDataUtil.GetToolboxRecordResponseJson();
var versionJson = TestDataUtil.GetToolboxVersionResponseJson();
var callCount = 0;
using var httpHandler = new HttpHandlerAssert((request) =>
{
callCount++;
var path = request.RequestUri!.PathAndQuery;
if (!path.Contains("/versions/"))
{
Assert.Contains("/toolboxes/research_tools", path);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(recordJson, Encoding.UTF8, "application/json")
};
}
Assert.Contains("/toolboxes/research_tools/versions/v5", path);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(versionJson, Encoding.UTF8, "application/json")
};
});
#pragma warning disable CA5399
using var httpClient = new HttpClient(httpHandler);
#pragma warning restore CA5399
var clientOptions = new AgentAdministrationClientOptions { Transport = new HttpClientPipelineTransport(httpClient) };
var result = await FoundryToolbox.GetToolboxVersionAsync(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
"research_tools",
version: null,
clientOptions: clientOptions,
cancellationToken: default);
Assert.Equal(2, callCount);
Assert.Equal("research_tools", result.Name);
Assert.Equal("v5", result.Version);
}
[Fact]
public async Task GetToolboxVersionAsync_ApiError_ThrowsClientResultExceptionAsync()
{
using var httpHandler = new HttpHandlerAssert((_) =>
new HttpResponseMessage(HttpStatusCode.NotFound)
{
Content = new StringContent("{\"error\":\"not found\"}", Encoding.UTF8, "application/json")
});
#pragma warning disable CA5399
using var httpClient = new HttpClient(httpHandler);
#pragma warning restore CA5399
var clientOptions = new AgentAdministrationClientOptions { Transport = new HttpClientPipelineTransport(httpClient) };
await Assert.ThrowsAsync<ClientResultException>(() =>
FoundryToolbox.GetToolboxVersionAsync(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
"nonexistent-toolbox",
version: "v1",
clientOptions: clientOptions,
cancellationToken: default));
}
[Fact]
public async Task GetToolsAsync_ReturnsConvertedAIToolsAsync()
{
var versionJson = TestDataUtil.GetToolboxVersionResponseJson();
using var httpHandler = new HttpHandlerAssert((_) =>
new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(versionJson, Encoding.UTF8, "application/json")
});
#pragma warning disable CA5399
using var httpClient = new HttpClient(httpHandler);
#pragma warning restore CA5399
var clientOptions = new AgentAdministrationClientOptions { Transport = new HttpClientPipelineTransport(httpClient) };
var result = await FoundryToolbox.GetToolboxVersionAsync(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
"research_tools",
version: "v5",
clientOptions: clientOptions,
cancellationToken: default);
var tools = result.ToAITools();
Assert.Single(tools);
Assert.IsAssignableFrom<AITool>(tools[0]);
}
#endregion
#region AIProjectClient extension tests
[Fact]
public async Task AIProjectClientExtension_GetToolboxToolsAsync_ReturnsAIToolsAsync()
{
var versionJson = TestDataUtil.GetToolboxVersionResponseJson();
using var httpHandler = new HttpHandlerAssert((_) =>
new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(versionJson, Encoding.UTF8, "application/json")
});
#pragma warning disable CA5399
using var httpClient = new HttpClient(httpHandler);
#pragma warning restore CA5399
var clientOptions = new AIProjectClientOptions();
clientOptions.Transport = new HttpClientPipelineTransport(httpClient);
var client = new AIProjectClient(s_testEndpoint, new FakeAuthenticationTokenProvider(), clientOptions);
var tools = await client.GetToolboxToolsAsync("research_tools", version: "v5");
Assert.Single(tools);
Assert.IsAssignableFrom<AITool>(tools[0]);
}
#endregion
}
@@ -1,40 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
internal sealed class HttpHandlerAssert : HttpClientHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage>? _assertion;
private readonly Func<HttpRequestMessage, Task<HttpResponseMessage>>? _assertionAsync;
public HttpHandlerAssert(Func<HttpRequestMessage, HttpResponseMessage> assertion)
{
this._assertion = assertion;
}
public HttpHandlerAssert(Func<HttpRequestMessage, Task<HttpResponseMessage>> assertionAsync)
{
this._assertionAsync = assertionAsync;
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (this._assertionAsync is not null)
{
return await this._assertionAsync.Invoke(request);
}
return this._assertion!.Invoke(request);
}
#if NET
protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken)
{
return this._assertion!(request);
}
#endif
}
@@ -20,16 +20,4 @@
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="TestData\ToolboxRecordResponse.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="TestData\ToolboxVersionResponse.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="TestData\ToolboxVersionWithDecorationFields.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -1,5 +0,0 @@
{
"id": "tbx-123",
"name": "research_tools",
"default_version": "v5"
}
@@ -1,11 +0,0 @@
{
"metadata": {},
"id": "tbv-research_tools-v5",
"name": "research_tools",
"version": "v5",
"description": "Example research toolbox",
"created_at": 1775779200,
"tools": [
{ "type": "code_interpreter" }
]
}
@@ -1,11 +0,0 @@
{
"metadata": {},
"id": "tbv-dirty-v1",
"name": "dirty_toolbox",
"version": "v1",
"description": "Toolbox with decoration fields on tools",
"created_at": 1775779200,
"tools": [
{ "type": "code_interpreter", "name": "code_interpreter", "description": "Execute Python code" }
]
}
@@ -1,30 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.IO;
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
/// <summary>
/// Utility class for loading toolbox-related test data files.
/// </summary>
internal static class TestDataUtil
{
private static readonly string s_toolboxRecordResponseJson = File.ReadAllText("TestData/ToolboxRecordResponse.json");
private static readonly string s_toolboxVersionResponseJson = File.ReadAllText("TestData/ToolboxVersionResponse.json");
private static readonly string s_toolboxVersionWithDecorationFieldsJson = File.ReadAllText("TestData/ToolboxVersionWithDecorationFields.json");
/// <summary>
/// Gets the toolbox record response JSON.
/// </summary>
public static string GetToolboxRecordResponseJson() => s_toolboxRecordResponseJson;
/// <summary>
/// Gets the toolbox version response JSON.
/// </summary>
public static string GetToolboxVersionResponseJson() => s_toolboxVersionResponseJson;
/// <summary>
/// Gets the toolbox version response JSON with decoration fields on tools.
/// </summary>
public static string GetToolboxVersionWithDecorationFieldsJson() => s_toolboxVersionWithDecorationFieldsJson;
}
@@ -245,29 +245,33 @@ public sealed class ClientHeadersExtensionsTests
}
// -------------------------------------------------------------------------------------------
// 10. ClientHeadersScope.Push is LIFO and AsyncLocal-isolated (parallel runs don't leak)
// 10. ClientHeadersScope is AsyncLocal-isolated across parallel runs and auto-restores on
// async-method return (no explicit Dispose needed).
// -------------------------------------------------------------------------------------------
[Fact]
public async Task ClientHeadersScope_IsLifoAndAsyncLocalIsolatedAsync()
public async Task ClientHeadersScope_IsAsyncLocalIsolatedAndAutoRestoresAsync()
{
// Arrange
var dictA = new Dictionary<string, string> { ["x-client-end-user-id"] = "alice" };
var dictB = new Dictionary<string, string> { ["x-client-end-user-id"] = "bob" };
// Act / Assert
// Act / Assert: parallel async flows do not see each other's mutations.
await Task.WhenAll(
ProbeAsync(dictA, "alice"),
ProbeAsync(dictB, "bob"));
async Task ProbeAsync(Dictionary<string, string> dict, string expected)
{
using (ClientHeadersScope.Push(dict))
{
await Task.Yield();
Assert.Equal(expected, ClientHeadersScope.Current!["x-client-end-user-id"]);
}
ClientHeadersScope.Current = dict;
await Task.Yield();
Assert.Equal(expected, ClientHeadersScope.Current!["x-client-end-user-id"]);
}
// Assert: setting Current inside an awaited async method does not leak back to the caller
// after the method returns. This is the AsyncLocal natural-restoration behavior the
// ClientHeadersAgent relies on.
Assert.Null(ClientHeadersScope.Current);
}
// -------------------------------------------------------------------------------------------
@@ -320,16 +324,19 @@ public sealed class ClientHeadersExtensionsTests
perTryPolicies: default,
beforeTransportPolicies: default);
var perCall = new Dictionary<string, string> { ["x-client-end-user-id"] = "alice" };
// Act
using (ClientHeadersScope.Push(perCall))
ClientHeadersScope.Current = new Dictionary<string, string> { ["x-client-end-user-id"] = "alice" };
try
{
var msg = pipeline.CreateMessage();
msg.Request.Method = "GET";
msg.Request.Uri = new Uri("https://example.test/");
await pipeline.SendAsync(msg);
}
finally
{
ClientHeadersScope.Current = null;
}
// Assert: the per-call value won.
Assert.Equal("alice", handler.Headers["x-client-end-user-id"]);
@@ -1311,4 +1311,64 @@ public class PerServiceCallChatHistoryPersistingChatClientTests
// Assert — session should NOT have the sentinel
Assert.NotEqual(PerServiceCallChatHistoryPersistingChatClient.LocalHistoryConversationId, session!.ConversationId);
}
/// <summary>
/// Verifies that when the consumer abandons enumeration early (the streaming enumerator is
/// disposed before completing — e.g. <c>ToolApprovalAgent.RunStreamingAsync</c> doing a
/// <c>yield break</c>), the decorator still persists the input messages via its <c>finally</c>
/// block. This regression-guards the dropped-FunctionResultContent → HTTP 400 bug.
/// </summary>
[Fact]
public async Task RunStreamingAsync_PersistsInputMessages_WhenConsumerAbandonsEnumerationAsync()
{
// Arrange — emit multiple updates so the consumer can break after the first.
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns(CreateAsyncEnumerableAsync(
new ChatResponseUpdate(ChatRole.Assistant, "first "),
new ChatResponseUpdate(ChatRole.Assistant, "second "),
new ChatResponseUpdate(ChatRole.Assistant, "third")));
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
mockChatHistoryProvider
.Protected()
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
mockChatHistoryProvider
.Protected()
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
.Returns(new ValueTask());
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act — consumer breaks out after the first update, mirroring ToolApprovalAgent's
// yield-break-on-approval-required path.
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
await foreach (var _ in agent.RunStreamingAsync([new(ChatRole.User, "frc-input")], session))
{
break;
}
// Assert — even though the consumer abandoned the stream, the input messages
// must still have been persisted (so we don't lose function-call/function-result
// pairings).
mockChatHistoryProvider
.Protected()
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(),
ItExpr.Is<ChatHistoryProvider.InvokedContext>(x =>
x.RequestMessages.Any(m => m.Text == "frc-input") &&
(x.ResponseMessages == null || !x.ResponseMessages.Any()) &&
x.InvokeException == null),
ItExpr.IsAny<CancellationToken>());
}
}
+11 -6
View File
@@ -23,23 +23,28 @@ response = await a2a_agent.run("Hello!")
```python
from agent_framework.a2a import A2AExecutor
from a2a.server.apps import A2AStarletteApplication
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes
from a2a.server.tasks import InMemoryTaskStore
from starlette.applications import Starlette
# Create an A2A executor for your agent
executor = A2AExecutor(agent=my_agent)
# Set up the request handler and server application
# Set up the request handler (agent_card is required)
request_handler = DefaultRequestHandler(
agent_executor=executor,
task_store=InMemoryTaskStore(),
agent_card=my_agent_card,
)
app = A2AStarletteApplication(
agent_card=my_agent_card,
http_handler=request_handler,
).build()
# Build a Starlette app with A2A routes
app = Starlette(
routes=[
*create_agent_card_routes(my_agent_card),
*create_jsonrpc_routes(request_handler),
]
)
```
## Import Path
@@ -1,16 +1,17 @@
# Copyright (c) Microsoft. All rights reserved.
import base64
import logging
from asyncio import CancelledError
from collections.abc import Mapping
from functools import partial
from typing import Any
from a2a.helpers import new_task_from_user_message
from a2a.server.agent_execution import AgentExecutor, RequestContext
from a2a.server.events import EventQueue
from a2a.server.tasks import TaskUpdater
from a2a.types import FilePart, FileWithBytes, FileWithUri, Part, TaskState, TextPart
from a2a.utils import new_task
from a2a.types import Part, TaskState
from agent_framework import (
AgentResponseUpdate,
AgentSession,
@@ -39,21 +40,24 @@ class A2AExecutor(AgentExecutor):
Example:
.. code-block:: python
from a2a.server.apps import A2AStarletteApplication
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.routes import create_jsonrpc_routes, create_agent_card_routes
from a2a.server.tasks import InMemoryTaskStore
from a2a.types import AgentCapabilities, AgentCard
from a2a.types import AgentCapabilities, AgentCard, AgentInterface
from agent_framework.a2a import A2AExecutor
from agent_framework.openai import OpenAIResponsesClient
from starlette.applications import Starlette
public_agent_card = AgentCard(
name="Food Agent",
description="A simple agent that provides food-related information.",
url="http://localhost:9999/",
version="1.0.0",
defaultInputModes=["text"],
defaultOutputModes=["text"],
default_input_modes=["text"],
default_output_modes=["text"],
capabilities=AgentCapabilities(streaming=True),
supported_interfaces=[
AgentInterface(url="http://localhost:9999/", protocol_binding="JSONRPC"),
],
skills=[],
)
@@ -68,12 +72,15 @@ class A2AExecutor(AgentExecutor):
request_handler = DefaultRequestHandler(
agent_executor=A2AExecutor(agent, stream=True, run_kwargs={"client_kwargs": {"max_tokens": 500}}),
task_store=InMemoryTaskStore(),
agent_card=public_agent_card,
)
server = A2AStarletteApplication(
agent_card=public_agent_card,
http_handler=request_handler,
).build()
app = Starlette(
routes=[
*create_agent_card_routes(public_agent_card),
*create_jsonrpc_routes(request_handler),
],
)
Args:
agent: The AI agent to execute.
@@ -143,7 +150,7 @@ class A2AExecutor(AgentExecutor):
task = context.current_task
if not task:
task = new_task(context.message)
task = new_task_from_user_message(context.message)
await event_queue.enqueue_event(task)
updater = TaskUpdater(event_queue, task.id, context.context_id)
@@ -162,13 +169,12 @@ class A2AExecutor(AgentExecutor):
# Mark as complete
await updater.complete()
except CancelledError:
await updater.update_status(state=TaskState.canceled, final=True)
await updater.update_status(state=TaskState.TASK_STATE_CANCELED)
except Exception as e:
logger.exception("A2AExecutor encountered an error during execution.", exc_info=e)
await updater.update_status(
state=TaskState.failed,
final=True,
message=updater.new_agent_message([Part(root=TextPart(text=str(e)))]),
state=TaskState.TASK_STATE_FAILED,
message=updater.new_agent_message([Part(text=str(e))]),
)
async def _run_stream(self, query: Any, session: AgentSession, updater: TaskUpdater) -> None:
@@ -221,9 +227,9 @@ class A2AExecutor(AgentExecutor):
) -> None:
# Custom logic to transform item contents
if item.role == "assistant" and item.contents:
parts = [Part(root=TextPart(text=f"Custom: {item.contents[0].text}"))]
parts = [Part(text=f"Custom: {item.contents[0].text}")]
await updater.update_status(
state=TaskState.working,
state=TaskState.TASK_STATE_WORKING,
message=updater.new_agent_message(parts=parts),
)
else:
@@ -242,12 +248,12 @@ class A2AExecutor(AgentExecutor):
for content in contents:
if content.type == "text" and content.text:
parts.append(Part(root=TextPart(text=content.text)))
parts.append(Part(text=content.text))
elif content.type == "data" and content.uri:
base64_str = get_uri_data(content.uri)
parts.append(Part(root=FilePart(file=FileWithBytes(bytes=base64_str, mime_type=content.media_type))))
parts.append(Part(raw=base64.b64decode(base64_str), media_type=content.media_type or ""))
elif content.type == "uri" and content.uri:
parts.append(Part(root=FilePart(file=FileWithUri(uri=content.uri, mime_type=content.media_type))))
parts.append(Part(url=content.uri, media_type=content.media_type or ""))
else:
# Silently skip unsupported content types
logger.warning("A2AExecutor does not yet support content type: %s. Omitted.", content.type)
@@ -270,6 +276,6 @@ class A2AExecutor(AgentExecutor):
else:
# For final messages, we send TaskStatusUpdateEvent with 'working' state
await updater.update_status(
state=TaskState.working,
state=TaskState.TASK_STATE_WORKING,
message=updater.new_agent_message(parts=parts, metadata=metadata),
)
+130 -132
View File
@@ -3,7 +3,6 @@
from __future__ import annotations
import base64
import json
import uuid
from collections.abc import AsyncIterable, Awaitable, Mapping, Sequence
from typing import Any, Final, Literal, TypeAlias, overload
@@ -14,17 +13,14 @@ from a2a.client.auth.interceptor import AuthInterceptor
from a2a.types import (
AgentCard,
Artifact,
FilePart,
FileWithBytes,
FileWithUri,
GetTaskRequest,
SendMessageRequest,
StreamResponse,
SubscribeToTaskRequest,
Task,
TaskArtifactUpdateEvent,
TaskIdParams,
TaskQueryParams,
TaskState,
TaskStatusUpdateEvent,
TextPart,
TransportProtocol,
)
from a2a.types import Message as A2AMessage
from a2a.types import Part as A2APart
@@ -45,6 +41,7 @@ from agent_framework import (
)
from agent_framework._types import AgentRunInputs
from agent_framework.observability import AgentTelemetryLayer
from google.protobuf.json_format import MessageToDict
__all__ = ["A2AAgent", "A2AContinuationToken"]
@@ -61,20 +58,19 @@ class A2AContinuationToken(ContinuationToken):
TERMINAL_TASK_STATES = [
TaskState.completed,
TaskState.failed,
TaskState.canceled,
TaskState.rejected,
TaskState.TASK_STATE_COMPLETED,
TaskState.TASK_STATE_FAILED,
TaskState.TASK_STATE_CANCELED,
TaskState.TASK_STATE_REJECTED,
]
IN_PROGRESS_TASK_STATES = [
TaskState.submitted,
TaskState.working,
TaskState.input_required,
TaskState.auth_required,
TaskState.TASK_STATE_SUBMITTED,
TaskState.TASK_STATE_WORKING,
TaskState.TASK_STATE_INPUT_REQUIRED,
TaskState.TASK_STATE_AUTH_REQUIRED,
]
A2AClientEvent: TypeAlias = tuple[Task, TaskStatusUpdateEvent | TaskArtifactUpdateEvent | None]
A2AStreamItem: TypeAlias = A2AMessage | A2AClientEvent
A2AStreamItem: TypeAlias = StreamResponse
class A2AAgent(AgentTelemetryLayer, BaseAgent):
@@ -139,7 +135,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
if url is None:
raise ValueError("Either agent_card or url must be provided")
# Create minimal agent card from URL
agent_card = minimal_agent_card(url, [TransportProtocol.jsonrpc])
agent_card = minimal_agent_card(url, ["JSONRPC"])
# Create or use provided httpx client
if http_client is None:
@@ -151,7 +147,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
# Create A2A client using factory
config = ClientConfig(
httpx_client=http_client,
supported_transports=[TransportProtocol.jsonrpc],
supported_protocol_bindings=["JSONRPC"],
)
factory = ClientFactory(config)
interceptors = [auth_interceptor] if auth_interceptor is not None else None
@@ -161,7 +157,16 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
self.client = factory.create(agent_card, interceptors=interceptors) # type: ignore
except Exception as transport_error:
# Transport negotiation failed - fall back to minimal agent card with JSONRPC
fallback_card = minimal_agent_card(agent_card.url, [TransportProtocol.jsonrpc])
fallback_url = (
agent_card.supported_interfaces[0].url if agent_card.supported_interfaces else url
)
if not fallback_url:
raise ValueError(
"A2A transport negotiation failed and no fallback URL is available. "
"Provide a 'url' argument or ensure 'agent_card.supported_interfaces' "
"contains at least one interface with a URL."
) from transport_error
fallback_card = minimal_agent_card(fallback_url, ["JSONRPC"])
try:
self.client = factory.create(fallback_card, interceptors=interceptors) # type: ignore
except Exception as fallback_error:
@@ -280,8 +285,8 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
normalized_messages = normalize_messages(messages)
if continuation_token is not None:
a2a_stream: AsyncIterable[A2AStreamItem] = self.client.resubscribe(
TaskIdParams(id=continuation_token["task_id"])
a2a_stream: AsyncIterable[A2AStreamItem] = self.client.subscribe(
SubscribeToTaskRequest(id=continuation_token["task_id"])
)
else:
if not normalized_messages:
@@ -290,7 +295,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
normalized_messages[-1],
context_id=session.service_session_id if session else None,
)
a2a_stream = self.client.send_message(a2a_message)
a2a_stream = self.client.send_message(SendMessageRequest(message=a2a_message))
provider_session = session
if provider_session is None and self.context_providers:
@@ -361,38 +366,54 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
all_updates: list[AgentResponseUpdate] = []
streamed_artifact_ids_by_task: dict[str, set[str]] = {}
async for item in a2a_stream:
if isinstance(item, A2AMessage):
payload_type = item.WhichOneof("payload")
if payload_type == "message":
# Process A2A Message
contents = self._parse_contents_from_a2a(item.parts)
msg = item.message
contents = self._parse_contents_from_a2a(msg.parts)
metadata = MessageToDict(msg.metadata) if msg.metadata else None
update = AgentResponseUpdate(
contents=contents,
role="assistant" if item.role == A2ARole.agent else "user",
response_id=str(getattr(item, "message_id", uuid.uuid4())),
additional_properties={"a2a_metadata": item.metadata} if item.metadata else None,
raw_representation=item,
role="assistant" if msg.role == A2ARole.ROLE_AGENT else "user",
response_id=msg.message_id or str(uuid.uuid4()),
additional_properties={"a2a_metadata": metadata} if metadata else None,
raw_representation=msg,
)
all_updates.append(update)
yield update
elif isinstance(item, tuple) and len(item) == 2 and isinstance(item[0], Task):
task, update_event = item
elif payload_type == "task":
task = item.task
updates = self._updates_from_task(
task,
update_event=update_event,
background=background,
emit_intermediate=emit_intermediate,
streamed_artifact_ids=streamed_artifact_ids_by_task.get(task.id),
)
if isinstance(update_event, TaskArtifactUpdateEvent) and any(
update.raw_representation is update_event for update in updates
):
streamed_artifact_ids_by_task.setdefault(task.id, set()).add(update_event.artifact.artifact_id)
if task.status.state in TERMINAL_TASK_STATES:
streamed_artifact_ids_by_task.pop(task.id, None)
for update in updates:
all_updates.append(update)
yield update
elif payload_type == "status_update":
status_event = item.status_update
updates = self._updates_from_task_update_event(status_event)
if emit_intermediate:
for update in updates:
all_updates.append(update)
yield update
elif payload_type == "artifact_update":
artifact_event = item.artifact_update
updates = self._updates_from_task_update_event(artifact_event)
if updates:
streamed_artifact_ids_by_task.setdefault(artifact_event.task_id, set()).add(
artifact_event.artifact.artifact_id
)
if emit_intermediate:
for update in updates:
all_updates.append(update)
yield update
else:
raise NotImplementedError("Only Message and Task responses are supported")
raise NotImplementedError(f"Unsupported StreamResponse payload: {payload_type}")
# Set the response on the context for after_run providers
if all_updates:
@@ -408,7 +429,6 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
self,
task: Task,
*,
update_event: TaskStatusUpdateEvent | TaskArtifactUpdateEvent | None = None,
background: bool = False,
emit_intermediate: bool = False,
streamed_artifact_ids: set[str] | None = None,
@@ -424,17 +444,11 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
completion.
"""
status = task.status
if (
emit_intermediate
and update_event is not None
and (event_updates := self._updates_from_task_update_event(update_event))
):
return event_updates
task_metadata = MessageToDict(task.metadata) if task.metadata else None
if status.state in TERMINAL_TASK_STATES:
task_messages = self._parse_messages_from_task(task)
if task.artifacts is not None and streamed_artifact_ids:
if task.artifacts and streamed_artifact_ids:
task_messages = [
message
for message in task_messages
@@ -448,20 +462,20 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
response_id=task.id,
message_id=getattr(message.raw_representation, "artifact_id", None),
additional_properties={"a2a_metadata": merged}
if (merged := {**message.additional_properties, **(task.metadata or {})})
if (merged := {**message.additional_properties, **(task_metadata or {})})
else None,
raw_representation=task,
)
for message in task_messages
]
if task.artifacts is not None:
if task.artifacts:
return []
return [
AgentResponseUpdate(
contents=[],
role="assistant",
response_id=task.id,
additional_properties={"a2a_metadata": task.metadata} if task.metadata else None,
additional_properties={"a2a_metadata": task_metadata} if task_metadata else None,
raw_representation=task,
)
]
@@ -474,18 +488,16 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
role="assistant",
response_id=task.id,
continuation_token=token,
additional_properties={"a2a_metadata": task.metadata} if task.metadata else None,
additional_properties={"a2a_metadata": task_metadata} if task_metadata else None,
raw_representation=task,
)
]
# Surface message content from in-progress status updates (e.g. working state)
# Only emitted when the caller opts in (streaming), so non-streaming
# consumers keep receiving only terminal task outputs.
if (
emit_intermediate
and status.state in IN_PROGRESS_TASK_STATES
and status.message is not None
and status.HasField("message")
and status.message.parts
):
contents = self._parse_contents_from_a2a(status.message.parts)
@@ -493,9 +505,9 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
return [
AgentResponseUpdate(
contents=contents,
role="assistant" if status.message.role == A2ARole.agent else "user",
role="assistant" if status.message.role == A2ARole.ROLE_AGENT else "user",
response_id=task.id,
additional_properties={"a2a_metadata": task.metadata} if task.metadata else None,
additional_properties={"a2a_metadata": task_metadata} if task_metadata else None,
raw_representation=task,
)
]
@@ -510,10 +522,9 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
contents = self._parse_contents_from_a2a(update_event.artifact.parts)
if not contents:
return []
merged_metadata = {
**(update_event.artifact.metadata or {}),
**(update_event.metadata or {}),
} or None
artifact_meta = MessageToDict(update_event.artifact.metadata) if update_event.artifact.metadata else {}
event_meta = MessageToDict(update_event.metadata) if update_event.metadata else {}
merged_metadata = {**artifact_meta, **event_meta} or None
return [
AgentResponseUpdate(
contents=contents,
@@ -528,22 +539,21 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
if not isinstance(update_event, TaskStatusUpdateEvent):
return []
message = update_event.status.message
if message is None or not message.parts:
if not update_event.status.HasField("message") or not update_event.status.message.parts:
return []
message = update_event.status.message
contents = self._parse_contents_from_a2a(message.parts)
if not contents:
return []
merged_metadata = {
**(message.metadata or {}),
**(update_event.metadata or {}),
} or None
msg_meta = MessageToDict(message.metadata) if message.metadata else {}
event_meta = MessageToDict(update_event.metadata) if update_event.metadata else {}
merged_metadata = {**msg_meta, **event_meta} or None
return [
AgentResponseUpdate(
contents=contents,
role="assistant" if message.role == A2ARole.agent else "user",
role="assistant" if message.role == A2ARole.ROLE_AGENT else "user",
response_id=update_event.task_id,
additional_properties={"a2a_metadata": merged_metadata} if merged_metadata else None,
raw_representation=update_event,
@@ -572,7 +582,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
is still in progress, or ``None`` when it has reached a terminal state.
"""
task_id = continuation_token["task_id"]
task = await self.client.get_task(TaskQueryParams(id=task_id))
task = await self.client.get_task(GetTaskRequest(id=task_id))
updates = self._updates_from_task(task, background=True)
if updates:
return AgentResponse.from_updates(updates)
@@ -607,19 +617,15 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
raise ValueError("Text content requires a non-null text value")
parts.append(
A2APart(
root=TextPart(
text=content.text,
metadata=content.additional_properties,
)
text=content.text,
metadata=content.additional_properties or {},
)
)
case "error":
parts.append(
A2APart(
root=TextPart(
text=content.message or "An error occurred.",
metadata=content.additional_properties,
)
text=content.message or "An error occurred.",
metadata=content.additional_properties or {},
)
)
case "uri":
@@ -627,27 +633,20 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
raise ValueError("URI content requires a non-null uri value")
parts.append(
A2APart(
root=FilePart(
file=FileWithUri(
uri=content.uri,
mime_type=content.media_type,
),
metadata=content.additional_properties,
)
url=content.uri,
media_type=content.media_type or "",
metadata=content.additional_properties or {},
)
)
case "data":
if content.uri is None:
raise ValueError("Data content requires a non-null uri value")
base64_data = get_uri_data(content.uri)
parts.append(
A2APart(
root=FilePart(
file=FileWithBytes(
bytes=get_uri_data(content.uri),
mime_type=content.media_type,
),
metadata=content.additional_properties,
)
raw=base64.b64decode(base64_data),
media_type=content.media_type or "",
metadata=content.additional_properties or {},
)
)
case "hosted_file":
@@ -655,93 +654,91 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
raise ValueError("Hosted file content requires a non-null file_id value")
parts.append(
A2APart(
root=FilePart(
file=FileWithUri(
uri=content.file_id,
mime_type=None, # HostedFileContent doesn't specify media_type
),
metadata=content.additional_properties,
)
url=content.file_id,
metadata=content.additional_properties or {},
)
)
case _:
raise ValueError(f"Unknown content type: {content.type}")
metadata = message.additional_properties.get("a2a_metadata")
a2a_metadata = message.additional_properties.get("a2a_metadata")
return A2AMessage(
role=A2ARole("user"),
role=A2ARole.ROLE_USER,
parts=parts,
message_id=message.message_id or uuid.uuid4().hex,
context_id=message.additional_properties.get("context_id") or context_id,
metadata=metadata,
metadata=a2a_metadata or {},
)
def _parse_contents_from_a2a(self, parts: Sequence[A2APart]) -> list[Content]:
"""Parse A2A Parts into Agent Framework Content.
Transforms A2A protocol Parts into framework-native Content objects,
handling text, file (URI/bytes), and data parts with metadata preservation.
handling text, url, raw, and data parts with metadata preservation.
"""
contents: list[Content] = []
for part in parts:
inner_part = part.root
match inner_part.kind:
part_metadata = MessageToDict(part.metadata) if part.metadata else None
content_type = part.WhichOneof("content")
match content_type:
case "text":
contents.append(
Content.from_text(
text=inner_part.text,
additional_properties=inner_part.metadata,
raw_representation=inner_part,
text=part.text,
additional_properties=part_metadata,
raw_representation=part,
)
)
case "file":
if isinstance(inner_part.file, FileWithUri):
contents.append(
Content.from_uri(
uri=inner_part.file.uri,
media_type=inner_part.file.mime_type or "",
additional_properties=inner_part.metadata,
raw_representation=inner_part,
)
case "url":
contents.append(
Content.from_uri(
uri=part.url,
media_type=part.media_type or "",
additional_properties=part_metadata,
raw_representation=part,
)
elif isinstance(inner_part.file, FileWithBytes):
contents.append(
Content.from_data(
data=base64.b64decode(inner_part.file.bytes),
media_type=inner_part.file.mime_type or "",
additional_properties=inner_part.metadata,
raw_representation=inner_part,
)
)
case "raw":
contents.append(
Content.from_data(
data=part.raw,
media_type=part.media_type or "",
additional_properties=part_metadata,
raw_representation=part,
)
)
case "data":
from google.protobuf.json_format import MessageToJson
contents.append(
Content.from_text(
text=json.dumps(inner_part.data),
additional_properties=inner_part.metadata,
raw_representation=inner_part,
text=MessageToJson(part.data),
additional_properties=part_metadata,
raw_representation=part,
)
)
case _:
raise ValueError(f"Unknown Part kind: {inner_part.kind}")
raise ValueError(f"Unknown Part content type: {content_type}")
return contents
def _parse_messages_from_task(self, task: Task) -> list[Message]:
"""Parse A2A Task artifacts into Messages with ASSISTANT role."""
messages: list[Message] = []
if task.artifacts is not None:
if task.artifacts:
for artifact in task.artifacts:
messages.append(self._parse_message_from_artifact(artifact))
elif task.history is not None and len(task.history) > 0:
elif task.history:
# Include the last history item as the agent response
history_item = task.history[-1]
contents = self._parse_contents_from_a2a(history_item.parts)
history_metadata = MessageToDict(history_item.metadata) if history_item.metadata else None
messages.append(
Message(
role="assistant" if history_item.role == A2ARole.agent else "user",
role="assistant" if history_item.role == A2ARole.ROLE_AGENT else "user",
contents=contents,
additional_properties=history_item.metadata,
additional_properties=history_metadata,
raw_representation=history_item,
)
)
@@ -751,9 +748,10 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
def _parse_message_from_artifact(self, artifact: Artifact) -> Message:
"""Parse A2A Artifact into Message using part contents."""
contents = self._parse_contents_from_a2a(artifact.parts)
artifact_metadata = MessageToDict(artifact.metadata) if artifact.metadata else None
return Message(
role="assistant",
contents=contents,
additional_properties=artifact.metadata,
additional_properties=artifact_metadata,
raw_representation=artifact,
)
+1 -1
View File
@@ -24,7 +24,7 @@ classifiers = [
]
dependencies = [
"agent-framework-core>=1.3.0,<2",
"a2a-sdk>=0.3.5,<0.3.24",
"a2a-sdk>=1.0.0,<2",
]
[tool.uv]
+148 -239
View File
@@ -9,16 +9,13 @@ import httpx
from a2a.types import (
AgentCard,
Artifact,
DataPart,
FilePart,
FileWithUri,
Part,
StreamResponse,
Task,
TaskArtifactUpdateEvent,
TaskState,
TaskStatus,
TaskStatusUpdateEvent,
TextPart,
)
from a2a.types import Message as A2AMessage
from a2a.types import Role as A2ARole
@@ -43,59 +40,42 @@ class MockA2AClient:
def __init__(self) -> None:
self.call_count: int = 0
self.responses: list[Any] = []
self.resubscribe_responses: list[Any] = []
self.responses: list[StreamResponse] = []
self.subscribe_responses: list[StreamResponse] = []
self.get_task_response: Task | None = None
self.last_message: Any = None
def add_message_response(self, message_id: str, text: str, role: str = "agent") -> None:
"""Add a mock Message response."""
# Create actual TextPart instance and wrap it in Part
text_part = Part(root=TextPart(text=text))
# Create actual Message instance
message = A2AMessage(
message_id=message_id, role=A2ARole.agent if role == "agent" else A2ARole.user, parts=[text_part]
message_id=message_id,
role=A2ARole.ROLE_AGENT if role == "agent" else A2ARole.ROLE_USER,
parts=[Part(text=text)],
)
self.responses.append(message)
self.responses.append(StreamResponse(message=message))
def add_task_response(self, task_id: str, artifacts: list[dict[str, Any]]) -> None:
"""Add a mock Task response."""
# Create mock artifacts
mock_artifacts = []
for artifact_data in artifacts:
# Create actual TextPart instance and wrap it in Part
text_part = Part(root=TextPart(text=artifact_data.get("content", "Test content")))
artifact = Artifact(
artifact_id=artifact_data.get("id", str(uuid4())),
name=artifact_data.get("name", "test-artifact"),
description=artifact_data.get("description", "Test artifact"),
parts=[text_part],
parts=[Part(text=artifact_data.get("content", "Test content"))],
)
mock_artifacts.append(artifact)
# Create task status
status = TaskStatus(state=TaskState.completed, message=None)
# Create actual Task instance
task = Task(
id=task_id, context_id="test-context", status=status, artifacts=mock_artifacts if mock_artifacts else None
)
# Mock the ClientEvent tuple format
update_event = None # No specific update event for completed tasks
client_event = (task, update_event)
self.responses.append(client_event)
status = TaskStatus(state=TaskState.TASK_STATE_COMPLETED)
task = Task(id=task_id, context_id="test-context", status=status, artifacts=mock_artifacts)
self.responses.append(StreamResponse(task=task))
def add_in_progress_task_response(
self,
task_id: str,
context_id: str = "test-context",
state: TaskState = TaskState.working,
state: TaskState = TaskState.TASK_STATE_WORKING,
text: str | None = None,
role: A2ARole = A2ARole.agent,
role: A2ARole = A2ARole.ROLE_AGENT,
) -> None:
"""Add a mock in-progress Task response (non-terminal)."""
message = None
@@ -103,30 +83,28 @@ class MockA2AClient:
message = A2AMessage(
message_id=str(uuid4()),
role=role,
parts=[Part(root=TextPart(text=text))],
parts=[Part(text=text)],
)
status = TaskStatus(state=state, message=message)
task = Task(id=task_id, context_id=context_id, status=status)
client_event = (task, None)
self.responses.append(client_event)
self.responses.append(StreamResponse(task=task))
async def send_message(self, message: Any) -> AsyncIterator[Any]:
async def send_message(self, request: Any) -> AsyncIterator[StreamResponse]:
"""Mock send_message method that yields responses."""
self.last_message = message
self.last_message = getattr(request, "message", request)
self.call_count += 1
# All queued responses are delivered as a single streaming batch per call.
for response in self.responses:
yield response
self.responses.clear()
async def resubscribe(self, request: Any) -> AsyncIterator[Any]:
"""Mock resubscribe method that yields responses."""
async def subscribe(self, request: Any) -> AsyncIterator[StreamResponse]:
"""Mock subscribe method that yields responses."""
self.call_count += 1
for response in self.resubscribe_responses:
for response in self.subscribe_responses:
yield response
self.resubscribe_responses.clear()
self.subscribe_responses.clear()
async def get_task(self, request: Any) -> Task:
"""Mock get_task method that returns a task."""
@@ -282,16 +260,16 @@ async def test_run_with_task_response_no_artifacts(a2a_agent: A2AAgent, mock_a2a
async def test_run_with_unknown_response_type_raises_error(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
"""Test run() method with unknown response type raises NotImplementedError."""
mock_a2a_client.responses.append("invalid_response")
# An empty StreamResponse has no payload set (WhichOneof returns None)
mock_a2a_client.responses.append(StreamResponse())
with raises(NotImplementedError, match="Only Message and Task responses are supported"):
with raises(NotImplementedError, match="Unsupported StreamResponse payload"):
await a2a_agent.run("Test message")
def test_parse_messages_from_task_empty_artifacts(a2a_agent: A2AAgent) -> None:
"""Test _parse_messages_from_task with task containing no artifacts."""
task = MagicMock()
task.artifacts = None
task = Task(id="test", context_id="test", status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED))
result = a2a_agent._parse_messages_from_task(task)
@@ -300,28 +278,14 @@ def test_parse_messages_from_task_empty_artifacts(a2a_agent: A2AAgent) -> None:
def test_parse_messages_from_task_with_artifacts(a2a_agent: A2AAgent) -> None:
"""Test _parse_messages_from_task with task containing artifacts."""
task = MagicMock()
# Create mock artifacts
artifact1 = MagicMock()
artifact1.artifact_id = "art-1"
text_part1 = MagicMock()
text_part1.root = MagicMock()
text_part1.root.kind = "text"
text_part1.root.text = "Content 1"
text_part1.root.metadata = None
artifact1.parts = [text_part1]
artifact2 = MagicMock()
artifact2.artifact_id = "art-2"
text_part2 = MagicMock()
text_part2.root = MagicMock()
text_part2.root.kind = "text"
text_part2.root.text = "Content 2"
text_part2.root.metadata = None
artifact2.parts = [text_part2]
task.artifacts = [artifact1, artifact2]
artifact1 = Artifact(artifact_id="art-1", parts=[Part(text="Content 1")])
artifact2 = Artifact(artifact_id="art-2", parts=[Part(text="Content 2")])
task = Task(
id="test",
context_id="test",
status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED),
artifacts=[artifact1, artifact2],
)
result = a2a_agent._parse_messages_from_task(task)
@@ -333,16 +297,7 @@ def test_parse_messages_from_task_with_artifacts(a2a_agent: A2AAgent) -> None:
def test_parse_message_from_artifact(a2a_agent: A2AAgent) -> None:
"""Test _parse_message_from_artifact conversion."""
artifact = MagicMock()
artifact.artifact_id = "test-artifact"
text_part = MagicMock()
text_part.root = MagicMock()
text_part.root.kind = "text"
text_part.root.text = "Artifact content"
text_part.root.metadata = None
artifact.parts = [text_part]
artifact = Artifact(artifact_id="test-artifact", parts=[Part(text="Artifact content")])
result = a2a_agent._parse_message_from_artifact(artifact)
@@ -373,7 +328,7 @@ def test_parse_contents_from_a2a_conversion(a2a_agent: A2AAgent) -> None:
agent = A2AAgent(name="Test Agent", client=MockA2AClient(), http_client=None)
# Create A2A parts
parts = [Part(root=TextPart(text="First part")), Part(root=TextPart(text="Second part"))]
parts = [Part(text="First part"), Part(text="Second part")]
# Convert to contents
contents = agent._parse_contents_from_a2a(parts)
@@ -398,7 +353,7 @@ def test_prepare_message_for_a2a_with_error_content(a2a_agent: A2AAgent) -> None
# Verify conversion
assert len(a2a_message.parts) == 1
assert a2a_message.parts[0].root.text == "Test error message"
assert a2a_message.parts[0].text == "Test error message"
def test_prepare_message_for_a2a_with_uri_content(a2a_agent: A2AAgent) -> None:
@@ -413,8 +368,8 @@ def test_prepare_message_for_a2a_with_uri_content(a2a_agent: A2AAgent) -> None:
# Verify conversion
assert len(a2a_message.parts) == 1
assert a2a_message.parts[0].root.file.uri == "http://example.com/file.pdf"
assert a2a_message.parts[0].root.file.mime_type == "application/pdf"
assert a2a_message.parts[0].url == "http://example.com/file.pdf"
assert a2a_message.parts[0].media_type == "application/pdf"
def test_prepare_message_for_a2a_with_data_content(a2a_agent: A2AAgent) -> None:
@@ -429,8 +384,8 @@ def test_prepare_message_for_a2a_with_data_content(a2a_agent: A2AAgent) -> None:
# Verify conversion
assert len(a2a_message.parts) == 1
assert a2a_message.parts[0].root.file.bytes == "SGVsbG8gV29ybGQ="
assert a2a_message.parts[0].root.file.mime_type == "text/plain"
assert a2a_message.parts[0].raw == b"Hello World"
assert a2a_message.parts[0].media_type == "text/plain"
def test_prepare_message_for_a2a_empty_contents_raises_error(a2a_agent: A2AAgent) -> None:
@@ -518,10 +473,10 @@ def test_prepare_message_for_a2a_with_multiple_contents() -> None:
assert len(result.parts) == 4
# Check each part type
assert result.parts[0].root.kind == "text" # Regular text
assert result.parts[1].root.kind == "file" # Binary data
assert result.parts[2].root.kind == "file" # URI content
assert result.parts[3].root.kind == "text" # JSON text remains as text (no parsing)
assert result.parts[0].WhichOneof("content") == "text" # Regular text
assert result.parts[1].WhichOneof("content") == "raw" # Binary data
assert result.parts[2].WhichOneof("content") == "url" # URI content
assert result.parts[3].WhichOneof("content") == "text" # JSON text remains as text (no parsing)
def test_prepare_message_for_a2a_forwards_context_id() -> None:
@@ -573,19 +528,29 @@ def test_prepare_message_for_a2a_message_context_id_takes_precedence() -> None:
def test_parse_contents_from_a2a_with_data_part() -> None:
"""Test conversion of A2A DataPart."""
"""Test conversion of A2A data Part."""
from google.protobuf.json_format import ParseDict
from google.protobuf.struct_pb2 import Struct, Value
agent = A2AAgent(client=MagicMock(), http_client=None)
# Create DataPart
data_part = Part(root=DataPart(data={"key": "value", "number": 42}, metadata={"source": "test"}))
# Create Part with data (protobuf Value containing a struct)
value = ParseDict({"key": "value", "number": 42}, Value())
metadata = Struct()
metadata.update({"source": "test"})
data_part = Part(data=value, metadata=metadata)
contents = agent._parse_contents_from_a2a([data_part])
assert len(contents) == 1
assert contents[0].type == "text"
assert contents[0].text == '{"key": "value", "number": 42}'
# MessageToJson may format slightly differently — verify the parsed structure
import json
parsed = json.loads(contents[0].text)
assert parsed["key"] == "value"
assert parsed["number"] == 42
assert contents[0].additional_properties == {"source": "test"}
@@ -593,12 +558,11 @@ def test_parse_contents_from_a2a_unknown_part_kind() -> None:
"""Test error handling for unknown A2A part kind."""
agent = A2AAgent(client=MagicMock(), http_client=None)
# Create a mock part with unknown kind
mock_part = MagicMock()
mock_part.root.kind = "unknown_kind"
# Create a Part with no content field set (WhichOneof returns None)
empty_part = Part()
with raises(ValueError, match="Unknown Part kind: unknown_kind"):
agent._parse_contents_from_a2a([mock_part])
with raises(ValueError, match="Unknown Part content type"):
agent._parse_contents_from_a2a([empty_part])
def test_prepare_message_for_a2a_with_hosted_file() -> None:
@@ -617,14 +581,8 @@ def test_prepare_message_for_a2a_with_hosted_file() -> None:
# Verify the conversion
assert len(result.parts) == 1
part = result.parts[0]
assert part.root.kind == "file"
# Verify it's a FilePart with FileWithUri
assert isinstance(part.root, FilePart)
assert isinstance(part.root.file, FileWithUri)
assert part.root.file.uri == "hosted://storage/document.pdf"
assert part.root.file.mime_type is None # HostedFileContent doesn't specify media_type
assert part.WhichOneof("content") == "url"
assert part.url == "hosted://storage/document.pdf"
def test_parse_contents_from_a2a_with_hosted_file_uri() -> None:
@@ -632,15 +590,8 @@ def test_parse_contents_from_a2a_with_hosted_file_uri() -> None:
agent = A2AAgent(client=MagicMock(), http_client=None)
# Create FilePart with hosted file URI (simulating what A2A would send back)
file_part = Part(
root=FilePart(
file=FileWithUri(
uri="hosted://storage/document.pdf",
mime_type=None,
)
)
)
# Create Part with hosted file URL (simulating what A2A would send back)
file_part = Part(url="hosted://storage/document.pdf")
contents = agent._parse_contents_from_a2a([file_part]) # noqa: SLF001
@@ -671,9 +622,11 @@ def test_auth_interceptor_parameter() -> None:
def test_transport_negotiation_both_fail() -> None:
"""Test that RuntimeError is raised when both primary and fallback transport negotiation fail."""
# Create a mock agent card
# Create a mock agent card with supported_interfaces
mock_agent_card = MagicMock(spec=AgentCard)
mock_agent_card.url = "http://test-agent.example.com"
mock_interface = MagicMock()
mock_interface.url = "http://test-agent.example.com"
mock_agent_card.supported_interfaces = [mock_interface]
mock_agent_card.name = "Test Agent"
mock_agent_card.description = "A test agent"
@@ -751,7 +704,7 @@ def test_a2a_agent_initialization_with_timeout_parameter() -> None:
async def test_working_task_emits_continuation_token(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
"""Test that a working (non-terminal) task yields an update with a continuation token when background=True."""
mock_a2a_client.add_in_progress_task_response("task-wip", context_id="ctx-1", state=TaskState.working)
mock_a2a_client.add_in_progress_task_response("task-wip", context_id="ctx-1", state=TaskState.TASK_STATE_WORKING)
response = await a2a_agent.run("Start long task", background=True)
@@ -763,7 +716,7 @@ async def test_working_task_emits_continuation_token(a2a_agent: A2AAgent, mock_a
async def test_submitted_task_emits_continuation_token(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
"""Test that a submitted task yields a continuation token when background=True."""
mock_a2a_client.add_in_progress_task_response("task-sub", state=TaskState.submitted)
mock_a2a_client.add_in_progress_task_response("task-sub", state=TaskState.TASK_STATE_SUBMITTED)
response = await a2a_agent.run("Submit task", background=True)
@@ -775,7 +728,7 @@ async def test_input_required_task_emits_continuation_token(
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
) -> None:
"""Test that an input_required task yields a continuation token when background=True."""
mock_a2a_client.add_in_progress_task_response("task-input", state=TaskState.input_required)
mock_a2a_client.add_in_progress_task_response("task-input", state=TaskState.TASK_STATE_INPUT_REQUIRED)
response = await a2a_agent.run("Need input", background=True)
@@ -785,7 +738,7 @@ async def test_input_required_task_emits_continuation_token(
async def test_working_task_no_token_without_background(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
"""Test that background=False (default) does not emit continuation tokens for in-progress tasks."""
mock_a2a_client.add_in_progress_task_response("task-fg", context_id="ctx-fg", state=TaskState.working)
mock_a2a_client.add_in_progress_task_response("task-fg", context_id="ctx-fg", state=TaskState.TASK_STATE_WORKING)
response = await a2a_agent.run("Foreground task")
@@ -805,7 +758,7 @@ async def test_completed_task_has_no_continuation_token(a2a_agent: A2AAgent, moc
async def test_streaming_emits_continuation_token(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
"""Test that streaming with background=True yields updates with continuation tokens."""
mock_a2a_client.add_in_progress_task_response("task-stream", context_id="ctx-s", state=TaskState.working)
mock_a2a_client.add_in_progress_task_response("task-stream", context_id="ctx-s", state=TaskState.TASK_STATE_WORKING)
updates: list[AgentResponseUpdate] = []
async for update in a2a_agent.run("Stream task", stream=True, background=True):
@@ -820,14 +773,14 @@ async def test_streaming_emits_continuation_token(a2a_agent: A2AAgent, mock_a2a_
async def test_resume_via_continuation_token(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
"""Test that run() with continuation_token uses resubscribe instead of send_message."""
# Set up the resubscribe response (completed task)
status = TaskStatus(state=TaskState.completed, message=None)
status = TaskStatus(state=TaskState.TASK_STATE_COMPLETED, message=None)
artifact = Artifact(
artifact_id="art-resume",
name="result",
parts=[Part(root=TextPart(text="Resumed result"))],
parts=[Part(text="Resumed result")],
)
task = Task(id="task-resume", context_id="ctx-r", status=status, artifacts=[artifact])
mock_a2a_client.resubscribe_responses.append((task, None))
mock_a2a_client.subscribe_responses.append(StreamResponse(task=task))
token = A2AContinuationToken(task_id="task-resume", context_id="ctx-r")
response = await a2a_agent.run(continuation_token=token)
@@ -841,17 +794,17 @@ async def test_resume_via_continuation_token(a2a_agent: A2AAgent, mock_a2a_clien
async def test_resume_streaming_via_continuation_token(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
"""Test that streaming run() with continuation_token and background=True uses resubscribe."""
# Still working
status_wip = TaskStatus(state=TaskState.working, message=None)
status_wip = TaskStatus(state=TaskState.TASK_STATE_WORKING, message=None)
task_wip = Task(id="task-rs", context_id="ctx-rs", status=status_wip)
# Then completed
status_done = TaskStatus(state=TaskState.completed, message=None)
status_done = TaskStatus(state=TaskState.TASK_STATE_COMPLETED, message=None)
artifact = Artifact(
artifact_id="art-rs",
name="result",
parts=[Part(root=TextPart(text="Stream resumed"))],
parts=[Part(text="Stream resumed")],
)
task_done = Task(id="task-rs", context_id="ctx-rs", status=status_done, artifacts=[artifact])
mock_a2a_client.resubscribe_responses.extend([(task_wip, None), (task_done, None)])
mock_a2a_client.subscribe_responses.extend([StreamResponse(task=task_wip), StreamResponse(task=task_done)])
token = A2AContinuationToken(task_id="task-rs", context_id="ctx-rs")
updates: list[AgentResponseUpdate] = []
@@ -868,7 +821,7 @@ async def test_resume_streaming_via_continuation_token(a2a_agent: A2AAgent, mock
async def test_poll_task_in_progress(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
"""Test poll_task returns continuation token when task is still in progress."""
status = TaskStatus(state=TaskState.working, message=None)
status = TaskStatus(state=TaskState.TASK_STATE_WORKING, message=None)
mock_a2a_client.get_task_response = Task(id="task-poll", context_id="ctx-p", status=status)
token = A2AContinuationToken(task_id="task-poll", context_id="ctx-p")
@@ -880,11 +833,11 @@ async def test_poll_task_in_progress(a2a_agent: A2AAgent, mock_a2a_client: MockA
async def test_poll_task_completed(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
"""Test poll_task returns result with no continuation token when task is complete."""
status = TaskStatus(state=TaskState.completed, message=None)
status = TaskStatus(state=TaskState.TASK_STATE_COMPLETED, message=None)
artifact = Artifact(
artifact_id="art-poll",
name="result",
parts=[Part(root=TextPart(text="Poll result"))],
parts=[Part(text="Poll result")],
)
mock_a2a_client.get_task_response = Task(
id="task-poll-done", context_id="ctx-pd", status=status, artifacts=[artifact]
@@ -1105,9 +1058,9 @@ async def test_run_with_continuation_token_does_not_require_messages(mock_a2a_cl
task = Task(
id="task-cont",
context_id="ctx-cont",
status=TaskStatus(state=TaskState.completed, message=None),
status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED, message=None),
)
mock_a2a_client.resubscribe_responses.append((task, None))
mock_a2a_client.subscribe_responses.append(StreamResponse(task=task))
agent = A2AAgent(
name="Test Agent",
@@ -1176,8 +1129,10 @@ async def test_streaming_working_update_without_message_is_skipped(
async def test_streaming_working_update_user_role_mapping(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
"""Test that A2ARole.user in status message maps to role='user'."""
mock_a2a_client.add_in_progress_task_response("task-u", context_id="ctx-u", text="User echo", role=A2ARole.user)
"""Test that A2ARole.ROLE_USER in status message maps to role='user'."""
mock_a2a_client.add_in_progress_task_response(
"task-u", context_id="ctx-u", text="User echo", role=A2ARole.ROLE_USER
)
mock_a2a_client.add_task_response("task-u", [{"id": "art-u", "content": "Done"}])
updates: list[AgentResponseUpdate] = []
@@ -1224,9 +1179,9 @@ async def test_terminal_no_artifacts_after_working_with_content(
"""Test that a terminal task with no artifacts after working-state messages does not re-emit the working content."""
mock_a2a_client.add_in_progress_task_response("task-t", context_id="ctx-t", text="Working on it...")
# Terminal task with no artifacts and no history
status = TaskStatus(state=TaskState.completed, message=None)
status = TaskStatus(state=TaskState.TASK_STATE_COMPLETED, message=None)
task = Task(id="task-t", context_id="ctx-t", status=status)
mock_a2a_client.responses.append((task, None))
mock_a2a_client.responses.append(StreamResponse(task=task))
updates: list[AgentResponseUpdate] = []
async for update in a2a_agent.run("Hello", stream=True):
@@ -1245,12 +1200,12 @@ async def test_streaming_working_update_with_empty_parts_is_skipped(
# Construct a message with an empty parts list (distinct from message=None)
message = A2AMessage(
message_id=str(uuid4()),
role=A2ARole.agent,
role=A2ARole.ROLE_AGENT,
parts=[],
)
status = TaskStatus(state=TaskState.working, message=message)
status = TaskStatus(state=TaskState.TASK_STATE_WORKING, message=message)
task = Task(id="task-ep", context_id="ctx-ep", status=status)
mock_a2a_client.responses.append((task, None))
mock_a2a_client.responses.append(StreamResponse(task=task))
mock_a2a_client.add_task_response("task-ep", [{"id": "art-ep", "content": "Result"}])
updates: list[AgentResponseUpdate] = []
@@ -1265,13 +1220,12 @@ async def test_streaming_artifact_update_event_yields_content(
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
) -> None:
"""Test that streaming artifact update events yield incremental content."""
task = Task(id="task-art", context_id="ctx-art", status=TaskStatus(state=TaskState.working, message=None))
artifact = Artifact(
artifact_id="artifact-1",
parts=[Part(root=TextPart(text="Hello"))],
parts=[Part(text="Hello")],
)
update_event = TaskArtifactUpdateEvent(task_id="task-art", context_id="ctx-art", artifact=artifact, append=False)
mock_a2a_client.responses.append((task, update_event))
mock_a2a_client.responses.append(StreamResponse(artifact_update=update_event))
updates: list[AgentResponseUpdate] = []
async for update in a2a_agent.run("Hello", stream=True):
@@ -1291,17 +1245,15 @@ async def test_streaming_status_update_event_yields_content(
task_id="task-status",
context_id="ctx-status",
status=TaskStatus(
state=TaskState.working,
state=TaskState.TASK_STATE_WORKING,
message=A2AMessage(
message_id=str(uuid4()),
role=A2ARole.agent,
parts=[Part(root=TextPart(text="Still working"))],
role=A2ARole.ROLE_AGENT,
parts=[Part(text="Still working")],
),
),
final=False,
)
task = Task(id="task-status", context_id="ctx-status", status=TaskStatus(state=TaskState.working, message=None))
mock_a2a_client.responses.append((task, update_event))
mock_a2a_client.responses.append(StreamResponse(status_update=update_event))
updates: list[AgentResponseUpdate] = []
async for update in a2a_agent.run("Hello", stream=True):
@@ -1317,13 +1269,12 @@ async def test_streaming_artifact_update_event_does_not_duplicate_terminal_task_
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
) -> None:
"""Test that streamed artifact chunks are not re-emitted from the final terminal task."""
working_task = Task(id="task-art-dup", context_id="ctx-art-dup", status=TaskStatus(state=TaskState.working))
first_chunk = TaskArtifactUpdateEvent(
task_id="task-art-dup",
context_id="ctx-art-dup",
artifact=Artifact(
artifact_id="artifact-dup",
parts=[Part(root=TextPart(text="Hello "))],
parts=[Part(text="Hello ")],
),
append=False,
)
@@ -1332,32 +1283,26 @@ async def test_streaming_artifact_update_event_does_not_duplicate_terminal_task_
context_id="ctx-art-dup",
artifact=Artifact(
artifact_id="artifact-dup",
parts=[Part(root=TextPart(text="world"))],
parts=[Part(text="world")],
),
append=True,
)
terminal_task = Task(
id="task-art-dup",
context_id="ctx-art-dup",
status=TaskStatus(state=TaskState.completed, message=None),
status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED),
artifacts=[
Artifact(
artifact_id="artifact-dup",
parts=[Part(root=TextPart(text="Hello world"))],
parts=[Part(text="Hello world")],
)
],
)
terminal_event = TaskStatusUpdateEvent(
task_id="task-art-dup",
context_id="ctx-art-dup",
status=TaskStatus(state=TaskState.completed, message=None),
final=True,
)
mock_a2a_client.responses.extend([
(working_task, first_chunk),
(working_task, second_chunk),
(terminal_task, terminal_event),
StreamResponse(artifact_update=first_chunk),
StreamResponse(artifact_update=second_chunk),
StreamResponse(task=terminal_task),
])
stream = a2a_agent.run("Hello", stream=True)
@@ -1378,21 +1323,15 @@ async def test_streaming_terminal_task_artifacts_are_emitted_when_terminal_event
terminal_task = Task(
id="task-art-final",
context_id="ctx-art-final",
status=TaskStatus(state=TaskState.completed, message=None),
status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED),
artifacts=[
Artifact(
artifact_id="artifact-final",
parts=[Part(root=TextPart(text="Final artifact"))],
parts=[Part(text="Final artifact")],
)
],
)
terminal_event = TaskStatusUpdateEvent(
task_id="task-art-final",
context_id="ctx-art-final",
status=TaskStatus(state=TaskState.completed, message=None),
final=True,
)
mock_a2a_client.responses.append((terminal_task, terminal_event))
mock_a2a_client.responses.append(StreamResponse(task=terminal_task))
updates: list[AgentResponseUpdate] = []
async for update in a2a_agent.run("Hello", stream=True):
@@ -1407,41 +1346,34 @@ async def test_streaming_terminal_task_only_emits_unstreamed_artifacts(
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
) -> None:
"""Test that the terminal task only emits artifacts that were not already streamed incrementally."""
working_task = Task(id="task-art-mixed", context_id="ctx-art-mixed", status=TaskStatus(state=TaskState.working))
streamed_chunk = TaskArtifactUpdateEvent(
task_id="task-art-mixed",
context_id="ctx-art-mixed",
artifact=Artifact(
artifact_id="artifact-streamed",
parts=[Part(root=TextPart(text="Hello"))],
parts=[Part(text="Hello")],
),
append=False,
)
terminal_task = Task(
id="task-art-mixed",
context_id="ctx-art-mixed",
status=TaskStatus(state=TaskState.completed, message=None),
status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED),
artifacts=[
Artifact(
artifact_id="artifact-streamed",
parts=[Part(root=TextPart(text="Hello"))],
parts=[Part(text="Hello")],
),
Artifact(
artifact_id="artifact-final",
parts=[Part(root=TextPart(text="Goodbye"))],
parts=[Part(text="Goodbye")],
),
],
)
terminal_event = TaskStatusUpdateEvent(
task_id="task-art-mixed",
context_id="ctx-art-mixed",
status=TaskStatus(state=TaskState.completed, message=None),
final=True,
)
mock_a2a_client.responses.extend([
(working_task, streamed_chunk),
(terminal_task, terminal_event),
StreamResponse(artifact_update=streamed_chunk),
StreamResponse(task=terminal_task),
])
stream = a2a_agent.run("Hello", stream=True)
@@ -1463,11 +1395,11 @@ async def test_message_metadata_propagated(a2a_agent: A2AAgent, mock_a2a_client:
"""A2AMessage.metadata should appear on response.additional_properties."""
msg = A2AMessage(
message_id="msg-meta",
role=A2ARole.agent,
parts=[Part(root=TextPart(text="hi"))],
role=A2ARole.ROLE_AGENT,
parts=[Part(text="hi")],
metadata={"source": "server", "trace_id": "abc"},
)
mock_a2a_client.responses.append(msg)
mock_a2a_client.responses.append(StreamResponse(message=msg))
response = await a2a_agent.run("hello")
assert response.additional_properties["a2a_metadata"]["source"] == "server"
@@ -1479,16 +1411,16 @@ async def test_artifact_metadata_propagated(a2a_agent: A2AAgent, mock_a2a_client
task = Task(
id="task-art-meta",
context_id="ctx",
status=TaskStatus(state=TaskState.completed),
status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED),
artifacts=[
Artifact(
artifact_id="a1",
parts=[Part(root=TextPart(text="result"))],
parts=[Part(text="result")],
metadata={"artifact_key": "artifact_value"},
),
],
)
mock_a2a_client.responses.append((task, None))
mock_a2a_client.responses.append(StreamResponse(task=task))
response = await a2a_agent.run("go")
assert response.additional_properties["a2a_metadata"]["artifact_key"] == "artifact_value"
@@ -1499,13 +1431,13 @@ async def test_task_metadata_propagated_to_response(a2a_agent: A2AAgent, mock_a2
task = Task(
id="task-meta",
context_id="ctx",
status=TaskStatus(state=TaskState.completed),
status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED),
artifacts=[
Artifact(artifact_id="a1", parts=[Part(root=TextPart(text="done"))]),
Artifact(artifact_id="a1", parts=[Part(text="done")]),
],
metadata={"task_key": "task_value"},
)
mock_a2a_client.responses.append((task, None))
mock_a2a_client.responses.append(StreamResponse(task=task))
response = await a2a_agent.run("go")
assert response.additional_properties["a2a_metadata"]["task_key"] == "task_value"
@@ -1518,33 +1450,22 @@ async def test_task_artifact_update_event_metadata_merged(a2a_agent: A2AAgent, m
context_id="ctx",
artifact=Artifact(
artifact_id="a1",
parts=[Part(root=TextPart(text="chunk"))],
parts=[Part(text="chunk")],
metadata={"from_artifact": True},
),
metadata={"from_event": True},
)
working_task = Task(
id="task-ae",
context_id="ctx",
status=TaskStatus(state=TaskState.working),
)
terminal_task = Task(
id="task-ae",
context_id="ctx",
status=TaskStatus(state=TaskState.completed),
status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED),
artifacts=[
Artifact(artifact_id="a1", parts=[Part(root=TextPart(text="chunk"))]),
Artifact(artifact_id="a1", parts=[Part(text="chunk")]),
],
)
terminal_event = TaskStatusUpdateEvent(
task_id="task-ae",
context_id="ctx",
status=TaskStatus(state=TaskState.completed),
final=True,
)
mock_a2a_client.responses.extend([
(working_task, artifact_event),
(terminal_task, terminal_event),
StreamResponse(artifact_update=artifact_event),
StreamResponse(task=terminal_task),
])
stream = a2a_agent.run("hello", stream=True)
@@ -1563,39 +1484,27 @@ async def test_task_status_update_event_metadata_merged(a2a_agent: A2AAgent, moc
task_id="task-se",
context_id="ctx",
status=TaskStatus(
state=TaskState.working,
state=TaskState.TASK_STATE_WORKING,
message=A2AMessage(
message_id="m1",
role=A2ARole.agent,
parts=[Part(root=TextPart(text="working..."))],
role=A2ARole.ROLE_AGENT,
parts=[Part(text="working...")],
metadata={"msg_key": "msg_val"},
),
),
final=False,
metadata={"event_key": "event_val"},
)
working_task = Task(
id="task-se",
context_id="ctx",
status=TaskStatus(state=TaskState.working),
)
terminal_task = Task(
id="task-se",
context_id="ctx",
status=TaskStatus(state=TaskState.completed),
status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED),
artifacts=[
Artifact(artifact_id="a1", parts=[Part(root=TextPart(text="done"))]),
Artifact(artifact_id="a1", parts=[Part(text="done")]),
],
)
terminal_event = TaskStatusUpdateEvent(
task_id="task-se",
context_id="ctx",
status=TaskStatus(state=TaskState.completed),
final=True,
)
mock_a2a_client.responses.extend([
(working_task, status_event),
(terminal_task, terminal_event),
StreamResponse(status_update=status_event),
StreamResponse(task=terminal_task),
])
stream = a2a_agent.run("hello", stream=True)
@@ -1613,17 +1522,17 @@ async def test_history_message_metadata_propagated(a2a_agent: A2AAgent, mock_a2a
task = Task(
id="task-hist",
context_id="ctx",
status=TaskStatus(state=TaskState.completed),
status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED),
history=[
A2AMessage(
message_id="h1",
role=A2ARole.agent,
parts=[Part(root=TextPart(text="reply"))],
role=A2ARole.ROLE_AGENT,
parts=[Part(text="reply")],
metadata={"history_key": "history_value"},
),
],
)
mock_a2a_client.responses.append((task, None))
mock_a2a_client.responses.append(StreamResponse(task=task))
response = await a2a_agent.run("go")
assert response.additional_properties["a2a_metadata"]["history_key"] == "history_value"
@@ -1636,10 +1545,10 @@ async def test_continuation_token_update_carries_task_metadata(
task = Task(
id="task-cont",
context_id="ctx",
status=TaskStatus(state=TaskState.working),
status=TaskStatus(state=TaskState.TASK_STATE_WORKING),
metadata={"bg_key": "bg_value"},
)
mock_a2a_client.responses.append((task, None))
mock_a2a_client.responses.append(StreamResponse(task=task))
response = await a2a_agent.run("go", background=True)
assert response.continuation_token is not None
@@ -1652,10 +1561,10 @@ async def test_none_metadata_leaves_additional_properties_empty(
"""When A2A types have no metadata, additional_properties should remain empty/default."""
msg = A2AMessage(
message_id="msg-none",
role=A2ARole.agent,
parts=[Part(root=TextPart(text="no meta"))],
role=A2ARole.ROLE_AGENT,
parts=[Part(text="no meta")],
)
mock_a2a_client.responses.append(msg)
mock_a2a_client.responses.append(StreamResponse(message=msg))
response = await a2a_agent.run("hello")
assert not response.additional_properties
+12 -16
View File
@@ -3,7 +3,7 @@ from asyncio import CancelledError
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
from a2a.types import Task, TaskState, TextPart
from a2a.types import Part, Task, TaskState
from agent_framework import (
AgentResponseUpdate,
Content,
@@ -48,7 +48,7 @@ def mock_task() -> Task:
task = MagicMock(spec=Task)
task.id = str(uuid4())
task.context_id = str(uuid4())
task.state = TaskState.completed
task.state = TaskState.TASK_STATE_COMPLETED
return task
@@ -244,7 +244,7 @@ class TestA2AExecutorExecute:
executor._agent.run = AsyncMock(return_value=response)
executor._agent.create_session = MagicMock()
with patch("agent_framework_a2a._a2a_executor.new_task") as mock_new_task:
with patch("agent_framework_a2a._a2a_executor.new_task_from_user_message") as mock_new_task:
mock_task = MagicMock(spec=Task)
mock_task.id = "task-new"
mock_task.context_id = "ctx-123"
@@ -341,9 +341,7 @@ class TestA2AExecutorExecute:
# Assert
mock_updater.update_status.assert_called()
call_args_list = mock_updater.update_status.call_args_list
assert any(
call[1].get("state") == TaskState.canceled and call[1].get("final") is True for call in call_args_list
)
assert any(call[1].get("state") == TaskState.TASK_STATE_CANCELED for call in call_args_list)
async def test_execute_handles_generic_exception(
self,
@@ -382,14 +380,12 @@ class TestA2AExecutorExecute:
args, _ = mock_updater.new_agent_message.call_args
parts = args[0]
assert len(parts) == 1
assert isinstance(parts[0].root, TextPart)
assert parts[0].root.text == error_message
assert isinstance(parts[0], Part)
assert parts[0].text == error_message
call_args_list = mock_updater.update_status.call_args_list
assert any(
call[1].get("state") == TaskState.failed
and call[1].get("final") is True
and call[1].get("message") == "error_message_obj"
call[1].get("state") == TaskState.TASK_STATE_FAILED and call[1].get("message") == "error_message_obj"
for call in call_args_list
)
@@ -630,7 +626,7 @@ class TestA2AExecutorHandleEvents:
# Assert
mock_updater.update_status.assert_called_once()
call_args = mock_updater.update_status.call_args
assert call_args.kwargs["state"] == TaskState.working
assert call_args.kwargs["state"] == TaskState.TASK_STATE_WORKING
assert mock_updater.new_agent_message.called
async def test_handle_multiple_text_contents(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
@@ -666,7 +662,7 @@ class TestA2AExecutorHandleEvents:
# Assert
mock_updater.update_status.assert_called_once()
call_args = mock_updater.update_status.call_args
assert call_args.kwargs["state"] == TaskState.working
assert call_args.kwargs["state"] == TaskState.TASK_STATE_WORKING
async def test_handle_uri_content(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
"""Test handling messages with URI content."""
@@ -683,7 +679,7 @@ class TestA2AExecutorHandleEvents:
# Assert
mock_updater.update_status.assert_called_once()
call_args = mock_updater.update_status.call_args
assert call_args.kwargs["state"] == TaskState.working
assert call_args.kwargs["state"] == TaskState.TASK_STATE_WORKING
async def test_handle_mixed_content_types(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
"""Test handling messages with mixed content types."""
@@ -705,7 +701,7 @@ class TestA2AExecutorHandleEvents:
# Assert
mock_updater.update_status.assert_called_once()
call_args = mock_updater.update_status.call_args
assert call_args.kwargs["state"] == TaskState.working
assert call_args.kwargs["state"] == TaskState.TASK_STATE_WORKING
async def test_handle_with_additional_properties(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
"""Test handling messages with additional properties metadata."""
@@ -778,7 +774,7 @@ class TestA2AExecutorHandleEvents:
# Assert
call_kwargs = mock_updater.update_status.call_args.kwargs
assert call_kwargs["state"] == TaskState.working
assert call_kwargs["state"] == TaskState.TASK_STATE_WORKING
async def test_handle_agent_response_update_no_streamed_set(
self, executor: A2AExecutor, mock_updater: MagicMock
+11 -7
View File
@@ -5,14 +5,15 @@ import os
import sys
import uvicorn
from a2a.server.apps.jsonrpc.starlette_app import A2AStarletteApplication
from a2a.server.request_handlers.default_request_handler import DefaultRequestHandler
from a2a.server.tasks.inmemory_task_store import InMemoryTaskStore
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes
from a2a.server.tasks import InMemoryTaskStore
from agent_definitions import AGENT_CARD_FACTORIES, AGENT_FACTORIES
from agent_executor import AgentFrameworkExecutor
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
from starlette.applications import Starlette
# Load environment variables from .env file
load_dotenv()
@@ -96,11 +97,14 @@ def main() -> None:
request_handler = DefaultRequestHandler(
agent_executor=executor,
task_store=task_store,
agent_card=agent_card,
)
a2a_app = A2AStarletteApplication(
agent_card=agent_card,
http_handler=request_handler,
app = Starlette(
routes=[
*create_agent_card_routes(agent_card),
*create_jsonrpc_routes(request_handler),
]
)
print(f"Starting A2A server: {agent_card.name}")
@@ -110,7 +114,7 @@ def main() -> None:
print()
uvicorn.run(
a2a_app.build(),
app,
host=args.host,
port=args.port,
)
@@ -10,7 +10,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from a2a.types import AgentCapabilities, AgentCard, AgentSkill
from a2a.types import AgentCapabilities, AgentCard, AgentInterface, AgentSkill
from invoice_data import query_by_invoice_id, query_by_transaction_id, query_invoices
if TYPE_CHECKING:
@@ -94,11 +94,11 @@ def get_invoice_agent_card(url: str) -> AgentCard:
return AgentCard(
name="InvoiceAgent",
description="Handles requests relating to invoices.",
url=url,
version="1.0.0",
default_input_modes=["text"],
default_output_modes=["text"],
capabilities=_CAPABILITIES,
supported_interfaces=[AgentInterface(url=url, protocol_binding="JSONRPC")],
skills=[
AgentSkill(
id="id_invoice_agent",
@@ -116,11 +116,11 @@ def get_policy_agent_card(url: str) -> AgentCard:
return AgentCard(
name="PolicyAgent",
description="Handles requests relating to policies and customer communications.",
url=url,
version="1.0.0",
default_input_modes=["text"],
default_output_modes=["text"],
capabilities=_CAPABILITIES,
supported_interfaces=[AgentInterface(url=url, protocol_binding="JSONRPC")],
skills=[
AgentSkill(
id="id_policy_agent",
@@ -138,11 +138,11 @@ def get_logistics_agent_card(url: str) -> AgentCard:
return AgentCard(
name="LogisticsAgent",
description="Handles requests relating to logistics.",
url=url,
version="1.0.0",
default_input_modes=["text"],
default_output_modes=["text"],
capabilities=_CAPABILITIES,
supported_interfaces=[AgentInterface(url=url, protocol_binding="JSONRPC")],
skills=[
AgentSkill(
id="id_logistics_agent",
@@ -21,7 +21,6 @@ from a2a.types import (
TaskState,
TaskStatus,
TaskStatusUpdateEvent,
TextPart,
)
if TYPE_CHECKING:
@@ -56,8 +55,7 @@ class AgentFrameworkExecutor(AgentExecutor):
TaskStatusUpdateEvent(
task_id=task_id,
context_id=context_id,
status=TaskStatus(state=TaskState.working),
final=False,
status=TaskStatus(state=TaskState.TASK_STATE_WORKING),
)
)
@@ -68,10 +66,10 @@ class AgentFrameworkExecutor(AgentExecutor):
response_parts: list[Part] = []
for msg in response.messages:
if msg.text:
response_parts.append(TextPart(text=msg.text))
response_parts.append(Part(text=msg.text))
if not response_parts:
response_parts.append(TextPart(text=str(response)))
response_parts.append(Part(text=str(response)))
# Publish the agent's response as a completed message
await event_queue.enqueue_event(
@@ -79,14 +77,13 @@ class AgentFrameworkExecutor(AgentExecutor):
task_id=task_id,
context_id=context_id,
status=TaskStatus(
state=TaskState.completed,
state=TaskState.TASK_STATE_COMPLETED,
message=Message(
message_id=str(uuid.uuid4()),
role=Role.agent,
role=Role.ROLE_AGENT,
parts=response_parts,
),
),
final=True,
)
)
except asyncio.CancelledError:
@@ -97,14 +94,13 @@ class AgentFrameworkExecutor(AgentExecutor):
task_id=task_id,
context_id=context_id,
status=TaskStatus(
state=TaskState.failed,
state=TaskState.TASK_STATE_FAILED,
message=Message(
message_id=str(uuid.uuid4()),
role=Role.agent,
parts=[TextPart(text=f"Agent error: {e}")],
role=Role.ROLE_AGENT,
parts=[Part(text=f"Agent error: {e}")],
),
),
final=True,
)
)
@@ -117,7 +113,6 @@ class AgentFrameworkExecutor(AgentExecutor):
TaskStatusUpdateEvent(
task_id=task_id,
context_id=context_id,
status=TaskStatus(state=TaskState.canceled),
final=True,
status=TaskStatus(state=TaskState.TASK_STATE_CANCELED),
)
)
@@ -1,18 +1,20 @@
# Copyright (c) Microsoft. All rights reserved.
import uvicorn
from a2a.server.apps import A2AStarletteApplication
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes
from a2a.server.tasks import InMemoryTaskStore
from a2a.types import (
AgentCapabilities,
AgentCard,
AgentInterface,
AgentSkill,
)
from agent_framework import Agent
from agent_framework.a2a import A2AExecutor
from agent_framework.openai import OpenAIChatClient
from dotenv import load_dotenv
from starlette.applications import Starlette
load_dotenv()
@@ -39,11 +41,11 @@ if __name__ == "__main__":
public_agent_card = AgentCard(
name="Europe Travel Agent",
description="A helpful Europe Travel Agent that can help users search and book flights and hotels across Europe.",
url="http://localhost:9999/",
version="1.0.0",
defaultInputModes=["text"],
defaultOutputModes=["text"],
default_input_modes=["text"],
default_output_modes=["text"],
capabilities=AgentCapabilities(streaming=True),
supported_interfaces=[AgentInterface(url="http://localhost:9999/", protocol_binding="JSONRPC")],
skills=[flight_skill, hotel_skill],
)
# --8<-- [end:AgentCard]
@@ -57,14 +59,14 @@ if __name__ == "__main__":
request_handler = DefaultRequestHandler(
agent_executor=A2AExecutor(agent),
task_store=InMemoryTaskStore(),
)
server = A2AStarletteApplication(
agent_card=public_agent_card,
http_handler=request_handler,
)
server = server.build()
# print(schemas.get_schema(server.routes))
server = Starlette(
routes=[
*create_agent_card_routes(public_agent_card),
*create_jsonrpc_routes(request_handler),
]
)
uvicorn.run(server, host="0.0.0.0", port=9999)
+44 -4
View File
@@ -67,18 +67,22 @@ overrides = [
[[package]]
name = "a2a-sdk"
version = "0.3.23"
version = "1.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "culsans", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" },
{ name = "google-api-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "googleapis-common-protos", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "httpx-sse", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "json-rpc", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2d/6a/2fe24e0a85240a651006c12f79bdb37156adc760a96c44bc002ebda77916/a2a_sdk-0.3.23.tar.gz", hash = "sha256:7c46b8572c4633a2b41fced2833e11e62871e8539a5b3c782ba2ba1e33d213c2", size = 255265, upload-time = "2026-02-17T08:34:34.648Z" }
sdist = { url = "https://files.pythonhosted.org/packages/88/f3/1c312eae0298542eef1a096be378a3ad2d20b171ea0ac6be26b81f542720/a2a_sdk-1.0.2.tar.gz", hash = "sha256:e4ee4dd509894c32c9a6df728319875fa4f049e70ae82476fa447353e3a4b648", size = 375193, upload-time = "2026-04-24T13:50:24.303Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/20/77d119f19ab03449d3e6bc0b1f11296d593dae99775c1d891ab1e290e416/a2a_sdk-0.3.23-py3-none-any.whl", hash = "sha256:8c2f01dffbfdd3509eafc15c4684743e6ae75e69a5df5d6f87be214c948e7530", size = 145689, upload-time = "2026-02-17T08:34:33.263Z" },
{ url = "https://files.pythonhosted.org/packages/c9/03/58c92a44e7b94a42614880df2365f074969e47067c4c736e31e855aca2fd/a2a_sdk-1.0.2-py3-none-any.whl", hash = "sha256:4dbc083b6808ee28207ac6daad263360f87612c37b2d06f5521efb530318141c", size = 234302, upload-time = "2026-04-24T13:50:22.412Z" },
]
[[package]]
@@ -168,7 +172,7 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "a2a-sdk", specifier = ">=0.3.5,<0.3.24" },
{ name = "a2a-sdk", specifier = ">=1.0.0,<2" },
{ name = "agent-framework-core", editable = "packages/core" },
]
@@ -975,6 +979,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/8f/87c56a1a1977d7dddea5b31e12189665a140fdb48a71e9038ff90bb564ec/aiohttp-3.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:014dcc10ec8ab8db681f0d68e939d1e9286a5aa2b993cbbdb0db130853e02144", size = 506381, upload-time = "2026-03-28T17:18:48.74Z" },
]
[[package]]
name = "aiologic"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "sniffio", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" },
{ name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" },
{ name = "wrapt", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a8/13/50b91a3ea6b030d280d2654be97c48b6ed81753a50286ee43c646ba36d3c/aiologic-0.16.0.tar.gz", hash = "sha256:c267ccbd3ff417ec93e78d28d4d577ccca115d5797cdbd16785a551d9658858f", size = 225952, upload-time = "2025-11-27T23:48:41.195Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f6/27/206615942005471499f6fbc36621582e24d0686f33c74b2d018fcfd4fe67/aiologic-0.16.0-py3-none-any.whl", hash = "sha256:e00ce5f68c5607c864d26aec99c0a33a83bdf8237aa7312ffbb96805af67d8b6", size = 135193, upload-time = "2025-11-27T23:48:40.099Z" },
]
[[package]]
name = "aiosignal"
version = "1.4.0"
@@ -2012,6 +2030,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/20/2a/1b016902351a523aa2bd446b50a5bc1175d7a7d1cf90fe2ef904f9b84ebc/cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", size = 3412829, upload-time = "2026-04-08T01:57:48.874Z" },
]
[[package]]
name = "culsans"
version = "0.11.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiologic", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" },
{ name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d9/e3/49afa1bc180e0d28008ec6bcdf82a4072d1c7a41032b5b759b60814ca4b0/culsans-0.11.0.tar.gz", hash = "sha256:0b43d0d05dce6106293d114c86e3fb4bfc63088cfe8ff08ed3fe36891447fe33", size = 107546, upload-time = "2025-12-31T23:15:38.196Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/5d/9fb19fb38f6d6120422064279ea5532e22b84aa2be8831d49607194feda3/culsans-0.11.0-py3-none-any.whl", hash = "sha256:278d118f63fc75b9db11b664b436a1b83cc30d9577127848ba41420e66eb5a47", size = 21811, upload-time = "2025-12-31T23:15:37.189Z" },
]
[[package]]
name = "cycler"
version = "0.12.1"
@@ -3163,6 +3194,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" },
]
[[package]]
name = "json-rpc"
version = "1.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6d/9e/59f4a5b7855ced7346ebf40a2e9a8942863f644378d956f68bcef2c88b90/json-rpc-1.15.0.tar.gz", hash = "sha256:e6441d56c1dcd54241c937d0a2dcd193bdf0bdc539b5316524713f554b7f85b9", size = 28854, upload-time = "2023-06-11T09:45:49.078Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/9e/820c4b086ad01ba7d77369fb8b11470a01fac9b4977f02e18659cf378b6b/json_rpc-1.15.0-py2.py3-none-any.whl", hash = "sha256:4a4668bbbe7116feb4abbd0f54e64a4adcf4b8f648f19ffa0848ad0f6606a9bf", size = 39450, upload-time = "2023-06-11T09:45:47.136Z" },
]
[[package]]
name = "jsonpath-ng"
version = "1.8.0"