Compare commits

..
Author SHA1 Message Date
Peter Ibekwe 6ea7b45290 Revamped sample to address PR comments. 2026-05-22 08:58:19 -07:00
Peter Ibekwe 894b2c6ce0 Resolve PR comments 2026-05-21 21:56:27 -07:00
Peter Ibekwe 111648ee5b Fix CI pipelines. 2026-05-21 17:08:37 -07:00
Peter Ibekwe 41eac64ee3 Addressed PR comments. 2026-05-21 16:02:01 -07:00
Peter Ibekwe e642371a20 Fix pyright issues. 2026-05-18 15:15:02 -07:00
Peter IbekweandCopilot c30f104b20 Python: address PR review on declarative toolbox sample
Two security fixes for PR #5933:

1. Add safe_mode flag to WorkflowFactory (default True) mirroring
   AgentFactory. Gates =Env.* exposure inside DeclarativeWorkflowState
   PowerFx symbols via _safe_mode_context, so workflow YAML loaded from
   untrusted sources no longer leaks the host's full os.environ snapshot
   into PowerFx evaluation. The flag is also forwarded to the
   internally-constructed AgentFactory so inline agent definitions
   follow the same policy.

2. Pin the invoke_foundry_toolbox_mcp sample's _client_provider to the
   resolved toolbox endpoint. The bearer-authenticated httpx client is
   now only returned when MCPToolInvocation.server_url matches the
   toolbox URL case-insensitively; any other URL gets None (the default
   unauthenticated path), preventing the Foundry AAD bearer token from
   being attached to a mis-configured or injected server URL. Mirrors
   the .NET sample's httpClientProvider guard.

The sample is updated to opt in to safe_mode=False because its YAML
intentionally uses =Env.FOUNDRY_TOOLBOX_* to keep configuration in env
vars under the developer's control.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-18 14:43:26 -07:00
Peter Ibekwe 800a58d6c1 Add Python parity sample for invoking Foundry Toolbox tools from declarative workflows 2026-05-18 13:10:55 -07:00
Yufeng HeandGitHub a60e541c9a .NET: fix: avoid AGUI tool result message id collisions (#5800)
* fix: avoid AGUI tool result message id collisions

* fix: split mixed tool result message ids
2026-05-15 21:52:25 +00:00
Tao ChenandGitHub da308f5f1e Python: New Foundry Hosted Agents samples: RAG, Skills, and Memory (#5822)
* WIP: Add rag sample; need deployment testing

* Rag sample ready

* Add Foundry Skills sample

* WIP: Foundry memory

* Done: Foundry Memory

* Address Copilot comments

* Fix README

* Restore uv.loack
2026-05-15 17:31:57 +00:00
westeyandGitHub 9b772f3413 .NET: Add observer for OpenAIWebSearch (#5894)
* Add observer for OpenAIWebSearch

* Update reference in comment

* Use types where possible.
2026-05-15 17:30:01 +00:00
westeyandGitHub c885ca3d7a .NET: Fix bug in store-false helper to ensure addition rather than replacement (#5895)
* Fix bug in store-false helper to ensure addition rather than replacement

* Address PR comments
2026-05-15 15:51:45 +00:00
0d09d40f0f Python: Fix GitHubCopilotAgent to include tools added by ContextProvider.before_run in session creation (#5780)
* Fix GitHubCopilotAgent ignoring tools from context providers (#5736)

_create_session and _resume_session only forwarded self._tools (constructor
tools) to CopilotClient.create_session, dropping any tools contributed by
context providers via session_context.extend_tools() during before_run.

Merge provider-contributed tools into runtime_options in both _run_impl and
_stream_updates before session creation, mirroring how RawAgent handles the
merge at lines 1435-1440 in _agents.py. Update _create_session and
_resume_session to combine self._tools with the merged runtime tools.

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

* Python: Fix GitHubCopilotAgent to include tools added by ContextProvider.before_run in session creation

Fixes #5736

* Fix provider tool merge to avoid mutating caller's list

- Replace in-place .extend() with fresh list creation in both
  _run_impl and _stream_updates paths to prevent mutating the
  caller-provided options['tools'] list (shallow copy issue)
- Also handles immutable Sequence types (e.g. tuple) correctly
- Add test for provider tools forwarded via _resume_session path

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

* Address review feedback for #5736: review comment fixes

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-15 14:59:22 +00:00
d81a8753d7 add AgentSession StateBag edge case coverage (#5838)
Co-authored-by: Challa Ravindranath <chravin@microsoft.com>
2026-05-15 11:02:37 +00:00
19b2367366 Python: Parse YAML block scalars in SKILL.md frontmatter (#5863)
The frontmatter parser previously matched only single-line `key: value` pairs, so block scalar indicators (`|` literal, `>` folded, with chomping `-`/`+`) were silently truncated to the indicator character. Multi-line descriptions like `description: >\n  ...` lost their content.

Add `_parse_yaml_scalar_value()` which detects block scalar indicators, collects indented continuation lines, strips the common leading indentation, joins per scalar style (newlines for `|`, spaces for `>`), and applies chomping per the YAML 1.2 spec. Update `_extract_frontmatter()` to use the helper for unquoted values.

Adds 15 unit tests covering literal/folded styles, all chomping variants, indentation handling, content containing colons, non-description fields, tab indentation, blank-line preservation, and a regression test for plain values.

Fixes #5713.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-15 09:47:00 +00:00
Roger BarretoandGitHub ad95f2f2fa .NET: Add Hosted-MemoryAgent sample with isolation key plumbing (#5692) (#5702)
* .NET: Add Hosted-MemoryAgent sample with isolation key plumbing (#5692)

Adds HostedSessionContext + HostedSessionIsolationKeyProvider in Microsoft.Agents.AI.Foundry.Hosting so AIContextProviders (notably FoundryMemoryProvider) can scope per user via the platform's x-agent-user-isolation-key / x-agent-chat-isolation-key headers.

- New types: HostedSessionContext (sealed), HostedSessionContextExtensions (public Get, internal Set), abstract HostedSessionIsolationKeyProvider (async), internal PlatformHostedSessionIsolationKeyProvider mapping ResponseContext.Isolation.

- AgentFrameworkResponseHandler now resolves the provider, tags fresh sessions, and validates resumed sessions against the live request (strict 403 'Hosted session identity context mismatch' on any mismatch; 500 on null keys).

- New shared sample project Hosted_Shared_Contributor_Setup hosts DevTemporaryTokenCredential and DevTemporaryLocalSessionIsolationKeyProvider plus AddDevTemporaryLocalContributorSetup. All 9 existing responses samples migrated to consume it so local runs keep working under the strict isolation contract.

- New Hosted-MemoryAgent sample: travel assistant wired through FoundryMemoryProvider with stateInitializer reading session.GetHostedContext().UserId. Includes Dockerfile, smoke.ps1, agent.yaml/manifest.

- New IT scenario 'memory' in Foundry.Hosting.IntegrationTests + MemoryHostedAgentFixture + MemoryHostedAgentTests. Verified end to end against the tao Foundry project.

- ADR 0026 captures the design tree.

* Address PR review feedback

- Dockerfile: add header noting it targets NuGet builds; contributors must use Dockerfile.contributor for ProjectReference source builds.

- PlatformHostedSessionIsolationKeyProvider: doc said 'returns context with empty values'; corrected to 'returns null' which the handler treats as 500.

- FakeHostedSessionIsolationKeyProvider: doc clarifies that null configurations are allowed for testing the handler error path.

- HostedSessionContextExtensions.SetHostedContext: enforce write-once with InvalidOperationException; doc + xml exception updated.

- AgentFrameworkResponseHandler: cache PlatformHostedSessionIsolationKeyProvider as static readonly to avoid per-request allocation.

- MemoryHostedAgentTests: tighten waits from 20s to 5s (FoundryMemoryProvider defaults UpdateDelay=0; ingestion ~3s).

- Sample Program.cs imports reordered to satisfy IDE0005.

* Add HostedFoundryMemoryProviderScopes built-in helpers (#5692)

Addresses review feedback from @lokitoth on Hosted-MemoryAgent/Program.cs:54.

- New HostedFoundryMemoryProviderScopes static class with PerUser, PerChat, PerUserAndChat factories returning Func<AgentSession?, FoundryMemoryProvider.State>.

- All helpers throw InvalidOperationException when GetHostedContext() is null, with a message pointing at writing a custom stateInitializer for non-hosted scenarios.

- New HostedFoundryMemoryScope enum and AddHostedFoundryMemoryProvider DI extension (two overloads: explicit AIProjectClient and DI-resolved). Singleton lifetime. Default scope = PerUser.

- Hosted-MemoryAgent sample and the memory IT scenario container both swap their inline lambdas for HostedFoundryMemoryProviderScopes.PerUser().

- 14 new unit tests (241/241 hosting unit tests pass).

* Replace HostedFoundryMemoryScope enum with Func<...> parameter (#5692)

Address PR review feedback from @westey-m: enums are a breaking-change hazard when extended, and the enum was redundant with the existing HostedFoundryMemoryProviderScopes static class.

- Delete HostedFoundryMemoryScope.cs.

- AddHostedFoundryMemoryProvider DI extensions now take Func<AgentSession?, FoundryMemoryProvider.State>? stateInitializer = null. When null, default to HostedFoundryMemoryProviderScopes.PerUser().

- Callers pick a built-in helper (PerUser/PerChat/PerUserAndChat) or pass a custom delegate. New built-ins are a single static method addition with zero impact on existing callers.

- Tests updated; 244/244 hosting unit tests pass.

* Fix isolation context resume for externally-created conversations (#5692)

Branch on the session's existing hosted-context (not on conversation_id presence) so a conversation provisioned externally (e.g. via conversations.CreateProjectConversationAsync) is treated as fresh on first hosted-agent request and stamped, rather than rejected with 403 hosted_session_identity_mismatch. Strict equality is preserved on real resume of an already-stamped session.

Also tighten dotnet/global.json to version 10.0.204 + rollForward latestPatch so local builds match the CI Docker image SDK and avoid 10.0.300 dotnet format stripping required usings.

* Revert global.json SDK pin to upstream (#5692)

The 10.0.204 + latestPatch pin from the previous commit broke the dotnet-format CI job (hostfxr_resolve_sdk2 could not find a compatible SDK in the mcr.microsoft.com/dotnet/sdk:10.0 image). Restore upstream 10.0.200 + minor; local Release builds with SDK 10.0.300 should set GITHUB_ACTIONS=true to bypass the auto-format-on-build target.
2026-05-15 05:42:12 +00:00
Evan MattsonandGitHub 97eaef029e Triage improvements (#5880) 2026-05-15 10:49:46 +09:00
47fa59f8e9 Python: bump package versions for 1.4.0 release (#5872)
* fixes

* fixes

* Python: bump package versions for 1.4.0 release

Cuts the python-1.4.0 release. MINOR bump on the released cohort
(agent-framework, agent-framework-core, agent-framework-openai,
agent-framework-foundry: 1.3.0 -> 1.4.0), driven by breaking changes
in experimental skills API and new features. All 21 beta packages
stamp 1.0.0b260514, all 3 alpha packages stamp 1.0.0a260514, and
ag-ui remains at 1.0.0rc1 (freshly promoted). Date stamp reflects
2026-05-14 Pacific.

- Released cohort: 1.3.0 -> 1.4.0
- Beta packages (21): 1.0.0b260507 -> 1.0.0b260514
- Alpha packages (3): 1.0.0a260507 -> 1.0.0a260514
- ag-ui: stays at 1.0.0rc1 (dep bound updated only)
- Inter-package dependency lower bounds updated (>=1.3.0 -> >=1.4.0)
- Fix chatkit StructuredInputItem exhaustiveness for openai-chatkit 1.6.4
- Update CHANGELOG compare links
- uv.lock refreshed

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-15 09:31:03 +09:00
68357b0250 Python: Fix A2A v1.0 non-streaming response and sample runtime issues (#5849)
- Fix non-streaming empty response by accumulating intermediate WORKING
  status updates and flushing them when an empty terminal event arrives
- Fix sample agent_executor.py to enqueue Task before status events
  (required by v1.0 ActiveTask validation)
- Fix create_jsonrpc_routes() calls to include required rpc_url param
- Fix TYPE_CHECKING imports in sample agent_definitions.py
- Add tests for non-streaming content accumulation behavior

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-14 22:28:02 +00:00
Yufeng HeandGitHub 410268b624 Python: forward MCP tool call metadata (#5815)
* Python: forward MCP tool call metadata

* fix: preserve MCP tool meta after prompt reload
2026-05-14 21:50:39 +00:00
CopilotGitHublokitothcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>Jacob Alber
67f3db6280 Python: Reject path-traversal context ids in Foundry Hosting Checkpoint Storage (#5851)
* Reject path-traversal context ids in foundry workflow checkpoint storage

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/fca3aae6-50eb-4726-8baf-2718217d4e79

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

* Address PR review feedback: clarify URL-decode comment, isolate test root, add e2e workflow rejection tests

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/832f45a6-c01e-4da9-bf85-1ba7b5f302e6

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

* Clarify MSRC repro padding length in regression test

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/832f45a6-c01e-4da9-bf85-1ba7b5f302e6

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

* add E2E http test for checkpoint context id rejection

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/730258ef-2781-4a7d-b7cf-b5c40c11defc

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>
Co-authored-by: Jacob Alber <jaalber@microsoft.com>
2026-05-14 21:38:37 +00:00
CopilotGitHublokitothcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>Jacob Alber
2ef20cd0aa .NET: Add Magentic E2E workflow coverage (#5833)
* Add E2E test plan for Magentic orchestrator

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/96d76349-1ffd-482b-a3ee-ed208778b1bb

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

* Add MagenticOrchestrationTests.cs scaffold for Magentic E2E tests

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/44a4fd8a-3828-40e5-9435-90381aeffdb8

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

* Fix MagenticOrchestrator output declaration and add first E2E test

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/322c9e2d-59bc-42ad-9a1e-f6fd4c866b26

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

* Add plan review test and event emission tests

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/322c9e2d-59bc-42ad-9a1e-f6fd4c866b26

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

* Add next speaker validation test

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/322c9e2d-59bc-42ad-9a1e-f6fd4c866b26

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

* Add Magentic E2E implementation review

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/b2c60ce7-4d05-4a0d-b05d-d4284f5b7bb3

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

* Add PlanSignoff_Disabled_Proceeds_Immediately E2E test

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/6e8bca46-448d-4f21-a7e9-240179571970

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

* Add NextSpeaker_Empty_Falls_Back_To_First E2E test

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/6e8bca46-448d-4f21-a7e9-240179571970

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

* Add Task_Completes_After_Multiple_Rounds E2E test

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/6e8bca46-448d-4f21-a7e9-240179571970

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

* Add PlanReview_Revised_Triggers_Replan E2E test

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/6e8bca46-448d-4f21-a7e9-240179571970

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

* Add MaxRoundLimit_Terminates_Workflow E2E test

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/6e8bca46-448d-4f21-a7e9-240179571970

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

* Add MaxStallCount_Triggers_Reset E2E test

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/6e8bca46-448d-4f21-a7e9-240179571970

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

* Update MagenticE2E_ImplementationReview.md with full coverage status

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/6e8bca46-448d-4f21-a7e9-240179571970

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

* Rewrite Magentic E2E implementation review

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/1f878ef4-61b0-410a-a8bc-ebf618b3e5de

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

* Add MaxResetLimit_Terminates_Workflow E2E test

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/aba19507-7c7e-40dd-850d-d1fabb5dfa65

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

* Add PlanReview_On_Stall_Replan E2E test

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/aba19507-7c7e-40dd-850d-d1fabb5dfa65

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

* Add Instruction_Message_Sent_When_Present E2E test

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/aba19507-7c7e-40dd-850d-d1fabb5dfa65

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

* Update ImplementationReview.md to reflect 14 tests

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/aba19507-7c7e-40dd-850d-d1fabb5dfa65

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

* Rewrite Magentic E2E implementation review

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/6fe88a80-2e05-40d5-9539-ca7c59b9022b

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

* Add ProgressLedger_Retry_On_Parse_Failure E2E test

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/125f6628-6b3b-4c51-9a51-ae84baece6bb

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

* Add ProgressLedger_Max_Retries_Triggers_Reset E2E test

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/125f6628-6b3b-4c51-9a51-ae84baece6bb

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

* Add Stall_NoProgress_Increments_StallCount E2E test

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/125f6628-6b3b-4c51-9a51-ae84baece6bb

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

* Add PlanReview_Multiple_Revisions E2E test

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/125f6628-6b3b-4c51-9a51-ae84baece6bb

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

* Update ImplementationReview.md to reflect 18 tests and new coverage

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/125f6628-6b3b-4c51-9a51-ae84baece6bb

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

* Rewrite Magentic E2E implementation review

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/21f3b1ae-183e-4fea-99ad-14efc19f084d

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

* Preserve IsStalled on stall-triggered plan review requests

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/1b9e74e8-69e1-43f2-8467-c5ba963c2622

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

* Rename isStalled parameter to replanAfterStall for clarity

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/1b9e74e8-69e1-43f2-8467-c5ba963c2622

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

* Add Task_Delegates_To_Correct_Agent E2E test with multi-participant routing assertion

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/9b34e409-61b8-4650-ae55-34efad034ed0

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

* Add Progress_Made_Decrements_StallCount E2E test verifying stall count decrement avoids reset

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/9b34e409-61b8-4650-ae55-34efad034ed0

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

* Add Consecutive_Stalls_Trigger_Reset E2E test for multi-stall threshold reset

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/9b34e409-61b8-4650-ae55-34efad034ed0

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

* Magentic E2E: preserve IsStalled on stall-triggered plan reviews, add routing/stall tests

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/9b34e409-61b8-4650-ae55-34efad034ed0

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

* Fix replan-on-every-turn: skip plan on agent return; align StallCount to > (match Python)

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/43e46b0d-4263-4353-856a-c3730abb1734

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

* Update implementation review doc for replan-fix and stall threshold alignment

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/43e46b0d-4263-4353-856a-c3730abb1734

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

* Rewrite Magentic E2E implementation review

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/3d15763b-3a68-488e-9412-3fa280e083c0

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

* Update stall docs to use > semantics, skip checkpoint-state tests, simplify NextSpeaker fallback test

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/cc9ea5a8-84d8-4b6d-bb60-ac9619824d81

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

* Rewrite Magentic implementation review

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/ed87670a-bf4d-4ba5-a2f3-395a2eead9de

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

* Add empty-team validation to MagenticWorkflowBuilder.Build() and E2E test

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/e490fdf7-f107-4fde-ba1f-efdfd9a729c6

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

* Add IsTerminated guard to TakeTurnAsync and post-termination rejection test

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/e490fdf7-f107-4fde-ba1f-efdfd9a729c6

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

* Rewrite ImplementationReview.md with final 23-test status

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/e490fdf7-f107-4fde-ba1f-efdfd9a729c6

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

* Add PR description markdown

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/df9b4579-10c3-4bfb-927e-da3a0e70009e

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

* Remove temporary markdown files

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/b3e67553-a3a3-4282-98f2-afd8ad7a6b5d

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

* Fix IDE1006: add Async suffix to async test methods in MagenticOrchestrationTests

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/629fcc07-865e-4832-9e59-ea13df561c5a

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

* Update error messages per review comments in MagenticOrchestrator and MagenticWorkflowBuilder

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/053e5ded-81e3-4e56-acf1-2a8a939a04b0

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

* Escape JSON string values in CreateProgressLedgerResponse test helper

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/ec610c61-0a14-44e2-82fd-1cf35e85d6cc

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>
Co-authored-by: Jacob Alber <jaalber@microsoft.com>
2026-05-14 19:53:07 +00:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>lokitothJacob Alber
27671974c2 .NET: Re-enable ObservabilityTests and WorkflowRunActivityStopTests (#5837)
Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/220699b9-7f9e-4d5d-87d0-fb621d169d84

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>
Co-authored-by: Jacob Alber <jaalber@microsoft.com>
2026-05-14 19:17:26 +00:00
7432105ebe Python: Support list[str] arguments for file-based skill scripts (#5850)
Port of .NET PR #5475. Broadens the args type from dict[str, Any] | None
to dict[str, Any] | list[str] | None across the skill script API surface,
enabling CLI-style argv forwarding to subprocess scripts.

Changes:
- SkillScript.run(), InlineSkillScript.run(), FileSkillScript.run(): widen
  args type; InlineSkillScript rejects list with TypeError
- FileSkillScript.parameters_schema: returns array-of-strings schema
- FileSkill.content: appends <scripts> block with parameters_schema
- SkillScriptRunner protocol: widen args type
- SkillsProvider._run_skill_script: widen args type
- run_skill_script tool schema: accept object, array, or null
- subprocess_script_runner sample: accept list[str], reject dict
- class_based_skill sample: fix missing SkillFrontmatter wrapper
- Standardize 'folder' to 'directory' in docstrings (#5712)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-14 17:58:10 +00:00
157 changed files with 7482 additions and 594 deletions
+3 -5
View File
@@ -149,16 +149,14 @@ jobs:
--apply-labels
- name: Stop after spam gate
if: ${{ steps.spam.outputs.decision != 'allow' }}
if: ${{ steps.spam.outputs.allow_triage != 'true' }}
shell: bash
env:
SPAM_DECISION: ${{ steps.spam.outputs.decision }}
run: |
echo "Stopping: spam gate decided: ${SPAM_DECISION}"
echo "Stopping: issue triage preflight did not allow automation."
exit 1
- name: Reproduce reported issue
if: ${{ steps.spam.outputs.decision == 'allow' }}
if: ${{ steps.spam.outputs.allow_triage == 'true' }}
id: repro
working-directory: ${{ env.DEVFLOW_PATH }}
env:
@@ -0,0 +1,84 @@
---
status: accepted
contact: rogerbarreto
date: 2026-05-07
deciders: rogerbarreto
consulted: []
informed: []
---
# Hosted session identity context for Foundry Hosting
## Context and Problem Statement
Server-hosted Foundry agents need a way to scope per-user state (most notably `FoundryMemoryProvider` memories) by the end user that initiated the request. The Foundry platform already injects `x-agent-user-isolation-key` and `x-agent-chat-isolation-key` headers on every Responses request, but the agent-framework hosting layer did not surface those values to `AIContextProvider` instances. The provider's `stateInitializer` only received an `AgentSession?` with no identity attached, so per-user scoping was impossible without out-of-band plumbing.
## Decision Drivers
- Memory and any future user-private context must be partitioned per end user without per-sample boilerplate.
- The identity must be **read-only** from the perspective of `AIContextProvider`s, so a buggy or hostile provider cannot escalate or leak across users.
- The persisted session must validate against the live request on every resume to defend against session-id leak and in-process tampering.
- The change must work for every existing hosted-agent type (`ChatClientAgent`, `FoundryAgent`, future ones) without per-type refactoring of cast-heavy code paths in `Microsoft.Agents.AI`.
- Local Docker debugging must remain possible when the platform headers are absent.
## Considered Options
1. **`HostedSessionContext` stored in `AgentSessionStateBag`, exposed via a public read accessor and an `internal` setter.** Hosting writes once on session creation and validates on every resume.
2. **Specialised `HostedAgentSession : AgentSession` wrapper** that carries `UserId`/`ChatId` properties, with `GetService<ChatClientAgentSession>()` as the unwrap escape hatch.
3. **New property on `AgentSession` base class** (`HostedSessionContext? HostedContext { get; internal set; }`).
4. **AsyncLocal middleware** that reads the headers and stuffs them into a per-request `AsyncLocal<HostedSessionContext>` consumed by the provider.
For the source of identity:
- A. The platform-injected `IsolationContext` exposed by `ResponseContext.Isolation` (typed `UserIsolationKey`/`ChatIsolationKey`).
- B. The OpenAI Responses spec's top-level `request.User` field.
- C. A custom HTTP header `x-client-user`.
## Decision Outcome
**Option 1** was chosen for the storage shape, sourced from **Option A** (`ResponseContext.Isolation`).
Rationale:
- **Wrapper rejected (Option 2).** `ChatClientAgentSession` is `sealed` and `ChatClientAgent` rejects any other session type via direct `is not ChatClientAgentSession` checks at multiple call sites. Wrapping would force non-trivial refactors across `Microsoft.Agents.AI` and a corresponding repeat for every other agent type.
- **Base-class property rejected (Option 3).** Leaks "hosted" semantics into the universal `AgentSession` abstraction used by Durable, A2A, and CopilotStudio agents that have no notion of a hosted user.
- **AsyncLocal rejected (Option 4).** Surfaces the concept only locally, requires every consumer to re-implement the bridge, and cannot be enforced as read-only.
- **`request.User` rejected (Option B).** Set by the caller, not the platform. Forging it client-side trivially defeats per-user partitioning.
- **`x-client-user` rejected (Option C).** Non-standard, requires custom HTTP plumbing, and duplicates the platform-provided isolation contract.
Implementation summary in `Microsoft.Agents.AI.Foundry.Hosting`:
| Type | Visibility | Purpose |
|---|---|---|
| `HostedSessionContext` | public sealed | Captures `UserId` and `ChatId` (both required, non-whitespace). |
| `HostedSessionContextExtensions.GetHostedContext` | public | Read accessor for `AIContextProvider`s. |
| `HostedSessionContextExtensions.SetHostedContext` | internal | Writer reserved for the hosting assembly. Backed by `AgentSessionStateBag` under a well-known key for serialisation. |
| `HostedSessionIsolationKeyProvider` (abstract) | public | DI-resolvable factory. Async signature: `ValueTask<HostedSessionContext?> GetKeysAsync(ResponseContext, CreateResponse, CancellationToken)`. |
| `PlatformHostedSessionIsolationKeyProvider` | internal sealed | Default implementation. Maps `context.Isolation.UserIsolationKey` and `context.Isolation.ChatIsolationKey`. Returns `null` when either is absent. |
Behaviour added to `AgentFrameworkResponseHandler.CreateAsync`:
1. Resolve `HostedSessionIsolationKeyProvider` from DI; fall back to `PlatformHostedSessionIsolationKeyProvider`.
2. Call `GetKeysAsync(context, request, cancellationToken)`. A `null` result throws `InvalidOperationException` (becomes 500). A null/whitespace `UserId` or `ChatId` is rejected by `HostedSessionContext`'s constructor.
3. Branch on the **session's existing context**, not on whether a `conversation_id` was supplied:
- **No session (`session is null`):** nothing to stamp; skip.
- **Session present but un-stamped (`GetHostedContext() is null`):** treat as fresh. This covers both newly-created sessions and pre-existing sessions whose `conversation_id` was provisioned externally (e.g. via `conversations.CreateProjectConversationAsync()`) before the first hosted-agent request. Stamp the resolved identity now.
- **Session present with stamped context:** strict resume. The persisted `UserId` and `ChatId` must equal the resolved values exactly. Mismatch throws `ResponsesApiException` with status 403 and body `Hosted session identity context mismatch`.
## Consequences
Positive:
- Per-user memory partitioning works out of the box for any agent that consumes a `Microsoft.Agents.AI.Foundry.FoundryMemoryProvider` configured to read `session.GetHostedContext().UserId`.
- Cross-user session-id leak and in-process tampering of the persisted identity both surface as a 403 with a deliberately uninformative body.
- The identity is opaque to the framework, matching the platform's semantics. The framework never inspects user identity; the `IsolationContext` keys are pre-partitioned per agent.
Negative:
- Every existing hosted sample fails locally without a `HostedSessionIsolationKeyProvider` registered, because the platform headers are absent outside the platform. Mitigated by shipping `Hosted_Shared_Contributor_Setup` with `DevTemporaryLocalSessionIsolationKeyProvider` and `AddDevTemporaryLocalContributorSetup`, and migrating all 9 existing responses samples.
- An attacker who can plant an un-stamped session under a victim's `conversation_id` *before* the victim's first hosted-agent request would be stamped with the attacker's identity on that first request. This is not a regression vs. behaviour without this contract, and is mitigated in practice because the `conversation_id` namespace is allocated by the platform per project. Once a session is stamped, the strict equality check fully defends the resume path.
## Out of scope
- Per-request `User` field on `CreateResponse` is intentionally not consumed; only the platform `IsolationContext` headers carry trustworthy identity.
- Generic (non-Foundry) hosting layers can re-define an equivalent type if needed; nothing in this ADR is moved into `Microsoft.Agents.AI.Hosting` because `Microsoft.Agents.AI.Foundry.Hosting` does not depend on it.
- HMAC tamper signatures over the persisted context are not implemented; comparison against `ResponseContext.Isolation` on every request is sufficient because the platform sets those headers at the trust boundary.
+6
View File
@@ -327,9 +327,15 @@
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/HostedMemoryAgent.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/HostedObservability.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted_Shared_Contributor_Setup/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted_Shared_Contributor_Setup/Hosted_Shared_Contributor_Setup.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj" />
</Folder>
@@ -31,6 +31,10 @@ public sealed class ToolCallDisplayObserver : ConsoleObserver
{
await ux.WriteInfoLineAsync($"🔧 Calling tool: {ToolCallFormatter.Format(this._formatters, functionCall)}...", ConsoleColor.DarkYellow);
}
else if (content is WebSearchToolCallContent)
{
// Handled by OpenAIResponsesWebSearchDisplayObserver when present; skip here to avoid duplication.
}
else if (content is ToolCallContent toolCall)
{
await ux.WriteInfoLineAsync($"🔧 Calling tool: {toolCall}...", ConsoleColor.DarkYellow);
@@ -0,0 +1,206 @@
// Copyright (c) Microsoft. All rights reserved.
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
using System.Text;
using Harness.Shared.Console;
using Harness.Shared.Console.Observers;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI.Responses;
namespace SampleApp;
/// <summary>
/// Displays web search activity in the scroll area. Shows search queries,
/// page opens, and find-in-page actions as they stream in from the API.
/// </summary>
internal sealed class OpenAIResponsesWebSearchDisplayObserver : ConsoleObserver
{
private const int MaxQueryDisplayLength = 120;
/// <inheritdoc/>
public override async Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session)
{
if (content is WebSearchToolResultContent resultContent
&& resultContent.RawRepresentation is WebSearchCallResponseItem wscri)
{
await WriteActionAsync(ux, wscri, resultContent.Outputs);
}
}
private static async Task WriteActionAsync(IUXStateDriver ux, WebSearchCallResponseItem wscri, IList<AIContent>? outputs)
{
WebSearchAction? action = wscri.Action;
if (action is null)
{
await ux.WriteInfoLineAsync("🌐 Web Search Tool (no action details)", ConsoleColor.DarkCyan);
return;
}
switch (action)
{
case WebSearchFindInPageAction findInPage:
await WriteFindInPageAsync(ux, findInPage);
break;
case WebSearchOpenPageAction openPage:
await WriteOpenPageAsync(ux, openPage);
break;
case WebSearchSearchAction search:
await WriteSearchAsync(ux, search, outputs);
break;
default:
await ux.WriteInfoLineAsync("🌐 Web Search Tool (unknown action)", ConsoleColor.DarkCyan);
break;
}
}
private static async Task WriteSearchAsync(IUXStateDriver ux, WebSearchSearchAction search, IList<AIContent>? outputs)
{
// Read queries directly from the typed action.
IList<string> queries = search.Queries;
if (queries.Count == 0)
{
await ux.WriteInfoLineAsync("🌐 Web Search Tool: search", ConsoleColor.DarkCyan);
return;
}
var sb = new StringBuilder();
sb.Append("🌐 Web Search Tool: search");
// Show the search queries.
bool hasResults = outputs is { Count: > 0 };
for (int i = 0; i < queries.Count; i++)
{
string connector = (i < queries.Count - 1 || hasResults) ? "├─" : "└─";
string query = Truncate(queries[i], MaxQueryDisplayLength);
sb.Append($"\n {connector} \"{query}\"");
}
// Show search result sources (URLs + titles) when available.
// Sources come from M.E.AI's Outputs when IncludedResponseProperty.WebSearchCallActionSources is set,
// or directly from the SDK's WebSearchSearchAction.Sources.
if (hasResults)
{
sb.Append("\n │");
for (int i = 0; i < outputs!.Count; i++)
{
string connector = i < outputs.Count - 1 ? "├─" : "└─";
string line = FormatOutput(outputs[i]);
sb.Append($"\n {connector} {line}");
}
}
else if (search.Sources is { Count: > 0 } sources)
{
sb.Append("\n │");
for (int i = 0; i < sources.Count; i++)
{
string connector = i < sources.Count - 1 ? "├─" : "└─";
string line = FormatSource(sources[i]);
sb.Append($"\n {connector} {line}");
}
}
await ux.WriteInfoLineAsync(sb.ToString(), ConsoleColor.DarkCyan);
}
private static async Task WriteOpenPageAsync(IUXStateDriver ux, WebSearchOpenPageAction openPage)
{
string url = openPage.Uri?.AbsoluteUri ?? "(unknown)";
await ux.WriteInfoLineAsync(
$"🌐 Web Search Tool: open page\n └─ {url}",
ConsoleColor.DarkCyan);
}
private static async Task WriteFindInPageAsync(IUXStateDriver ux, WebSearchFindInPageAction findInPage)
{
string url = findInPage.Uri?.AbsoluteUri ?? "(unknown)";
string pattern = findInPage.Pattern ?? "(unknown)";
await ux.WriteInfoLineAsync(
$"🌐 Web Search Tool: find in page\n ├─ \"{Truncate(pattern, MaxQueryDisplayLength)}\"\n └─ {url}",
ConsoleColor.DarkCyan);
}
/// <summary>
/// Formats a single search result source from the SDK's <see cref="WebSearchActionSource"/> for display.
/// </summary>
private static string FormatSource(WebSearchActionSource source)
{
if (source is WebSearchActionUriSource uriSource)
{
string url = uriSource.Uri?.AbsoluteUri ?? "(unknown)";
// WebSearchActionUriSource doesn't expose a title property,
// but the API may include one in the raw response JSON.
string? title = GetTitleFromRawRepresentation(uriSource);
return title is not null
? $"{Truncate(title, MaxQueryDisplayLength)} — {url}"
: url;
}
return source.ToString() ?? "(unknown source)";
}
/// <summary>
/// Formats a single search result output from M.E.AI's <see cref="AIContent"/> for display.
/// </summary>
private static string FormatOutput(AIContent output)
{
if (output is UriContent uriContent)
{
string url = uriContent.Uri?.AbsoluteUri ?? "(unknown)";
// Try to extract a title from the raw JSON of the source.
// The SDK's WebSearchActionUriSource doesn't expose a title property,
// but the API may include one in the raw response.
string? title = GetTitleFromRawRepresentation(uriContent.RawRepresentation)
?? (uriContent.AdditionalProperties?.TryGetValue("title", out var t) is true ? t?.ToString() : null);
return title is not null
? $"{Truncate(title, MaxQueryDisplayLength)} — {url}"
: url;
}
return output.ToString() ?? "(unknown output)";
}
/// <summary>
/// Attempts to extract a "title" field from a raw representation object by serializing it to JSON.
/// The SDK's <see cref="WebSearchActionUriSource"/> doesn't expose a title property,
/// but the API may include one in the raw JSON — this is forward-compatible for when
/// the SDK adds title support.
/// </summary>
private static string? GetTitleFromRawRepresentation(object? rawRepresentation)
{
if (rawRepresentation is null)
{
return null;
}
try
{
var data = System.ClientModel.Primitives.ModelReaderWriter.Write(rawRepresentation);
using var doc = System.Text.Json.JsonDocument.Parse(data);
if (doc.RootElement.TryGetProperty("title", out var titleEl)
&& titleEl.ValueKind == System.Text.Json.JsonValueKind.String)
{
return titleEl.GetString();
}
}
catch
{
// Serialization may not be supported for this object type.
}
return null;
}
private static string Truncate(string text, int maxLength)
=> text.Length <= maxLength ? text : string.Concat(text.AsSpan(0, maxLength - 1), "…");
}
@@ -163,12 +163,14 @@ await HarnessConsole.RunAgentAsync(
userPrompt: "Enter a research topic to get started.",
new HarnessConsoleOptions
{
Observers = HarnessConsoleOptions.BuildObserversWithPlanning(
agent,
planModeName: "plan",
executionModeName: "execute",
maxContextWindowTokens: MaxContextWindowTokens,
maxOutputTokens: MaxOutputTokens,
toolFormatters: [new DownloadUriToolFormatter(), .. ToolCallFormatter.BuildDefaultToolFormatters()]),
Observers = [
new OpenAIResponsesWebSearchDisplayObserver(),
.. HarnessConsoleOptions.BuildObserversWithPlanning(
agent,
planModeName: "plan",
executionModeName: "execute",
maxContextWindowTokens: MaxContextWindowTokens,
maxOutputTokens: MaxOutputTokens,
toolFormatters: [new DownloadUriToolFormatter(), .. ToolCallFormatter.BuildDefaultToolFormatters()])],
CommandHandlers = HarnessConsoleOptions.BuildDefaultCommandHandlers(agent),
});
@@ -18,6 +18,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
</ItemGroup>
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
@@ -4,6 +4,7 @@ using Azure.AI.Projects;
using Azure.Core;
using Azure.Identity;
using DotNetEnv;
using Hosted_Shared_Contributor_Setup;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry.Hosting;
@@ -40,6 +41,7 @@ AIAgent agent = new AIProjectClient(projectEndpoint, credential)
// Host the agent as a Foundry Hosted Agent using the Responses API.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddFoundryResponses(agent);
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
var app = builder.Build();
app.MapFoundryResponses();
@@ -51,48 +53,3 @@ if (app.Environment.IsDevelopment())
}
app.Run();
/// <summary>
/// A <see cref="TokenCredential"/> for local Docker debugging only.
///
/// When debugging and testing a hosted agent in a local Docker container, Azure CLI
/// and other interactive credentials are not available. This credential reads a
/// pre-fetched bearer token from the <c>AZURE_BEARER_TOKEN</c> environment variable.
///
/// This should NOT be used in production — tokens expire (~1 hour) and cannot be refreshed.
/// In production, the Foundry platform injects a managed identity automatically.
///
/// 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 = Environment.GetEnvironmentVariable(EnvironmentVariable);
}
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
{
return this.GetAccessToken();
}
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
{
return new ValueTask<AccessToken>(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));
}
}
@@ -18,6 +18,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
</ItemGroup>
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
@@ -5,6 +5,7 @@ using Azure.AI.Projects.Agents;
using Azure.Core;
using Azure.Identity;
using DotNetEnv;
using Hosted_Shared_Contributor_Setup;
using Microsoft.Agents.AI.Foundry;
using Microsoft.Agents.AI.Foundry.Hosting;
@@ -33,6 +34,7 @@ FoundryAgent agent = aiProjectClient.AsAIAgent(agentRecord);
// Host the agent as a Foundry Hosted Agent using the Responses API.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddFoundryResponses(agent);
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
var app = builder.Build();
app.MapFoundryResponses();
@@ -44,48 +46,3 @@ if (app.Environment.IsDevelopment())
}
app.Run();
/// <summary>
/// A <see cref="TokenCredential"/> for local Docker debugging only.
///
/// When debugging and testing a hosted agent in a local Docker container, Azure CLI
/// and other interactive credentials are not available. This credential reads a
/// pre-fetched bearer token from the <c>AZURE_BEARER_TOKEN</c> environment variable.
///
/// This should NOT be used in production — tokens expire (~1 hour) and cannot be refreshed.
/// In production, the Foundry platform injects a managed identity automatically.
///
/// 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 = Environment.GetEnvironmentVariable(EnvironmentVariable);
}
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
{
return this.GetAccessToken();
}
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
{
return new ValueTask<AccessToken>(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));
}
}
@@ -20,6 +20,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
</ItemGroup>
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
@@ -11,6 +11,7 @@ using Azure.AI.Projects;
using Azure.Core;
using Azure.Identity;
using DotNetEnv;
using Hosted_Shared_Contributor_Setup;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Extensions.AI;
@@ -112,6 +113,7 @@ AIAgent agent = new AIProjectClient(new Uri(endpoint), credential)
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddFoundryResponses(agent);
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
var app = builder.Build();
app.MapFoundryResponses();
@@ -126,39 +128,3 @@ app.Run();
// ── Types ────────────────────────────────────────────────────────────────────
internal sealed record Hotel(string Name, int PricePerNight, double Rating, string Location);
/// <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 = 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));
}
}
@@ -21,6 +21,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
</ItemGroup>
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
@@ -19,6 +19,7 @@ using Azure.AI.Projects;
using Azure.Core;
using Azure.Identity;
using DotNetEnv;
using Hosted_Shared_Contributor_Setup;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Extensions.AI;
@@ -81,6 +82,7 @@ AIAgent agent = new AIProjectClient(projectEndpoint, credential)
// Host the agent as a Foundry Hosted Agent using the Responses API.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddFoundryResponses(agent);
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
var app = builder.Build();
app.MapFoundryResponses();
@@ -92,39 +94,3 @@ if (app.Environment.IsDevelopment())
}
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 = 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,12 @@
AZURE_AI_PROJECT_ENDPOINT=<your-azure-ai-project-endpoint>
ASPNETCORE_URLS=http://+:8088
ASPNETCORE_ENVIRONMENT=Development
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
AZURE_AI_EMBEDDING_DEPLOYMENT_NAME=text-embedding-ada-002
AZURE_AI_MEMORY_STORE_ID=hosted-memory-sample
AGENT_NAME=hosted-memory-agent
AZURE_BEARER_TOKEN=DefaultAzureCredential
# When running outside the Foundry platform the platform-injected isolation keys are absent.
# These two variables provide fallback values for local Docker debugging only.
HOSTED_USER_ISOLATION_KEY=local-dev-user
HOSTED_CHAT_ISOLATION_KEY=local-dev-chat
@@ -0,0 +1,26 @@
# Dockerfile for end-users consuming the Agent Framework via NuGet packages.
#
# This Dockerfile performs a full `dotnet restore` and `dotnet publish` inside the container,
# which only succeeds when the project references its dependencies via PackageReference (see the
# commented-out section in HostedMemoryAgent.csproj). Contributors building from the
# agent-framework repository source must use Dockerfile.contributor instead because
# ProjectReference dependencies live outside this folder and cannot be restored from inside
# this build context.
#
# 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", "HostedMemoryAgent.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-memory-agent .
# docker run --rm -p 8088:8088 \
# -e AGENT_NAME=hosted-memory-agent \
# -e HOSTED_USER_ISOLATION_KEY=alice \
# -e HOSTED_CHAT_ISOLATION_KEY=alice-chat-1 \
# --env-file .env hosted-memory-agent
#
# For end-users consuming the NuGet package (not ProjectReference), use the standard
# Dockerfile which performs a full dotnet restore + publish inside the container.
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
WORKDIR /app
COPY out/ .
EXPOSE 8088
ENV ASPNETCORE_URLS=http://+:8088
ENTRYPOINT ["dotnet", "HostedMemoryAgent.dll"]
@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
<RootNamespace>HostedMemoryAgent</RootNamespace>
<AssemblyName>HostedMemoryAgent</AssemblyName>
<NoWarn>$(NoWarn);MEAI001;OPENAI001</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="DotNetEnv" />
</ItemGroup>
<!-- For contributors: uses ProjectReference to build against local source -->
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.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" />
</ItemGroup>
-->
</Project>
@@ -0,0 +1,88 @@
// Copyright (c) Microsoft. All rights reserved.
// Hosted-MemoryAgent
//
// Demonstrates how to host an agent that uses FoundryMemoryProvider so that user-private memories
// persist across requests and across sessions, scoped per user via the Foundry platform's
// isolation key headers.
//
// Memory scope flows from request -> hosting layer -> session -> provider:
// 1. Foundry sets x-agent-user-isolation-key on every inbound request.
// 2. AgentFrameworkResponseHandler reads context.Isolation.UserIsolationKey via the registered
// HostedSessionIsolationKeyProvider and stores it on the session as a HostedSessionContext.
// 3. FoundryMemoryProvider's stateInitializer reads HostedSessionContext.UserId and uses it as
// the FoundryMemoryProviderScope, partitioning memories per user.
using Azure.AI.Projects;
using Azure.Core;
using Azure.Identity;
using DotNetEnv;
using Hosted_Shared_Contributor_Setup;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Extensions.AI;
// Load .env file if present (for local development).
Env.TraversePath().Load();
var projectEndpoint = new Uri(Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."));
var agentName = Environment.GetEnvironmentVariable("AGENT_NAME")
?? throw new InvalidOperationException("AGENT_NAME is not set.");
var deployment = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o";
var embeddingDeployment = Environment.GetEnvironmentVariable("AZURE_AI_EMBEDDING_DEPLOYMENT_NAME") ?? "text-embedding-ada-002";
var memoryStoreName = Environment.GetEnvironmentVariable("AZURE_AI_MEMORY_STORE_ID") ?? "hosted-memory-sample";
// 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 foundry).
TokenCredential credential = new ChainedTokenCredential(
new DevTemporaryTokenCredential(),
new DefaultAzureCredential());
AIProjectClient projectClient = new(projectEndpoint, credential);
// FoundryMemoryProvider partitions memories per end user via a built-in HostedFoundryMemoryProviderScopes
// helper that reads the platform-injected user isolation key from the HostedSessionContext that the
// hosting layer placed on the session.
FoundryMemoryProvider memoryProvider = new(
projectClient,
memoryStoreName,
stateInitializer: HostedFoundryMemoryProviderScopes.PerUser());
// Provision the memory store on startup if it does not already exist. EnsureMemoryStoreCreatedAsync
// is idempotent. Doing this once at start avoids per-request latency.
await memoryProvider.EnsureMemoryStoreCreatedAsync(deployment, embeddingDeployment, "Memory store for the hosted travel-assistant sample.");
const string AgentInstructions = """
You are a friendly travel assistant. When the user shares trip preferences, destinations,
travel companions, or constraints, remember them and use them in later turns. Use known
memories about the user when responding, and do not invent details.
""";
ChatClientAgent agent = projectClient.AsAIAgent(new ChatClientAgentOptions()
{
Name = agentName,
ChatOptions = new ChatOptions
{
ModelId = deployment,
Instructions = AgentInstructions
},
AIContextProviders = [memoryProvider]
});
// Host the agent as a Foundry Hosted Agent using the Responses API.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddFoundryResponses(agent);
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
var app = builder.Build();
app.MapFoundryResponses();
// In Development, also map the OpenAI-compatible route that AIProjectClient uses.
if (app.Environment.IsDevelopment())
{
app.MapFoundryResponses("openai/v1");
}
app.Run();
@@ -0,0 +1,155 @@
# Hosted-MemoryAgent
A hosted Foundry agent that uses **FoundryMemoryProvider** to remember user-private details across
requests and across sessions, scoped per end user via the Foundry platform's isolation keys. The
agent plays a friendly travel assistant: tell it about your trip, ask follow-up questions in a new
session, and it recalls what it learned about you.
This sample exists to demonstrate two things together:
1. How to host an agent that consumes a `Microsoft.Extensions.AI.AIContextProvider` (specifically
`FoundryMemoryProvider`) under the Foundry Responses hosting layer.
2. How the new `HostedSessionContext` flows from the `Foundry` platform isolation headers
(`x-agent-user-isolation-key`, `x-agent-chat-isolation-key`) through the
`HostedSessionIsolationKeyProvider` into the provider's `stateInitializer`, so memories are
partitioned per user automatically.
## Prerequisites
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
- An Azure AI Foundry project with at least one chat model deployment and one embedding model deployment
- Azure CLI logged in (`az login`)
## Configuration
Copy the template and fill in your values:
```bash
cp .env.example .env
```
Required:
```env
AZURE_AI_PROJECT_ENDPOINT=https://<account>.services.ai.azure.com/api/projects/<project>
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
AZURE_AI_EMBEDDING_DEPLOYMENT_NAME=text-embedding-ada-002
AZURE_AI_MEMORY_STORE_ID=hosted-memory-sample
AGENT_NAME=hosted-memory-agent
ASPNETCORE_URLS=http://+:8088
ASPNETCORE_ENVIRONMENT=Development
```
For local container runs only (the platform supplies these in production):
```env
HOSTED_USER_ISOLATION_KEY=alice
HOSTED_CHAT_ISOLATION_KEY=alice-chat-1
```
> `.env` is gitignored. The `.env.example` template is checked in as a reference.
## How memory scoping works
| Layer | Source of the user identity |
|---|---|
| Inbound request | The Foundry platform sets `x-agent-user-isolation-key` and `x-agent-chat-isolation-key` headers on every request. |
| Hosting layer | `AgentFrameworkResponseHandler` resolves a `HostedSessionIsolationKeyProvider` from DI and calls `GetKeysAsync(context, request, ct)`. The default implementation reads `context.Isolation.UserIsolationKey` and `context.Isolation.ChatIsolationKey`. |
| Session | The handler stores the resolved values on the session as a `HostedSessionContext` on the first request, and validates the values on every subsequent request that resumes the same conversation (mismatch returns 403). |
| Memory provider | The sample's `stateInitializer` reads `session.GetHostedContext().UserId` and uses it as the `FoundryMemoryProviderScope`. Memories are partitioned per user. |
When running outside the Foundry platform the headers are absent. The sample registers
`DevTemporaryLocalSessionIsolationKeyProvider` (via `AddDevTemporaryLocalContributorSetup`) which
falls back to the `HOSTED_USER_ISOLATION_KEY` and `HOSTED_CHAT_ISOLATION_KEY` environment variables,
defaulting to a single `local-dev-*` bucket when neither is set.
> **Production warning.** Never register `DevTemporaryLocalSessionIsolationKeyProvider` in
> production. The Foundry platform sets the isolation keys for every inbound request, and
> client-supplied environment variables can be forged.
## Running directly (contributors)
This project uses `ProjectReference` to build against the local Agent Framework source.
```bash
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent
dotnet run
```
The agent starts on `http://localhost:8088`.
### Test it
```bash
curl -X POST http://localhost:8088/responses \
-H "Content-Type: application/json" \
-d '{"input": "Hi! My name is Taylor and I am planning a hiking trip to Patagonia in November.", "model": "hosted-memory-agent"}'
```
Wait a few seconds for memory extraction, then ask a follow-up using the response id from the
previous call as `previous_response_id`:
```bash
curl -X POST http://localhost:8088/responses \
-H "Content-Type: application/json" \
-d '{"input": "What do you already know about my upcoming trip?", "previous_response_id": "<id>", "model": "hosted-memory-agent"}'
```
## Running with Docker
Since this project uses `ProjectReference`, the standard `Dockerfile` cannot resolve dependencies
outside this folder. 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-memory-agent .
```
### 3. Run the container
```bash
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-memory-agent \
-e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \
-e HOSTED_USER_ISOLATION_KEY=alice \
-e HOSTED_CHAT_ISOLATION_KEY=alice-chat-1 \
--env-file .env \
hosted-memory-agent
```
### 4. Smoke test the running container
A scripted smoke test that exercises memory recall and per-user isolation across two simulated
users is provided at `scripts/smoke.ps1`. From the sample folder:
```powershell
pwsh ./scripts/smoke.ps1
```
The script publishes the project, builds the image, runs the container with two distinct
`HOSTED_USER_ISOLATION_KEY` values, drives a multi-turn conversation per user, asserts that each
user only sees their own memories, and exits non-zero on failure.
## 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
`HostedMemoryAgent.csproj` for the `PackageReference` alternative.
## How it differs from sibling samples
| | Hosted-ChatClientAgent | Hosted-MemoryAgent |
|---|---|---|
| **Agent definition** | Inline (`AsAIAgent(model, instructions)`) | Inline, plus `AIContextProviders = [memoryProvider]` |
| **State** | None beyond the conversation history | Per-user memories persisted in Foundry Memory |
| **Identity** | Not used | Required: `HostedSessionContext.UserId` flows into the memory scope |
| **Local dev** | `AddDevTemporaryLocalContributorSetup()` keeps requests succeeding when isolation headers are absent | Same; additionally honours `HOSTED_USER_ISOLATION_KEY` to simulate distinct users |
@@ -0,0 +1,31 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml
name: hosted-memory-agent
displayName: "Hosted Memory Agent"
description: >
A travel-assistant hosted agent that uses FoundryMemoryProvider to remember user-private
preferences and details across sessions. Memory is scoped per end user via the Foundry
platform's isolation key headers.
metadata:
tags:
- AI Agent Hosting
- Azure AI AgentServer
- Responses Protocol
- Streaming
- Agent Framework
- Memory
- Foundry Memory
template:
name: hosted-memory-agent
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-memory-agent
protocols:
- protocol: responses
version: 1.0.0
resources:
cpu: "0.25"
memory: 0.5Gi
@@ -0,0 +1,110 @@
#requires -Version 7
<#
.SYNOPSIS
Local smoke test for the Hosted-MemoryAgent sample.
.DESCRIPTION
Publishes the sample, builds the contributor Docker image, runs the container twice with two
distinct HOSTED_USER_ISOLATION_KEY values, drives a multi-turn conversation per user via curl
invocations, and asserts that each user only sees their own remembered details.
Exits non-zero on failure.
Prerequisites:
- Docker
- az login (token is fetched from the host)
- .env populated with AZURE_AI_PROJECT_ENDPOINT and model deployments
.NOTES
This script is for local Docker debugging only. The Foundry platform supplies the isolation
keys for every inbound request in production and the dev fallback used here must not be
enabled in production deployments.
#>
[CmdletBinding()]
param(
[int]$Port = 8088,
[string]$ImageName = 'hosted-memory-agent-smoke',
[int]$RecallDelaySeconds = 25
)
$ErrorActionPreference = 'Stop'
Set-Location -Path $PSScriptRoot/..
if (-not (Test-Path .env)) {
throw '.env not found. Copy .env.example to .env and fill in AZURE_AI_PROJECT_ENDPOINT.'
}
Write-Host '==> Publishing sample for linux-musl-x64 ...'
dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out --tl:off | Out-Host
if ($LASTEXITCODE -ne 0) { throw 'dotnet publish failed.' }
Write-Host '==> Building docker image ...'
docker build -f Dockerfile.contributor -t $ImageName . | Out-Host
if ($LASTEXITCODE -ne 0) { throw 'docker build failed.' }
Write-Host '==> Fetching bearer token ...'
$bearer = az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv
if (-not $bearer) { throw 'Failed to obtain bearer token. Run az login.' }
function Start-Container([string]$UserKey, [string]$ChatKey, [string]$ContainerName) {
docker rm -f $ContainerName 2>$null | Out-Null
docker run -d --name $ContainerName -p ${Port}:8088 `
-e AGENT_NAME=hosted-memory-agent `
-e AZURE_BEARER_TOKEN=$bearer `
-e HOSTED_USER_ISOLATION_KEY=$UserKey `
-e HOSTED_CHAT_ISOLATION_KEY=$ChatKey `
--env-file .env `
$ImageName | Out-Host
if ($LASTEXITCODE -ne 0) { throw "docker run failed for $ContainerName." }
# Wait briefly for the listener to come up.
Start-Sleep -Seconds 6
}
function Invoke-Agent([string]$Prompt, [string]$PreviousResponseId = $null) {
$body = @{ input = $Prompt; model = 'hosted-memory-agent' }
if ($PreviousResponseId) { $body['previous_response_id'] = $PreviousResponseId }
$json = $body | ConvertTo-Json -Compress
$resp = Invoke-RestMethod -Method Post -Uri "http://localhost:$Port/responses" -ContentType 'application/json' -Body $json
return $resp
}
function Assert-Contains([string]$Haystack, [string]$Needle, [string]$Label) {
if ($Haystack -notmatch [regex]::Escape($Needle)) {
throw "FAILED [$Label]: expected response to contain '$Needle' but got: $Haystack"
}
Write-Host "PASS [$Label]: response contains '$Needle'."
}
function Assert-NotContains([string]$Haystack, [string]$Needle, [string]$Label) {
if ($Haystack -match [regex]::Escape($Needle)) {
throw "FAILED [$Label]: response unexpectedly contains '$Needle': $Haystack"
}
Write-Host "PASS [$Label]: response does not contain '$Needle'."
}
try {
Write-Host '==> Phase 1: alice teaches the agent her trip details ...'
Start-Container -UserKey 'alice' -ChatKey 'alice-chat-1' -ContainerName 'hosted-memory-smoke-alice'
$r1 = Invoke-Agent -Prompt 'Hi! My name is Taylor and I am planning a hiking trip to Patagonia in November.'
$r2 = Invoke-Agent -Prompt 'I am travelling with my sister and we love finding scenic viewpoints.' -PreviousResponseId $r1.id
Write-Host "==> Waiting $RecallDelaySeconds s for memory extraction ..."
Start-Sleep -Seconds $RecallDelaySeconds
$r3 = Invoke-Agent -Prompt 'What do you already know about my upcoming trip?' -PreviousResponseId $r2.id
$aliceText = ($r3.output | ForEach-Object { $_.content | ForEach-Object { $_.text } }) -join ' '
Assert-Contains $aliceText 'Patagonia' 'alice recall: Patagonia'
docker rm -f hosted-memory-smoke-alice | Out-Null
Write-Host '==> Phase 2: bob starts a fresh container with a different user isolation key ...'
Start-Container -UserKey 'bob' -ChatKey 'bob-chat-1' -ContainerName 'hosted-memory-smoke-bob'
$b1 = Invoke-Agent -Prompt 'Hello, what trip am I planning?'
$bobText = ($b1.output | ForEach-Object { $_.content | ForEach-Object { $_.text } }) -join ' '
Assert-NotContains $bobText 'Patagonia' 'bob isolation: no leak of alice memories'
Write-Host ''
Write-Host '==> All smoke assertions passed.'
}
finally {
docker rm -f hosted-memory-smoke-alice 2>$null | Out-Null
docker rm -f hosted-memory-smoke-bob 2>$null | Out-Null
}
@@ -20,6 +20,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
</ItemGroup>
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
@@ -10,6 +10,7 @@ using Azure.AI.Projects;
using Azure.Core;
using Azure.Identity;
using DotNetEnv;
using Hosted_Shared_Contributor_Setup;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Extensions.AI;
@@ -60,6 +61,7 @@ AIAgent agent = new AIProjectClient(new Uri(endpoint), credential)
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddFoundryResponses(agent);
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
var app = builder.Build();
app.MapFoundryResponses();
@@ -70,39 +72,3 @@ if (app.Environment.IsDevelopment())
}
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 = 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));
}
}
@@ -20,6 +20,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
@@ -8,6 +8,7 @@ using Azure.AI.Projects;
using Azure.Core;
using Azure.Identity;
using DotNetEnv;
using Hosted_Shared_Contributor_Setup;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Extensions.AI;
@@ -47,6 +48,7 @@ AIAgent agent = new AIProjectClient(new Uri(endpoint), credential)
// Host the agent as a Foundry Hosted Agent using the Responses API.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddFoundryResponses(agent);
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
var app = builder.Build();
app.MapFoundryResponses();
@@ -97,34 +99,3 @@ static Task<IEnumerable<TextSearchProvider.TextSearchResult>> MockSearchAsync(st
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>(results);
}
/// <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.
/// This should NOT be used in production — tokens expire (~1 hour) and cannot be refreshed.
///
/// 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";
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
=> GetAccessToken();
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
=> new(GetAccessToken());
private static AccessToken GetAccessToken()
{
var token = Environment.GetEnvironmentVariable(EnvironmentVariable);
if (string.IsNullOrEmpty(token) || token == "DefaultAzureCredential")
{
throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set.");
}
return new AccessToken(token, DateTimeOffset.UtcNow.AddHours(1));
}
}
@@ -20,6 +20,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
</ItemGroup>
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
@@ -21,6 +21,7 @@ using Azure.AI.Projects;
using Azure.Core;
using Azure.Identity;
using DotNetEnv;
using Hosted_Shared_Contributor_Setup;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry.Hosting;
@@ -57,6 +58,7 @@ var builder = WebApplication.CreateBuilder(args);
// Register the agent and response handler
builder.Services.AddFoundryResponses(agent);
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
// Register Foundry Toolbox: connects to the MCP proxy at startup and makes tools available.
// The toolset name must match a toolset registered in your Foundry project.
@@ -75,39 +77,3 @@ if (app.Environment.IsDevelopment())
app.Run();
// ── DevTemporaryTokenCredential ───────────────────────────────────────────────
/// <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 = 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.MaxValue);
}
}
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<OutputType>Exe</OutputType>
@@ -24,6 +24,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
@@ -17,9 +17,9 @@
using System.ComponentModel;
using Azure.AI.OpenAI;
using Azure.Core;
using Azure.Identity;
using DotNetEnv;
using Hosted_Shared_Contributor_Setup;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Agents.AI.Hosting;
@@ -192,30 +192,3 @@ static string GetWeather(
var condition = conditions[rng.Next(conditions.Length)];
return $"Weather in {location}: {temp}C, {condition}. Humidity: {rng.Next(30, 90)}%. Wind: {rng.Next(5, 30)} km/h.";
}
internal sealed class DevTemporaryTokenCredential : TokenCredential
{
private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
private readonly string? _token;
public DevTemporaryTokenCredential()
{
this._token = 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));
}
}
@@ -20,6 +20,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
</ItemGroup>
@@ -9,6 +9,7 @@ using Azure.AI.Projects;
using Azure.Core;
using Azure.Identity;
using DotNetEnv;
using Hosted_Shared_Contributor_Setup;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Agents.AI.Workflows;
@@ -49,6 +50,7 @@ AIAgent agent = new WorkflowBuilder(frenchAgent)
// Host the workflow agent as a Foundry Hosted Agent using the Responses API.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddFoundryResponses(agent);
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
var app = builder.Build();
app.MapFoundryResponses();
@@ -59,39 +61,3 @@ if (app.Environment.IsDevelopment())
}
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 = 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,72 @@
// Copyright (c) Microsoft. All rights reserved.
using Azure.AI.AgentServer.Responses;
using Azure.AI.AgentServer.Responses.Models;
using Microsoft.Agents.AI.Foundry.Hosting;
namespace Hosted_Shared_Contributor_Setup;
/// <summary>
/// A <see cref="HostedSessionIsolationKeyProvider"/> for local Docker debugging only.
///
/// When the Foundry platform's <c>x-agent-user-isolation-key</c> and
/// <c>x-agent-chat-isolation-key</c> headers are absent (i.e., when the container is running
/// outside the Foundry platform), the hosting layer rejects every request with a 500 because the
/// default <see cref="HostedSessionIsolationKeyProvider"/> returns null. This provider supplies
/// fallback values from the <c>HOSTED_USER_ISOLATION_KEY</c> and <c>HOSTED_CHAT_ISOLATION_KEY</c>
/// environment variables, defaulting to the constants below when neither is set.
///
/// This should NOT be used in production. The Foundry platform sets the isolation keys for every
/// inbound request and forging them client-side defeats the per-user partitioning. The dev
/// fallback exists solely so a contributor can <c>docker run</c> the sample on their laptop and
/// drive a few requests end to end.
/// </summary>
public sealed class DevTemporaryLocalSessionIsolationKeyProvider : HostedSessionIsolationKeyProvider
{
/// <summary>
/// Environment variable that supplies the user isolation key when the platform header is absent.
/// </summary>
public const string UserIsolationKeyEnvironmentVariable = "HOSTED_USER_ISOLATION_KEY";
/// <summary>
/// Environment variable that supplies the chat isolation key when the platform header is absent.
/// </summary>
public const string ChatIsolationKeyEnvironmentVariable = "HOSTED_CHAT_ISOLATION_KEY";
/// <summary>
/// Default user isolation key used when neither the platform header nor the environment variable
/// supplies a value. All local requests collapse onto this single bucket unless overridden.
/// </summary>
public const string DefaultLocalUserIsolationKey = "local-dev-user";
/// <summary>
/// Default chat isolation key used when neither the platform header nor the environment variable
/// supplies a value.
/// </summary>
public const string DefaultLocalChatIsolationKey = "local-dev-chat";
/// <inheritdoc />
public override ValueTask<HostedSessionContext?> GetKeysAsync(
ResponseContext context,
CreateResponse request,
CancellationToken cancellationToken)
{
var userKey = !string.IsNullOrWhiteSpace(context?.Isolation?.UserIsolationKey)
? context!.Isolation!.UserIsolationKey
: Environment.GetEnvironmentVariable(UserIsolationKeyEnvironmentVariable);
if (string.IsNullOrWhiteSpace(userKey))
{
userKey = DefaultLocalUserIsolationKey;
}
var chatKey = !string.IsNullOrWhiteSpace(context?.Isolation?.ChatIsolationKey)
? context!.Isolation!.ChatIsolationKey
: Environment.GetEnvironmentVariable(ChatIsolationKeyEnvironmentVariable);
if (string.IsNullOrWhiteSpace(chatKey))
{
chatKey = DefaultLocalChatIsolationKey;
}
return new ValueTask<HostedSessionContext?>(new HostedSessionContext(userKey!, chatKey!));
}
}
@@ -0,0 +1,57 @@
// Copyright (c) Microsoft. All rights reserved.
using Azure.Core;
using Azure.Identity;
namespace Hosted_Shared_Contributor_Setup;
/// <summary>
/// A <see cref="TokenCredential"/> for local Docker debugging only.
///
/// When debugging and testing a hosted agent in a local Docker container, Azure CLI
/// and other interactive credentials are not available. This credential reads a
/// pre-fetched bearer token from the <c>AZURE_BEARER_TOKEN</c> environment variable.
///
/// This should NOT be used in production. Tokens expire (around one hour) and cannot be refreshed.
/// In production, the Foundry platform injects a managed identity automatically.
///
/// 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>
public sealed class DevTemporaryTokenCredential : TokenCredential
{
private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
private readonly string? _token;
/// <summary>
/// Initializes a new instance of the <see cref="DevTemporaryTokenCredential"/> class.
/// Reads the bearer token from the <c>AZURE_BEARER_TOKEN</c> environment variable when present.
/// </summary>
public DevTemporaryTokenCredential()
{
this._token = Environment.GetEnvironmentVariable(EnvironmentVariable);
}
/// <inheritdoc />
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
{
return this.GetAccessToken();
}
/// <inheritdoc />
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
{
return new ValueTask<AccessToken>(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,33 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Extensions.DependencyInjection;
namespace Hosted_Shared_Contributor_Setup;
/// <summary>
/// Registration helpers for the developer-only utilities shipped in this sample-shared project.
/// </summary>
public static class HostedContributorSetupExtensions
{
/// <summary>
/// Registers developer-only services that allow a hosted Foundry agent to run outside the
/// Foundry platform (e.g., inside a Docker container during contributor debugging).
///
/// <para><b>For local Docker debugging only and should not be used in production.</b></para>
///
/// Currently this method registers a <see cref="DevTemporaryLocalSessionIsolationKeyProvider"/>
/// so that requests succeed when the platform's <c>x-agent-user-isolation-key</c> and
/// <c>x-agent-chat-isolation-key</c> headers are absent. In production those headers are
/// always present and the default platform isolation key provider (registered automatically by
/// the hosting layer) is used instead.
/// </summary>
/// <param name="services">The service collection to register the developer-only services into.</param>
/// <returns>The same <see cref="IServiceCollection"/> for chaining.</returns>
public static IServiceCollection AddDevTemporaryLocalContributorSetup(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.AddSingleton<HostedSessionIsolationKeyProvider, DevTemporaryLocalSessionIsolationKeyProvider>();
return services;
}
}
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
<RootNamespace>Hosted_Shared_Contributor_Setup</RootNamespace>
<AssemblyName>Hosted_Shared_Contributor_Setup</AssemblyName>
<NoWarn>$(NoWarn);</NoWarn>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<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>
</Project>
@@ -458,8 +458,9 @@ internal static class ChatResponseUpdateAGUIExtensions
// This ensures all AGUI events have a valid messageId regardless of agent type.
if (string.IsNullOrWhiteSpace(chatResponse.MessageId))
{
streamingMessageId ??= Guid.NewGuid().ToString("N");
chatResponse.MessageId = streamingMessageId;
chatResponse.MessageId = ContainsToolResult(chatResponse)
? Guid.NewGuid().ToString("N")
: (streamingMessageId ??= Guid.NewGuid().ToString("N"));
}
if (chatResponse is { Contents.Count: > 0 } &&
@@ -725,4 +726,17 @@ internal static class ChatResponseUpdateAGUIExtensions
_ => JsonSerializer.Serialize(functionResultContent.Result, options.GetTypeInfo(functionResultContent.Result.GetType())),
};
}
private static bool ContainsToolResult(ChatResponseUpdate chatResponse)
{
foreach (AIContent content in chatResponse.Contents)
{
if (content is FunctionResultContent)
{
return true;
}
}
return false;
}
}
@@ -26,6 +26,12 @@ public class AgentFrameworkResponseHandler : ResponseHandler
private readonly ILogger<AgentFrameworkResponseHandler> _logger;
private readonly FoundryToolboxService? _toolboxService;
/// <summary>
/// Cached fallback used when no <see cref="HostedSessionIsolationKeyProvider"/> is registered in DI.
/// Avoids a per-request allocation on the request hot path.
/// </summary>
private static readonly HostedSessionIsolationKeyProvider s_defaultIsolationKeyProvider = new PlatformHostedSessionIsolationKeyProvider();
/// <summary>
/// Initializes a new instance of the <see cref="AgentFrameworkResponseHandler"/> class
/// that resolves agents from keyed DI services.
@@ -67,6 +73,42 @@ public class AgentFrameworkResponseHandler : ResponseHandler
? await chatClientAgent.CreateSessionAsync(cancellationToken).ConfigureAwait(false)
: await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
// 2.5. Resolve and apply the per-request hosted session identity context.
// Fresh sessions are tagged once. Resumed sessions are validated against the live request
// to detect cross-user session leaks and in-process tampering of the persisted identity.
var isolationKeyProvider = this._serviceProvider.GetService<HostedSessionIsolationKeyProvider>()
?? s_defaultIsolationKeyProvider;
var resolvedHostedContext = await isolationKeyProvider.GetKeysAsync(context, request, cancellationToken).ConfigureAwait(false);
if (resolvedHostedContext is null)
{
throw new InvalidOperationException(
$"The registered {nameof(HostedSessionIsolationKeyProvider)} returned null for the current request. " +
"Ensure the Foundry platform is providing the x-agent-user-isolation-key and x-agent-chat-isolation-key headers, " +
"or register a custom provider that supplies fallback values for local development.");
}
if (session is not null)
{
var existingHostedContext = session.GetHostedContext();
if (existingHostedContext is null)
{
// Fresh path: the session has no hosted context yet (either freshly created here,
// or freshly loaded for a conversation_id that the platform supplied without any
// prior hosted-agent request having stamped a context). Stamp it now.
session.SetHostedContext(resolvedHostedContext);
}
else if (!string.Equals(existingHostedContext.UserId, resolvedHostedContext.UserId, StringComparison.Ordinal)
|| !string.Equals(existingHostedContext.ChatId, resolvedHostedContext.ChatId, StringComparison.Ordinal))
{
// Resume path: the persisted identity must match the live request. A mismatch
// signals either a cross-user session leak or in-process tampering of the
// persisted identity. Reject the request hard.
throw new ResponsesApiException(
new Error("hosted_session_identity_mismatch", "Hosted session identity context mismatch"),
403);
}
}
// 3. Create the SDK event stream builder
var stream = new ResponseEventStream(context, request);
@@ -0,0 +1,65 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Built-in <see cref="FoundryMemoryProvider"/> <c>stateInitializer</c> factories that derive the
/// <see cref="FoundryMemoryProviderScope"/> from the per-session <see cref="HostedSessionContext"/>
/// applied by the Foundry hosting layer.
/// </summary>
/// <remarks>
/// Pass the result of any of these helpers as the <c>stateInitializer</c> argument when constructing
/// <see cref="FoundryMemoryProvider"/>:
/// <code>
/// new FoundryMemoryProvider(client, "my-store",
/// stateInitializer: HostedFoundryMemoryProviderScopes.PerUser());
/// </code>
/// All helpers throw <see cref="InvalidOperationException"/> when
/// <see cref="HostedSessionContextExtensions.GetHostedContext"/> returns <see langword="null"/>.
/// That happens when the agent runs outside the Foundry hosting layer (e.g., a console app); in
/// that case write a custom <c>stateInitializer</c> instead of using these helpers.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public static class HostedFoundryMemoryProviderScopes
{
/// <summary>
/// Returns a <c>stateInitializer</c> that scopes memories per end user, using
/// <see cref="HostedSessionContext.UserId"/> as the partition key.
/// </summary>
/// <returns>A delegate suitable for the <c>stateInitializer</c> argument of <see cref="FoundryMemoryProvider"/>.</returns>
public static Func<AgentSession?, FoundryMemoryProvider.State> PerUser() =>
session => new FoundryMemoryProvider.State(new FoundryMemoryProviderScope(GetRequiredHostedContext(session).UserId));
/// <summary>
/// Returns a <c>stateInitializer</c> that scopes memories per conversation, using
/// <see cref="HostedSessionContext.ChatId"/> as the partition key. Use this when memories should
/// be visible to every participant in a shared conversation (for example, a Teams group chat).
/// </summary>
/// <returns>A delegate suitable for the <c>stateInitializer</c> argument of <see cref="FoundryMemoryProvider"/>.</returns>
public static Func<AgentSession?, FoundryMemoryProvider.State> PerChat() =>
session => new FoundryMemoryProvider.State(new FoundryMemoryProviderScope(GetRequiredHostedContext(session).ChatId));
/// <summary>
/// Returns a <c>stateInitializer</c> that scopes memories per (user, chat) pair, using
/// <c>"{UserId}:{ChatId}"</c> as the partition key. Use this when memories should be visible
/// only to the same user within the same conversation.
/// </summary>
/// <returns>A delegate suitable for the <c>stateInitializer</c> argument of <see cref="FoundryMemoryProvider"/>.</returns>
public static Func<AgentSession?, FoundryMemoryProvider.State> PerUserAndChat() =>
session =>
{
var ctx = GetRequiredHostedContext(session);
return new FoundryMemoryProvider.State(new FoundryMemoryProviderScope($"{ctx.UserId}:{ctx.ChatId}"));
};
private static HostedSessionContext GetRequiredHostedContext(AgentSession? session) =>
session?.GetHostedContext()
?? throw new InvalidOperationException(
$"{nameof(HostedSessionContext)} was not provided by the hosting layer. " +
$"The {nameof(HostedFoundryMemoryProviderScopes)} helpers require the agent to be hosted via the Foundry hosting layer. " +
"If running outside a hosted Foundry container, supply a custom stateInitializer to FoundryMemoryProvider instead.");
}
@@ -0,0 +1,88 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using Azure.AI.Projects;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Dependency-injection helpers that register a <see cref="FoundryMemoryProvider"/> wired with a
/// <see cref="HostedFoundryMemoryProviderScopes"/> strategy.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public static class HostedFoundryMemoryProviderServiceCollectionExtensions
{
/// <summary>
/// Registers a singleton <see cref="FoundryMemoryProvider"/> wired to the supplied
/// <see cref="AIProjectClient"/> and the supplied <paramref name="stateInitializer"/>.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="client">The <see cref="AIProjectClient"/> used to talk to Foundry Memory.</param>
/// <param name="memoryStoreName">The name of the memory store in Microsoft Foundry.</param>
/// <param name="stateInitializer">
/// Strategy that selects the per-session <see cref="FoundryMemoryProviderScope"/>. When
/// <see langword="null"/>, the extension uses <see cref="HostedFoundryMemoryProviderScopes.PerUser"/>.
/// Pass any other helper (or a custom delegate) to override.
/// </param>
/// <param name="options">Optional <see cref="FoundryMemoryProviderOptions"/>.</param>
/// <returns>The same <see cref="IServiceCollection"/> for chaining.</returns>
public static IServiceCollection AddHostedFoundryMemoryProvider(
this IServiceCollection services,
AIProjectClient client,
string memoryStoreName,
Func<AgentSession?, FoundryMemoryProvider.State>? stateInitializer = null,
FoundryMemoryProviderOptions? options = null)
{
Throw.IfNull(services);
Throw.IfNull(client);
Throw.IfNullOrWhitespace(memoryStoreName);
var initializer = stateInitializer ?? HostedFoundryMemoryProviderScopes.PerUser();
services.AddSingleton(sp => new FoundryMemoryProvider(
client,
memoryStoreName,
initializer,
options,
sp.GetService<ILoggerFactory>()));
return services;
}
/// <summary>
/// Registers a singleton <see cref="FoundryMemoryProvider"/> that resolves its
/// <see cref="AIProjectClient"/> from <see cref="IServiceProvider"/> at construction time.
/// Use this overload when an <see cref="AIProjectClient"/> is already registered with the
/// service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="memoryStoreName">The name of the memory store in Microsoft Foundry.</param>
/// <param name="stateInitializer">
/// Strategy that selects the per-session <see cref="FoundryMemoryProviderScope"/>. When
/// <see langword="null"/>, the extension uses <see cref="HostedFoundryMemoryProviderScopes.PerUser"/>.
/// Pass any other helper (or a custom delegate) to override.
/// </param>
/// <param name="options">Optional <see cref="FoundryMemoryProviderOptions"/>.</param>
/// <returns>The same <see cref="IServiceCollection"/> for chaining.</returns>
public static IServiceCollection AddHostedFoundryMemoryProvider(
this IServiceCollection services,
string memoryStoreName,
Func<AgentSession?, FoundryMemoryProvider.State>? stateInitializer = null,
FoundryMemoryProviderOptions? options = null)
{
Throw.IfNull(services);
Throw.IfNullOrWhitespace(memoryStoreName);
var initializer = stateInitializer ?? HostedFoundryMemoryProviderScopes.PerUser();
services.AddSingleton(sp => new FoundryMemoryProvider(
sp.GetRequiredService<AIProjectClient>(),
memoryStoreName,
initializer,
options,
sp.GetService<ILoggerFactory>()));
return services;
}
}
@@ -0,0 +1,61 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Captures the per-session identity values produced by a <see cref="HostedSessionIsolationKeyProvider"/>
/// when a Foundry hosted agent processes a request.
/// </summary>
/// <remarks>
/// <para>
/// The <see cref="UserId"/> partitions data that belongs to the individual who initiated the request
/// (e.g., personal memory, per-user preferences). The <see cref="ChatId"/> partitions data that belongs
/// to the conversation (e.g., conversation history, turn state). Both values are opaque strings whose
/// meaning is determined by the active <see cref="HostedSessionIsolationKeyProvider"/>.
/// </para>
/// <para>
/// Instances are constructed by the hosting layer from the platform-provided
/// <c>IsolationContext</c> headers and stored on the session via
/// <see cref="HostedSessionContextExtensions.SetHostedContext"/>. Consumers (typically
/// <see cref="AIContextProvider"/> implementations) read the values through
/// <see cref="HostedSessionContextExtensions.GetHostedContext"/>.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public sealed class HostedSessionContext
{
/// <summary>
/// Initializes a new instance of the <see cref="HostedSessionContext"/> class.
/// </summary>
/// <param name="userId">The opaque user identity for this hosted session. Must not be null or whitespace.</param>
/// <param name="chatId">The opaque chat (conversation) identity for this hosted session. Must not be null or whitespace.</param>
/// <exception cref="System.ArgumentException">Thrown when <paramref name="userId"/> or <paramref name="chatId"/> is null or whitespace.</exception>
public HostedSessionContext(string userId, string chatId)
{
this.UserId = Throw.IfNullOrWhitespace(userId);
this.ChatId = Throw.IfNullOrWhitespace(chatId);
}
/// <summary>
/// Gets the opaque user identity for this hosted session.
/// </summary>
/// <remarks>
/// Stable for a given user across sessions. In production this is sourced from the
/// <c>x-agent-user-isolation-key</c> platform header.
/// </remarks>
public string UserId { get; }
/// <summary>
/// Gets the opaque chat (conversation) identity for this hosted session.
/// </summary>
/// <remarks>
/// In a 1:1 user-to-agent chat this typically equals <see cref="UserId"/>. In shared-surface
/// scenarios (e.g., a Teams group chat) it represents the common partition all participants
/// write to. In production this is sourced from the <c>x-agent-chat-isolation-key</c> platform header.
/// </remarks>
public string ChatId { get; }
}
@@ -0,0 +1,81 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Extension methods for reading and writing the <see cref="HostedSessionContext"/> associated
/// with an <see cref="AgentSession"/> in a Foundry hosted agent.
/// </summary>
/// <remarks>
/// The hosted session context is written exactly once by the hosting layer when a session is created,
/// and is validated against the live request on every subsequent invocation. The <see cref="SetHostedContext"/>
/// method is intentionally <see langword="internal"/> so that only the hosting layer can establish the
/// identity values; consumers (such as <see cref="AIContextProvider"/> implementations) read the values
/// through the public <see cref="GetHostedContext"/> accessor.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public static class HostedSessionContextExtensions
{
/// <summary>
/// The well-known <see cref="AgentSessionStateBag"/> key used to store the
/// <see cref="HostedSessionContext"/> on a session.
/// </summary>
/// <remarks>
/// Exposed as a constant so consumers can correlate persisted state across processes.
/// External code must not write to this key directly; use <see cref="SetHostedContext"/> from the
/// hosting assembly instead.
/// </remarks>
public const string StateKey = "Microsoft.Agents.AI.Foundry.Hosting.HostedSessionContext";
/// <summary>
/// Gets the <see cref="HostedSessionContext"/> previously written by the hosting layer
/// for this session, if any.
/// </summary>
/// <param name="session">The session to read from.</param>
/// <returns>
/// The <see cref="HostedSessionContext"/> for the session, or <see langword="null"/> when the
/// session was not produced by a hosted agent (or the value has not yet been written).
/// </returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="session"/> is <see langword="null"/>.</exception>
public static HostedSessionContext? GetHostedContext(this AgentSession session)
{
Throw.IfNull(session);
return session.StateBag.TryGetValue<HostedSessionContext>(StateKey, out var context, HostedSessionJsonUtilities.DefaultOptions)
? context
: null;
}
/// <summary>
/// Writes the <see cref="HostedSessionContext"/> for this session.
/// </summary>
/// <param name="session">The session to write to.</param>
/// <param name="context">The hosted session context to associate with <paramref name="session"/>.</param>
/// <remarks>
/// Internal to the hosting assembly. Consumers must not invoke this method directly; the hosting
/// layer is the single writer and uses validation against the live request to detect any tampering
/// that does occur via lower-level APIs. Throws when a context has already been written for this
/// session to enforce the write-once contract.
/// </remarks>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="session"/> or <paramref name="context"/> is <see langword="null"/>.</exception>
/// <exception cref="InvalidOperationException">Thrown when this session already carries a <see cref="HostedSessionContext"/>.</exception>
internal static void SetHostedContext(this AgentSession session, HostedSessionContext context)
{
Throw.IfNull(session);
Throw.IfNull(context);
if (session.StateBag.TryGetValue<HostedSessionContext>(StateKey, out _, HostedSessionJsonUtilities.DefaultOptions))
{
throw new InvalidOperationException(
$"A {nameof(HostedSessionContext)} has already been written to this session. " +
"The hosted session identity is write-once; resumed sessions must validate against the existing context, not overwrite it.");
}
session.StateBag.SetValue(StateKey, context, HostedSessionJsonUtilities.DefaultOptions);
}
}
@@ -0,0 +1,54 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.AgentServer.Responses;
using Azure.AI.AgentServer.Responses.Models;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Resolves the per-request <see cref="HostedSessionContext"/> for a Foundry hosted agent.
/// </summary>
/// <remarks>
/// <para>
/// Implementations are invoked once per incoming Responses API request. The returned
/// <see cref="HostedSessionContext"/> establishes the identity of a freshly created session and
/// is validated against the live request on every subsequent invocation that resumes the same session.
/// </para>
/// <para>
/// The default implementation registered when no custom <see cref="HostedSessionIsolationKeyProvider"/>
/// is present in DI maps the platform-injected <c>x-agent-user-isolation-key</c> and
/// <c>x-agent-chat-isolation-key</c> headers via <see cref="ResponseContext.Isolation"/>. Hosting samples and contributor-only environments
/// can register an alternate implementation in DI to provide values when the platform headers are absent
/// (e.g., during local Docker debugging).
/// </para>
/// <para>
/// Implementations must return a <see cref="HostedSessionContext"/> whose <see cref="HostedSessionContext.UserId"/>
/// and <see cref="HostedSessionContext.ChatId"/> are both non-null and non-whitespace. Returning either as null
/// (or throwing from <see cref="GetKeysAsync"/>) is treated as a configuration error and surfaces as a
/// 500 from the hosting layer.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public abstract class HostedSessionIsolationKeyProvider
{
/// <summary>
/// Resolves the <see cref="HostedSessionContext"/> for the supplied request.
/// </summary>
/// <param name="context">The per-request <see cref="ResponseContext"/> from the Azure AI Responses Server SDK.</param>
/// <param name="request">The <see cref="CreateResponse"/> describing the incoming request.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>
/// A <see cref="HostedSessionContext"/> with non-null <see cref="HostedSessionContext.UserId"/> and
/// <see cref="HostedSessionContext.ChatId"/>, or <see langword="null"/> when the implementation cannot
/// produce identity keys for the current request. A <see langword="null"/> result is treated as a
/// configuration error by the hosting layer and surfaces as 500.
/// </returns>
public abstract ValueTask<HostedSessionContext?> GetKeysAsync(
ResponseContext context,
CreateResponse request,
CancellationToken cancellationToken);
}
@@ -0,0 +1,39 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// JSON serialization utilities for hosted session identity types.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
internal static class HostedSessionJsonUtilities
{
/// <summary>
/// Default JSON serializer options for hosted session state.
/// </summary>
public static JsonSerializerOptions DefaultOptions { get; } = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false,
TypeInfoResolver = HostedSessionJsonContext.Default
};
}
/// <summary>
/// Source-generated JSON serialization context for hosted session identity types.
/// </summary>
[JsonSourceGenerationOptions(
JsonSerializerDefaults.General,
UseStringEnumConverter = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
WriteIndented = false)]
[JsonSerializable(typeof(HostedSessionContext))]
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
internal partial class HostedSessionJsonContext : JsonSerializerContext;
@@ -0,0 +1,44 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.AgentServer.Responses;
using Azure.AI.AgentServer.Responses.Models;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Default <see cref="HostedSessionIsolationKeyProvider"/> implementation that maps the platform-injected
/// <c>x-agent-user-isolation-key</c> and <c>x-agent-chat-isolation-key</c> headers from
/// <see cref="ResponseContext.Isolation"/> into a <see cref="HostedSessionContext"/>.
/// </summary>
/// <remarks>
/// This is the implementation used in production Foundry hosted environments. When running locally
/// outside the platform, both isolation keys are <see langword="null"/>, which causes
/// <see cref="GetKeysAsync"/> to return <see langword="null"/>. The hosting layer treats a null
/// result as a configuration error and surfaces it as a 500 from the request. Local development
/// should register an alternate <see cref="HostedSessionIsolationKeyProvider"/> implementation
/// that provides fallback values for the missing headers.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
internal sealed class PlatformHostedSessionIsolationKeyProvider : HostedSessionIsolationKeyProvider
{
/// <inheritdoc />
public override ValueTask<HostedSessionContext?> GetKeysAsync(
ResponseContext context,
CreateResponse request,
CancellationToken cancellationToken)
{
var userKey = context?.Isolation?.UserIsolationKey;
var chatKey = context?.Isolation?.ChatIsolationKey;
if (string.IsNullOrWhiteSpace(userKey) || string.IsNullOrWhiteSpace(chatKey))
{
return new ValueTask<HostedSessionContext?>((HostedSessionContext?)null);
}
return new ValueTask<HostedSessionContext?>(new HostedSessionContext(userKey!, chatKey!));
}
}
@@ -120,9 +120,24 @@ public static class OpenAIResponseClientExtensions
return Throw.IfNull(responseClient)
.AsIChatClient(model)
.AsBuilder()
.ConfigureOptions(x => x.RawRepresentationFactory = _ => includeReasoningEncryptedContent
? new CreateResponseOptions() { StoredOutputEnabled = false, IncludedProperties = { IncludedResponseProperty.ReasoningEncryptedContent } }
: new CreateResponseOptions() { StoredOutputEnabled = false })
.ConfigureOptions(x =>
{
var previousFactory = x.RawRepresentationFactory;
x.RawRepresentationFactory = state =>
{
var responseOptions = previousFactory?.Invoke(state) as CreateResponseOptions ?? new CreateResponseOptions();
responseOptions.StoredOutputEnabled = false;
if (includeReasoningEncryptedContent &&
!responseOptions.IncludedProperties.Contains(IncludedResponseProperty.ReasoningEncryptedContent))
{
responseOptions.IncludedProperties.Add(IncludedResponseProperty.ReasoningEncryptedContent);
}
return responseOptions;
};
})
.Build();
}
}
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
@@ -140,7 +141,15 @@ public class MagenticWorkflowBuilder(AIAgent managerAgent)
}
/// <inheritdoc cref="WorkflowBuilder.Build"/>
public Workflow Build() => this.ReduceToWorkflowBuilder().Build();
public Workflow Build()
{
if (this._team.Count == 0)
{
throw new InvalidOperationException("At least one participant must be added via AddParticipants() before building the workflow.");
}
return this.ReduceToWorkflowBuilder().Build();
}
private TaskLimits Limits => new(
MaxRoundCount: this._maxRounds,
@@ -101,6 +101,7 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
return base.ConfigureProtocol(protocolBuilder)
.SendsMessage<ChatMessage>()
.SendsMessage<ResetChatSignal>()
.YieldsOutput<List<ChatMessage>>()
.ConfigureRoutes(ConfigureRoutes);
void ConfigureRoutes(RouteBuilder routeBuilder) => routeBuilder.AddPortHandler<MagenticPlanReviewRequest, MagenticPlanReviewResponse>(
@@ -109,7 +110,7 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
out this._planReviewPort);
}
private ValueTask SubmitPlanReviewRequestAsync(MagenticTaskContext taskContext, IWorkflowContext workflowContext)
private ValueTask SubmitPlanReviewRequestAsync(MagenticTaskContext taskContext, IWorkflowContext workflowContext, bool replanAfterStall = false)
{
MagenticProgressLedger? progressLedger = taskContext.ProgressLedger;
if (progressLedger?.IsStarted is not true)
@@ -117,7 +118,7 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
progressLedger = null;
}
MagenticPlanReviewRequest request = new(taskContext.TaskLedger!.CurrentPlan, progressLedger, taskContext.IsStalled);
MagenticPlanReviewRequest request = new(taskContext.TaskLedger!.CurrentPlan, progressLedger, replanAfterStall);
return this._planReviewPort!.PostRequestAsync(request);
}
@@ -146,7 +147,7 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
if (this._taskContext.IsTerminated)
{
throw new InvalidOperationException("Magentic Orchestration has already been terminated and cannot process new messages. Please start a new session.");
throw new InvalidOperationException("This Magentic orchestration has already terminated. To process new messages, create a new workflow instance.");
}
if (response.IsApproved)
@@ -161,7 +162,7 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
}
}
private async ValueTask UpdatePlanAndDelegateAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
private async ValueTask UpdatePlanAndDelegateAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken, bool replanAfterStall = false)
{
bool isReplan = taskContext.TaskLedger != null;
@@ -177,7 +178,7 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
if (requirePlanSignoff)
{
await this.SubmitPlanReviewRequestAsync(taskContext, context).ConfigureAwait(false);
await this.SubmitPlanReviewRequestAsync(taskContext, context, replanAfterStall).ConfigureAwait(false);
}
else
{
@@ -187,9 +188,22 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
protected override async ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
{
// First Turn: Initialize the task context and send the initial messages to the planner agent
this._taskContext ??= new(messages, team, limits, emitEvents, []);
await this.UpdatePlanAndDelegateAsync(this._taskContext, context, cancellationToken).ConfigureAwait(false);
if (this._taskContext?.IsTerminated == true)
{
throw new InvalidOperationException("This Magentic orchestration has already terminated. To process new messages, create a new workflow instance.");
}
if (this._taskContext == null)
{
// First Turn: Initialize the task context and create the initial plan
this._taskContext = new(messages, team, limits, emitEvents, []);
await this.UpdatePlanAndDelegateAsync(this._taskContext, context, cancellationToken).ConfigureAwait(false);
}
else
{
// Subsequent turns: agent returned control, go directly to coordination (progress ledger only, no replan)
await this.RunCoordinationRoundAsync(this._taskContext, context, cancellationToken).ConfigureAwait(false);
}
}
private ChatMessage? _fullTaskLedgerMessage;
@@ -288,10 +302,11 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
private async ValueTask ResetAndReplanAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
{
bool wasStalled = taskContext.IsStalled;
taskContext.Reset();
await context.SendMessageAsync(new ResetChatSignal(), cancellationToken: cancellationToken).ConfigureAwait(false);
await this.UpdatePlanAndDelegateAsync(taskContext, context, cancellationToken).ConfigureAwait(false);
await this.UpdatePlanAndDelegateAsync(taskContext, context, cancellationToken, replanAfterStall: wasStalled).ConfigureAwait(false);
}
private async ValueTask PrepareFinalAnswerAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
@@ -58,7 +58,7 @@ internal class MagenticTaskContext(List<ChatMessage> taskDefinition, List<AIAgen
public bool IsTerminated { get; internal set; }
public bool IsStalled => this.TaskCounters.StallCount >= this.TaskLimits.MaxStallCount;
public bool IsStalled => this.TaskCounters.StallCount > this.TaskLimits.MaxStallCount;
public (bool HitRoundLimit, bool HitResetLimit) CheckLimits()
{
@@ -7,6 +7,7 @@ using Azure.Identity;
using Azure.Search.Documents;
using Azure.Search.Documents.Models;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Extensions.AI;
@@ -34,6 +35,7 @@ AIAgent agent = scenario switch
"tool-calling-approval" => CreateToolCallingApprovalAgent(projectClient, deployment),
"mcp-toolbox" => CreateMcpToolboxAgent(projectClient, deployment),
"custom-storage" => CreateCustomStorageAgent(projectClient, deployment),
"memory" => await CreateMemoryAgentAsync(projectClient, deployment).ConfigureAwait(false),
"azure-search-rag" => CreateAzureSearchRagAgent(projectClient, deployment),
"session-files" => CreateSessionFilesAgent(projectClient, deployment),
_ => throw new InvalidOperationException($"Unknown IT_SCENARIO '{scenario}'.")
@@ -179,6 +181,34 @@ static AIAgent CreateSessionFilesAgent(AIProjectClient client, string deployment
AIFunctionFactory.Create(ReadFile)
]);
// Memory scenario. The agent uses FoundryMemoryProvider scoped per user via the
// HostedSessionContext that the hosting layer applies from the platform isolation headers.
// In production the platform sets the headers; here we rely on the default
// PlatformHostedSessionIsolationKeyProvider that AgentFrameworkResponseHandler resolves.
static async Task<AIAgent> CreateMemoryAgentAsync(AIProjectClient client, string deployment)
{
var embedding = Environment.GetEnvironmentVariable("AZURE_AI_EMBEDDING_DEPLOYMENT_NAME") ?? "text-embedding-ada-002";
var memoryStoreName = Environment.GetEnvironmentVariable("IT_MEMORY_STORE_ID") ?? "it-memory-store";
var memoryProvider = new FoundryMemoryProvider(
client,
memoryStoreName,
stateInitializer: HostedFoundryMemoryProviderScopes.PerUser());
await memoryProvider.EnsureMemoryStoreCreatedAsync(deployment, embedding, "Memory store for hosted-memory IT scenario.").ConfigureAwait(false);
return client.AsAIAgent(new ChatClientAgentOptions
{
Name = "memory-agent",
ChatOptions = new ChatOptions
{
ModelId = deployment,
Instructions = "You are a friendly travel assistant. Use known memories about the user when responding, and do not invent details."
},
AIContextProviders = [memoryProvider]
});
}
[Description("Returns the current UTC date and time as an ISO 8601 string.")]
static string GetUtcNow() => DateTime.UtcNow.ToString("o");
@@ -0,0 +1,28 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
namespace Foundry.Hosting.IntegrationTests.Fixtures;
/// <summary>
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=memory</c> mode.
/// Used by tests that exercise <see cref="Microsoft.Agents.AI.Foundry.FoundryMemoryProvider"/>
/// running inside the Foundry hosted agent. The memory store name is randomised per fixture
/// instance so concurrent test runs do not share state.
/// </summary>
public sealed class MemoryHostedAgentFixture : HostedAgentFixture
{
protected override string ScenarioName => "memory";
/// <summary>
/// Memory store name passed to the test container via <c>IT_MEMORY_STORE_ID</c> so that each
/// fixture instance gets a fresh, isolated bucket of memories.
/// </summary>
public string MemoryStoreId { get; } = $"it-memory-{Guid.NewGuid():N}";
protected override void ConfigureEnvironment(IDictionary<string, string> environment)
{
environment["IT_MEMORY_STORE_ID"] = this.MemoryStoreId;
}
}
@@ -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;
#pragma warning disable OPENAI001 // Experimental Responses API surfaces
namespace Foundry.Hosting.IntegrationTests;
/// <summary>
/// Validates the Hosted-MemoryAgent end-to-end against a deployed test container running the
/// <c>IT_SCENARIO=memory</c> scenario. Asserts that <see cref="Microsoft.Agents.AI.Foundry.FoundryMemoryProvider"/>
/// scoped via <see cref="Microsoft.Agents.AI.Foundry.Hosting.HostedSessionContext"/> recalls user
/// preferences across multiple turns of a conversation.
/// </summary>
[Trait("Category", "FoundryHostedAgents")]
public sealed class MemoryHostedAgentTests(MemoryHostedAgentFixture fixture) : IClassFixture<MemoryHostedAgentFixture>
{
private readonly MemoryHostedAgentFixture _fixture = fixture;
[Fact]
public async Task Memory_RecallsAcrossTurnsAsync()
{
// Arrange
var agent = this._fixture.Agent;
var session = await agent.CreateSessionAsync();
// Act: teach the agent two pieces of information about the user.
var first = await agent.RunAsync("My name is Taylor and I am planning a hiking trip to Patagonia in November.", session);
Assert.False(string.IsNullOrWhiteSpace(first.Text));
var second = await agent.RunAsync("I am travelling with my sister and we love finding scenic viewpoints.", session);
Assert.False(string.IsNullOrWhiteSpace(second.Text));
// FoundryMemoryProvider defaults to UpdateDelay=0 (immediate trigger). Server-side ingestion
// typically completes within ~3 seconds; allow a small margin.
await Task.Delay(TimeSpan.FromSeconds(5));
var recall = await agent.RunAsync("What do you already know about my upcoming trip?", session);
// Assert
Assert.Contains("Patagonia", recall.Text, StringComparison.OrdinalIgnoreCase);
}
[Fact(Skip = "Foundry Memory write propagation is eventually consistent and the in-container WhenUpdatesCompletedAsync flush hook is not callable from the test process; this scenario is exercised manually via the sample's smoke.ps1.")]
public async Task Memory_PersistsAcrossSessionsForSameUserAsync()
{
// Arrange: drive a session that establishes some user-private memory. Foundry Memory
// extracts memories more reliably from multi-turn conversations than from a single
// imperative utterance, so mirror the sample's two-turn teaching pattern.
var agent = this._fixture.Agent;
var teachingSession = await agent.CreateSessionAsync();
await agent.RunAsync("My preferred airline is Iberia and I always fly business class.", teachingSession);
await agent.RunAsync("I also prefer aisle seats whenever they are available.", teachingSession);
// FoundryMemoryProvider defaults to UpdateDelay=0 (immediate trigger). Server-side
// ingestion typically completes within ~3 seconds; poll a fresh-session recall a few
// times before failing so the test does not flake on cold caches.
AgentResponse recall = null!;
const int MaxAttempts = 6;
for (var attempt = 1; attempt <= MaxAttempts; attempt++)
{
await Task.Delay(TimeSpan.FromSeconds(5));
var freshSession = await agent.CreateSessionAsync();
recall = await agent.RunAsync("Which airline do I prefer? Reply with just the airline name.", freshSession);
if (recall.Text.Contains("Iberia", StringComparison.OrdinalIgnoreCase))
{
break;
}
}
// Assert
Assert.Contains("Iberia", recall.Text, StringComparison.OrdinalIgnoreCase);
}
}
@@ -45,6 +45,7 @@ $Scenarios = @(
'tool-calling-approval',
'mcp-toolbox',
'custom-storage',
'memory',
'azure-search-rag',
'session-files'
)
@@ -149,6 +149,93 @@ public sealed class AGUIStreamingMessageIdTests
"ParentMessageId should have a generated fallback for empty provider MessageId");
}
/// <summary>
/// Tool results are separate tool-role messages, so their fallback IDs must not
/// collide with the assistant message that requested the tool call.
/// </summary>
[Fact]
public async Task ToolResults_NullMessageId_GeneratesDistinctMessageIdAsync()
{
FunctionCallContent functionCall = new("call_abc123", "GetWeather")
{
Arguments = new Dictionary<string, object?> { ["location"] = "San Francisco" }
};
List<ChatResponseUpdate> providerUpdates =
[
new ChatResponseUpdate(ChatRole.Assistant, "Checking the weather"),
new ChatResponseUpdate
{
Role = ChatRole.Assistant,
Contents = [functionCall]
},
new ChatResponseUpdate(ChatRole.Tool, [new FunctionResultContent("call_abc123", "72F and sunny")])
];
List<BaseEvent> aguiEvents = [];
await foreach (BaseEvent evt in providerUpdates.ToAsyncEnumerableAsync()
.AsAGUIEventStreamAsync("thread-1", "run-1", AGUIJsonSerializerContext.Default.Options))
{
aguiEvents.Add(evt);
}
TextMessageStartEvent textStart = Assert.Single(aguiEvents.OfType<TextMessageStartEvent>());
ToolCallStartEvent toolCallStart = Assert.Single(aguiEvents.OfType<ToolCallStartEvent>());
ToolCallResultEvent toolCallResult = Assert.Single(aguiEvents.OfType<ToolCallResultEvent>());
Assert.Equal(textStart.MessageId, toolCallStart.ParentMessageId);
Assert.Equal("call_abc123", toolCallResult.ToolCallId);
Assert.False(string.IsNullOrEmpty(toolCallResult.MessageId));
Assert.NotEqual(textStart.MessageId, toolCallResult.MessageId);
}
[Fact]
public async Task ToolResults_WithTextContent_GeneratesDistinctMessageIdAsync()
{
FunctionCallContent functionCall = new("call_abc123", "GetWeather")
{
Arguments = new Dictionary<string, object?> { ["location"] = "San Francisco" }
};
List<ChatResponseUpdate> providerUpdates =
[
new ChatResponseUpdate(ChatRole.Assistant, "Checking the weather"),
new ChatResponseUpdate
{
Role = ChatRole.Assistant,
Contents = [functionCall]
},
new ChatResponseUpdate
{
Role = ChatRole.Tool,
Contents =
[
new TextContent("Tool says: "),
new FunctionResultContent("call_abc123", "72F and sunny")
]
}
];
List<BaseEvent> aguiEvents = [];
await foreach (BaseEvent evt in providerUpdates.ToAsyncEnumerableAsync()
.AsAGUIEventStreamAsync("thread-1", "run-1", AGUIJsonSerializerContext.Default.Options))
{
aguiEvents.Add(evt);
}
TextMessageStartEvent[] textStarts = aguiEvents.OfType<TextMessageStartEvent>().ToArray();
TextMessageContentEvent toolText = Assert.Single(
aguiEvents.OfType<TextMessageContentEvent>(),
content => content.Delta == "Tool says: ");
ToolCallStartEvent toolCallStart = Assert.Single(aguiEvents.OfType<ToolCallStartEvent>());
ToolCallResultEvent toolCallResult = Assert.Single(aguiEvents.OfType<ToolCallResultEvent>());
Assert.Equal(textStarts[0].MessageId, toolCallStart.ParentMessageId);
Assert.NotEqual(textStarts[0].MessageId, toolCallResult.MessageId);
Assert.Equal(toolCallResult.MessageId, toolText.MessageId);
Assert.Equal(textStarts[^1].MessageId, toolCallResult.MessageId);
}
/// <summary>
/// When a provider properly sets MessageId (e.g., OpenAI), the AGUI pipeline
/// produces valid events with correct messageId values.
@@ -24,6 +24,45 @@ public class AgentSessionTests
Assert.Equal("value1", session.StateBag.GetValue<string>("key1"));
}
[Fact]
public void StateBag_Default_IsEmpty()
{
// Arrange & Act
var session = new TestAgentSession();
// Assert
Assert.Equal(0, session.StateBag.Count);
}
[Fact]
public void StateBag_MultipleKeys_StoreAndRetrieveIndependently()
{
// Arrange
var session = new TestAgentSession();
// Act
session.StateBag.SetValue("key1", "value1");
session.StateBag.SetValue("key2", "value2");
// Assert
Assert.Equal("value1", session.StateBag.GetValue<string>("key1"));
Assert.Equal("value2", session.StateBag.GetValue<string>("key2"));
}
[Fact]
public void StateBag_OverwriteValue_ReturnsUpdatedValue()
{
// Arrange
var session = new TestAgentSession();
session.StateBag.SetValue("key1", "original");
// Act
session.StateBag.SetValue("key1", "updated");
// Assert
Assert.Equal("updated", session.StateBag.GetValue<string>("key1"));
}
#endregion
#region GetService Method Tests
@@ -47,6 +47,7 @@ public class AgentFrameworkResponseHandlerTelemetryTests
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddSingleton<AIAgent>(agent);
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
@@ -75,6 +76,7 @@ public class AgentFrameworkResponseHandlerTelemetryTests
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddKeyedSingleton<AIAgent>("keyed-agent", agent);
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
@@ -116,6 +118,7 @@ public class AgentFrameworkResponseHandlerTelemetryTests
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddSingleton(preWrapped);
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
@@ -147,6 +150,7 @@ public class AgentFrameworkResponseHandlerTelemetryTests
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddSingleton<AIAgent>(agent);
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
@@ -29,6 +29,7 @@ public class AgentFrameworkResponseHandlerTests
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddSingleton<AIAgent>(agent);
services.AddSingleton<ILogger<AgentFrameworkResponseHandler>>(NullLogger<AgentFrameworkResponseHandler>.Instance);
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
@@ -67,6 +68,7 @@ public class AgentFrameworkResponseHandlerTests
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddKeyedSingleton<AIAgent>("my-agent", agent);
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
@@ -102,6 +104,7 @@ public class AgentFrameworkResponseHandlerTests
// Arrange
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
@@ -151,6 +154,7 @@ public class AgentFrameworkResponseHandlerTests
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddKeyedSingleton<AIAgent>("my-agent", agent);
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
@@ -188,6 +192,7 @@ public class AgentFrameworkResponseHandlerTests
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddKeyedSingleton<AIAgent>("entity-agent", agent);
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
@@ -228,6 +233,7 @@ public class AgentFrameworkResponseHandlerTests
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddSingleton<AIAgent>(agent);
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
@@ -263,6 +269,7 @@ public class AgentFrameworkResponseHandlerTests
// Arrange
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
@@ -297,6 +304,7 @@ public class AgentFrameworkResponseHandlerTests
// Arrange
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
@@ -331,6 +339,7 @@ public class AgentFrameworkResponseHandlerTests
// Arrange
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
@@ -376,6 +385,7 @@ public class AgentFrameworkResponseHandlerTests
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddSingleton<AIAgent>(agent);
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
@@ -424,6 +434,7 @@ public class AgentFrameworkResponseHandlerTests
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddSingleton<AIAgent>(agent);
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
@@ -467,6 +478,7 @@ public class AgentFrameworkResponseHandlerTests
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddSingleton<AIAgent>(agent);
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
@@ -506,6 +518,7 @@ public class AgentFrameworkResponseHandlerTests
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddSingleton<AIAgent>(agent);
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
@@ -548,6 +561,7 @@ public class AgentFrameworkResponseHandlerTests
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddSingleton<AIAgent>(agent);
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
@@ -589,6 +603,7 @@ public class AgentFrameworkResponseHandlerTests
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddKeyedSingleton<AIAgent>("agent-1", agent1);
services.AddKeyedSingleton<AIAgent>("agent-2", agent2);
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
@@ -626,6 +641,7 @@ public class AgentFrameworkResponseHandlerTests
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddSingleton<AIAgent>(agent);
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
@@ -663,6 +679,7 @@ public class AgentFrameworkResponseHandlerTests
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddSingleton<AIAgent>(agent);
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
@@ -144,6 +144,7 @@ public class AgentFrameworkResponseHandlerWorkflowTests
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddKeyedSingleton("my-workflow", workflowAgent);
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
@@ -166,6 +167,7 @@ public class AgentFrameworkResponseHandlerWorkflowTests
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddSingleton(agent);
services.AddSingleton<ILogger<AgentFrameworkResponseHandler>>(NullLogger<AgentFrameworkResponseHandler>.Instance);
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
@@ -0,0 +1,35 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.AgentServer.Responses;
using Azure.AI.AgentServer.Responses.Models;
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
/// <summary>
/// Test fake that returns a non-null <see cref="HostedSessionContext"/> by default, allowing tests
/// that were written before the strict isolation-key contract to keep passing without each test
/// having to stub <c>ResponseContext.Isolation</c>. The constructor also accepts <see langword="null"/>
/// values so individual tests can exercise the handler's null-key error path.
/// </summary>
internal sealed class FakeHostedSessionIsolationKeyProvider : HostedSessionIsolationKeyProvider
{
public const string DefaultUserId = "test-user-isolation";
public const string DefaultChatId = "test-chat-isolation";
private readonly HostedSessionContext? _context;
public FakeHostedSessionIsolationKeyProvider(string? userId = DefaultUserId, string? chatId = DefaultChatId)
{
this._context = userId is null || chatId is null
? null
: new HostedSessionContext(userId, chatId);
}
public override ValueTask<HostedSessionContext?> GetKeysAsync(
ResponseContext context,
CreateResponse request,
CancellationToken cancellationToken)
=> new(this._context);
}
@@ -0,0 +1,114 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
/// <summary>
/// Tests for <see cref="HostedFoundryMemoryProviderScopes"/> built-in stateInitializer factories.
/// </summary>
public class HostedFoundryMemoryProviderScopesTests
{
private const string TestUserId = "user-isolation-key-1";
private const string TestChatId = "chat-isolation-key-1";
[Fact]
public void PerUser_UsesUserIdAsScope()
{
// Arrange
var session = CreateTaggedSession(TestUserId, TestChatId);
var initializer = HostedFoundryMemoryProviderScopes.PerUser();
// Act
var state = initializer(session);
// Assert
Assert.NotNull(state);
Assert.Equal(TestUserId, state.Scope.Scope);
}
[Fact]
public void PerChat_UsesChatIdAsScope()
{
// Arrange
var session = CreateTaggedSession(TestUserId, TestChatId);
var initializer = HostedFoundryMemoryProviderScopes.PerChat();
// Act
var state = initializer(session);
// Assert
Assert.NotNull(state);
Assert.Equal(TestChatId, state.Scope.Scope);
}
[Fact]
public void PerUserAndChat_ComposesUserAndChatWithColon()
{
// Arrange
var session = CreateTaggedSession(TestUserId, TestChatId);
var initializer = HostedFoundryMemoryProviderScopes.PerUserAndChat();
// Act
var state = initializer(session);
// Assert
Assert.NotNull(state);
Assert.Equal($"{TestUserId}:{TestChatId}", state.Scope.Scope);
}
[Fact]
public void PerUser_NullSession_Throws()
{
// Arrange
var initializer = HostedFoundryMemoryProviderScopes.PerUser();
// Act & Assert
var ex = Assert.Throws<InvalidOperationException>(() => initializer(null));
Assert.Contains(nameof(HostedSessionContext), ex.Message);
}
[Fact]
public void PerChat_NullSession_Throws()
{
// Arrange
var initializer = HostedFoundryMemoryProviderScopes.PerChat();
// Act & Assert
Assert.Throws<InvalidOperationException>(() => initializer(null));
}
[Fact]
public void PerUserAndChat_NullSession_Throws()
{
// Arrange
var initializer = HostedFoundryMemoryProviderScopes.PerUserAndChat();
// Act & Assert
Assert.Throws<InvalidOperationException>(() => initializer(null));
}
[Fact]
public void PerUser_SessionWithoutHostedContext_Throws()
{
// Arrange
var session = new BareAgentSession();
var initializer = HostedFoundryMemoryProviderScopes.PerUser();
// Act & Assert
var ex = Assert.Throws<InvalidOperationException>(() => initializer(session));
Assert.Contains(nameof(HostedFoundryMemoryProviderScopes), ex.Message);
}
private static BareAgentSession CreateTaggedSession(string userId, string chatId)
{
var session = new BareAgentSession();
session.SetHostedContext(new HostedSessionContext(userId, chatId));
return session;
}
private sealed class BareAgentSession : AgentSession
{
public BareAgentSession() : base(new AgentSessionStateBag()) { }
}
}
@@ -0,0 +1,118 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Azure.AI.Projects;
using Azure.Identity;
using Microsoft.Extensions.DependencyInjection;
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
/// <summary>
/// Tests for <see cref="HostedFoundryMemoryProviderServiceCollectionExtensions"/>.
/// </summary>
public class HostedFoundryMemoryProviderServiceCollectionExtensionsTests
{
private const string TestUserId = "ext-user-1";
private const string TestChatId = "ext-chat-1";
private const string MemoryStoreName = "test-memory-store";
[Fact]
public void AddHostedFoundryMemoryProvider_ExplicitClient_RegistersSingleton()
{
// Arrange
var services = new ServiceCollection();
var client = CreateClient();
// Act
services.AddHostedFoundryMemoryProvider(client, MemoryStoreName);
var sp = services.BuildServiceProvider();
// Assert
var first = sp.GetRequiredService<FoundryMemoryProvider>();
var second = sp.GetRequiredService<FoundryMemoryProvider>();
Assert.Same(first, second);
}
[Fact]
public void AddHostedFoundryMemoryProvider_DiResolvedClient_RegistersSingleton()
{
// Arrange
var services = new ServiceCollection();
services.AddSingleton(CreateClient());
// Act
services.AddHostedFoundryMemoryProvider(MemoryStoreName);
var sp = services.BuildServiceProvider();
// Assert
var first = sp.GetRequiredService<FoundryMemoryProvider>();
var second = sp.GetRequiredService<FoundryMemoryProvider>();
Assert.Same(first, second);
}
[Fact]
public void AddHostedFoundryMemoryProvider_DiResolvedClient_MissingClient_Throws()
{
// Arrange
var services = new ServiceCollection();
// Act
services.AddHostedFoundryMemoryProvider(MemoryStoreName);
var sp = services.BuildServiceProvider();
// Assert
Assert.Throws<InvalidOperationException>(() => sp.GetRequiredService<FoundryMemoryProvider>());
}
[Fact]
public void AddHostedFoundryMemoryProvider_NullStateInitializer_DefaultsToPerUser()
{
// Arrange
var session = CreateTaggedSession();
// Act
var services = new ServiceCollection();
services.AddHostedFoundryMemoryProvider(CreateClient(), MemoryStoreName);
var provider = services.BuildServiceProvider().GetRequiredService<FoundryMemoryProvider>();
// Assert
Assert.NotNull(provider);
var defaultInitializer = HostedFoundryMemoryProviderScopes.PerUser();
var state = defaultInitializer(session);
Assert.Equal(TestUserId, state.Scope.Scope);
}
[Fact]
public void AddHostedFoundryMemoryProvider_CustomStateInitializer_IsHonored()
{
// Arrange
var session = CreateTaggedSession();
static FoundryMemoryProvider.State Custom(AgentSession? _)
=> new(new FoundryMemoryProviderScope("custom-scope"));
// Act
var services = new ServiceCollection();
services.AddHostedFoundryMemoryProvider(CreateClient(), MemoryStoreName, Custom);
var provider = services.BuildServiceProvider().GetRequiredService<FoundryMemoryProvider>();
// Assert
Assert.NotNull(provider);
var state = Custom(session);
Assert.Equal("custom-scope", state.Scope.Scope);
}
private static AIProjectClient CreateClient()
=> new(new Uri("https://example.services.ai.azure.com/api/projects/test"), new DefaultAzureCredential());
private static BareAgentSession CreateTaggedSession()
{
var session = new BareAgentSession();
session.SetHostedContext(new HostedSessionContext(TestUserId, TestChatId));
return session;
}
private sealed class BareAgentSession : AgentSession
{
public BareAgentSession() : base(new AgentSessionStateBag()) { }
}
}
@@ -101,6 +101,7 @@ public sealed class HostedOutboundUserAgentTests : IAsyncDisposable
AIAgent agent = new ChatClientAgent(chatClient);
builder.Services.AddFoundryResponses(agent);
builder.Services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
builder.Services.AddLogging();
this._app = builder.Build();
@@ -0,0 +1,364 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.AgentServer.Responses;
using Azure.AI.AgentServer.Responses.Models;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
/// <summary>
/// Tests covering the per-session identity context that <see cref="AgentFrameworkResponseHandler"/>
/// applies via the registered <see cref="HostedSessionIsolationKeyProvider"/>.
/// </summary>
public class HostedSessionIdentityContextTests
{
private const string TestUserId = "user-isolation-key-1";
private const string TestChatId = "chat-isolation-key-1";
[Fact]
public void HostedSessionContext_RejectsNullOrWhitespaceKeys()
{
// Assert
Assert.Throws<ArgumentNullException>(() => new HostedSessionContext(null!, TestChatId));
Assert.Throws<ArgumentNullException>(() => new HostedSessionContext(TestUserId, null!));
Assert.Throws<ArgumentException>(() => new HostedSessionContext(string.Empty, TestChatId));
Assert.Throws<ArgumentException>(() => new HostedSessionContext(TestUserId, " "));
}
[Fact]
public async Task PlatformProvider_MapsIsolationContextValuesAsync()
{
// Arrange
var provider = new PlatformHostedSessionIsolationKeyProvider();
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
mockContext.Setup(x => x.Isolation).Returns(new IsolationContext(TestUserId, TestChatId));
var request = new CreateResponse { Model = "test" };
// Act
var result = await provider.GetKeysAsync(mockContext.Object, request, CancellationToken.None);
// Assert
Assert.NotNull(result);
Assert.Equal(TestUserId, result.UserId);
Assert.Equal(TestChatId, result.ChatId);
}
[Fact]
public async Task PlatformProvider_ReturnsNullWhenIsolationKeysAreEmptyAsync()
{
// Arrange
var provider = new PlatformHostedSessionIsolationKeyProvider();
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
// CallBase delegates to ResponseContext.Isolation default which is IsolationContext.Empty.
var request = new CreateResponse { Model = "test" };
// Act
var result = await provider.GetKeysAsync(mockContext.Object, request, CancellationToken.None);
// Assert
Assert.Null(result);
}
[Fact]
public async Task Handler_FreshSession_AppliesContextFromCustomProviderAsync()
{
// Arrange
var capturingAgent = new HostedContextCapturingAgent();
var fakeProvider = new FakeHostedSessionIsolationKeyProvider("alice", "chat-A");
var handler = BuildHandler(capturingAgent, fakeProvider);
var (request, mockContext) = BuildFreshRequest();
// Act
await DrainAsync(handler.CreateAsync(request, mockContext.Object, CancellationToken.None));
// Assert
Assert.NotNull(capturingAgent.LastSession);
var ctx = capturingAgent.LastSession.GetHostedContext();
Assert.NotNull(ctx);
Assert.Equal("alice", ctx.UserId);
Assert.Equal("chat-A", ctx.ChatId);
}
[Fact]
public async Task Handler_NullKeysFromProvider_ThrowsInvalidOperationAsync()
{
// Arrange
var capturingAgent = new HostedContextCapturingAgent();
var fakeProvider = new FakeHostedSessionIsolationKeyProvider(userId: null, chatId: null);
var handler = BuildHandler(capturingAgent, fakeProvider);
var (request, mockContext) = BuildFreshRequest();
// Act & Assert
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => DrainAsync(handler.CreateAsync(request, mockContext.Object, CancellationToken.None)));
Assert.Contains(nameof(HostedSessionIsolationKeyProvider), ex.Message);
}
[Fact]
public async Task Handler_ResumeSession_MatchingKeys_PassesAsync()
{
// Arrange
var capturingAgent = new HostedContextCapturingAgent();
var fakeProvider = new FakeHostedSessionIsolationKeyProvider("alice", "chat-A");
var sessionStore = new InMemoryAgentSessionStore();
var handler = BuildHandler(capturingAgent, fakeProvider, sessionStore);
// Step 1: drive a fresh request to populate the session store with a tagged session.
var (freshRequest, freshContext) = BuildFreshRequest();
await DrainAsync(handler.CreateAsync(freshRequest, freshContext.Object, CancellationToken.None));
Assert.NotNull(capturingAgent.LastSession);
// Step 2: persist the session under a known conversation id (mimics what the handler does
// when it has a conversation id; here we plant it directly so we can drive a resume request).
const string ConversationId = "resume-chat-id";
await sessionStore.SaveSessionAsync(capturingAgent, ConversationId, capturingAgent.LastSession, CancellationToken.None);
// Step 3: drive a resume request with the same isolation keys.
var (resumeRequest, resumeContext) = BuildResumeRequest(ConversationId);
capturingAgent.LastSession = null;
// Act
await DrainAsync(handler.CreateAsync(resumeRequest, resumeContext.Object, CancellationToken.None));
// Assert
Assert.NotNull(capturingAgent.LastSession);
var ctx = capturingAgent.LastSession.GetHostedContext();
Assert.NotNull(ctx);
Assert.Equal("alice", ctx.UserId);
}
[Fact]
public async Task Handler_ResumeSession_MismatchedUserId_Returns403Async()
{
// Arrange
var capturingAgent = new HostedContextCapturingAgent();
var aliceProvider = new FakeHostedSessionIsolationKeyProvider("alice", "chat-A");
var sessionStore = new InMemoryAgentSessionStore();
var aliceHandler = BuildHandler(capturingAgent, aliceProvider, sessionStore);
var (freshRequest, freshContext) = BuildFreshRequest();
await DrainAsync(aliceHandler.CreateAsync(freshRequest, freshContext.Object, CancellationToken.None));
const string ConversationId = "resume-chat-id";
await sessionStore.SaveSessionAsync(capturingAgent, ConversationId, capturingAgent.LastSession!, CancellationToken.None);
// Bob attempts to resume Alice's conversation.
var bobProvider = new FakeHostedSessionIsolationKeyProvider("bob", "chat-A");
var bobHandler = BuildHandler(capturingAgent, bobProvider, sessionStore);
var (resumeRequest, resumeContext) = BuildResumeRequest(ConversationId);
// Act & Assert
var ex = await Assert.ThrowsAsync<ResponsesApiException>(() => DrainAsync(bobHandler.CreateAsync(resumeRequest, resumeContext.Object, CancellationToken.None)));
Assert.Equal(403, ex.StatusCode);
Assert.Equal("Hosted session identity context mismatch", ex.Error.Message);
}
[Fact]
public async Task Handler_ResumeSession_MismatchedChatId_Returns403Async()
{
// Arrange
var capturingAgent = new HostedContextCapturingAgent();
var chatAProvider = new FakeHostedSessionIsolationKeyProvider("alice", "chat-A");
var sessionStore = new InMemoryAgentSessionStore();
var chatAHandler = BuildHandler(capturingAgent, chatAProvider, sessionStore);
var (freshRequest, freshContext) = BuildFreshRequest();
await DrainAsync(chatAHandler.CreateAsync(freshRequest, freshContext.Object, CancellationToken.None));
const string ConversationId = "resume-chat-id";
await sessionStore.SaveSessionAsync(capturingAgent, ConversationId, capturingAgent.LastSession!, CancellationToken.None);
var chatBProvider = new FakeHostedSessionIsolationKeyProvider("alice", "chat-B");
var chatBHandler = BuildHandler(capturingAgent, chatBProvider, sessionStore);
var (resumeRequest, resumeContext) = BuildResumeRequest(ConversationId);
// Act & Assert
var ex = await Assert.ThrowsAsync<ResponsesApiException>(() => DrainAsync(chatBHandler.CreateAsync(resumeRequest, resumeContext.Object, CancellationToken.None)));
Assert.Equal(403, ex.StatusCode);
}
[Fact]
public async Task Handler_ResumeSession_WithoutPriorContext_StampsAsFreshAsync()
{
// Arrange: store an untagged session. This case arises in production when the platform
// (or the caller) creates a Foundry conversation_id externally, and the very first
// hosted-agent request for that conversation hits the handler before any context is
// stamped. Such a session is treated as "fresh" rather than "resume" because there is
// no prior identity to defend; the stamp made now is what future resumes will validate.
var capturingAgent = new HostedContextCapturingAgent();
var sessionStore = new InMemoryAgentSessionStore();
const string ConversationId = "untagged-chat-id";
var untagged = await capturingAgent.CreateSessionAsync(CancellationToken.None);
await sessionStore.SaveSessionAsync(capturingAgent, ConversationId, untagged, CancellationToken.None);
var fakeProvider = new FakeHostedSessionIsolationKeyProvider("alice", "chat-A");
var handler = BuildHandler(capturingAgent, fakeProvider, sessionStore);
var (resumeRequest, resumeContext) = BuildResumeRequest(ConversationId);
// Act
await DrainAsync(handler.CreateAsync(resumeRequest, resumeContext.Object, CancellationToken.None));
// Assert
Assert.NotNull(capturingAgent.LastSession);
var ctx = capturingAgent.LastSession.GetHostedContext();
Assert.NotNull(ctx);
Assert.Equal("alice", ctx.UserId);
Assert.Equal("chat-A", ctx.ChatId);
}
[Fact]
public void GetHostedContext_ReturnsNullWhenAbsent()
{
// Arrange
var session = new HostedContextCapturingSession();
// Act
var ctx = session.GetHostedContext();
// Assert
Assert.Null(ctx);
}
[Fact]
public void SetHostedContext_ThenGet_RoundTrips()
{
// Arrange
var session = new HostedContextCapturingSession();
// Act
session.SetHostedContext(new HostedSessionContext("alice", "chat-A"));
var ctx = session.GetHostedContext();
// Assert
Assert.NotNull(ctx);
Assert.Equal("alice", ctx.UserId);
Assert.Equal("chat-A", ctx.ChatId);
}
private static AgentFrameworkResponseHandler BuildHandler(
AIAgent agent,
HostedSessionIsolationKeyProvider provider,
AgentSessionStore? sessionStore = null)
{
var services = new ServiceCollection();
services.AddSingleton(sessionStore ?? new InMemoryAgentSessionStore());
services.AddSingleton(agent);
services.AddSingleton(provider);
var sp = services.BuildServiceProvider();
return new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
}
private static (CreateResponse Request, Mock<ResponseContext> Context) BuildFreshRequest()
{
var request = new CreateResponse { Model = "test" };
request.Input = BinaryData.FromObjectAsJson(new[]
{
new { type = "message", id = "msg_1", status = "completed", role = "user",
content = new[] { new { type = "input_text", text = "Hello" } } }
});
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
mockContext.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<OutputItem>());
mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<Item>());
return (request, mockContext);
}
private static (CreateResponse Request, Mock<ResponseContext> Context) BuildResumeRequest(string conversationId)
{
var (request, mockContext) = BuildFreshRequest();
request.Conversation = BinaryData.FromString($"\"{conversationId}\"");
return (request, mockContext);
}
private static async Task DrainAsync(IAsyncEnumerable<ResponseStreamEvent> stream)
{
await foreach (var _ in stream)
{
}
}
/// <summary>
/// Minimal <see cref="AIAgent"/> subclass that captures the session it was invoked with so tests
/// can inspect the <see cref="HostedSessionContext"/> applied by the handler.
/// </summary>
private sealed class HostedContextCapturingAgent : AIAgent
{
public AgentSession? LastSession { get; set; }
protected override IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session,
AgentRunOptions? options,
CancellationToken cancellationToken = default)
{
this.LastSession = session;
return ToAsyncEnumerableAsync(new AgentResponseUpdate
{
MessageId = "resp_msg_1",
Contents = [new Extensions.AI.TextContent("ok")]
});
}
protected override Task<AgentResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session,
AgentRunOptions? options,
CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
protected override ValueTask<AgentSession> CreateSessionCoreAsync(
CancellationToken cancellationToken = default) =>
new(new HostedContextCapturingSession());
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
AgentSession session,
JsonSerializerOptions? jsonSerializerOptions = null,
CancellationToken cancellationToken = default) =>
new(((HostedContextCapturingSession)session).Serialize());
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
JsonElement serializedState,
JsonSerializerOptions? jsonSerializerOptions = null,
CancellationToken cancellationToken = default) =>
new(HostedContextCapturingSession.Deserialize(serializedState));
private static async IAsyncEnumerable<AgentResponseUpdate> ToAsyncEnumerableAsync(params AgentResponseUpdate[] items)
{
foreach (var item in items)
{
yield return item;
}
await Task.CompletedTask;
}
}
/// <summary>
/// Minimal session implementation that round-trips its <see cref="AgentSessionStateBag"/> via JSON.
/// </summary>
private sealed class HostedContextCapturingSession : AgentSession
{
public HostedContextCapturingSession()
{
}
private HostedContextCapturingSession(AgentSessionStateBag bag)
{
this.StateBag = bag;
}
public JsonElement Serialize() => this.StateBag.Serialize();
public static HostedContextCapturingSession Deserialize(JsonElement element)
=> new(AgentSessionStateBag.Deserialize(element));
}
}
@@ -129,6 +129,75 @@ public sealed class ProjectResponsesClientExtensionsTests
Assert.DoesNotContain(IncludedResponseProperty.ReasoningEncryptedContent, createResponseOptions.IncludedProperties);
}
/// <summary>
/// Verify that AsIChatClientWithStoredOutputDisabled preserves an existing RawRepresentationFactory
/// set on ChatOptions, augmenting it with StoredOutputEnabled and ReasoningEncryptedContent
/// rather than replacing it.
/// </summary>
[Fact]
public void AsIChatClientWithStoredOutputDisabled_PreservesExistingRawRepresentationFactory()
{
// Arrange
var responseClient = CreateTestClient();
var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled();
// Simulate a caller setting their own RawRepresentationFactory on ChatOptions
// (e.g., to add WebSearchCallActionSources).
var options = new ChatOptions
{
RawRepresentationFactory = _ => new CreateResponseOptions
{
IncludedProperties = { IncludedResponseProperty.WebSearchCallActionSources },
},
};
// Act
var createResponseOptions = GetCreateResponseOptionsFromPipeline(chatClient, options);
// Assert
Assert.NotNull(createResponseOptions);
Assert.False(createResponseOptions.StoredOutputEnabled);
Assert.Contains(IncludedResponseProperty.ReasoningEncryptedContent, createResponseOptions.IncludedProperties);
Assert.Contains(IncludedResponseProperty.WebSearchCallActionSources, createResponseOptions.IncludedProperties);
}
/// <summary>
/// Verify that AsIChatClientWithStoredOutputDisabled does not duplicate ReasoningEncryptedContent
/// when the existing factory already includes it.
/// </summary>
[Fact]
public void AsIChatClientWithStoredOutputDisabled_DoesNotDuplicateReasoningEncryptedContent()
{
// Arrange
var responseClient = CreateTestClient();
var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled();
// Simulate a caller that already includes ReasoningEncryptedContent
var options = new ChatOptions
{
RawRepresentationFactory = _ => new CreateResponseOptions
{
IncludedProperties = { IncludedResponseProperty.ReasoningEncryptedContent },
},
};
// Act
var createResponseOptions = GetCreateResponseOptionsFromPipeline(chatClient, options);
// Assert - ReasoningEncryptedContent should appear exactly once
Assert.NotNull(createResponseOptions);
int count = 0;
foreach (var prop in createResponseOptions.IncludedProperties)
{
if (prop == IncludedResponseProperty.ReasoningEncryptedContent)
{
count++;
}
}
Assert.Equal(1, count);
}
/// <summary>
/// Verify that AsIChatClientWithStoredOutputDisabled works with an optional deployment name.
/// </summary>
@@ -153,6 +222,15 @@ public sealed class ProjectResponsesClientExtensionsTests
/// by using reflection to access the configure action and invoking it on a test <see cref="ChatOptions"/>.
/// </summary>
private static CreateResponseOptions? GetCreateResponseOptionsFromPipeline(IChatClient chatClient)
{
return GetCreateResponseOptionsFromPipeline(chatClient, new ChatOptions());
}
/// <summary>
/// Overload that runs the configure action on caller-supplied <see cref="ChatOptions"/>,
/// useful for testing that existing factories are preserved.
/// </summary>
private static CreateResponseOptions? GetCreateResponseOptionsFromPipeline(IChatClient chatClient, ChatOptions options)
{
var configureField = chatClient.GetType().GetField("_configureOptions", BindingFlags.NonPublic | BindingFlags.Instance);
Assert.NotNull(configureField);
@@ -160,7 +238,6 @@ public sealed class ProjectResponsesClientExtensionsTests
var configureAction = configureField.GetValue(chatClient) as Action<ChatOptions>;
Assert.NotNull(configureAction);
var options = new ChatOptions();
configureAction(options);
Assert.NotNull(options.RawRepresentationFactory);
@@ -370,6 +370,75 @@ public sealed class OpenAIResponseClientExtensionsTests
Assert.DoesNotContain(IncludedResponseProperty.ReasoningEncryptedContent, createResponseOptions.IncludedProperties);
}
/// <summary>
/// Verify that AsIChatClientWithStoredOutputDisabled preserves an existing RawRepresentationFactory
/// set on ChatOptions, augmenting it with StoredOutputEnabled and ReasoningEncryptedContent
/// rather than replacing it.
/// </summary>
[Fact]
public void AsIChatClientWithStoredOutputDisabled_PreservesExistingRawRepresentationFactory()
{
// Arrange
var responseClient = new TestOpenAIResponseClient();
var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled();
// Simulate a caller setting their own RawRepresentationFactory on ChatOptions
// (e.g., to add WebSearchCallActionSources).
var options = new ChatOptions
{
RawRepresentationFactory = _ => new CreateResponseOptions
{
IncludedProperties = { IncludedResponseProperty.WebSearchCallActionSources },
},
};
// Act
var createResponseOptions = GetCreateResponseOptionsFromPipeline(chatClient, options);
// Assert
Assert.NotNull(createResponseOptions);
Assert.False(createResponseOptions.StoredOutputEnabled);
Assert.Contains(IncludedResponseProperty.ReasoningEncryptedContent, createResponseOptions.IncludedProperties);
Assert.Contains(IncludedResponseProperty.WebSearchCallActionSources, createResponseOptions.IncludedProperties);
}
/// <summary>
/// Verify that AsIChatClientWithStoredOutputDisabled does not duplicate ReasoningEncryptedContent
/// when the existing factory already includes it.
/// </summary>
[Fact]
public void AsIChatClientWithStoredOutputDisabled_DoesNotDuplicateReasoningEncryptedContent()
{
// Arrange
var responseClient = new TestOpenAIResponseClient();
var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled();
// Simulate a caller that already includes ReasoningEncryptedContent
var options = new ChatOptions
{
RawRepresentationFactory = _ => new CreateResponseOptions
{
IncludedProperties = { IncludedResponseProperty.ReasoningEncryptedContent },
},
};
// Act
var createResponseOptions = GetCreateResponseOptionsFromPipeline(chatClient, options);
// Assert - ReasoningEncryptedContent should appear exactly once
Assert.NotNull(createResponseOptions);
int count = 0;
foreach (var prop in createResponseOptions.IncludedProperties)
{
if (prop == IncludedResponseProperty.ReasoningEncryptedContent)
{
count++;
}
}
Assert.Equal(1, count);
}
/// <summary>
/// A simple test IServiceProvider implementation for testing.
/// </summary>
@@ -394,6 +463,15 @@ public sealed class OpenAIResponseClientExtensionsTests
/// by using reflection to access the configure action and invoking it on a test <see cref="ChatOptions"/>.
/// </summary>
private static CreateResponseOptions? GetCreateResponseOptionsFromPipeline(IChatClient chatClient)
{
return GetCreateResponseOptionsFromPipeline(chatClient, new ChatOptions());
}
/// <summary>
/// Overload that runs the configure action on caller-supplied <see cref="ChatOptions"/>,
/// useful for testing that existing factories are preserved.
/// </summary>
private static CreateResponseOptions? GetCreateResponseOptionsFromPipeline(IChatClient chatClient, ChatOptions options)
{
// The ConfigureOptionsChatClient stores the configure action in a private field.
var configureField = chatClient.GetType().GetField("_configureOptions", BindingFlags.NonPublic | BindingFlags.Instance);
@@ -402,7 +480,6 @@ public sealed class OpenAIResponseClientExtensionsTests
var configureAction = configureField.GetValue(chatClient) as Action<ChatOptions>;
Assert.NotNull(configureAction);
var options = new ChatOptions();
configureAction(options);
Assert.NotNull(options.RawRepresentationFactory);
+24 -1
View File
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.4.0] - 2026-05-14
### Added
- **agent-framework-core**: Forward MCP tool call metadata ([#5815](https://github.com/microsoft/agent-framework/pull/5815))
- **agent-framework-core**: Support `list[str]` arguments for file-based skill scripts ([#5850](https://github.com/microsoft/agent-framework/pull/5850))
- **agent-framework-core**: Strip server-issued response item IDs under storage ([#5690](https://github.com/microsoft/agent-framework/pull/5690))
- **agent-framework-ag-ui**: Add tool result display channel ([#5762](https://github.com/microsoft/agent-framework/pull/5762))
- **agent-framework-ag-ui**: Promote to release candidate stage ([#5844](https://github.com/microsoft/agent-framework/pull/5844))
- **agent-framework-devui**: Improvements for DevUI ([#5840](https://github.com/microsoft/agent-framework/pull/5840))
### Changed
- **agent-framework-core**: [BREAKING — experimental skills API] Align file skill folder discovery with agentskills.io spec ([#5807](https://github.com/microsoft/agent-framework/pull/5807))
- **agent-framework-core**: [BREAKING — experimental skills API] Extract skill spec metadata into `SkillFrontmatter` ([#5775](https://github.com/microsoft/agent-framework/pull/5775))
- **agent-framework-devui**: [BREAKING] Tighten default access controls and CORS posture ([#5740](https://github.com/microsoft/agent-framework/pull/5740))
- **agent-framework-a2a**: [BREAKING] Migrate to a2a-sdk v1.0 ([#5752](https://github.com/microsoft/agent-framework/pull/5752))
### Fixed
- **agent-framework-a2a**: Fix A2A v1.0 non-streaming response and sample runtime issues ([#5849](https://github.com/microsoft/agent-framework/pull/5849))
- **agent-framework-foundry-hosting**: Reject path-traversal context IDs in checkpoint storage ([#5851](https://github.com/microsoft/agent-framework/pull/5851))
- **agent-framework-core**: Prevent MCP message_handler deadlock on notification reload ([#4866](https://github.com/microsoft/agent-framework/pull/4866))
## [1.3.0] - 2026-05-07
### Added
@@ -1050,7 +1071,9 @@ Release candidate for **agent-framework-core** and **agent-framework-azure-ai**
For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/).
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.2.2...HEAD
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.4.0...HEAD
[1.4.0]: https://github.com/microsoft/agent-framework/compare/python-1.3.0...python-1.4.0
[1.3.0]: https://github.com/microsoft/agent-framework/compare/python-1.2.2...python-1.3.0
[1.2.2]: https://github.com/microsoft/agent-framework/compare/python-1.2.1...python-1.2.2
[1.2.1]: https://github.com/microsoft/agent-framework/compare/python-1.2.0...python-1.2.1
[1.2.0]: https://github.com/microsoft/agent-framework/compare/python-1.1.1...python-1.2.0
+1 -1
View File
@@ -42,7 +42,7 @@ request_handler = DefaultRequestHandler(
app = Starlette(
routes=[
*create_agent_card_routes(my_agent_card),
*create_jsonrpc_routes(request_handler),
*create_jsonrpc_routes(request_handler, "/"),
]
)
```
@@ -78,7 +78,7 @@ class A2AExecutor(AgentExecutor):
app = Starlette(
routes=[
*create_agent_card_routes(public_agent_card),
*create_jsonrpc_routes(request_handler),
*create_jsonrpc_routes(request_handler, "/"),
],
)
@@ -157,9 +157,7 @@ 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_url = (
agent_card.supported_interfaces[0].url if agent_card.supported_interfaces else url
)
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. "
@@ -365,6 +363,10 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
all_updates: list[AgentResponseUpdate] = []
streamed_artifact_ids_by_task: dict[str, set[str]] = {}
# In non-streaming mode, accumulate intermediate status content so it
# can be surfaced when the terminal event arrives (mirroring v0.3.x
# behavior where the full Task history was available at completion).
pending_updates_by_task: dict[str, list[AgentResponseUpdate]] = {}
async for item in a2a_stream:
payload_type = item.WhichOneof("payload")
if payload_type == "message":
@@ -391,27 +393,55 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
)
if task.status.state in TERMINAL_TASK_STATES:
streamed_artifact_ids_by_task.pop(task.id, None)
# If the terminal Task has no content, flush accumulated updates
if not updates or all(not u.contents for u in updates):
pending = pending_updates_by_task.pop(task.id, [])
for update in pending:
all_updates.append(update)
yield update
else:
pending_updates_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)
is_terminal = status_event.status.state in TERMINAL_TASK_STATES
if emit_intermediate:
for update in updates:
all_updates.append(update)
yield update
elif is_terminal:
if updates:
# Terminal event with content — discard accumulated intermediates
pending_updates_by_task.pop(status_event.task_id, None)
for update in updates:
all_updates.append(update)
yield update
else:
# Terminal event with NO content — flush accumulated updates
pending = pending_updates_by_task.pop(status_event.task_id, [])
for update in pending:
all_updates.append(update)
yield update
else:
# Non-streaming intermediate: accumulate for later
if updates:
pending_updates_by_task.setdefault(status_event.task_id, []).extend(updates)
elif payload_type == "artifact_update":
artifact_event = item.artifact_update
updates = self._updates_from_task_update_event(artifact_event)
# Always yield artifact updates — they carry actual response
# content (files, data). Track IDs so that a subsequent
# terminal Task doesn't duplicate the same artifacts.
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
for update in updates:
all_updates.append(update)
yield update
else:
raise NotImplementedError(f"Unsupported StreamResponse payload: {payload_type}")
+2 -2
View File
@@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260507"
version = "1.0.0b260514"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.3.0,<2",
"agent-framework-core>=1.4.0,<2",
"a2a-sdk>=1.0.0,<2",
]
@@ -1570,4 +1570,102 @@ async def test_none_metadata_leaves_additional_properties_empty(
assert not response.additional_properties
async def test_non_streaming_terminal_status_update_surfaces_content(
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
) -> None:
"""Non-streaming run() should surface content from terminal status_update events."""
completed_msg = A2AMessage(
message_id="msg-complete",
role=A2ARole.ROLE_AGENT,
parts=[Part(text="Done! Here is your answer.")],
)
status = TaskStatus(state=TaskState.TASK_STATE_COMPLETED, message=completed_msg)
event = TaskStatusUpdateEvent(task_id="task-ts", context_id="ctx-ts", status=status)
mock_a2a_client.responses.append(StreamResponse(status_update=event))
response = await a2a_agent.run("Hello")
assert len(response.messages) == 1
assert response.messages[0].text == "Done! Here is your answer."
async def test_non_streaming_accumulates_working_content_for_empty_terminal(
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
) -> None:
"""Non-streaming run() accumulates WORKING content and flushes on empty terminal event."""
# Intermediate WORKING event with content
working_msg = A2AMessage(
message_id="msg-working",
role=A2ARole.ROLE_AGENT,
parts=[Part(text="Here is your answer from working state.")],
)
working_status = TaskStatus(state=TaskState.TASK_STATE_WORKING, message=working_msg)
working_event = TaskStatusUpdateEvent(task_id="task-acc", context_id="ctx-acc", status=working_status)
mock_a2a_client.responses.append(StreamResponse(status_update=working_event))
# Terminal COMPLETED event with NO content
completed_status = TaskStatus(state=TaskState.TASK_STATE_COMPLETED)
completed_event = TaskStatusUpdateEvent(task_id="task-acc", context_id="ctx-acc", status=completed_status)
mock_a2a_client.responses.append(StreamResponse(status_update=completed_event))
response = await a2a_agent.run("Hello")
# The accumulated WORKING content is flushed when terminal arrives empty
assert len(response.messages) == 1
assert response.messages[0].text == "Here is your answer from working state."
async def test_non_streaming_intermediate_discarded_when_terminal_has_content(
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
) -> None:
"""Non-streaming: if terminal event has content, intermediate content is discarded."""
# Intermediate WORKING event
working_msg = A2AMessage(
message_id="msg-working",
role=A2ARole.ROLE_AGENT,
parts=[Part(text="Still thinking...")],
)
working_status = TaskStatus(state=TaskState.TASK_STATE_WORKING, message=working_msg)
working_event = TaskStatusUpdateEvent(task_id="task-wi", context_id="ctx-wi", status=working_status)
mock_a2a_client.responses.append(StreamResponse(status_update=working_event))
# Terminal COMPLETED event WITH content
completed_msg = A2AMessage(
message_id="msg-final",
role=A2ARole.ROLE_AGENT,
parts=[Part(text="Final answer")],
)
completed_status = TaskStatus(state=TaskState.TASK_STATE_COMPLETED, message=completed_msg)
completed_event = TaskStatusUpdateEvent(task_id="task-wi", context_id="ctx-wi", status=completed_status)
mock_a2a_client.responses.append(StreamResponse(status_update=completed_event))
response = await a2a_agent.run("Hello")
# Terminal content supersedes accumulated intermediates
assert len(response.messages) == 1
assert response.messages[0].text == "Final answer"
async def test_non_streaming_artifact_update_surfaces_content(
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
) -> None:
"""Non-streaming run() should surface content from artifact_update events."""
artifact = Artifact(
artifact_id="art-ns",
parts=[Part(text="Artifact content")],
)
event = TaskArtifactUpdateEvent(task_id="task-anu", context_id="ctx-anu", artifact=artifact, append=False)
mock_a2a_client.responses.append(StreamResponse(artifact_update=event))
# Terminal task with the same artifact ID — should be deduped
mock_a2a_client.add_task_response("task-anu", [{"id": "art-ns", "content": "Artifact content"}])
response = await a2a_agent.run("Hello")
# Artifact update + terminal task with same artifact ID = content emitted once from
# the artifact_update, then the duplicate from the task is filtered by streamed_artifact_ids
assert len(response.messages) == 1
assert response.messages[0].text == "Artifact content"
# endregion
+1 -1
View File
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.3.0,<2",
"agent-framework-core>=1.4.0,<2",
"ag-ui-protocol>=0.1.16,<0.2",
"fastapi>=0.115.0,<0.133.1",
"uvicorn[standard]>=0.30.0,<0.42.0"
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Anthropic integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260507"
version = "1.0.0b260514"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.3.0,<2",
"agent-framework-core>=1.4.0,<2",
"anthropic>=0.80.0,<0.80.1",
]
@@ -4,7 +4,7 @@ description = "Azure AI Search integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260507"
version = "1.0.0b260514"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.3.0,<2",
"agent-framework-core>=1.4.0,<2",
"azure-search-documents>=11.7.0b2,<11.7.0b3",
]
@@ -4,7 +4,7 @@ description = "Azure Content Understanding integration for Microsoft Agent Frame
authors = [{ name = "Microsoft", email = "af-support@microsoft.com" }]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0a260507"
version = "1.0.0a260514"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,8 +23,8 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.3.0,<2",
"agent-framework-foundry>=1.3.0,<2",
"agent-framework-core>=1.4.0,<2",
"agent-framework-foundry>=1.4.0,<2",
"azure-ai-contentunderstanding>=1.0.1,<1.1",
"aiohttp>=3.9,<4",
"filetype>=1.2,<2",
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Azure Cosmos DB history provider integration for Microsoft Agent
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260507"
version = "1.0.0b260514"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.3.0,<2",
"agent-framework-core>=1.4.0,<2",
"azure-cosmos>=4.3.0,<5",
]
@@ -4,7 +4,7 @@ description = "Azure Functions integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260507"
version = "1.0.0b260514"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.3.0,<2",
"agent-framework-core>=1.4.0,<2",
"agent-framework-durabletask",
"azure-functions>=1.24.0,<2",
"azure-functions-durable>=1.3.1,<2",
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Amazon Bedrock integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260507"
version = "1.0.0b260514"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.3.0,<2",
"agent-framework-core>=1.4.0,<2",
"boto3>=1.35.0,<2.0.0",
"botocore>=1.35.0,<2.0.0",
]
@@ -21,6 +21,7 @@ from chatkit.types import (
HiddenContextItem,
ImageAttachment,
SDKHiddenContextItem,
StructuredInputItem,
TaskItem,
ThreadItem,
UserMessageItem,
@@ -527,6 +528,9 @@ class ThreadItemConverter:
case GeneratedImageItem():
# TODO(evmattso): Implement generated image handling in a future PR
return []
case StructuredInputItem():
# TODO(evmattso): Implement structured input handling in a future PR
return []
case _:
assert_never(item)
+2 -2
View File
@@ -4,7 +4,7 @@ description = "OpenAI ChatKit integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260507"
version = "1.0.0b260514"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.3.0,<2",
"agent-framework-core>=1.4.0,<2",
"openai-chatkit>=1.4.1,<2.0.0",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Claude Agent SDK integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260507"
version = "1.0.0b260514"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.3.0,<2",
"agent-framework-core>=1.4.0,<2",
"claude-agent-sdk>=0.1.36,<0.1.49",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Copilot Studio integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260507"
version = "1.0.0b260514"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.3.0,<2",
"agent-framework-core>=1.4.0,<2",
"microsoft-agents-copilotstudio-client>=0.3.1,<0.3.2",
]
+9 -3
View File
@@ -261,6 +261,7 @@ class MCPTool:
self.request_timeout = request_timeout
self.client = client
self._functions: list[FunctionTool] = []
self._tool_call_meta_by_name: dict[str, dict[str, Any]] = {}
self.is_connected: bool = False
self._tools_loaded: bool = False
self._prompts_loaded: bool = False
@@ -1026,6 +1027,7 @@ class MCPTool:
# Track existing function names to prevent duplicates
existing_names = {func.name for func in self._functions}
self._tool_call_meta_by_name.clear()
params: types.PaginatedRequestParams | None = None
while True:
@@ -1035,6 +1037,9 @@ class MCPTool:
tool_list = await self.session.list_tools(params=params) # type: ignore[union-attr]
for tool in tool_list.tools:
if tool.meta is not None:
self._tool_call_meta_by_name[tool.name] = dict(tool.meta)
normalized_name = _normalize_mcp_name(tool.name)
local_name = _build_prefixed_mcp_name(normalized_name, self.tool_name_prefix)
@@ -1185,14 +1190,15 @@ class MCPTool:
}
}
# Inject OpenTelemetry trace context into MCP _meta for distributed tracing.
otel_meta = _inject_otel_into_mcp_meta()
# Some MCP proxies require their tools/list metadata to be echoed on tools/call.
tool_meta = self._tool_call_meta_by_name.get(tool_name)
meta = _inject_otel_into_mcp_meta(dict(tool_meta) if tool_meta is not None else None)
parser = self.parse_tool_results or self._parse_tool_result_from_mcp
# Try the operation, reconnecting once if the connection is closed
for attempt in range(2):
try:
result = await self.session.call_tool(tool_name, arguments=filtered_kwargs, meta=otel_meta) # type: ignore
result = await self.session.call_tool(tool_name, arguments=filtered_kwargs, meta=meta) # type: ignore
if result.isError:
parsed = parser(result)
text = (
+175 -22
View File
@@ -289,13 +289,15 @@ class SkillScript(ABC):
return None
@abstractmethod
async def run(self, skill: Skill, args: dict[str, Any] | None = None, **kwargs: Any) -> Any:
async def run(self, skill: Skill, args: dict[str, Any] | list[str] | None = None, **kwargs: Any) -> Any:
"""Run this script.
Args:
skill: The skill that owns this script.
args: Optional keyword arguments for the script, provided by the
agent/LLM.
args: Optional arguments for the script, provided by the
agent/LLM. May be a ``dict`` (named keyword arguments
for inline scripts) or a ``list[str]`` (positional CLI
arguments for file-based scripts).
**kwargs: Runtime keyword arguments forwarded only to script
functions that accept ``**kwargs``.
@@ -361,19 +363,31 @@ class InlineSkillScript(SkillScript):
self._parameters_schema_resolved = True
return self._parameters_schema
async def run(self, skill: Skill, args: dict[str, Any] | None = None, **kwargs: Any) -> Any:
async def run(self, skill: Skill, args: dict[str, Any] | list[str] | None = None, **kwargs: Any) -> Any:
"""Run the script by invoking the callable in-process.
Args:
skill: The skill that owns this script.
args: Optional keyword arguments for the script, provided by the
agent/LLM.
agent/LLM. Must be a ``dict`` or ``None``; passing a
``list`` raises :class:`TypeError` because inline scripts
bind arguments by keyword name.
**kwargs: Runtime keyword arguments forwarded only to script
functions that accept ``**kwargs``.
Returns:
The script execution result.
Raises:
TypeError: If ``args`` is a ``list`` (array-style arguments
are only supported for file-based scripts).
"""
if isinstance(args, list):
raise TypeError(
f"Inline script '{self.name}' requires keyword arguments (dict), "
f"but received a list. Array-style arguments are only supported "
f"for file-based scripts."
)
if self._accepts_kwargs: # noqa: SIM108
result = self.function(**(args or {}), **kwargs)
else:
@@ -431,13 +445,23 @@ class FileSkillScript(SkillScript):
self.full_path = full_path
self._runner = runner
async def run(self, skill: Skill, args: dict[str, Any] | None = None, **kwargs: Any) -> Any:
@property
def parameters_schema(self) -> dict[str, Any] | None:
"""JSON Schema advertising that file scripts accept a string array.
Returns a fixed schema ``{"type": "array", "items": {"type": "string"}}``
so that the LLM knows to pass positional CLI arguments as a JSON array
of strings.
"""
return {"type": "array", "items": {"type": "string"}}
async def run(self, skill: Skill, args: dict[str, Any] | list[str] | None = None, **kwargs: Any) -> Any:
"""Run the script by delegating to the configured runner.
Args:
skill: The skill that owns this script. Must be a
:class:`FileSkill`.
args: Optional keyword arguments for the script.
args: Optional arguments for the script.
**kwargs: Additional runtime keyword arguments (unused).
Returns:
@@ -1348,6 +1372,7 @@ class FileSkill(Skill):
self.path = path
self._resources: list[SkillResource] = list(resources) if resources is not None else []
self._scripts: list[SkillScript] = list(scripts) if scripts is not None else []
self._cached_content: str | None = None
@property
def frontmatter(self) -> SkillFrontmatter:
@@ -1356,8 +1381,23 @@ class FileSkill(Skill):
@property
def content(self) -> str:
"""The skill content provided at construction time."""
return self._content
"""The skill content with appended scripts block.
When scripts are present, a ``<scripts>`` XML block is appended
to the raw SKILL.md content so that the LLM can discover each
script's ``<parameters_schema>``.
The result is cached after the first access. Adding scripts
after the first access will not be reflected.
"""
if self._cached_content is not None:
return self._cached_content
if not self._scripts:
self._cached_content = self._content
else:
script_lines = "\n".join(_create_script_element(s) for s in self._scripts)
self._cached_content = f"{self._content}\n\n<scripts>\n{script_lines}\n</scripts>"
return self._cached_content
@property
def resources(self) -> list[SkillResource]:
@@ -1392,7 +1432,9 @@ class SkillScriptRunner(Protocol):
satisfies this protocol.
"""
def __call__(self, skill: FileSkill, script: FileSkillScript, args: dict[str, Any] | None = None) -> Any:
def __call__(
self, skill: FileSkill, script: FileSkillScript, args: dict[str, Any] | list[str] | None = None
) -> Any:
"""Run a skill script.
The :class:`SkillsProvider` resolves skill and script names
@@ -1402,7 +1444,7 @@ class SkillScriptRunner(Protocol):
Args:
skill: The file-based skill that owns the script.
script: The file-based script to run.
args: Optional keyword arguments for the script.
args: Optional arguments for the script.
Returns:
The result. May be any type; the framework
@@ -1471,6 +1513,97 @@ YAML_INDENTED_KV_RE = re.compile(
# must not start or end with a hyphen, and must not contain consecutive hyphens.
VALID_NAME_RE = re.compile(r"^[a-z0-9]([a-z0-9]*-[a-z0-9])*[a-z0-9]*$")
# Block scalar indicator characters recognised by the lightweight YAML parser.
_BLOCK_SCALAR_INDICATORS = ("|", ">")
def _parse_yaml_scalar_value(yaml_content: str, kv_match: re.Match[str]) -> str:
"""Resolve the scalar value for an unquoted YAML key-value match.
If the captured value starts with a YAML block scalar indicator (``|`` or
``>``), the function reads subsequent indented continuation lines, strips
the common leading indentation, and joins them according to the scalar
style (literal preserves newlines, folded replaces them with spaces).
Chomping indicators are respected per YAML 1.2 §8.1.1.2:
* ``-`` (strip) final line break and trailing empty lines excluded
* ``+`` (keep) final line break and any trailing empty lines preserved
* default (clip) final line break preserved, trailing empty lines excluded
For plain (non-block-scalar) values the captured text is returned as-is.
Note: explicit indentation indicators (e.g. ``|2``) are not supported;
indentation is auto-detected from the common leading whitespace.
"""
value: str = kv_match.group(3)
if not value or value[0] not in _BLOCK_SCALAR_INDICATORS:
return value
scalar_style = value[0]
keep_trailing_newline = len(value) > 1 and value[1] == "+"
strip_trailing_newline = len(value) > 1 and value[1] == "-"
# Find the start of the next line after this key-value match.
next_line_start = yaml_content.find("\n", kv_match.end())
if next_line_start < 0:
return value
next_line_start += 1 # skip the newline character itself
# Collect indented continuation lines (or blank lines within the block).
block_lines: list[str] = []
pos = next_line_start
while pos < len(yaml_content):
line_end = yaml_content.find("\n", pos)
if line_end < 0:
line = yaml_content[pos:]
line_end = len(yaml_content)
else:
line = yaml_content[pos:line_end]
if not line or line.isspace():
# Blank / whitespace-only lines are part of the block.
block_lines.append("")
pos = line_end + 1 if line_end < len(yaml_content) else line_end
continue
if line[0] not in (" ", "\t"):
# Non-indented, non-blank line — end of the block.
break
block_lines.append(line)
pos = line_end + 1 if line_end < len(yaml_content) else line_end
# Strip trailing blank lines collected from the block.
while block_lines and block_lines[-1] == "":
block_lines.pop()
if not block_lines:
return ""
# Determine the common leading indentation across non-empty lines.
# Only space/tab characters count as indentation (matches YAML semantics).
def _indent_width(s: str) -> int:
i = 0
while i < len(s) and s[i] in (" ", "\t"):
i += 1
return i
common_indent = min(_indent_width(line) for line in block_lines if line)
normalized = [line[common_indent:] if line else "" for line in block_lines]
# Literal preserves newlines; folded joins non-empty lines with spaces.
parsed = "\n".join(normalized) if scalar_style == "|" else " ".join(line for line in normalized if line)
if keep_trailing_newline:
return parsed + "\n"
if strip_trailing_newline:
return parsed
# Clip (default): literal gets a trailing newline, folded does not.
if scalar_style == "|":
return parsed + "\n"
return parsed
# Default system prompt template for advertising available skills to the model.
# Use {skills} as the placeholder for the generated skills XML list.
@@ -1982,7 +2115,7 @@ class SkillsProvider(ContextProvider):
if include_script_runner_tool:
async def _run_script(
skill_name: str, script_name: str, args: dict[str, Any] | None = None, **kwargs: Any
skill_name: str, script_name: str, args: dict[str, Any] | list[str] | None = None, **kwargs: Any
) -> Any:
return await self._run_skill_script(skills, skill_name, script_name, args, **kwargs)
@@ -2005,12 +2138,31 @@ class SkillsProvider(ContextProvider):
),
},
"args": {
"type": ["object", "null"],
"additionalProperties": True,
"oneOf": [
{
"type": "object",
"additionalProperties": True,
"description": (
"Named arguments as key-value pairs "
'(e.g. {"length": 24, "uppercase": true}).'
),
},
{
"type": "array",
"items": {"type": "string"},
"description": (
"Positional CLI arguments as a string array "
'(e.g. ["input.docx", "--output", "result.idx"]).'
),
},
{"type": "null"},
],
"default": None,
"description": (
"Arguments to pass to the script as key-value pairs. "
"Use parameter names as keys without leading dashes "
"Arguments to pass to the script. "
"Use an array of strings for CLI-style positional arguments "
'(e.g. ["input.docx", "--output", "result.idx"]), '
"or an object for named parameters "
'(e.g. {"length": 24, "uppercase": true}). '
"How these values are mapped to the underlying script "
"is determined by the script implementation or configured runner."
@@ -2060,7 +2212,7 @@ class SkillsProvider(ContextProvider):
skills: Sequence[Skill],
skill_name: str,
script_name: str,
args: dict[str, Any] | None = None,
args: dict[str, Any] | list[str] | None = None,
**kwargs: Any,
) -> Any:
"""Run a named script from a skill.
@@ -2072,9 +2224,8 @@ class SkillsProvider(ContextProvider):
skills: The skills to look up the skill from.
skill_name: The name of the owning skill.
script_name: The script name to look up (case-insensitive).
args: Optional keyword arguments for the script, provided by the
agent/LLM. These are mapped to the function's declared
parameters.
args: Optional arguments for the script, provided by the
agent/LLM.
**kwargs: Runtime keyword arguments forwarded only to script
functions that accept ``**kwargs`` (e.g. arguments passed via
``agent.run(user_id="123")``).
@@ -2254,7 +2405,7 @@ class FileSkillsSource(SkillsSource):
Args:
skill_paths: One or more directory paths to search for file-based
skills. Each path may point to an individual skill folder
skills. Each path may point to an individual skill directory
(containing ``SKILL.md``) or to a parent that contains skill
subdirectories.
@@ -2819,7 +2970,9 @@ class FileSkillsSource(SkillsSource):
for kv_match in YAML_KV_RE.finditer(yaml_content):
key = kv_match.group(1)
value = kv_match.group(2) if kv_match.group(2) is not None else kv_match.group(3)
value = (
kv_match.group(2) if kv_match.group(2) is not None else _parse_yaml_scalar_value(yaml_content, kv_match)
)
key_lower = key.lower()
if key_lower == "name":
+1 -1
View File
@@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.3.0"
version = "1.4.0"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -4194,6 +4194,57 @@ async def test_mcp_tool_call_tool_otel_meta(use_span, expect_traceparent, span_e
assert meta is None
async def test_mcp_tool_call_tool_forwards_tool_list_meta():
"""call_tool echoes per-tool metadata returned by tools/list."""
from opentelemetry import trace
tool_meta = {
"tool_configuration": {
"name": "WorkIQSharePoint.readSmallBinaryFile",
"type": "foundry_toolbox",
}
}
class TestServer(MCPTool):
async def connect(self):
self.session = Mock(spec=ClientSession)
self.session.list_tools = AsyncMock(
return_value=types.ListToolsResult(
tools=[
types.Tool(
name="WorkIQSharePoint.readSmallBinaryFile",
description="Read a binary file",
inputSchema={
"type": "object",
"properties": {"fileId": {"type": "string"}},
"required": ["fileId"],
},
_meta=tool_meta,
)
]
)
)
self.session.call_tool = AsyncMock(
return_value=types.CallToolResult(content=[types.TextContent(type="text", text="result")])
)
self.session.list_prompts = AsyncMock(
return_value=types.ListPromptsResult(prompts=[])
)
def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
return None
server = TestServer(name="test_server")
async with server:
await server.load_tools()
await server.load_prompts()
with trace.use_span(trace.NonRecordingSpan(trace.INVALID_SPAN_CONTEXT)):
await server.call_tool("WorkIQSharePoint.readSmallBinaryFile", fileId="file-1")
assert server.session.call_tool.call_args.kwargs["meta"] == tool_meta
async def test_mcp_streamable_http_tool_hook_not_duplicated_on_repeated_get_mcp_client():
"""Test that calling get_mcp_client multiple times does not accumulate duplicate hooks."""
tool = MCPStreamableHTTPTool(
+316 -12
View File
@@ -319,9 +319,7 @@ class TestDiscoverResourceFiles:
refs = skill_dir / "references"
refs.mkdir(parents=True)
(refs / "doc.md").write_text("content", encoding="utf-8")
resources = FileSkillsSource._discover_resource_files(
str(skill_dir), directories=("references", "references")
)
resources = FileSkillsSource._discover_resource_files(str(skill_dir), directories=("references", "references"))
assert resources == ["references/doc.md"]
def test_results_are_sorted(self, tmp_path: Path) -> None:
@@ -1675,9 +1673,7 @@ class TestValidateAndNormalizeDirectoryNames:
FileSkillsSource._validate_and_normalize_directory_names([" "])
def test_multiple_directories(self) -> None:
result = FileSkillsSource._validate_and_normalize_directory_names(
[".", "references", "assets", "scripts"]
)
result = FileSkillsSource._validate_and_normalize_directory_names([".", "references", "assets", "scripts"])
assert result == [".", "references", "assets", "scripts"]
def test_default_resource_directories(self) -> None:
@@ -2163,6 +2159,145 @@ class TestExtractFrontmatterEdgeCases:
assert result.description == desc
# ---------------------------------------------------------------------------
# Tests: _extract_frontmatter block scalar parsing
# ---------------------------------------------------------------------------
class TestExtractFrontmatterBlockScalars:
"""Tests for YAML block scalar (| and >) parsing in _extract_frontmatter."""
def test_literal_block_scalar(self) -> None:
content = "---\nname: test-skill\ndescription: |\n Line one\n Line two\n---\nBody."
result = FileSkillsSource._extract_frontmatter(content, "test.md")
assert result is not None
assert result.description == "Line one\nLine two\n"
def test_folded_block_scalar(self) -> None:
content = "---\nname: test-skill\ndescription: >\n This is a multi-line\n description block\n---\nBody."
result = FileSkillsSource._extract_frontmatter(content, "test.md")
assert result is not None
assert result.description == "This is a multi-line description block"
def test_literal_strip_chomping(self) -> None:
content = "---\nname: test-skill\ndescription: |-\n No trailing newline\n---\nBody."
result = FileSkillsSource._extract_frontmatter(content, "test.md")
assert result is not None
assert result.description == "No trailing newline"
def test_folded_strip_chomping(self) -> None:
content = "---\nname: test-skill\ndescription: >-\n Folded with\n strip chomping\n---\nBody."
result = FileSkillsSource._extract_frontmatter(content, "test.md")
assert result is not None
assert result.description == "Folded with strip chomping"
def test_literal_keep_chomping(self) -> None:
content = "---\nname: test-skill\ndescription: |+\n Keep trailing\n---\nBody."
result = FileSkillsSource._extract_frontmatter(content, "test.md")
assert result is not None
assert result.description == "Keep trailing\n"
def test_folded_keep_chomping(self) -> None:
content = "---\nname: test-skill\ndescription: >+\n Keep trailing\n newline\n---\nBody."
result = FileSkillsSource._extract_frontmatter(content, "test.md")
assert result is not None
assert result.description == "Keep trailing newline\n"
def test_block_scalar_no_continuation_lines(self) -> None:
content = "---\nname: test-skill\ndescription: |\nlicense: MIT\n---\nBody."
result = FileSkillsSource._extract_frontmatter(content, "test.md")
# description becomes empty string which fails validation (empty/whitespace)
assert result is None
def test_block_scalar_varying_indentation(self) -> None:
content = (
"---\n"
"name: test-skill\n"
"description: |\n"
" Line with 4-space indent\n"
" Line with 4-space indent\n"
"---\n"
"Body."
)
result = FileSkillsSource._extract_frontmatter(content, "test.md")
assert result is not None
assert result.description == "Line with 4-space indent\nLine with 4-space indent\n"
def test_folded_block_scalar_real_skill_format(self) -> None:
"""End-to-end test matching the format used in .github/skills/ SKILL.md files."""
content = (
"---\n"
"name: python-development\n"
"description: >\n"
" Coding standards, conventions, and patterns for developing Python code in the\n"
" Agent Framework repository. Use this when writing or modifying Python source\n"
" files in the python/ directory.\n"
"---\n"
"\n"
"# Python Development Standards\n"
)
result = FileSkillsSource._extract_frontmatter(content, "test.md")
assert result is not None
assert result.description == (
"Coding standards, conventions, and patterns for developing Python code in the "
"Agent Framework repository. Use this when writing or modifying Python source "
"files in the python/ directory."
)
def test_block_scalar_with_other_fields_after(self) -> None:
content = "---\nname: test-skill\ndescription: >\n A folded\n description\nlicense: MIT\n---\nBody."
result = FileSkillsSource._extract_frontmatter(content, "test.md")
assert result is not None
assert result.description == "A folded description"
assert result.license == "MIT"
def test_plain_value_unchanged(self) -> None:
"""Non-block-scalar values must not be affected by the block scalar logic."""
content = "---\nname: test-skill\ndescription: A simple description.\n---\nBody."
result = FileSkillsSource._extract_frontmatter(content, "test.md")
assert result is not None
assert result.description == "A simple description."
def test_block_scalar_content_with_colons(self) -> None:
"""Lines inside a block scalar that look like YAML key-value pairs must be preserved verbatim."""
content = (
"---\nname: test-skill\ndescription: |\n Some text with colon: in it\n Another: line here\n---\nBody."
)
result = FileSkillsSource._extract_frontmatter(content, "test.md")
assert result is not None
assert result.description == "Some text with colon: in it\nAnother: line here\n"
def test_block_scalar_on_license_field(self) -> None:
"""Block scalars should work on any field, not only description."""
content = (
"---\n"
"name: test-skill\n"
"description: A skill.\n"
"license: >\n"
" Custom license\n"
" spanning multiple lines\n"
"---\n"
"Body."
)
result = FileSkillsSource._extract_frontmatter(content, "test.md")
assert result is not None
assert result.license == "Custom license spanning multiple lines"
def test_block_scalar_tab_indentation(self) -> None:
"""Tab characters should count as indentation for block scalar continuation lines."""
content = "---\nname: test-skill\ndescription: |\n\tTab-indented line one\n\tTab-indented line two\n---\nBody."
result = FileSkillsSource._extract_frontmatter(content, "test.md")
assert result is not None
assert result.description == "Tab-indented line one\nTab-indented line two\n"
def test_block_scalar_blank_line_within_block(self) -> None:
"""Blank lines within a block scalar should be preserved as paragraph separators."""
content = "---\nname: test-skill\ndescription: |\n First paragraph\n\n Second paragraph\n---\nBody."
result = FileSkillsSource._extract_frontmatter(content, "test.md")
assert result is not None
assert result.description == "First paragraph\n\nSecond paragraph\n"
# ---------------------------------------------------------------------------
# Tests: Skill spec fields (via SkillFrontmatter)
# ---------------------------------------------------------------------------
@@ -3518,7 +3653,6 @@ class TestSkillsProviderFactories:
await _init_provider(provider)
run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script")
args_desc = run_tool.parameters()["properties"]["args"]["description"]
assert "without leading dashes" in args_desc
assert "script implementation or configured runner" in args_desc
async def test_require_script_approval_sets_approval_mode(self) -> None:
@@ -4744,12 +4878,16 @@ class TestCreateScriptElement:
def test_name_only(self) -> None:
s = FileSkillScript(name="run.py", full_path=f"{_ABS}/test/scripts/run.py")
elem = _create_script_element(s)
assert elem == ' <script name="run.py"/>'
assert 'name="run.py"' in elem
assert "<parameters_schema>" in elem
assert '"type": "array"' in elem
def test_with_description(self) -> None:
s = FileSkillScript(name="run.py", description="Execute script.", full_path=f"{_ABS}/test/scripts/run.py")
elem = _create_script_element(s)
assert elem == ' <script name="run.py" description="Execute script."/>'
assert 'name="run.py"' in elem
assert 'description="Execute script."' in elem
assert "<parameters_schema>" in elem
def test_xml_escapes_name(self) -> None:
s = FileSkillScript(name='script"special', full_path=f"{_ABS}/test/scripts/s.py")
@@ -4776,10 +4914,12 @@ class TestCreateScriptElement:
assert "query" in elem
assert "&quot;" not in elem
def test_no_parameters_for_file_script(self) -> None:
def test_file_script_includes_array_parameters(self) -> None:
s = FileSkillScript(name="run.py", full_path=f"{_ABS}/test/scripts/run.py")
elem = _create_script_element(s)
assert "<parameters_schema>" not in elem
assert "<parameters_schema>" in elem
assert '"type": "array"' in elem
assert '"type": "string"' in elem
# ---------------------------------------------------------------------------
@@ -4800,7 +4940,7 @@ class TestSkillScriptParametersSchema:
def test_none_for_file_based_script(self) -> None:
script = FileSkillScript(name="run.py", full_path=f"{_ABS}/test/scripts/run.py")
assert script.parameters_schema is None
assert script.parameters_schema == {"type": "array", "items": {"type": "string"}}
def test_no_params_function_returns_none(self) -> None:
def noop() -> None:
@@ -5407,3 +5547,167 @@ class TestInlineSkillContentCaching:
second = skill.content
assert first is second # Same object (cached)
assert "<name>test-skill</name>" in first
# ---------------------------------------------------------------------------
# Tests: Array-style (list[str]) script arguments
# ---------------------------------------------------------------------------
class TestArrayStyleScriptArgs:
"""Tests for list[str] arguments on skill scripts (port of .NET PR #5475)."""
async def test_inline_script_rejects_list_args(self) -> None:
"""InlineSkillScript.run() raises TypeError when args is a list."""
script = InlineSkillScript(name="greet", function=lambda name="world": f"hello {name}")
skill = InlineSkill(frontmatter=SkillFrontmatter(name="s", description="d"), instructions="c")
with pytest.raises(TypeError, match="requires keyword arguments"):
await script.run(skill, args=["hello", "--name", "Alice"])
async def test_inline_script_error_message_mentions_script_name(self) -> None:
"""The TypeError message includes the script name for debugging."""
script = InlineSkillScript(name="my-script", function=lambda: None)
skill = InlineSkill(frontmatter=SkillFrontmatter(name="s", description="d"), instructions="c")
with pytest.raises(TypeError, match="my-script"):
await script.run(skill, args=["arg1"])
async def test_file_script_passes_list_to_runner(self) -> None:
"""FileSkillScript.run() passes list[str] args through to the runner."""
captured: dict[str, Any] = {}
def runner(skill: Any, script: Any, args: Any = None) -> str:
captured["args"] = args
return "ok"
script = FileSkillScript(name="run.py", full_path=f"{_ABS}/test/run.py", runner=runner)
skill = FileSkill(
frontmatter=SkillFrontmatter(name="my-skill", description="d"), content="c", path=f"{_ABS}/test"
)
result = await script.run(skill, args=["input.docx", "--output", "result.idx"])
assert result == "ok"
assert captured["args"] == ["input.docx", "--output", "result.idx"]
async def test_file_script_passes_dict_to_runner(self) -> None:
"""FileSkillScript.run() still passes dict args through to the runner."""
captured: dict[str, Any] = {}
def runner(skill: Any, script: Any, args: Any = None) -> str:
captured["args"] = args
return "ok"
script = FileSkillScript(name="run.py", full_path=f"{_ABS}/test/run.py", runner=runner)
skill = FileSkill(
frontmatter=SkillFrontmatter(name="my-skill", description="d"), content="c", path=f"{_ABS}/test"
)
result = await script.run(skill, args={"key": "val"})
assert result == "ok"
assert captured["args"] == {"key": "val"}
async def test_file_script_passes_none_to_runner(self) -> None:
"""FileSkillScript.run() passes None args through to the runner."""
captured: dict[str, Any] = {}
def runner(skill: Any, script: Any, args: Any = None) -> str:
captured["args"] = args
return "ok"
script = FileSkillScript(name="run.py", full_path=f"{_ABS}/test/run.py", runner=runner)
skill = FileSkill(
frontmatter=SkillFrontmatter(name="my-skill", description="d"), content="c", path=f"{_ABS}/test"
)
result = await script.run(skill)
assert result == "ok"
assert captured["args"] is None
def test_file_script_parameters_schema_returns_array(self) -> None:
"""FileSkillScript.parameters_schema returns the string-array JSON schema."""
script = FileSkillScript(name="run.py", full_path=f"{_ABS}/test/run.py")
assert script.parameters_schema == {"type": "array", "items": {"type": "string"}}
async def test_runner_protocol_accepts_list_args(self) -> None:
"""A runner accepting list[str] args satisfies the SkillScriptRunner protocol."""
captured: dict[str, Any] = {}
def my_runner(skill: Any, script: Any, args: Any = None) -> str:
captured["args"] = args
return "ok"
assert isinstance(my_runner, SkillScriptRunner)
skill = FileSkill(frontmatter=SkillFrontmatter(name="s", description="d"), content="c", path=f"{_ABS}/test")
script = FileSkillScript(name="run.py", full_path=f"{_ABS}/test/run.py")
result = my_runner(skill, script, args=["--flag", "value"])
assert result == "ok"
assert captured["args"] == ["--flag", "value"]
async def test_tool_schema_accepts_array_args(self) -> None:
"""The run_skill_script tool schema accepts array-style args via oneOf."""
skill = InlineSkill(frontmatter=SkillFrontmatter(name="my-skill", description="test"), instructions="body")
skill.scripts.append(InlineSkillScript(name="s1", function=lambda: None))
provider = SkillsProvider([skill])
await _init_provider(provider)
run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script")
args_schema = run_tool.parameters()["properties"]["args"]
assert "oneOf" in args_schema
types = [s.get("type") for s in args_schema["oneOf"]]
assert "object" in types
assert "array" in types
assert "null" in types
async def test_run_skill_script_with_list_args_via_provider(self) -> None:
"""End-to-end: list args flow through provider to file-based script runner."""
captured: dict[str, Any] = {}
def runner(skill: Any, script: Any, args: Any = None) -> str:
captured["args"] = args
return "list_result"
script = FileSkillScript(name="run.py", full_path=f"{_ABS}/test/run.py", runner=runner)
skill = FileSkill(
frontmatter=SkillFrontmatter(name="my-skill", description="test"),
content="Body",
path=f"{_ABS}/test",
scripts=[script],
)
provider = SkillsProvider([skill])
await _init_provider(provider)
run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script")
result = await run_tool.func(skill_name="my-skill", script_name="run.py", args=["input.docx", "--verbose"])
assert result == "list_result"
assert captured["args"] == ["input.docx", "--verbose"]
async def test_run_skill_script_inline_with_list_args_returns_error(self) -> None:
"""Inline script called with list args through provider returns error (TypeError caught)."""
skill = InlineSkill(frontmatter=SkillFrontmatter(name="my-skill", description="test"), instructions="body")
skill.scripts.append(InlineSkillScript(name="s1", function=lambda: "ok"))
provider = SkillsProvider([skill])
await _init_provider(provider)
run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script")
result = await run_tool.func(skill_name="my-skill", script_name="s1", args=["arg1"])
assert "Error" in result
assert "Failed to run" in result
def test_file_skill_content_includes_scripts_block(self) -> None:
"""FileSkill.content appends a <scripts> block when scripts are present."""
script = FileSkillScript(name="run.py", full_path=f"{_ABS}/test/run.py")
skill = FileSkill(
frontmatter=SkillFrontmatter(name="my-skill", description="test"),
content="---\nname: my-skill\n---\nBody",
path=f"{_ABS}/test",
scripts=[script],
)
assert "<scripts>" in skill.content
assert 'name="run.py"' in skill.content
assert "<parameters_schema>" in skill.content
assert '"type": "array"' in skill.content
def test_file_skill_content_no_scripts_no_block(self) -> None:
"""FileSkill.content does not append a <scripts> block when no scripts."""
skill = FileSkill(
frontmatter=SkillFrontmatter(name="my-skill", description="test"),
content="---\nname: my-skill\n---\nBody",
path=f"{_ABS}/test",
)
assert "<scripts>" not in skill.content
@@ -27,12 +27,15 @@ from __future__ import annotations
import locale
import logging
import os
import re
import sys
import uuid
from collections.abc import Mapping
from dataclasses import dataclass
from dataclasses import dataclass, field
from decimal import Decimal as _Decimal
from enum import Enum
from types import MappingProxyType
from typing import Any, Literal, cast
from agent_framework import (
@@ -58,6 +61,100 @@ else:
logger = logging.getLogger(__name__)
_ENV_REFERENCE_RE = re.compile(r"\bEnv\.([A-Za-z_][A-Za-z0-9_]*)")
@dataclass(frozen=True)
class DeclarativeEnvConfig:
"""Configuration that populates the PowerFx ``Env`` symbol for a workflow.
Configuration values are always exposed under ``Env.<name>``;
``os.environ`` is consulted only when ``restrict_to_configuration``
is ``False`` AND the YAML literally references the name in a PowerFx
expression (the allowlist enforced via ``referenced_names``).
Attributes:
values: Caller-supplied configuration resolved by name when the
workflow YAML references ``=Env.NAME``. Always exposed in
the ``Env`` symbol regardless of ``restrict_to_configuration``.
restrict_to_configuration: When ``True`` (default), the ``Env``
symbol is populated exclusively from ``values``; ``os.environ``
is never consulted. Set to ``False`` to additionally fall back
to ``os.environ`` for names absent from ``values`` that the
workflow YAML explicitly references.
referenced_names: The set of ``Env.NAME`` symbols discovered in
PowerFx expressions inside the workflow definition. The
``os.environ`` fallback is constrained to this allowlist so
unrelated environment variables never enter the PowerFx scope.
"""
values: Mapping[str, str] = field(default_factory=lambda: MappingProxyType({}))
restrict_to_configuration: bool = True
referenced_names: frozenset[str] = field(default_factory=lambda: frozenset[str]())
def __post_init__(self) -> None:
# Defensive snapshots so the frozen guarantee extends to the
# contents of ``values`` / ``referenced_names``: caller mutations
# to the original objects after construction cannot leak into
# ``resolve()``.
object.__setattr__(self, "values", MappingProxyType(dict(self.values)))
object.__setattr__(self, "referenced_names", frozenset(self.referenced_names))
def resolve(self) -> dict[str, str]:
"""Return the resolved ``Env`` symbol mapping for the workflow.
Configuration values are always included (stringified).
``os.environ`` is consulted only when ``restrict_to_configuration``
is ``False`` and the name appears in ``referenced_names``, so
unrelated environment variables never enter the PowerFx scope.
Configuration values always win over the environment fallback.
"""
resolved = {name: str(value) for name, value in self.values.items()}
if self.restrict_to_configuration:
return resolved
for name in self.referenced_names.difference(resolved):
env_value = os.environ.get(name)
if env_value is not None:
resolved[name] = env_value
return resolved
def discover_env_references(node: Any) -> set[str]:
"""Discover ``Env.NAME`` references in PowerFx expressions inside ``node``.
Walks any nested ``Mapping``/``list``/scalar structure and inspects every
string value. To avoid false positives from doc/description fields that
happen to mention ``Env.SOMETHING`` as plain text, the scan only inspects
strings that begin with ``=`` (PowerFx expression marker, matching the
convention enforced by :meth:`DeclarativeWorkflowState.eval`).
Args:
node: A parsed workflow definition (typically the dict produced by
``yaml.safe_load``).
Returns:
The set of ``Env`` identifier names referenced in PowerFx
expressions inside ``node``.
"""
names: set[str] = set()
def visit(value: Any) -> None:
if isinstance(value, str):
if value.startswith("="):
names.update(_ENV_REFERENCE_RE.findall(value))
return
if isinstance(value, Mapping):
for inner in cast(Mapping[Any, Any], value).values(): # type: ignore[redundant-cast]
visit(inner)
return
if isinstance(value, list):
for item in cast(list[Any], value): # type: ignore[redundant-cast]
visit(item)
visit(node)
return names
class ConversationData(TypedDict):
"""Structure for conversation-related state data.
@@ -169,13 +266,18 @@ class DeclarativeWorkflowState:
- Conversation: Conversation history
"""
def __init__(self, state: State):
def __init__(self, state: State, env_config: DeclarativeEnvConfig | None = None):
"""Initialize with a State instance.
Args:
state: The workflow's state for persistence
env_config: Configuration that populates the PowerFx ``Env``
symbol when ``_to_powerfx_symbols`` is called. Defaults to
an empty configuration which results in no ``Env`` binding,
matching the safe default of the :class:`WorkflowFactory`.
"""
self._state = state
self._env_config = env_config if env_config is not None else DeclarativeEnvConfig()
def initialize(self, inputs: Mapping[str, Any] | None = None) -> None:
"""Initialize the declarative state with inputs.
@@ -714,6 +816,14 @@ class DeclarativeWorkflowState:
# Custom namespaces
**state_data.get("Custom", {}),
}
# Resolve the ``Env`` symbol from the workflow-level
# :class:`DeclarativeEnvConfig`. When both ``values`` and the
# ``os.environ`` allowlist produce no entries the symbol is
# omitted so ``=Env.X`` falls back to the literal expression
# string (preserving the legacy "unbound identifier" behaviour).
env_bound = self._env_config.resolve()
if env_bound:
symbols["Env"] = env_bound
# Debug log the Local symbols to help diagnose type issues
if local_data:
for key, value in local_data.items():
@@ -867,6 +977,11 @@ class DeclarativeActionExecutor(Executor):
action_id = id or action_def.get("id") or f"{action_def.get('kind', 'action')}_{hash(str(action_def)) % 10000}"
super().__init__(id=action_id, defer_discovery=True)
self._action_def = action_def
# The active :class:`DeclarativeEnvConfig` is stamped onto the
# executor by :class:`DeclarativeWorkflowBuilder` after construction.
# Defaults to an empty configuration so direct ``DeclarativeActionExecutor``
# construction (e.g. in unit tests) doesn't expose ``os.environ``.
self._declarative_env_config: DeclarativeEnvConfig = DeclarativeEnvConfig()
# Manually register handlers after initialization
self._handlers = {}
@@ -874,6 +989,16 @@ class DeclarativeActionExecutor(Executor):
self._discover_handlers()
self._discover_response_handlers()
def set_declarative_env_config(self, env_config: DeclarativeEnvConfig) -> None:
"""Set the workflow-level :class:`DeclarativeEnvConfig` for this executor.
Called by :class:`DeclarativeWorkflowBuilder` after each executor is
created so that ``_to_powerfx_symbols`` populates the ``Env`` symbol
according to the caller-supplied configuration on the
:class:`WorkflowFactory`.
"""
self._declarative_env_config = env_config
@property
def action_def(self) -> dict[str, Any]:
"""Get the action definition."""
@@ -886,7 +1011,7 @@ class DeclarativeActionExecutor(Executor):
def _get_state(self, state: State) -> DeclarativeWorkflowState:
"""Get the declarative workflow state wrapper."""
return DeclarativeWorkflowState(state)
return DeclarativeWorkflowState(state, env_config=self._declarative_env_config)
async def _ensure_state_initialized(
self,
@@ -24,6 +24,7 @@ from agent_framework import (
from ._declarative_base import (
ConditionResult,
DeclarativeActionExecutor,
DeclarativeEnvConfig,
LoopIterationResult,
)
from ._errors import DeclarativeWorkflowError
@@ -140,6 +141,7 @@ class DeclarativeWorkflowBuilder:
max_iterations: int | None = None,
http_request_handler: HttpRequestHandler | None = None,
mcp_tool_handler: MCPToolHandler | None = None,
env_config: DeclarativeEnvConfig | None = None,
):
"""Initialize the builder.
@@ -158,6 +160,10 @@ class DeclarativeWorkflowBuilder:
mcp_tool_handler: Handler used to dispatch InvokeMcpTool calls.
Must be supplied when the workflow contains any InvokeMcpTool;
otherwise build raises ``DeclarativeWorkflowError``.
env_config: Optional :class:`DeclarativeEnvConfig` controlling
how the ``Env`` PowerFx symbol is populated for every
executor built by this builder. Defaults to an empty
configuration (``Env`` not exposed).
"""
self._yaml_def = yaml_definition
self._workflow_id = workflow_id or yaml_definition.get("name", "declarative_workflow")
@@ -171,6 +177,7 @@ class DeclarativeWorkflowBuilder:
self._seen_explicit_ids: set[str] = set() # Track explicit IDs for duplicate detection
self._http_request_handler = http_request_handler
self._mcp_tool_handler = mcp_tool_handler
self._env_config: DeclarativeEnvConfig = env_config if env_config is not None else DeclarativeEnvConfig()
# Resolve max_iterations: explicit arg > YAML maxTurns > core default
resolved = max_iterations if max_iterations is not None else yaml_definition.get("maxTurns")
if resolved is not None and (not isinstance(resolved, int) or resolved <= 0):
@@ -221,6 +228,15 @@ class DeclarativeWorkflowBuilder:
# Resolve pending gotos (back-edges for loops, forward-edges for jumps)
self._resolve_pending_gotos(builder)
# Stamp the resolved DeclarativeEnvConfig onto every executor so they
# expose the configured Env binding through their _get_state(). This
# happens after _create_executors_for_actions and _resolve_pending_gotos
# so it covers the entry node, join nodes, evaluators, foreach
# init/next/exit nodes, and goto placeholders.
for executor in self._executors.values():
if isinstance(executor, DeclarativeActionExecutor):
executor.set_declarative_env_config(self._env_config)
return builder.build()
def _validate_workflow(self, actions: list[dict[str, Any]]) -> None:
@@ -26,6 +26,7 @@ from agent_framework import (
)
from .._loader import AgentFactory
from ._declarative_base import DeclarativeEnvConfig, discover_env_references
from ._declarative_builder import DeclarativeWorkflowBuilder
from ._errors import DeclarativeWorkflowError
from ._http_handler import HttpRequestHandler
@@ -93,6 +94,8 @@ class WorkflowFactory:
max_iterations: int | None = None,
http_request_handler: HttpRequestHandler | None = None,
mcp_tool_handler: MCPToolHandler | None = None,
configuration: Mapping[str, str] | None = None,
restrict_env_to_configuration: bool = True,
) -> None:
"""Initialize the workflow factory.
@@ -119,6 +122,23 @@ class WorkflowFactory:
for a default backed by :class:`agent_framework.MCPStreamableHTTPTool`,
or supply your own implementation to enforce SSRF guards, allowlisting,
or auth/connection resolution.
configuration: Optional mapping that populates the PowerFx ``Env``
symbol referenced from workflow YAML expressions (e.g.
``=Env.MY_KEY``). Keys supplied here are always exposed
under ``Env.<key>``; the process ``os.environ`` is consulted
only when ``restrict_env_to_configuration`` is ``False``.
When neither source produces a value the ``Env`` symbol is
omitted so ``=Env.X`` evaluates to the literal expression
string.
restrict_env_to_configuration: When ``True`` (default), the
``Env`` PowerFx symbol is populated exclusively from
``configuration``; ``os.environ`` is never consulted. Set to
``False`` to additionally fall back to ``os.environ`` for
names absent from ``configuration`` that the workflow YAML
explicitly references. The fallback is constrained to names
discovered in PowerFx expressions inside the workflow
definition so unrelated environment variables never enter
the PowerFx scope.
Examples:
.. code-block:: python
@@ -151,6 +171,18 @@ class WorkflowFactory:
checkpoint_storage=FileCheckpointStorage("./checkpoints"),
env_file=".env",
)
.. code-block:: python
from agent_framework.declarative import WorkflowFactory
# Inject named values for =Env.* references in the workflow YAML
factory = WorkflowFactory(
configuration={
"MY_SERVER_URL": "https://example.com",
"MY_TOOL_NAME": "search",
},
)
"""
self._agent_factory = agent_factory or AgentFactory(env_file_path=env_file)
self._agents: dict[str, SupportsAgentRun | AgentExecutor] = dict(agents) if agents else {}
@@ -160,6 +192,8 @@ class WorkflowFactory:
self._max_iterations = max_iterations
self._http_request_handler = http_request_handler
self._mcp_tool_handler = mcp_tool_handler
self._configuration: dict[str, str] = dict(configuration) if configuration else {}
self._restrict_env_to_configuration = restrict_env_to_configuration
def create_workflow_from_yaml_path(
self,
@@ -394,6 +428,16 @@ class WorkflowFactory:
if description:
normalized_def["description"] = description
# Build the DeclarativeEnvConfig from the factory's configuration and the
# set of Env references actually used in the workflow PowerFx expressions.
# The referenced-name allowlist constrains ``os.environ`` fallback (when
# enabled) so unrelated variables never enter the PowerFx scope.
env_config = DeclarativeEnvConfig(
values=dict(self._configuration),
restrict_to_configuration=self._restrict_env_to_configuration,
referenced_names=frozenset(discover_env_references(normalized_def)),
)
# Build the graph-based workflow, passing agents and tools for specialized executors
try:
graph_builder = DeclarativeWorkflowBuilder(
@@ -405,6 +449,7 @@ class WorkflowFactory:
max_iterations=self._max_iterations,
http_request_handler=self._http_request_handler,
mcp_tool_handler=self._mcp_tool_handler,
env_config=env_config,
)
workflow = graph_builder.build()
except ValueError as e:
@@ -33,7 +33,7 @@ import logging
from collections import OrderedDict
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Protocol, cast, runtime_checkable
from typing import TYPE_CHECKING, Any, ClassVar, Protocol, cast, runtime_checkable
import httpx
@@ -194,6 +194,21 @@ class DefaultMCPToolHandler:
Defaults to ``32``.
"""
LIST_TOOLS_TOOL_NAME: ClassVar[str] = "tools/list"
"""Reserved ``tool_name`` that maps an :class:`MCPToolHandler` invocation
to the MCP protocol ``tools/list`` discovery operation.
The constant matches the underlying MCP method name so a single
string travels unchanged through host code, YAML, and the protocol
wire. When this handler receives an invocation with this name it
pages through ``session.list_tools()`` and returns the catalog as a
single ``TextContent`` containing JSON of shape
``{"tools": [{name, description, inputSchema, outputSchema}, ...]}``.
Workflows can reference this name from an ``InvokeMcpTool`` declarative
action to introspect a server's tool surface without an extra round-trip
from host code.
"""
def __init__(
self,
*,
@@ -217,10 +232,27 @@ class DefaultMCPToolHandler:
self._closed = False
async def invoke_tool(self, invocation: MCPToolInvocation) -> MCPToolResult:
"""Invoke ``invocation.tool_name`` on the cached MCP client for the server."""
"""Invoke ``invocation.tool_name`` on the cached MCP client for the server.
The reserved name :attr:`LIST_TOOLS_TOOL_NAME` (``"tools/list"``) is
intercepted client-side: instead of being forwarded as a tool call,
it is translated to an MCP ``session.list_tools()`` discovery
operation (paginated automatically) and returned as a single
``TextContent`` containing a JSON tool catalog.
"""
from agent_framework import Content
from agent_framework.exceptions import ToolExecutionException
# Reserved-name args validation runs before connect: rejecting bad
# input shouldn't require establishing an MCP session.
if invocation.tool_name == self.LIST_TOOLS_TOOL_NAME and invocation.arguments:
message = f"The reserved MCP '{self.LIST_TOOLS_TOOL_NAME}' operation does not accept tool arguments."
return MCPToolResult(
outputs=[Content.from_text(f"Error: {message}")],
is_error=True,
error_message=message,
)
try:
entry = await self._get_or_create_entry(invocation)
except Exception as exc:
@@ -240,6 +272,8 @@ class DefaultMCPToolHandler:
)
try:
if invocation.tool_name == self.LIST_TOOLS_TOOL_NAME:
return await self._invoke_list_tools(entry)
raw = await entry.tool.call_tool(invocation.tool_name, **invocation.arguments)
except ToolExecutionException as exc:
logger.info(
@@ -284,6 +318,59 @@ class DefaultMCPToolHandler:
outputs = list(raw)
return MCPToolResult(outputs=outputs)
@staticmethod
async def _invoke_list_tools(entry: _CacheEntry) -> MCPToolResult:
"""Handle the reserved :attr:`LIST_TOOLS_TOOL_NAME` invocation.
Pages through ``session.list_tools()`` (mirroring the pagination loop
in :meth:`agent_framework.MCPTool.load_tools`) and serialises the
full catalog as a single ``TextContent`` containing JSON of shape
``{"tools": [{name, description, inputSchema, outputSchema}, ...]}``.
The output shape, property names, and property order are stable so
downstream PowerFx expressions can rely on the schema. ``indent=2``
produces human-readable JSON for the conversation log;
``allow_nan=False`` guards against producing non-conformant JSON
``NaN``/``Infinity`` tokens if a misbehaving server returns such
values in a schema.
"""
from agent_framework import Content
session = getattr(entry.tool, "session", None)
if session is None:
message = "MCP session is not connected; cannot list tools."
return MCPToolResult(
outputs=[Content.from_text(f"Error: {message}")],
is_error=True,
error_message=message,
)
# Lazy import keeps ``mcp`` types out of module import time.
from mcp import types as mcp_types
collected: list[Any] = []
params: mcp_types.PaginatedRequestParams | None = None
while True:
tool_list = await session.list_tools(params=params)
collected.extend(tool_list.tools)
next_cursor = getattr(tool_list, "nextCursor", None)
if not next_cursor:
break
params = mcp_types.PaginatedRequestParams(cursor=next_cursor)
payload = {
"tools": [
{
"name": tool.name,
"description": tool.description,
"inputSchema": tool.inputSchema,
"outputSchema": tool.outputSchema,
}
for tool in collected
],
}
return MCPToolResult(outputs=[Content.from_text(json.dumps(payload, indent=2, allow_nan=False))])
async def aclose(self) -> None:
"""Close all cached MCP clients and the owned httpx clients.
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Declarative specification support for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260507"
version = "1.0.0b260514"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.3.0,<2",
"agent-framework-core>=1.4.0,<2",
"httpx>=0.27,<1",
"powerfx>=0.0.32,<0.0.35; python_version < '3.14'",
"pyyaml>=6.0,<7.0",
@@ -13,6 +13,7 @@ owned-vs-caller httpx close semantics.
from __future__ import annotations
import asyncio
import json
import sys
from typing import Any
from unittest.mock import patch
@@ -33,6 +34,55 @@ pytestmark = pytest.mark.skipif(
)
class FakeListToolsResult: # noqa: B903 - mimics ``mcp.types.ListToolsResult`` shape, not a value type
"""Stand-in for ``mcp.types.ListToolsResult`` returned by ``session.list_tools()``."""
def __init__(self, tools: list[Any], next_cursor: str | None = None) -> None:
self.tools = tools
self.nextCursor = next_cursor
class FakeMcpTool:
"""Stand-in for an MCP ``Tool`` (subset used by ``_invoke_list_tools``)."""
def __init__(
self,
name: str,
description: str | None = None,
inputSchema: dict[str, Any] | None = None,
outputSchema: dict[str, Any] | None = None,
) -> None:
self.name = name
self.description = description
self.inputSchema = inputSchema if inputSchema is not None else {"type": "object", "properties": {}}
self.outputSchema = outputSchema
class FakeMcpSession:
"""Stand-in for ``mcp.ClientSession``.
``list_tools_pages`` lets a test enqueue multiple paginated responses;
when None (default), an empty single-page result is returned. ``list_tools_error``
raises a synthetic error on the next call when set.
"""
def __init__(self) -> None:
self.list_tools_pages: list[FakeListToolsResult] | None = None
self.list_tools_calls: list[Any] = []
self.list_tools_error: BaseException | None = None
async def list_tools(self, params: Any = None) -> FakeListToolsResult:
self.list_tools_calls.append(params)
if self.list_tools_error is not None:
raise self.list_tools_error
if self.list_tools_pages is None:
return FakeListToolsResult(tools=[])
index = len(self.list_tools_calls) - 1
if index >= len(self.list_tools_pages):
return FakeListToolsResult(tools=[])
return self.list_tools_pages[index]
class FakeTool:
"""Stand-in for ``MCPStreamableHTTPTool``.
@@ -50,6 +100,7 @@ class FakeTool:
self.connect_error: BaseException | None = None
self.call_handler: Any = lambda **_a: [Content.from_text("ok")]
self._httpx_client: httpx.AsyncClient | None = None
self.session: FakeMcpSession | None = None
# Mimic MCPStreamableHTTPTool: when no caller client AND header_provider
# is set, lazily allocate an owned httpx client during connect.
FakeTool.instances.append(self)
@@ -63,6 +114,9 @@ class FakeTool:
# Mimic lazy httpx allocation when no client provided AND header_provider set.
if self.kwargs.get("http_client") is None and self.kwargs.get("header_provider") is not None:
self._httpx_client = httpx.AsyncClient()
# Mimic MCPStreamableHTTPTool: a live session becomes available after connect.
if self.session is None:
self.session = FakeMcpSession()
async def close(self) -> None:
self.close_count += 1
@@ -541,3 +595,185 @@ class TestCacheKey:
k1 = DefaultMCPToolHandler._cache_key("https://x/", None, None, {"X": "Bearer-A"})
k2 = DefaultMCPToolHandler._cache_key("https://x/", None, None, {"X": "bearer-a"})
assert k1 != k2
# ---------- tools/list reserved name --------------------------------------
class TestListTools:
"""Exercise the reserved :attr:`DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME` interception path."""
@pytest.mark.asyncio
async def test_list_tools_returns_json_catalog(self) -> None:
handler = DefaultMCPToolHandler()
with _patch_tool():
# Prime the cache so the FakeTool session exists.
await handler.invoke_tool(_invocation())
FakeTool.instances[0].session.list_tools_pages = [ # type: ignore[union-attr]
FakeListToolsResult(
tools=[
FakeMcpTool(
name="search",
description="Search docs",
inputSchema={"type": "object", "properties": {"q": {"type": "string"}}},
outputSchema={"type": "object"},
),
FakeMcpTool(name="echo", description=None, outputSchema=None),
],
),
]
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
assert result.is_error is False
assert len(result.outputs) == 1
payload = json.loads(result.outputs[0].text) # type: ignore[reportAttributeAccessIssue]
assert payload == {
"tools": [
{
"name": "search",
"description": "Search docs",
"inputSchema": {"type": "object", "properties": {"q": {"type": "string"}}},
"outputSchema": {"type": "object"},
},
{
"name": "echo",
"description": None,
"inputSchema": {"type": "object", "properties": {}},
"outputSchema": None,
},
],
}
@pytest.mark.asyncio
async def test_list_tools_property_order_is_stable(self) -> None:
"""JSON property order is stable: name, description, inputSchema, outputSchema."""
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation())
FakeTool.instances[0].session.list_tools_pages = [ # type: ignore[union-attr]
FakeListToolsResult(tools=[FakeMcpTool(name="t1", description="d")]),
]
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
text = result.outputs[0].text # type: ignore[reportAttributeAccessIssue]
name_idx = text.find('"name"')
desc_idx = text.find('"description"')
input_idx = text.find('"inputSchema"')
output_idx = text.find('"outputSchema"')
assert 0 <= name_idx < desc_idx < input_idx < output_idx
@pytest.mark.asyncio
async def test_list_tools_indented_output(self) -> None:
"""Output is JSON with a 2-space indent so the conversation log is human-readable."""
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation())
FakeTool.instances[0].session.list_tools_pages = [ # type: ignore[union-attr]
FakeListToolsResult(tools=[FakeMcpTool(name="t1")]),
]
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
text = result.outputs[0].text # type: ignore[reportAttributeAccessIssue]
# Indented output contains newlines and a 2-space indented key.
assert "\n " in text
@pytest.mark.asyncio
async def test_list_tools_rejects_arguments(self) -> None:
"""Reserved name does NOT accept tool arguments. Fails fast before connect."""
handler = DefaultMCPToolHandler()
with _patch_tool():
result = await handler.invoke_tool(
_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME, arguments={"q": "test"}),
)
assert result.is_error is True
assert "does not accept tool arguments" in (result.error_message or "")
# Args validation runs before connect, so no tool was instantiated.
assert FakeTool.instances == []
@pytest.mark.asyncio
async def test_list_tools_empty_args_dict_is_accepted(self) -> None:
"""An empty arguments dict is equivalent to no arguments."""
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation())
result = await handler.invoke_tool(
_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME, arguments={}),
)
assert result.is_error is False
@pytest.mark.asyncio
async def test_list_tools_paginates(self) -> None:
"""Pagination loop calls list_tools repeatedly until nextCursor is empty."""
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation())
FakeTool.instances[0].session.list_tools_pages = [ # type: ignore[union-attr]
FakeListToolsResult(tools=[FakeMcpTool(name="a")], next_cursor="cursor1"),
FakeListToolsResult(tools=[FakeMcpTool(name="b")], next_cursor="cursor2"),
FakeListToolsResult(tools=[FakeMcpTool(name="c")], next_cursor=None),
]
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
payload = json.loads(result.outputs[0].text) # type: ignore[reportAttributeAccessIssue]
assert [t["name"] for t in payload["tools"]] == ["a", "b", "c"]
session = FakeTool.instances[0].session
assert session is not None
assert len(session.list_tools_calls) == 3
# First call has no cursor; second/third use the cursor from the prior page.
assert session.list_tools_calls[0] is None
assert getattr(session.list_tools_calls[1], "cursor", None) == "cursor1"
assert getattr(session.list_tools_calls[2], "cursor", None) == "cursor2"
@pytest.mark.asyncio
async def test_list_tools_shares_cache_with_call_tool(self) -> None:
"""tools/list reuses the same cached MCP session as a regular call_tool."""
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation(tool_name="search"))
await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
assert len(FakeTool.instances) == 1
assert FakeTool.instances[0].connect_count == 1
@pytest.mark.asyncio
async def test_list_tools_propagates_session_errors_as_error_result(self) -> None:
"""Errors raised by session.list_tools become MCPToolResult(is_error=True), not crashes."""
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation())
FakeTool.instances[0].session.list_tools_error = httpx.ReadTimeout("read timed out") # type: ignore[union-attr]
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
assert result.is_error is True
assert "ReadTimeout" in (result.error_message or "")
@pytest.mark.asyncio
async def test_list_tools_returns_error_when_session_is_none(self) -> None:
"""If somehow the cached tool has no session, return a clear error rather than crashing."""
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation())
FakeTool.instances[0].session = None
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
assert result.is_error is True
assert "not connected" in (result.error_message or "")
@pytest.mark.asyncio
async def test_list_tools_does_not_call_call_tool(self) -> None:
"""The reserved name is intercepted; the inner call_tool path is bypassed."""
handler = DefaultMCPToolHandler()
call_tool_invoked = False
def fail(**_a: Any) -> Any:
nonlocal call_tool_invoked
call_tool_invoked = True
raise AssertionError("call_tool should not run for tools/list")
with _patch_tool():
await handler.invoke_tool(_invocation())
FakeTool.instances[0].call_handler = fail
FakeTool.instances[0].session.list_tools_pages = [ # type: ignore[union-attr]
FakeListToolsResult(tools=[]),
]
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
assert call_tool_invoked is False
assert result.is_error is False
def test_class_attribute_value(self) -> None:
# Constant must equal the MCP protocol method name so a single
# string travels unchanged through host code, YAML, and the wire.
assert DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME == "tools/list"
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Debug UI for Microsoft Agent Framework with OpenAI-compatible API
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260507"
version = "1.0.0b260514"
license-files = ["LICENSE"]
urls.homepage = "https://github.com/microsoft/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.3.0,<2",
"agent-framework-core>=1.4.0,<2",
"openai>=1.99.0,<3",
"opentelemetry-sdk>=1.39.0,<2",
"fastapi>=0.115.0,<0.133.1",
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Durable Task integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260507"
version = "1.0.0b260514"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.3.0,<2",
"agent-framework-core>=1.4.0,<2",
"durabletask>=1.3.0,<2",
"durabletask-azuremanaged>=1.3.0,<2",
"python-dateutil>=2.8.0,<3",
+3 -3
View File
@@ -4,7 +4,7 @@ description = "Microsoft Foundry integrations for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.3.0"
version = "1.4.0"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,8 +23,8 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.3.0,<2",
"agent-framework-openai>=1.3.0,<2",
"agent-framework-core>=1.4.0,<2",
"agent-framework-openai>=1.4.0,<2",
"azure-ai-inference>=1.0.0b9,<1.0.0b10",
"azure-ai-projects>=2.1.0,<3.0",
]
@@ -11,6 +11,7 @@ import tempfile
import threading
from collections.abc import AsyncIterable, AsyncIterator, Generator, Mapping, Sequence
from contextlib import suppress
from pathlib import Path
from typing import Protocol, cast
from agent_framework import (
@@ -205,6 +206,47 @@ class FileBasedFunctionApprovalStorage:
return await asyncio.to_thread(self._load_sync, approval_request_id)
def _checkpoint_storage_for_context(root: str, context_id: str) -> FileCheckpointStorage:
"""Build a ``FileCheckpointStorage`` for ``context_id`` rooted under ``root``.
``context_id`` originates from caller-controlled fields such as
``previous_response_id`` or from server-generated fields such as
``conversation_id`` / ``response_id``. In every case it must be treated as
an untrusted single path segment: path separators, drive letters, parent
references and similar would otherwise let the resulting directory escape
the configured checkpoint root (CWE-22). The check resolves the joined
path and verifies it stays under the resolved root before any directory is
created on disk.
"""
if not isinstance(context_id, str) or not context_id:
raise RuntimeError("Invalid checkpoint context id: must be a non-empty string.")
# Reject any segment that is not a single safe path component. This covers
# POSIX/Windows separators, NUL bytes, drive letters, and all-dot segments
# (``.``, ``..``, ``...``, ...). We deliberately do not URL-decode the id
# here: the hosting layer never decodes context ids before joining them, so
# forms such as ``%2e%2e`` are accepted as literal directory names. Do NOT
# add decoding here without re-validating after the decode -- decode-then-
# join is exactly the pattern that reintroduces traversal. We also do not
# attempt to "sanitize" by stripping characters because that can introduce
# collisions between distinct ids.
if (
"/" in context_id
or "\\" in context_id
or "\x00" in context_id
# All-dot segments (``.``, ``..``, ``...``, ...) reduce to "" after stripping dots.
or context_id.strip(".") == ""
or os.path.isabs(context_id)
or os.path.splitdrive(context_id)[0]
):
raise RuntimeError(f"Invalid checkpoint context id: {context_id!r}")
root_path = Path(root).resolve()
storage_path = (root_path / context_id).resolve()
if not storage_path.is_relative_to(root_path):
raise RuntimeError(f"Invalid checkpoint context id: {context_id!r}")
return FileCheckpointStorage(storage_path)
class ResponsesHostServer(ResponsesAgentServerHost):
"""A responses server host for an agent."""
@@ -400,7 +442,7 @@ class ResponsesHostServer(ResponsesAgentServerHost):
latest_checkpoint_id: str | None = None
restore_storage: FileCheckpointStorage | None = None
if context_id is not None:
restore_storage = FileCheckpointStorage(os.path.join(self._checkpoint_storage_path, context_id))
restore_storage = _checkpoint_storage_for_context(self._checkpoint_storage_path, context_id)
latest_checkpoint = await restore_storage.get_latest(workflow_name=self._agent.workflow.name)
if latest_checkpoint is not None:
latest_checkpoint_id = latest_checkpoint.checkpoint_id
@@ -414,7 +456,7 @@ class ResponsesHostServer(ResponsesAgentServerHost):
# supplied, restore_storage points at the *prior* response's
# directory and write_storage points at the *current* response's.
write_context_id = context.conversation_id or context.response_id
write_storage = FileCheckpointStorage(os.path.join(self._checkpoint_storage_path, write_context_id))
write_storage = _checkpoint_storage_for_context(self._checkpoint_storage_path, write_context_id)
# Multi-turn pattern: when we have a prior checkpoint, restore it
# first (drive the workflow back to idle with prior state intact),
@@ -4,7 +4,7 @@ description = "Foundry Hosting integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0a260507"
version = "1.0.0a260514"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.3.0,<2",
"agent-framework-core>=1.4.0,<2",
"azure-ai-agentserver-core>=2.0.0b3,<3",
"azure-ai-agentserver-responses>=1.0.0b5,<2",
"azure-ai-agentserver-invocations>=1.0.0b3,<2",

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