Compare commits

...

25 Commits

Author SHA1 Message Date
SergeyMenshykh 7ca73c0645 Update .NET version to 1.13.0 (#6900)
Co-authored-by: SergeyMenshykh <SergeMenshikh@outlook.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-07-03 16:30:21 +00:00
Eduard van Valkenburg 0260ea0e61 Python: implement ADR-0029 service_session_id lifecycle mapping (#6724)
* python: implement ADR-0029 service_session_id lifecycle mapping

- Extend AgentSession service_session_id to support structured values
- Add agent-owned conversation id extraction for chat forwarding and telemetry
- Migrate A2A durable continuation state to A2AServiceSessionId
- Keep A2AAgentSession as compatibility shim and mark it deprecated
- Update core/a2a tests and package guidance

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

* Fix service_session_id type fallout across packages

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

* Fix remaining test typing signatures for service_session_id

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

* Fix hosting test stubs for widened get_session type

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

* Fix remaining test stubs for get_session union type

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

* Simplify A2A session state handling

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

* fix import

* Fix hosting-telegram test get_session typing

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-02 18:36:48 +00:00
westey 24581d6865 Python: Allow devs to opt-out of file-access approvals (#6879) 2026-07-02 18:36:44 +00:00
SergeyMenshykh 331d17c5a1 .NET: fix: Require explicit TokenCredential in AddFoundryToolboxes (#6877)
* fix: require explicit TokenCredential in AddFoundryToolboxes

The AddFoundryToolboxes extension methods now require callers to
pass a TokenCredential explicitly rather than relying on an
internally-created default credential. This makes the credential
choice intentional and avoids non-deterministic credential probing
in production environments.

Breaking change (experimental API):
- AddFoundryToolboxes(IServiceCollection, params string[]) becomes
  AddFoundryToolboxes(IServiceCollection, TokenCredential, params string[])
- AddFoundryToolboxes(IServiceCollection, Action?, params string[]) becomes
  AddFoundryToolboxes(IServiceCollection, TokenCredential, Action?, params string[])
- Azure.Identity package dependency removed from Foundry.Hosting library.

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

* fix: simplify redundant generic type argument (IDE0001)

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

* fix: avoid duplicate FoundryToolboxService registration

Inject the AddFoundryToolboxes credential directly into the
FoundryToolboxService factory and fail early if the service was
already registered. This avoids registering TokenCredential in the
host DI container while preserving a single toolbox service instance
for both request handling and hosted startup.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-02 15:44:56 +00:00
Ben Thomas db80926f31 .NET: Improving DotNet samples (#6869)
* fix: resolve CA1873 in GitHubCopilotAgent by using LoggerMessage source generator

Replace the direct logger.LogWarning() call (which eagerly evaluates
string.Join()) with a [LoggerMessage]-generated extension method in
GitHubCopilotAgentLogMessages.cs.

Fixes build error:
  GitHubCopilotAgent.cs(580,13): error CA1873: Evaluation of this argument
  may be expensive and unnecessary if logging is disabled

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

* Fixing more dotnet samples

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Ben Thomas <25218250+alliscode@users.noreply.github.com>
2026-07-02 15:16:26 +00:00
westey 48436f8ab6 .NET: Make default-approval harness features configurable + customizable shell tool (#6880)
* Dotnet: Allow devs to opt-out of file-access approvals

* Address PR comments
2026-07-02 14:57:59 +00:00
Roger Barreto 551b44f04f .NET: Bump Azure.AI.Projects to 2.1.0-beta.4 (#6795)
* .NET: Bump Azure.AI.Projects to 2.1.0-alpha.20260629.1

Bumps Azure.AI.Projects beta.3 to alpha.20260629.1 and aligns transitive deps (System.ClientModel 1.14.0, Azure.Core 1.59.0, Msal 4.84.2). Adapts to renamed AgentSessionFiles APIs (Upload/GetAll/Delete, scoped GetAgentSessionFiles, SizeInBytes), AgentToolboxes (CreateVersion/Delete), and strongly typed toolbox tools (WebSearchToolboxTool, MCPToolboxTool). Adds azure-sdk public dev feed for prerelease restore.

* Use positional arg for AgentSessionFiles.DeleteAsync cleanup

* Move to Azure.AI.Projects 2.1.0-beta.4 (released beta)

Swaps the alpha daily build for the published 2.1.0-beta.4. Drops the azure-sdk public dev feed since beta.4 and its deps are on nuget.org. Beta.4 requires Azure.Core 1.60.0, which cascades the 10.0.8 servicing packages (Microsoft.Bcl.AsyncInterfaces, System.Diagnostics.DiagnosticSource, System.Text.Json, System.Threading.Channels, Microsoft.Extensions.DependencyInjection.Abstractions, Microsoft.Extensions.Logging.Abstractions) to 10.0.9.

* Reconcile Azure.Core 1.60.0 bump with merged main

Reverts the over-eager System.Threading.Channels 10.0.9 bump back to 10.0.8 (it was not part of the Azure.Core 1.60.0 cascade and caused a net472 MSB3277 conflict against the 10.0.8 that Microsoft.Extensions.AI pulls). Drops the now-obsolete Azure.Core VersionOverride=1.59.0 in HostedWorkflowHandoff (added on main to satisfy AgentServer while the central pin was lower); the central pin is now 1.60.0 which already satisfies the >=1.59.0 floor, and the override was downgrading this project below sibling projects (CS1705).
2026-07-02 14:40:03 +00:00
Eduard van Valkenburg 09ea690062 Python: Fix Hyperlight workspace staging (#6856)
* Fix Hyperlight workspace link staging

Reject symlinks, Windows junctions, and reparse points during Hyperlight input staging, and harden output collection/cleanup against the same link types.

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

* Address Hyperlight staging review

Anchor workspace enumeration to the resolved root and avoid following links while classifying output cleanup entries.

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

* Improve Hyperlight path resolve errors

Handle RuntimeError from path resolution alongside OSError when validating Hyperlight sandbox paths and report the source-root validation context in the error message.

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

* Mark Hyperlight real sandbox tests as integration

Ensure Windows unit CI excludes real Hyperlight sandbox tests by applying the integration marker consistently.

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

* Clean up Hyperlight integration sandboxes

Close real sandbox fixtures and provider-owned registries in Hyperlight integration tests so they do not rely on process teardown.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-02 14:02:38 +00:00
Roger Barreto 62f0024707 .NET: Foundry Hosting gracefully tolerates lacking user identity when run locally (#6870)
* .NET: Make Foundry Hosting resilient to missing user identity in local runs

AgentFrameworkResponseHandler threw InvalidOperationException (surfaced as a
500 on every request) when the isolation-key provider returned null, which
always happens locally because the platform x-agent-user-id header is absent.
Running a hosted image outside Foundry therefore failed out of the box.

The handler now branches on FoundryEnvironment.IsHosted: hosted stays strict
(null identity is still a hard error), but non-hosted (local docker run /
dotnet run) tolerates a null identity - per-user isolation is simply not
triggered, the request proceeds with userId null (no partition), and no
hosted context is stamped or validated.

Because local runs no longer need a fallback, the sample-side
DevTemporaryLocalUserIdProvider and AddDevTemporaryLocalContributorSetup are
removed from Hosted_Shared_Contributor_Setup and all sample Program.cs files.
To simulate distinct users locally, send an x-agent-user-id request header;
the default provider reads it exactly as it reads the platform-injected value.
The Memory sample smoke script now drives alice/bob against one container via
that header. AGENT_NAME defaults added to Hosted-ChatClientAgent and
Hosted-MemoryAgent so a hosted deploy (where AGENT_* is a reserved env var)
does not crash at startup.

Updates the two affected unit tests to assert the local-success path and
amends ADR 0031.

* Address review: correct isolation-guarantee and Memory-sample local docs

- AgentFrameworkResponseHandler: note the null/local case is unscoped/shared,
  not fully partitioned per user.
- HostedSessionIsolationKeyProvider XML docs: phrase the non-null UserId rule as
  a constraint on the returned-context case, since null is now allowed locally.
- Hosted-MemoryAgent: the PerUser() memory scope requires a resolved user, so a
  local run needs an x-agent-user-id header; corrected the Program.cs comment
  and README (removed the inaccurate "shared bucket locally" claim).
- Test: assert absence of any u-* per-user directory via a wildcard search
  rather than checking for a literal "u-" directory.
2026-07-02 09:32:22 +00:00
Eduard van Valkenburg e38592a23c Python: Fix Anthropic messages and function-loop fallback (#6794)
* Fix function loop fallback and Anthropic messages

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

* Address PR feedback for fallback and instructions

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-02 05:37:23 +00:00
Tao Chen 08a09e7ebb Python: Update FHA samples after v2 changes (#6841)
* Update FHA samples after v2 changes

* Add missing pakcage pin

* Address comments
2026-07-01 21:45:42 +00:00
SergeyMenshykh c1e20632f7 .NET: Remove Experimental attribute from Skills API in Microsoft.Agents.AI (#6861)
* .NET: Remove Experimental attribute from Skills API in Microsoft.Agents.AI

Closes microsoft/agent-framework#6835

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

* Restore MAAI001 suppression for Step07 sample (still uses ToolApproval experimental APIs)

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

* Keep bare NoWarn placeholder in Step01 and Step02 samples

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

---------

Co-authored-by: SergeyMenshykh <SergeMenshikh@outlook.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-07-01 17:33:10 +00:00
Giles Odigwe c41676682e Python: [BREAKING] Extract caching from SkillsProvider into a CachingSkillsSource decorator (#6847)
* Python: [BREAKING] Extract caching from SkillsProvider into CachingSkillsSource decorator

Adds a composable CachingSkillsSource(DelegatingSkillsSource) decorator that caches the inner source's skills list, and rewires SkillsProvider to wrap its resolved source in it by default (skipped when disable_caching=True). Removes the provider's baked-in caching (_cached_context field and _get_or_create_context). Mirrors .NET #6768.

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

* Add ty ignore for dynamic _test_context attribute in skills test helper

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-01 16:21:08 +00:00
Giles Odigwe effbd17325 Python: [BREAKING] Treat nested SKILL.md content as part of the parent skill (#6849)
* Python: Stop skill discovery at skill boundaries

File-based skill discovery kept descending after finding a SKILL.md, which treated content nested beneath a skill boundary as an independent skill root. Return immediately after recording a directory that contains SKILL.md so everything below it stays part of that skill, and add a regression test with a nested SKILL.md.

Fixes #6682

* Python: Attach nested skill content to the parent skill

Removing the SKILL.md subdirectory skip in resource and script scanning so that content beneath a skill boundary is attached to that skill, and update the discovery docstring and the nested-skill test to match. Complements the discovery early-return so a nested SKILL.md is never treated as an independent skill root.
2026-07-01 16:06:56 +00:00
westey bc8dd4b63c Python: [BREAKING] FileAccess/FileMemory replace_lines literal replacement with line deletion (#6859)
* FileAccess/FileMemory: Allowing removing lines by using full line replace

* Add agents.md changes

* Address PR comments
2026-07-01 15:44:11 +00:00
VectorPeak 0f1fa21070 Python: Accept A2A data URI media parameters (#6818)
Co-authored-by: VectorPeak <VectorPeak@users.noreply.github.com>
2026-07-01 15:27:53 +00:00
westey 300dfa7e36 .NET: [BREAKING] Refactor OpenAI Hosting OptionsMapping to disallow passing options by default (#6855)
* Refactor OptionsMapping to disallow passing options by default

* Address PR comments

* Address PR comment
2026-07-01 15:05:53 +00:00
westey e56f34c521 .NET: [BREAKING] Add file editing tools and align FileAccess/FileMemory store API (#6807)
* Add support for editing to file access and memory plus renames

* Address PR comments

* Address PR comments
2026-07-01 13:35:48 +00:00
SergeyMenshykh 51c05fc862 .NET: Add skill approval options (#6843)
* .NET: Add skill approval options

Add per-tool options for disabling approval on skills provider tools and cover the behavior with unit tests.

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

* .NET: Document mixed approval behavior

Document the non-approval bypass requirement and update tests to avoid file-discovery dependency.

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

---------

Co-authored-by: SergeyMenshykh <SergeMenshikh@outlook.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-07-01 12:46:08 +00:00
SergeyMenshykh b7fc23c61f .NET: Consolidate skill-source caching and make skill sources disposable (#6827)
* .NET: Consolidate skill-source caching and make skill sources disposable

Move all caching into the generic CachingAgentSkillsSource decorator and
remove the duplicate inline cache from AgentMcpSkillsSource, so a single
cache layer governs skill fetching. Add RefreshInterval-based expiry to
CachingAgentSkillsSourceOptions.

Make AgentSkillsSource (and its decorators) IDisposable so pipelines can
release owned resources, and give AgentSkillsProvider an ownsSource flag
controlling whether it disposes the source it wraps. Provider convenience
constructors and the builder set ownsSource: true.

Serialize ArchiveEntryLoader's reconcile/extract/read of the shared on-disk
directory with a per-instance lock to prevent concurrent corruption.

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

* .NET: Fix IDE0032 by using an auto-property in test source

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

* .NET: Make cancellation cache test deterministic

Ensure the first caller owns the fetch before the second caller queues, so
the cancellation-restart assertion is no longer race-prone.

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

* .NET: Throw ObjectDisposedException from CachingAgentSkillsSource after disposal

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

* .NET: Document AgentSkillsProviderBuilder source ownership and single-build contract

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

* .NET: Update API compatibility suppressions for AgentSkillsProvider ctor change

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

* .NET: Add test asserting archive skill updates are observed after reconcile

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

---------

Co-authored-by: SergeyMenshykh <SergeMenshikh@outlook.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-01 12:46:07 +00:00
SergeyMenshykh 00e4d4ffde Make skills source classes public and sealed with Experimental attribute (#6838)
- AgentInMemorySkillsSource: internal sealed → public sealed
- AggregatingAgentSkillsSource: internal sealed → public sealed
- CachingAgentSkillsSource: internal sealed → public sealed + [Experimental]
- DeduplicatingAgentSkillsSource: internal sealed partial → public sealed partial + [Experimental]
- FilteringAgentSkillsSource: internal sealed partial → public sealed partial + [Experimental]
- DelegatingAgentSkillsSource: internal abstract → public abstract + [Experimental]

Co-authored-by: SergeyMenshykh <SergeMenshikh@outlook.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-01 11:41:21 +00:00
Copilot 2cb97545bf .NET: Pin patched OpenAPI dependencies to unblock NU1903 in sample restores (#6853)
* Initial plan

* Bump OpenAPI packages to patched versions

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-01 11:25:58 +00:00
Giles Odigwe d50698bb79 Python: Allow custom argument parsing for skill scripts (#6817)
* Python: Allow custom argument marshaling for skill scripts

Add an optional argument_marshaler hook so callers can plug in their own argument conversion logic for inline skill scripts. Supplied at the InlineSkillScript, InlineSkill, and ClassSkill levels; when omitted, behavior is unchanged. This supports backends (e.g. vLLM) that send tool-call arguments in a non-conforming shape such as a JSON string.

Port of .NET PR #6498. Closes #6543.

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

* Address review feedback on skill argument marshaling

- Widen InlineSkillScript.run args to accept a raw str (the one place a marshaler-converted value is valid), and drop the now-unneeded type: ignore markers in tests.

- Constrain the SkillScriptArgumentMarshaler output type to dict | None so the type enforces the inline-script contract instead of a docstring note.

- Add a clear TypeError when a str reaches an inline script with no marshaler configured.

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

* Rename SkillScriptArgumentMarshaler to SkillScriptArgumentParser

In Python 'marshalling' specifically connotes the stdlib marshal module, so the term is misleading here. Rename the type alias, the argument_parser parameter/attribute, docstrings, exports, and tests accordingly.

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

* Fold argument_parser docstring into Args section

The skill constructors are fully keyword-only, so name/description/function are already documented under Args. Singling out argument_parser into its own Keyword Args section was inconsistent; merge it into Args for InlineSkillScript, InlineSkill, and ClassSkill.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-01 05:25:12 +00:00
Giles Odigwe 059e1e055f Python: Fix local history not injected when non-history context providers are present (#6810)
The auto-injection of InMemoryHistoryProvider was gated on there being no
context providers at all, so registering any non-history provider (e.g.
SkillsProvider, FileAccessProvider, or a RAG memory provider) suppressed local
history. On stateless clients this dropped prior messages across turns — most
visibly the tool-approval resume turn lost the prior assistant function_call,
causing a 400 "Expected toolResult blocks" error.

Gate the injection on the absence of a loading HistoryProvider instead, matching
the pattern already used in _workflows/_agent.py. Add regression tests covering
a non-history provider, an existing loading provider, and a persist-only
provider.

Fixes #5672

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-30 21:03:23 +00:00
Giles Odigwe 43f2095244 Python: Fix GeminiChatClient dropping image/file content (#6751)
* Python: Fix GeminiChatClient dropping image/file content

GeminiChatClient._convert_message_contents only handled text and function_call content, so data/uri (image, PDF, audio) parts were silently dropped and never reached Gemini. Convert data URIs to inline_data Parts and external URIs to file_data Parts, warning on genuinely unconvertible content. Adds tests for the multimodal conversion paths.

Fixes #6688

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

* Address review: strip data-URI mime params and handle non-inferable URIs

Strip parameters (e.g. charset) from a data URI media type before passing it to Gemini, and wrap types.Part.from_uri so a URI with no media_type and no guessable extension is passed through as file_data without a mime type instead of raising ValueError. Adds tests for both paths.

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

* Address review: reuse shared data-URI helpers

Reuse _get_data_bytes and detect_media_type_from_base64 from agent_framework instead of reimplementing base64 extraction/decoding and data-URI header parsing in the Gemini client. This also removes the manual header parsing that previously needed charset-parameter stripping. Updates tests accordingly.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-30 21:00:54 +00:00
231 changed files with 7013 additions and 1907 deletions
@@ -100,3 +100,20 @@ Negative:
- Encryption at rest and quota enforcement remain platform concerns.
- Non-Foundry hosting layers can adopt an equivalent scheme independently.
## Update (2026-07-01): local runs no longer fail closed; sample dev provider removed
Superseding the ADR-0026/0030 behavior where a `null` result from `HostedSessionIsolationKeyProvider`
always became a 500, `AgentFrameworkResponseHandler` now branches on `FoundryEnvironment.IsHosted`:
- **Hosted** (`IsHosted == true`, production): a `null` identity is still a hard error (500). Isolation
stays strict; the platform always injects `x-agent-user-id`.
- **Not hosted** (local `docker run` / `dotnet run`): a `null` identity is tolerated. Per-user isolation
is simply not triggered — the handler passes `userId == null` to the store (the documented "no user
partition", `{root}/a-{agent}/c-{conv}.json`), stamps no `HostedSessionContext`, and runs no
strict-resume check. Contributors can run a hosted image locally with zero extra setup.
Consequently the sample-side `DevTemporaryLocalUserIdProvider` and `AddDevTemporaryLocalContributorSetup`
were removed. To simulate distinct users locally, send an `x-agent-user-id` request header; the default
`PlatformHostedSessionIsolationKeyProvider` reads it via `ResponseContext.PlatformContext.UserIdKey`
(the SDK's `PlatformContext.FromRequest` populates it from the header unconditionally, hosted or not).
+11 -10
View File
@@ -27,10 +27,10 @@
<PackageVersion Include="Azure.AI.AgentServer.Invocations" Version="1.0.0-beta.5" />
<PackageVersion Include="Azure.AI.AgentServer.Responses" Version="1.0.0-beta.6" />
<PackageVersion Include="Azure.Search.Documents" Version="12.0.0" />
<PackageVersion Include="Azure.AI.Projects" Version="2.1.0-beta.3" />
<PackageVersion Include="Azure.AI.Projects" Version="2.1.0-beta.4" />
<PackageVersion Include="Azure.AI.Agents.Persistent" Version="1.2.0-beta.10" />
<PackageVersion Include="Azure.AI.OpenAI" Version="2.9.0-beta.1" />
<PackageVersion Include="Azure.Core" Version="1.57.0" />
<PackageVersion Include="Azure.Core" Version="1.60.0" />
<PackageVersion Include="Azure.Identity" Version="1.21.0" />
<PackageVersion Include="DotNetEnv" Version="3.1.1" />
<PackageVersion Include="Azure.Monitor.OpenTelemetry.Exporter" Version="1.5.0" />
@@ -42,18 +42,18 @@
<!-- Newtonsoft.Json -->
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
<!-- System.* -->
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.8" />
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.9" />
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
<PackageVersion Include="Microsoft.Bcl.Memory" Version="10.0.5" />
<PackageVersion Include="System.ClientModel" Version="1.13.0" />
<PackageVersion Include="System.ClientModel" Version="1.14.0" />
<PackageVersion Include="System.CodeDom" Version="10.0.0" />
<PackageVersion Include="System.Collections.Immutable" Version="10.0.1" />
<PackageVersion Include="System.CommandLine" Version="2.0.0-rc.2.25502.107" />
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="10.0.8" />
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="10.0.9" />
<PackageVersion Include="System.Linq.AsyncEnumerable" Version="10.0.5" />
<PackageVersion Include="System.Net.Http.Json" Version="10.0.0" />
<PackageVersion Include="System.Net.ServerSentEvents" Version="10.0.5" />
<PackageVersion Include="System.Text.Json" Version="10.0.8" />
<PackageVersion Include="System.Text.Json" Version="10.0.9" />
<PackageVersion Include="System.Threading.Channels" Version="10.0.8" />
<PackageVersion Include="System.Threading.Tasks.Extensions" Version="4.6.3" />
<PackageVersion Include="System.Net.Security" Version="4.3.2" />
@@ -70,7 +70,8 @@
<!-- Microsoft.AspNetCore.* -->
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.9" />
<PackageVersion Include="Microsoft.OpenApi" Version="2.7.5" /> <!-- Pin patched OpenAPI.NET to remediate GHSA-v5pm-xwqc-g5wc -->
<PackageVersion Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.0.0" />
<!-- Microsoft.Extensions.* -->
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.6.0" />
@@ -87,12 +88,12 @@
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.FileSystemGlobbing" Version="10.0.6" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.VectorData.Abstractions" Version="9.7.0" />
@@ -120,7 +121,7 @@
<PackageVersion Include="OllamaSharp" Version="5.4.8" />
<PackageVersion Include="OpenAI" Version="2.10.0" />
<!-- Identity -->
<PackageVersion Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.83.1" />
<PackageVersion Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.84.2" />
<!-- Workflows -->
<PackageVersion Include="Microsoft.Agents.ObjectModel" Version="2026.2.4.1" />
<PackageVersion Include="Microsoft.Agents.ObjectModel.Json" Version="2026.2.4.1" />
+5 -5
View File
@@ -335,8 +335,8 @@ internal static class AgentsSamples
{
Name = "Agent_Step01_FileBasedSkills",
ProjectPath = "samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills",
RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"],
OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"],
RequiredEnvironmentVariables = ["FOUNDRY_PROJECT_ENDPOINT"],
OptionalEnvironmentVariables = ["FOUNDRY_MODEL"],
MustContain =
[
"Converting units with file-based skills",
@@ -354,8 +354,8 @@ internal static class AgentsSamples
{
Name = "Agent_Step06_McpBasedSkills",
ProjectPath = "samples/02-agents/AgentSkills/Agent_Step06_McpBasedSkills",
RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"],
OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"],
RequiredEnvironmentVariables = ["FOUNDRY_PROJECT_ENDPOINT"],
OptionalEnvironmentVariables = ["FOUNDRY_MODEL"],
MustContain =
[
"Discovering MCP-based skills",
@@ -701,7 +701,7 @@ internal static class AgentsSamples
OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
ExpectedOutputDescription =
[
"The output should contain a list of countries or information about countries that use the EUR currency.",
"The output should contain the current EUR exchange rate against USD and GBP as numeric values.",
"The output should not contain error messages or stack traces.",
],
},
+3 -3
View File
@@ -1,14 +1,14 @@
<Project>
<PropertyGroup>
<!-- Central version prefix - applies to all nuget packages. -->
<VersionPrefix>1.12.0</VersionPrefix>
<VersionPrefix>1.13.0</VersionPrefix>
<RCNumber>1</RCNumber>
<DateSuffix>260629</DateSuffix>
<DateSuffix>260703</DateSuffix>
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).$(DateSuffix).1</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.$(DateSuffix).1</PackageVersion>
<PackageVersion Condition="'$(IsReleased)' == 'true'">$(VersionPrefix)</PackageVersion>
<GitTag>1.12.0</GitTag>
<GitTag>1.13.0</GitTag>
<Configurations>Debug;Release;Publish</Configurations>
<IsPackable>true</IsPackable>
@@ -12,7 +12,7 @@ using Microsoft.Extensions.AI;
string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
const string AgentInstructions = "You are a helpful assistant that can use the countries API to retrieve information about countries by their currency code. When calling the API, always pass fields=name to limit the response to just country names.";
const string AgentInstructions = "You are a helpful assistant that can retrieve the latest currency exchange rates using the Frankfurter API. Always call the API to get live data rather than guessing.";
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
@@ -25,44 +25,45 @@ AIAgent agent = aiProjectClient.AsAIAgent(deploymentName,
name: "OpenAPIToolsAgent",
tools: [openApiTool]);
// Run the agent with a question about countries
Console.WriteLine(await agent.RunAsync("What countries use the Euro (EUR) as their currency? Please list them."));
// Run the agent with a question about EUR exchange rates
Console.WriteLine(await agent.RunAsync("What is the latest EUR exchange rate against the US Dollar (USD) and British Pound (GBP)?"));
OpenApiFunctionDefinition CreateOpenAPIFunctionDefinition()
{
// A simple OpenAPI specification for the REST Countries API
const string CountriesOpenApiSpec = """
// OpenAPI spec for Frankfurter — a free, no-auth exchange rate API backed by ECB data.
// See https://www.frankfurter.dev/ for documentation.
const string FrankfurterOpenApiSpec = """
{
"openapi": "3.1.0",
"info": {
"title": "REST Countries API",
"description": "Retrieve information about countries by currency code",
"version": "v3.1"
"title": "Frankfurter Exchange Rate API",
"description": "Free currency exchange rates from the European Central Bank",
"version": "v1"
},
"servers": [
{
"url": "https://restcountries.com/v3.1"
"url": "https://api.frankfurter.dev/v1"
}
],
"paths": {
"/currency/{currency}": {
"/latest": {
"get": {
"description": "Get countries that use a specific currency code (e.g., USD, EUR, GBP)",
"operationId": "GetCountriesByCurrency",
"description": "Get the latest exchange rates for a given base currency",
"operationId": "GetLatestExchangeRates",
"parameters": [
{
"name": "currency",
"in": "path",
"description": "Currency code (e.g., USD, EUR, GBP)",
"required": true,
"name": "from",
"in": "query",
"description": "Base currency code (e.g. EUR, USD, GBP). Defaults to EUR.",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "fields",
"name": "to",
"in": "query",
"description": "Comma-separated list of fields to include in the response (e.g., name,currencies)",
"description": "Comma-separated list of target currency codes (e.g. USD,GBP,JPY).",
"required": false,
"schema": {
"type": "string"
@@ -71,20 +72,14 @@ OpenApiFunctionDefinition CreateOpenAPIFunctionDefinition()
],
"responses": {
"200": {
"description": "Successful response with list of countries",
"description": "Latest exchange rates",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "object"
}
"type": "object"
}
}
}
},
"404": {
"description": "No countries found for the currency"
}
}
}
@@ -93,12 +88,11 @@ OpenApiFunctionDefinition CreateOpenAPIFunctionDefinition()
}
""";
// Create the OpenAPI function definition
return new(
"get_countries",
BinaryData.FromString(CountriesOpenApiSpec),
"get_exchange_rates",
BinaryData.FromString(FrankfurterOpenApiSpec),
new OpenAPIAnonymousAuthenticationDetails())
{
Description = "Retrieve information about countries by currency code"
Description = "Get live currency exchange rates from the European Central Bank via Frankfurter"
};
}
@@ -5,7 +5,7 @@ This sample shows how to use OpenAPI tools with a `ChatClientAgent` using the Re
## What this sample demonstrates
- Defining an OpenAPI specification inline
- Creating an `OpenAPIFunctionDefinition` for the REST Countries API
- Creating an `OpenAPIFunctionDefinition` for the Frankfurter exchange rate API
- Using `FoundryAITool.CreateOpenApiTool()` with `ChatClientAgent`
- Server-side execution of OpenAPI tool calls
@@ -90,7 +90,7 @@ static async Task<string> CreateSampleToolboxAsync(string name, string endpoint,
// Delete existing toolbox if present (ignore 404).
try
{
await toolboxClient.DeleteToolboxAsync(name);
await toolboxClient.DeleteAsync(name);
Console.WriteLine($"Deleted existing toolbox '{name}'");
}
catch (ClientResultException ex) when (ex.Status == 404)
@@ -99,12 +99,13 @@ static async Task<string> CreateSampleToolboxAsync(string name, string endpoint,
}
// Create a fresh version with a single MCP tool.
ProjectsAgentTool mcpTool = ProjectsAgentTool.AsProjectTool(ResponseTool.CreateMcpTool(
serverLabel: "api-specs",
serverUri: new Uri("https://gitmcp.io/Azure/azure-rest-api-specs"),
toolCallApprovalPolicy: new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.NeverRequireApproval)));
MCPToolboxTool mcpTool = new("api-specs")
{
ServerUri = new Uri("https://gitmcp.io/Azure/azure-rest-api-specs"),
ToolCallApprovalPolicy = new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.NeverRequireApproval),
};
ToolboxVersion created = (await toolboxClient.CreateToolboxVersionAsync(
ToolboxVersion created = (await toolboxClient.CreateVersionAsync(
name: name,
tools: [mcpTool],
description: "Sample toolbox with an MCP tool — created by Agent_Step25 sample.")).Value;
@@ -6,7 +6,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<NoWarn>$(NoWarn);MAAI001</NoWarn>
<NoWarn>$(NoWarn);</NoWarn>
</PropertyGroup>
<ItemGroup>
@@ -38,7 +38,17 @@ AIAgent agent = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredentia
Instructions = "You are a helpful assistant that can convert units.",
},
AIContextProviders = [skillsProvider],
});
})
.AsBuilder()
.UseToolApproval(new ToolApprovalAgentOptions
{
// NOTE: Auto-approving all skill tools is done here for simplicity in
// this demonstration. In production, you should prompt the user before
// allowing script execution. See Agent_Step07_SkillsAutoApproval for a
// walkthrough of the full approval flow.
AutoApprovalRules = [AgentSkillsProvider.AllToolsAutoApprovalRule],
})
.Build();
// --- Example: Unit conversion ---
Console.WriteLine("Converting units with file-based skills");
@@ -6,7 +6,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<NoWarn>$(NoWarn);MAAI001</NoWarn>
<NoWarn>$(NoWarn);</NoWarn>
</PropertyGroup>
<ItemGroup>
@@ -6,7 +6,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<NoWarn>$(NoWarn);MAAI001;IDE0051</NoWarn>
<NoWarn>$(NoWarn);IDE0051</NoWarn>
</PropertyGroup>
<ItemGroup>
@@ -6,7 +6,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<NoWarn>$(NoWarn);MAAI001;IDE0051</NoWarn>
<NoWarn>$(NoWarn);IDE0051</NoWarn>
</PropertyGroup>
<ItemGroup>
@@ -6,7 +6,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<NoWarn>$(NoWarn);MAAI001;CA1812;IDE0051</NoWarn>
<NoWarn>$(NoWarn);CA1812;IDE0051</NoWarn>
</PropertyGroup>
<ItemGroup>
@@ -6,7 +6,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<NoWarn>$(NoWarn);MAAI001;MCPEXP001</NoWarn>
<NoWarn>$(NoWarn);MCPEXP001</NoWarn>
</PropertyGroup>
<ItemGroup>
@@ -63,7 +63,17 @@ AIAgent agent = new AIProjectClient(new Uri(openAiEndpoint), new DefaultAzureCre
Instructions = "You are a helpful assistant. Use available skills to answer the user.",
},
AIContextProviders = [skillsProvider],
});
})
.AsBuilder()
.UseToolApproval(new ToolApprovalAgentOptions
{
// NOTE: Auto-approving all skill tools is done here for simplicity in
// this demonstration. In production, you should prompt the user before
// allowing skill tools to execute. See Agent_Step07_SkillsAutoApproval
// for a walkthrough of the full approval flow.
AutoApprovalRules = [AgentSkillsProvider.AllToolsAutoApprovalRule],
})
.Build();
// --- Run ---
Console.WriteLine(new string('-', 60));
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
@@ -1,12 +1,13 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console.ToolFormatters;
/// <summary>
/// Formats <c>file_memory_*</c> tool calls, showing file names and search patterns
/// with tree-view corners for save operations.
/// with tree-view corners for write and edit operations.
/// </summary>
public sealed class FileMemoryToolFormatter : ToolCallFormatter
{
@@ -16,14 +17,16 @@ public sealed class FileMemoryToolFormatter : ToolCallFormatter
/// <inheritdoc/>
public override string? FormatDetail(FunctionCallContent call) => call.Name switch
{
"file_memory_save_file" => FormatSaveFile(call),
"file_memory_read_file" => FormatStringArg(call, "fileName"),
"file_memory_delete_file" => FormatStringArg(call, "fileName"),
"file_memory_search_files" => FormatSearchFiles(call),
"file_memory_write" => FormatWriteFile(call),
"file_memory_read" => FormatStringArg(call, "fileName"),
"file_memory_delete" => FormatStringArg(call, "fileName"),
"file_memory_replace" => FormatReplaceFile(call),
"file_memory_replace_lines" => FormatReplaceLinesFile(call),
"file_memory_grep" => FormatGrep(call),
_ => null,
};
private static string? FormatSaveFile(FunctionCallContent call)
private static string? FormatWriteFile(FunctionCallContent call)
{
string? fileName = GetStringArgumentValue(call, "fileName");
string? description = GetStringArgumentValue(call, "description");
@@ -38,19 +41,60 @@ public sealed class FileMemoryToolFormatter : ToolCallFormatter
: $"\n └─ {fileName} (with description)";
}
private static string? FormatSearchFiles(FunctionCallContent call)
private static string? FormatReplaceFile(FunctionCallContent call)
{
string? fileName = GetStringArgumentValue(call, "fileName");
if (fileName is null)
{
return null;
}
bool replaceAll = string.Equals(GetStringArgumentValue(call, "replaceAll"), "true", StringComparison.OrdinalIgnoreCase);
return replaceAll
? $"\n └─ {fileName} (replace all)"
: $"\n └─ {fileName} (replace)";
}
private static string? FormatReplaceLinesFile(FunctionCallContent call)
{
string? fileName = GetStringArgumentValue(call, "fileName");
if (fileName is null)
{
return null;
}
int count = GetEditsCount(call, "edits");
return $"\n └─ {fileName} ({count} line(s))";
}
private static int GetEditsCount(FunctionCallContent call, string paramName)
{
if (call.Arguments?.TryGetValue(paramName, out object? value) == true &&
value is JsonElement je && je.ValueKind == JsonValueKind.Array)
{
return je.GetArrayLength();
}
return 0;
}
private static string? FormatGrep(FunctionCallContent call)
{
string? pattern = GetStringArgumentValue(call, "regexPattern");
string? filePattern = GetStringArgumentValue(call, "filePattern");
string? globPattern = GetStringArgumentValue(call, "globPattern");
if (pattern is null)
{
return null;
}
return string.IsNullOrEmpty(filePattern)
return string.IsNullOrEmpty(globPattern)
? $"(/{pattern}/)"
: $"(/{pattern}/ in {filePattern})";
: $"(/{pattern}/ in {globPattern})";
}
private static string? FormatStringArg(FunctionCallContent call, string paramName)
@@ -37,7 +37,7 @@ var instructions =
You are a data analyst assistant. You have access to a folder of data files via the file_access_* tools.
## Getting started
- Start by listing available files with file_access_list_files to see what data is available.
- Start by listing available files with file_access_ls to see what data is available.
- Read the files to understand their structure and contents.
## Working with data
@@ -46,7 +46,7 @@ var instructions =
- When calculations are needed, work through them step by step and show your reasoning.
## Writing output
- When asked to produce output files (e.g., reports, summaries, filtered data), use file_access_save_file to write them.
- When asked to produce output files (e.g., reports, summaries, filtered data), use file_access_write to write them.
- Use appropriate file formats: CSV for tabular data, Markdown for reports.
- Confirm what you wrote and where.
@@ -155,7 +155,7 @@ internal sealed class Program
try
{
await toolboxClient.DeleteToolboxAsync(name);
await toolboxClient.DeleteAsync(name);
Console.WriteLine($"Deleted existing toolbox '{name}'");
}
catch (ClientResultException ex) when (ex.Status == 404)
@@ -163,14 +163,15 @@ internal sealed class Program
// Toolbox does not exist.
}
ProjectsAgentTool webTool = ProjectsAgentTool.AsProjectTool(ResponseTool.CreateWebSearchTool());
WebSearchToolboxTool webTool = new();
ProjectsAgentTool mcpTool = ProjectsAgentTool.AsProjectTool(ResponseTool.CreateMcpTool(
serverLabel: serverLabel,
serverUri: new Uri("https://learn.microsoft.com/api/mcp"),
toolCallApprovalPolicy: new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.NeverRequireApproval)));
MCPToolboxTool mcpTool = new(serverLabel)
{
ServerUri = new Uri("https://learn.microsoft.com/api/mcp"),
ToolCallApprovalPolicy = new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.NeverRequireApproval),
};
ToolboxVersion created = (await toolboxClient.CreateToolboxVersionAsync(
ToolboxVersion created = (await toolboxClient.CreateVersionAsync(
name: name,
tools: [webTool, mcpTool],
description: "Sample toolbox combining Foundry web search with the Microsoft Learn MCP tools for the declarative InvokeFoundryToolboxMcp sample.")).Value;
@@ -7,7 +7,4 @@ SKILL_NAMES=support-style,escalation-policy
# Set to true to provision sample skills to Foundry on startup (first-run convenience).
# In production, skills are provisioned externally — leave this unset or false.
PROVISION_SAMPLE_SKILLS=true
AZURE_BEARER_TOKEN=DefaultAzureCredential
# When running outside the Foundry platform the platform-injected user-identity key is absent.
# This variable provides a fallback value for local Docker debugging only.
HOSTED_USER_ID=local-dev-user
AZURE_BEARER_TOKEN=DefaultAzureCredential
@@ -112,7 +112,6 @@ AIAgent agent = projectClient.AsAIAgent(new ChatClientAgentOptions
// 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();
@@ -14,9 +14,9 @@
- .env populated with FOUNDRY_PROJECT_ENDPOINT and model deployment
- Skills provisioned to Foundry (set PROVISION_SAMPLE_SKILLS=true on first run)
.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.
This script is for local Docker debugging only. Running locally the container needs no user
identity: per-user isolation simply is not triggered. On the Foundry platform the caller identity
(x-agent-user-id) is supplied automatically for every request.
#>
[CmdletBinding()]
@@ -50,7 +50,6 @@ function Start-Container {
docker run -d --name $ContainerName -p ${Port}:8088 `
-e AGENT_NAME=hosted-agent-skills `
-e AZURE_BEARER_TOKEN=$bearer `
-e HOSTED_USER_ID=smoke-user `
--env-file .env `
$ImageName | Out-Host
if ($LASTEXITCODE -ne 0) { throw "docker run failed." }
@@ -70,7 +70,6 @@ AIAgent agent = new AIProjectClient(new Uri(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();
@@ -14,8 +14,7 @@ Env.TraversePath().Load();
var projectEndpoint = new Uri(Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."));
var agentName = Environment.GetEnvironmentVariable("AGENT_NAME")
?? throw new InvalidOperationException("AGENT_NAME is not set.");
var agentName = Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-chat-client-agent";
var deployment = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-4o";
@@ -44,7 +43,6 @@ 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();
@@ -179,7 +179,6 @@ 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();
@@ -37,7 +37,6 @@ 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();
@@ -107,7 +107,6 @@ 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();
@@ -116,7 +116,6 @@ 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();
@@ -85,7 +85,6 @@ 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();
@@ -5,7 +5,4 @@ FOUNDRY_MODEL=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 user-identity key is absent.
# This variable provides a fallback value for local Docker debugging only.
HOSTED_USER_ID=local-dev-user
AZURE_BEARER_TOKEN=DefaultAzureCredential
@@ -28,8 +28,7 @@ Env.TraversePath().Load();
var projectEndpoint = new Uri(Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."));
var agentName = Environment.GetEnvironmentVariable("AGENT_NAME")
?? throw new InvalidOperationException("AGENT_NAME is not set.");
var agentName = Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-memory-agent";
var deployment = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "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";
@@ -75,9 +74,15 @@ ChatClientAgent agent = projectClient.AsAIAgent(new ChatClientAgentOptions()
});
// Host the agent as a Foundry Hosted Agent using the Responses API.
//
// Per-user memory isolation comes from the platform-injected x-agent-user-id header, resolved by the
// default HostedSessionIsolationKeyProvider into the session's HostedSessionContext. This sample scopes
// memory per user via HostedFoundryMemoryProviderScopes.PerUser(), which REQUIRES that context: a
// request with no resolved user identity throws. So locally you must send an x-agent-user-id request
// header (see scripts/smoke.ps1); vary it to simulate distinct users. On the Foundry platform the
// header is always present, so no local provider registration is needed.
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();
@@ -39,12 +39,6 @@ ASPNETCORE_URLS=http://+:8088
ASPNETCORE_ENVIRONMENT=Development
```
For local container runs only (the platform supplies these in production):
```env
HOSTED_USER_ID=alice
```
> `.env` is gitignored. The `.env.example` template is checked in as a reference.
## How memory scoping works
@@ -56,14 +50,11 @@ HOSTED_USER_ID=alice
| Session | The handler stores the resolved value on the session as a `HostedSessionContext` on the first request, and validates it 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 header is absent. The sample registers
`DevTemporaryLocalUserIdProvider` (via `AddDevTemporaryLocalContributorSetup`) which
falls back to the `HOSTED_USER_ID` environment variable,
defaulting to a single `local-dev-*` bucket when it is not set.
> **Production warning.** Never register `DevTemporaryLocalUserIdProvider` in
> production. The Foundry platform sets the user-identity key for every inbound request, and
> client-supplied environment variables can be forged.
This sample scopes memory per user via `HostedFoundryMemoryProviderScopes.PerUser()`, which requires a
resolved user identity — a request with none throws. So locally you **must** send an `x-agent-user-id`
request header (vary it to simulate distinct users); the default `HostedSessionIsolationKeyProvider`
reads it exactly as it reads the platform-injected value. On the Foundry platform the header is always
present, so no local provider registration is needed.
## Running directly (contributors)
@@ -78,9 +69,13 @@ The agent starts on `http://localhost:8088`.
### Test it
Per-user memories require an identity. Send an `x-agent-user-id` header to scope the call to a user
(locally you set it yourself; on the platform it is set for you):
```bash
curl -X POST http://localhost:8088/responses \
-H "Content-Type: application/json" \
-H "x-agent-user-id: alice" \
-d '{"input": "Hi! My name is Taylor and I am planning a hiking trip to Patagonia in November.", "model": "hosted-memory-agent"}'
```
@@ -90,6 +85,7 @@ previous call as `previous_response_id`:
```bash
curl -X POST http://localhost:8088/responses \
-H "Content-Type: application/json" \
-H "x-agent-user-id: alice" \
-d '{"input": "What do you already know about my upcoming trip?", "previous_response_id": "<id>", "model": "hosted-memory-agent"}'
```
@@ -118,23 +114,22 @@ export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.az
docker run --rm -p 8088:8088 \
-e AGENT_NAME=hosted-memory-agent \
-e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \
-e HOSTED_USER_ID=alice \
--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:
A scripted smoke test that exercises memory recall and per-user isolation 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_ID` values, drives a multi-turn conversation per user, asserts that each
user only sees their own memories, and exits non-zero on failure.
The script publishes the project, builds the image, runs a **single** container, and drives two users
(alice, bob) against it by varying the `x-agent-user-id` request header. It asserts that each user
only sees their own memories, and exits non-zero on failure.
## Deploying to Foundry (azd spec)
@@ -177,4 +172,4 @@ standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented sec
| **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 the user-identity header is absent | Same; additionally honours `HOSTED_USER_ID` to simulate distinct users |
| **Local dev** | Works with no identity header (per-user isolation not triggered) | Requires an `x-agent-user-id` header (memory is per-user); vary it to simulate distinct users |
@@ -3,19 +3,17 @@
.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_ID 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.
Publishes the sample, builds the contributor Docker image, runs ONE container, and drives two
users (alice, bob) against it by varying the x-agent-user-id request header. 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 FOUNDRY_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.
The x-agent-user-id header is set here only to simulate distinct users locally. On the Foundry
platform it is supplied automatically for every request.
#>
[CmdletBinding()]
@@ -44,12 +42,11 @@ 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]$ContainerName) {
function Start-Container([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_ID=$UserKey `
--env-file .env `
$ImageName | Out-Host
if ($LASTEXITCODE -ne 0) { throw "docker run failed for $ContainerName." }
@@ -57,11 +54,14 @@ function Start-Container([string]$UserKey, [string]$ContainerName) {
Start-Sleep -Seconds 6
}
function Invoke-Agent([string]$Prompt, [string]$PreviousResponseId = $null) {
function Invoke-Agent([string]$Prompt, [string]$UserId, [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
# x-agent-user-id is the identity the Foundry platform injects in production. Sending it locally
# is how a contributor drives per-user isolation.
$headers = @{ 'x-agent-user-id' = $UserId }
$resp = Invoke-RestMethod -Method Post -Uri "http://localhost:$Port/responses" -ContentType 'application/json' -Headers $headers -Body $json
return $resp
}
@@ -80,23 +80,23 @@ function Assert-NotContains([string]$Haystack, [string]$Needle, [string]$Label)
}
try {
# One container serves BOTH users; per-user isolation is driven purely by the x-agent-user-id
# header, exactly as the Foundry platform does in production (there the platform sets it).
Start-Container -ContainerName 'hosted-memory-smoke'
Write-Host '==> Phase 1: alice teaches the agent her trip details ...'
Start-Container -UserKey 'alice' -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
$r1 = Invoke-Agent -UserId 'alice' -Prompt 'Hi! My name is Taylor and I am planning a hiking trip to Patagonia in November.'
$r2 = Invoke-Agent -UserId 'alice' -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
$r3 = Invoke-Agent -UserId 'alice' -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' -ContainerName 'hosted-memory-smoke-bob'
$b1 = Invoke-Agent -Prompt 'Hello, what trip am I planning?'
Write-Host '==> Phase 2: bob asks the SAME container with a different x-agent-user-id ...'
$b1 = Invoke-Agent -UserId 'bob' -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'
@@ -104,6 +104,5 @@ try {
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
docker rm -f hosted-memory-smoke 2>$null | Out-Null
}
@@ -64,7 +64,6 @@ 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();
@@ -51,7 +51,6 @@ 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();
@@ -62,7 +62,7 @@ TokenCredential credential = new ChainedTokenCredential(
new DefaultAzureCredential());
// Notes on toolbox wiring — there are two ways to attach a Foundry Toolbox to an agent:
// - Server-side "baked-in" (what this sample uses): calling AddFoundryToolboxes(name)
// - Server-side "baked-in" (what this sample uses): calling AddFoundryToolboxes(credential, name)
// below registers the toolbox with the Foundry.Hosting layer, which resolves that
// toolbox's MCP tools once at startup and automatically makes them available to the
// agent on every request. The agent code does nothing per request.
@@ -94,7 +94,7 @@ builder.Services.AddFoundryResponses(agent);
// Pre-register the toolbox name so FoundryToolboxService resolves the foundry-toolbox://
// marker at request time. With FOUNDRY_PROJECT_ENDPOINT injected by the platform, startup
// MCP tools/list against the toolbox proxy is typically <100ms in-region.
builder.Services.AddFoundryToolboxes(toolboxName);
builder.Services.AddFoundryToolboxes(credential, toolboxName);
var app = builder.Build();
app.MapFoundryResponses();
@@ -8,7 +8,7 @@ Drive the agent across the auth paths with the shared [`Using-Samples/SimpleAgen
| Aspect | This sample | Existing siblings |
|---|---|---|
| Toolbox marker pattern | `FoundryAITool.CreateHostedMcpToolbox(name)` + `AddFoundryToolboxes(name)` | Same as [`Hosted-Toolbox/`](../Hosted-Toolbox/) |
| Toolbox marker pattern | `FoundryAITool.CreateHostedMcpToolbox(name)` + `AddFoundryToolboxes(credential, name)` | Same as [`Hosted-Toolbox/`](../Hosted-Toolbox/) |
| Tools per toolbox | **Three MCP tools, each with a different auth method** | `Hosted-Toolbox/`: typically one demo tool |
| Consumption | Server-side (Foundry resolves the marker) | Same |
| Client | Shared [`Using-Samples/SimpleAgent/`](../Using-Samples/SimpleAgent/) REPL, pointed at this agent | `Hosted-Toolbox/`: any client |
@@ -203,4 +203,3 @@ Inline `authorization` on a toolbox tool entry stores credentials **inside the t
- Local development against a test MCP server with a throwaway token.
For everything else use `project_connection_id` and let the platform inject credentials.
@@ -72,13 +72,12 @@ 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 toolbox name must match a toolbox registered in your Foundry project.
// When FOUNDRY_PROJECT_ENDPOINT is absent (e.g., in local development without Foundry
// infrastructure), startup succeeds without error and no toolbox tools are loaded.
builder.Services.AddFoundryToolboxes(toolboxName);
builder.Services.AddFoundryToolboxes(credential, toolboxName);
var app = builder.Build();
app.MapFoundryResponses();
@@ -2,7 +2,7 @@
A hosted Foundry agent that loads tools from a single Foundry Toolbox via the AF Foundry hosting bridge.
`AddFoundryToolboxes(name)` registers a `FoundryToolboxService` that connects to the Foundry Toolboxes MCP proxy at startup, discovers the toolbox's bundled tools via `tools/list`, and makes them available to the agent on every request. The agent code does nothing per request; the toolbox is baked in on the server.
`AddFoundryToolboxes(credential, name)` registers a `FoundryToolboxService` that connects to the Foundry Toolboxes MCP proxy at startup, discovers the toolbox's bundled tools via `tools/list`, and makes them available to the agent on every request. The agent code does nothing per request; the toolbox is baked in on the server.
This is the minimal toolbox intro. For a richer walkthrough where a single toolbox bundles three MCP tools each authenticated differently, see [`Hosted-Toolbox-AuthPaths/`](../Hosted-Toolbox-AuthPaths/).
@@ -4,7 +4,7 @@ displayName: "Hosted Toolbox"
description: >
A hosted agent that loads its tools from a single Foundry Toolbox via the
AF Foundry hosting bridge. AddFoundryToolboxes(name) connects to the Foundry
AF Foundry hosting bridge. AddFoundryToolboxes(credential, name) connects to the Foundry
Toolboxes MCP proxy at startup and exposes the toolbox's bundled tools to the
agent on every request. The toolbox itself is provisioned out of band; see this
sample's README for the portal walkthrough.
@@ -91,7 +91,6 @@ AIAgent agent = new AIProjectClient(new Uri(projectEndpoint), credential)
// ── Build the host ───────────────────────────────────────────────────────────
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();
@@ -13,10 +13,10 @@
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<!-- AgentServer 1.0.0-beta.26 (pulled transitively via Foundry.Hosting) requires Azure.Core 1.59.0.
This sample disables transitive pinning and references Azure.Core directly, so override just
this project to the SDK-required version without moving the solution-wide central pin. -->
<PackageReference Include="Azure.Core" VersionOverride="1.59.0" />
<!-- AgentServer (pulled transitively via Foundry.Hosting) requires Azure.Core >= 1.59.0.
The solution-wide central pin is 1.60.0, which satisfies that floor, so this project
references Azure.Core directly (transitive pinning is disabled here) without a version override. -->
<PackageReference Include="Azure.Core" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="DotNetEnv" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
@@ -53,7 +53,6 @@ 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();
@@ -1,52 +0,0 @@
// 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-id</c> header is 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 a
/// fallback value from the <c>HOSTED_USER_ID</c> environment variable, defaulting to the
/// constant below when it is not set.
///
/// This should NOT be used in production. The Foundry platform sets the user id for every inbound
/// request and forging it 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 DevTemporaryLocalUserIdProvider : HostedSessionIsolationKeyProvider
{
/// <summary>
/// Environment variable that supplies the user id when the platform header is absent.
/// </summary>
public const string UserIdEnvironmentVariable = "HOSTED_USER_ID";
/// <summary>
/// Default user id 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 DefaultLocalUserId = "local-dev-user";
/// <inheritdoc />
public override ValueTask<HostedSessionContext?> GetKeysAsync(
ResponseContext context,
CreateResponse request,
CancellationToken cancellationToken)
{
var userId = !string.IsNullOrWhiteSpace(context?.PlatformContext?.UserIdKey)
? context!.PlatformContext!.UserIdKey
: Environment.GetEnvironmentVariable(UserIdEnvironmentVariable);
if (string.IsNullOrWhiteSpace(userId))
{
userId = DefaultLocalUserId;
}
return new ValueTask<HostedSessionContext?>(new HostedSessionContext(userId!));
}
}
@@ -1,32 +0,0 @@
// 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="DevTemporaryLocalUserIdProvider"/>
/// so that requests succeed when the platform's <c>x-agent-user-id</c> header is absent. In
/// production that header is always present and the default platform user-id 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, DevTemporaryLocalUserIdProvider>();
return services;
}
}
@@ -83,23 +83,34 @@ public class AgentFrameworkResponseHandler : ResponseHandler
var isolationKeyProvider = this._serviceProvider.GetService<HostedSessionIsolationKeyProvider>()
?? s_defaultIsolationKeyProvider;
var resolvedHostedContext = await isolationKeyProvider.GetKeysAsync(context, request, cancellationToken).ConfigureAwait(false);
if (resolvedHostedContext is null)
if (resolvedHostedContext is null && FoundryEnvironment.IsHosted)
{
// Reached only when the container is NOT hosted by Foundry (local development without a
// fallback provider), or in the unexpected case of a 2.0.0 request that carried a call id
// but no x-agent-user-id. Hosted 1.0.0 requests are already handled above.
// Hosted by Foundry yet the provider produced no user identity. Protocol 1.0.0 (no call id)
// was already turned into a clear 501 above, so this is the unexpected case of a 2.0.0
// request that carried a call id but no x-agent-user-id, or a custom provider that returned
// null in production. Reject rather than silently persist an unscoped, cross-user session.
throw new InvalidOperationException(
$"The registered {nameof(HostedSessionIsolationKeyProvider)} returned null for the current request. " +
"Ensure the Foundry platform is providing the x-agent-user-id header, " +
"or register a custom provider that supplies fallback values for local development.");
}
// When resolvedHostedContext is null here the container is NOT hosted by Foundry (local
// development: docker run / dotnet run outside the platform, so no x-agent-user-id header).
// Per-user isolation simply does not apply in that case: the request proceeds with a null user
// id (the session store treats null as "no user partition") and no hosted context is stamped or
// validated. This lets contributors run the image locally without registering a fallback
// provider, while production stays strict because FoundryEnvironment.IsHosted is true there.
var resolvedUserId = resolvedHostedContext?.UserId;
// 3. Load or create a new session from the interaction.
// Map the request to a stable MAF AgentSession key: conversation_id when present, else the
// partition embedded in previous_response_id (chains converge), else the minted response id
// (cold start). Container session id is intentionally not used — it spans many conversations.
// The session store partitions persisted state per user via resolvedHostedContext.UserId so one
// user can never observe another user's session, even with a forged conversation id.
// The session store partitions persisted state per user via resolvedUserId so one user can
// never observe another user's session, even with a forged conversation id. Locally
// (resolvedUserId is null) there is no user to partition on, so the session is unscoped/shared
// by design — per-user isolation applies only when a user identity was resolved (hosted).
var conversationId = request.GetConversationId();
var sessionConversationId = HostedConversationKey.Resolve(
conversationId, request.PreviousResponseId, context.ResponseId);
@@ -107,7 +118,7 @@ public class AgentFrameworkResponseHandler : ResponseHandler
var chatClientAgent = agent.GetService<ChatClientAgent>();
AgentSession? session = !string.IsNullOrWhiteSpace(sessionConversationId)
? await sessionStore.GetSessionAsync(agent, sessionConversationId, resolvedHostedContext.UserId, cancellationToken).ConfigureAwait(false)
? await sessionStore.GetSessionAsync(agent, sessionConversationId, resolvedUserId, cancellationToken).ConfigureAwait(false)
: chatClientAgent is not null
? await chatClientAgent.CreateSessionAsync(cancellationToken).ConfigureAwait(false)
: await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
@@ -119,7 +130,9 @@ public class AgentFrameworkResponseHandler : ResponseHandler
var platformCallId = context.PlatformContext?.CallId;
HostedCallContext.CallId = platformCallId;
if (session is not null)
// Stamp/validate the hosted identity only when one was resolved. Locally (non-hosted) there is
// no user identity, so there is nothing to partition or tamper-check and the session is shared.
if (session is not null && resolvedHostedContext is not null)
{
var existingHostedContext = session.GetHostedContext();
if (existingHostedContext is null)
@@ -434,7 +447,7 @@ public class AgentFrameworkResponseHandler : ResponseHandler
// persisted session per end user, mirroring the load above so multi-turn continuity is preserved.
if (session is not null && !string.IsNullOrWhiteSpace(sessionConversationId))
{
await sessionStore.SaveSessionAsync(agent, sessionConversationId, session, resolvedHostedContext.UserId, cancellationToken).ConfigureAwait(false);
await sessionStore.SaveSessionAsync(agent, sessionConversationId, session, resolvedUserId, cancellationToken).ConfigureAwait(false);
}
}
}
@@ -5,6 +5,7 @@ using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Azure.Core;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Shared.DiagnosticIds;
@@ -16,7 +17,7 @@ namespace Microsoft.Agents.AI.Foundry.Hosting;
/// HealthChecks pipeline so the <c>GET /readiness</c> probe (mapped by
/// <see cref="FoundryHostingExtensions.MapFoundryResponses"/>) reflects whether
/// pre-registered toolbox connections are usable. Registered automatically by
/// <see cref="FoundryHostingExtensions.AddFoundryToolboxes(IServiceCollection, string[])"/>
/// <see cref="FoundryHostingExtensions.AddFoundryToolboxes(IServiceCollection, TokenCredential, string[])"/>
/// and its overloads.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
@@ -26,9 +26,12 @@ namespace Microsoft.Agents.AI.Foundry.Hosting;
/// (e.g., during local Docker debugging).
/// </para>
/// <para>
/// Implementations must return a <see cref="HostedSessionContext"/> whose <see cref="HostedSessionContext.UserId"/>
/// is non-null and non-whitespace. Returning null (or throwing from <see cref="GetKeysAsync"/>) is treated
/// as a configuration error and surfaces as a 500 from the hosting layer.
/// When an implementation returns a <see cref="HostedSessionContext"/>, its
/// <see cref="HostedSessionContext.UserId"/> must be non-null and non-whitespace. Returning null (or
/// throwing from <see cref="GetKeysAsync"/>) when the container is hosted by Foundry is treated as a
/// configuration error and surfaces as a 500 from the hosting layer. When the container is not hosted
/// (local development), a null result is tolerated: per-user isolation is simply not triggered and the
/// request proceeds without user partitioning.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
@@ -43,8 +46,9 @@ public abstract class HostedSessionIsolationKeyProvider
/// <returns>
/// A <see cref="HostedSessionContext"/> with non-null <see cref="HostedSessionContext.UserId"/>,
/// 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.
/// produce identity keys for the current request. A <see langword="null"/> result is a configuration
/// error (surfaced as 500) only when the container is hosted by Foundry; when running locally it is
/// tolerated and per-user isolation is not applied.
/// </returns>
public abstract ValueTask<HostedSessionContext?> GetKeysAsync(
ResponseContext context,
@@ -33,7 +33,6 @@
<ItemGroup>
<PackageReference Include="Azure.AI.AgentServer.Responses" />
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="ModelContextProtocol" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
</ItemGroup>
@@ -18,9 +18,11 @@ namespace Microsoft.Agents.AI.Foundry.Hosting;
/// This is the implementation used in production Foundry hosted environments. When running locally
/// outside the platform, the user id is <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 a fallback value for the missing header.
/// result differently depending on where the container runs: when hosted by Foundry
/// (<c>FoundryEnvironment.IsHosted</c>) it is a configuration error and surfaces as a 500; when running
/// locally (not hosted) per-user isolation is simply not triggered and the request proceeds with no
/// user partitioning. Local development can still register an alternate
/// <see cref="HostedSessionIsolationKeyProvider"/> implementation to simulate distinct users.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
internal sealed class PlatformHostedSessionIsolationKeyProvider : HostedSessionIsolationKeyProvider
@@ -3,16 +3,18 @@
using System;
using System.ClientModel.Primitives;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Runtime.CompilerServices;
using Azure.AI.AgentServer.Responses;
using Azure.Core;
using Azure.Identity;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Foundry.Hosting;
@@ -122,31 +124,43 @@ public static class FoundryHostingExtensions
/// <para>
/// Example:
/// <code>
/// builder.Services.AddFoundryToolboxes("my-toolbox", "another-toolbox");
/// builder.Services.AddFoundryToolboxes(credential, "my-toolbox", "another-toolbox");
/// </code>
/// </para>
/// </remarks>
/// <param name="services">The service collection.</param>
/// <param name="credential">The <see cref="TokenCredential"/> used to authenticate with the Foundry Toolboxes MCP proxy.</param>
/// <param name="toolboxNames">Names of the Foundry toolboxes to connect to.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddFoundryToolboxes(
this IServiceCollection services,
TokenCredential credential,
params string[] toolboxNames)
=> services.AddFoundryToolboxes(configureOptions: null, toolboxNames);
=> services.AddFoundryToolboxes(credential, configureOptions: null, toolboxNames);
/// <summary>
/// Registers the Foundry Toolbox service with additional options configuration.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="credential">The <see cref="TokenCredential"/> used to authenticate with the Foundry Toolboxes MCP proxy.</param>
/// <param name="configureOptions">Callback to further configure <see cref="FoundryToolboxOptions"/> (e.g. set <see cref="FoundryToolboxOptions.StrictMode"/>).</param>
/// <param name="toolboxNames">Names of the Foundry toolboxes to pre-register at startup.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddFoundryToolboxes(
this IServiceCollection services,
TokenCredential credential,
Action<FoundryToolboxOptions>? configureOptions,
params string[] toolboxNames)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(credential);
if (services.Any(d => d.ServiceType == typeof(FoundryToolboxService)))
{
throw new InvalidOperationException(
$"{nameof(FoundryToolboxService)} is already registered. " +
$"Call {nameof(AddFoundryToolboxes)} only once per service collection.");
}
services.Configure<FoundryToolboxOptions>(opt =>
{
@@ -161,22 +175,20 @@ public static class FoundryHostingExtensions
configureOptions?.Invoke(opt);
});
// Register DefaultAzureCredential as the default TokenCredential if not already registered
services.TryAddSingleton<TokenCredential>(_ => new DefaultAzureCredential());
// Register FoundryToolboxService as a singleton so it can be injected into the handler
services.TryAddSingleton<FoundryToolboxService>();
// AddHostedService uses TryAddEnumerable internally, so calling AddFoundryToolboxes
// multiple times will not invoke StartAsync twice on the same singleton.
// Register FoundryToolboxService as a singleton, injecting the caller-provided credential
// directly rather than resolving TokenCredential from DI.
services.AddSingleton(sp => new FoundryToolboxService(
sp.GetRequiredService<IOptions<FoundryToolboxOptions>>(),
credential: credential,
sp.GetService<ILogger<FoundryToolboxService>>()));
services.AddHostedService(sp => sp.GetRequiredService<FoundryToolboxService>());
// Register the toolbox health check on the same /readiness pipeline that
// MapFoundryResponses maps. This gates the Foundry hosted runtime's readiness
// probe (per container-image-spec.md §3.1) on the outcome of the pre-registered
// toolbox connections opened in FoundryToolboxService.StartAsync.
// AddCheck<T>(name, ...) does NOT dedupe by name, so guard against duplicate
// registration when AddFoundryToolboxes is called multiple times.
// AddCheck<T>(name, ...) does NOT dedupe by name, so guard against a host that
// already registered a health check with this name.
const string HealthCheckName = "foundry-toolbox";
services.AddHealthChecks();
services.Configure<HealthCheckServiceOptions>(opts =>
@@ -583,7 +583,6 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
approvalRequiredToolNames.Count,
string.Join(", ", approvalRequiredToolNames));
}
return sessionConfig;
}
@@ -274,7 +274,9 @@ public sealed class HarnessAgent : DelegatingAIAgent
if (options?.ShellExecutor is ShellExecutor shellExecutor)
{
result.Tools ??= [];
result.Tools.Add(shellExecutor.AsAIFunction());
result.Tools.Add(options.ShellToolName is { } shellToolName
? shellExecutor.AsAIFunction(shellToolName, options.ShellToolDescription, !options.DisableShellToolApproval)
: shellExecutor.AsAIFunction(description: options.ShellToolDescription, requireApproval: !options.DisableShellToolApproval));
}
#endif
@@ -371,6 +371,45 @@ public sealed class HarnessAgentOptions
/// </remarks>
public ShellExecutor? ShellExecutor { get; set; }
/// <summary>
/// Gets or sets the name of the shell execution tool exposed to the model.
/// </summary>
/// <remarks>
/// When <see langword="null"/> (the default), the shell executor's default tool name (<c>run_shell</c>) is used.
/// This property is ignored when <see cref="ShellExecutor"/> is <see langword="null"/>.
/// </remarks>
public string? ShellToolName { get; set; }
/// <summary>
/// Gets or sets the description of the shell execution tool shown to the model.
/// </summary>
/// <remarks>
/// When <see langword="null"/> (the default), the shell executor's built-in description is used.
/// This property is ignored when <see cref="ShellExecutor"/> is <see langword="null"/>.
/// </remarks>
public string? ShellToolDescription { get; set; }
/// <summary>
/// Gets or sets a value indicating whether approval is disabled for the shell execution tool.
/// </summary>
/// <remarks>
/// <para>
/// When <see langword="false"/> (the default), the shell tool is wrapped in an <see cref="ApprovalRequiredAIFunction"/>
/// so every command requires explicit approval before executing. When <see langword="true"/>, the tool can be invoked
/// without approval. This property is ignored when <see cref="ShellExecutor"/> is <see langword="null"/>.
/// </para>
/// <para>
/// Setting this to <see langword="true"/> also requires the underlying <see cref="ShellExecutor"/> to permit
/// unapproved use. The inverse of this value is forwarded as the <c>requireApproval</c> argument to
/// <see cref="ShellExecutor.AsAIFunction"/>, and some executors enforce their own security boundary:
/// <see cref="LocalShellExecutor"/> throws an <see cref="System.InvalidOperationException"/> unless it was
/// constructed with <see cref="LocalShellExecutorOptions.AcknowledgeUnsafe"/> set to <see langword="true"/>,
/// because running unapproved commands directly on the host is inherently unsafe. Sandboxed executors such as
/// <see cref="DockerShellExecutor"/> impose no such requirement.
/// </para>
/// </remarks>
public bool DisableShellToolApproval { get; set; }
/// <summary>
/// Gets or sets optional configuration for the <see cref="ShellEnvironmentProvider"/>.
/// </summary>
@@ -10,6 +10,7 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Converters;
using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
using Microsoft.Agents.AI.Hosting.OpenAI.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.Extensions.AI;
@@ -18,19 +19,40 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions;
internal static class AIAgentChatCompletionsProcessor
{
public static async Task<IResult> CreateChatCompletionAsync(AIAgent agent, CreateChatCompletion request, CancellationToken cancellationToken)
public static async Task<IResult> CreateChatCompletionAsync(AIAgent agent, CreateChatCompletion request, OpenAIChatCompletionsMapOptions? mapOptions, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(agent);
var runOptionsFactory = (mapOptions ?? new OpenAIChatCompletionsMapOptions()).RunOptionsFactory;
AgentRunOptions? runOptions;
try
{
// The hosting developer controls, via OpenAIChatCompletionsMapOptions.RunOptionsFactory, which (if any)
// request settings are mapped onto the agent run. By default no request setting is mapped.
runOptions = runOptionsFactory(request.ToRequestInfo());
}
catch (NotSupportedException ex)
{
return Results.BadRequest(new ErrorResponse
{
Error = new ErrorDetails
{
Message = ex.Message,
Type = "invalid_request_error",
Code = "unsupported_parameter"
}
});
}
var chatMessages = request.Messages.Select(i => i.ToChatMessage());
var chatClientAgentRunOptions = request.BuildOptions();
if (request.Stream == true)
{
return new StreamingResponse(agent, request, chatMessages, chatClientAgentRunOptions);
return new StreamingResponse(agent, request, chatMessages, runOptions);
}
var response = await agent.RunAsync(chatMessages, options: chatClientAgentRunOptions, cancellationToken: cancellationToken).ConfigureAwait(false);
var response = await agent.RunAsync(chatMessages, options: runOptions, cancellationToken: cancellationToken).ConfigureAwait(false);
return Results.Ok(response.ToChatCompletion(request));
}
@@ -38,7 +60,7 @@ internal static class AIAgentChatCompletionsProcessor
AIAgent agent,
CreateChatCompletion request,
IEnumerable<ChatMessage> chatMessages,
ChatClientAgentRunOptions? options) : IResult
AgentRunOptions? options) : IResult
{
public Task ExecuteAsync(HttpContext httpContext)
{
@@ -12,35 +12,24 @@ internal static class ChatClientAgentRunOptionsConverter
{
private static readonly JsonElement s_emptyJson = JsonElement.Parse("{}");
public static ChatClientAgentRunOptions BuildOptions(this CreateChatCompletion request)
/// <summary>
/// Projects the request-supplied generation and tool settings into a public
/// <see cref="OpenAIChatCompletionRequestInfo"/> for use by a hosting-developer mapping callback.
/// </summary>
public static OpenAIChatCompletionRequestInfo ToRequestInfo(this CreateChatCompletion request) => new()
{
ChatOptions chatOptions = new()
{
Temperature = request.Temperature,
MaxOutputTokens = request.MaxCompletionTokens,
FrequencyPenalty = request.FrequencyPenalty,
PresencePenalty = request.PresencePenalty,
Seed = request.Seed,
TopP = request.TopP,
StopSequences = request.Stop?.SequenceList ?? [],
ResponseFormat = request.ResponseFormat?.ToChatResponseFormat()
};
if (request.ToolChoice is not null)
{
chatOptions.ToolMode = request.ToolChoice.ToChatToolMode();
}
if (request.Tools?.Count > 0)
{
chatOptions.Tools = request.Tools.Select(x => x.ToAITool()).ToList();
}
return new()
{
ChatOptions = chatOptions
};
}
Temperature = request.Temperature,
TopP = request.TopP,
MaxOutputTokens = request.MaxCompletionTokens,
FrequencyPenalty = request.FrequencyPenalty,
PresencePenalty = request.PresencePenalty,
Seed = request.Seed,
StopSequences = request.Stop?.SequenceList is { Count: > 0 } sequences ? [.. sequences] : null,
ResponseFormat = request.ResponseFormat?.ToChatResponseFormat(),
Model = request.Model,
ToolChoice = request.ToolChoice?.ToChatToolMode(),
Tools = request.Tools is { Count: > 0 } tools ? tools.Select(x => x.ToAITool()).ToList() : null,
};
private static ChatResponseFormat ToChatResponseFormat(this ResponseFormat responseFormat)
{
@@ -5,6 +5,7 @@ using System.Diagnostics.CodeAnalysis;
using System.Threading;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hosting;
using Microsoft.Agents.AI.Hosting.OpenAI;
using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions;
using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
using Microsoft.AspNetCore.Mvc;
@@ -29,10 +30,11 @@ public static partial class MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExt
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the OpenAI ChatCompletions endpoints to.</param>
/// <param name="agentBuilder">The builder for <see cref="AIAgent"/> to map the OpenAI ChatCompletions endpoints for.</param>
/// <param name="path">Custom route path for the chat completions endpoint.</param>
public static IEndpointConventionBuilder MapOpenAIChatCompletions(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string? path)
/// <param name="mapOptions">Optional options controlling how incoming requests are mapped onto the agent run.</param>
public static IEndpointConventionBuilder MapOpenAIChatCompletions(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string? path, OpenAIChatCompletionsMapOptions? mapOptions = null)
{
var agent = endpoints.ServiceProvider.GetRequiredKeyedService<AIAgent>(agentBuilder.Name);
return MapOpenAIChatCompletions(endpoints, agent, path);
return MapOpenAIChatCompletions(endpoints, agent, path, mapOptions);
}
/// <summary>
@@ -49,10 +51,12 @@ public static partial class MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExt
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the OpenAI ChatCompletions endpoints to.</param>
/// <param name="agent">The <see cref="AIAgent"/> instance to map the OpenAI ChatCompletions endpoints for.</param>
/// <param name="path">Custom route path for the chat completions endpoint.</param>
/// <param name="mapOptions">Optional options controlling how incoming requests are mapped onto the agent run.</param>
public static IEndpointConventionBuilder MapOpenAIChatCompletions(
this IEndpointRouteBuilder endpoints,
AIAgent agent,
[StringSyntax("Route")] string? path)
[StringSyntax("Route")] string? path,
OpenAIChatCompletionsMapOptions? mapOptions = null)
{
ArgumentNullException.ThrowIfNull(endpoints);
ArgumentNullException.ThrowIfNull(agent);
@@ -64,7 +68,7 @@ public static partial class MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExt
var endpointAgentName = agent.Name ?? agent.Id;
group.MapPost("/", async ([FromBody] CreateChatCompletion request, CancellationToken cancellationToken)
=> await AIAgentChatCompletionsProcessor.CreateChatCompletionAsync(agent, request, cancellationToken).ConfigureAwait(false))
=> await AIAgentChatCompletionsProcessor.CreateChatCompletionAsync(agent, request, mapOptions, cancellationToken).ConfigureAwait(false))
.WithName(endpointAgentName + "/CreateChatCompletion");
return group;
@@ -32,13 +32,14 @@ public static partial class MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExt
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the OpenAI Responses endpoints to.</param>
/// <param name="agentBuilder">The builder for <see cref="AIAgent"/> to map the OpenAI Responses endpoints for.</param>
/// <param name="path">Custom route path for the OpenAI Responses endpoint.</param>
public static IEndpointConventionBuilder MapOpenAIResponses(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string? path)
/// <param name="mapOptions">Optional options controlling how incoming requests are mapped onto the agent run.</param>
public static IEndpointConventionBuilder MapOpenAIResponses(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string? path, OpenAIResponsesMapOptions? mapOptions = null)
{
ArgumentNullException.ThrowIfNull(endpoints);
ArgumentNullException.ThrowIfNull(agentBuilder);
var agent = endpoints.ServiceProvider.GetRequiredKeyedService<AIAgent>(agentBuilder.Name);
return MapOpenAIResponses(endpoints, agent, path);
return MapOpenAIResponses(endpoints, agent, path, mapOptions);
}
/// <summary>
@@ -55,10 +56,12 @@ public static partial class MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExt
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the OpenAI Responses endpoints to.</param>
/// <param name="agent">The <see cref="AIAgent"/> instance to map the OpenAI Responses endpoints for.</param>
/// <param name="responsesPath">Custom route path for the responses endpoint.</param>
/// <param name="mapOptions">Optional options controlling how incoming requests are mapped onto the agent run.</param>
public static IEndpointConventionBuilder MapOpenAIResponses(
this IEndpointRouteBuilder endpoints,
AIAgent agent,
[StringSyntax("Route")] string? responsesPath)
[StringSyntax("Route")] string? responsesPath,
OpenAIResponsesMapOptions? mapOptions = null)
{
ArgumentNullException.ThrowIfNull(endpoints);
ArgumentNullException.ThrowIfNull(agent);
@@ -68,7 +71,7 @@ public static partial class MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExt
responsesPath ??= $"/{agent.Name}/v1/responses";
// Create an executor for this agent
var executor = new AIAgentResponseExecutor(agent);
var executor = new AIAgentResponseExecutor(agent, mapOptions);
var storageOptions = endpoints.ServiceProvider.GetService<InMemoryStorageOptions>() ?? new InMemoryStorageOptions();
var conversationStorage = endpoints.ServiceProvider.GetService<IConversationStorage>();
var responsesService = new InMemoryResponsesService(executor, storageOptions, conversationStorage);
@@ -0,0 +1,87 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hosting.OpenAI;
/// <summary>
/// Exposes the request-supplied generation and tool settings of an OpenAI ChatCompletions
/// <c>create chat completion</c> request that a hosting developer may choose to map onto the
/// <see cref="AgentRunOptions"/> used to run the target <see cref="AIAgent"/>.
/// </summary>
/// <remarks>
/// <para>
/// This type is passed to <see cref="OpenAIChatCompletionsMapOptions.RunOptionsFactory"/>. By default no
/// request setting is mapped onto the agent, because an agent is typically self-contained and
/// allowing callers to override its configuration (for example which tools it may invoke) can cause
/// it to behave in ways its author did not intend.
/// </para>
/// <para>
/// Tool and response-format settings are surfaced using their <c>Microsoft.Extensions.AI</c>
/// equivalents so that a hosting developer can map them directly without re-parsing the wire format.
/// </para>
/// </remarks>
public sealed class OpenAIChatCompletionRequestInfo
{
/// <summary>
/// Gets or sets the sampling temperature supplied on the request, if any.
/// </summary>
public float? Temperature { get; set; }
/// <summary>
/// Gets or sets the nucleus sampling value (<c>top_p</c>) supplied on the request, if any.
/// </summary>
public float? TopP { get; set; }
/// <summary>
/// Gets or sets the maximum number of completion tokens (<c>max_completion_tokens</c>) supplied on the request, if any.
/// </summary>
public int? MaxOutputTokens { get; set; }
/// <summary>
/// Gets or sets the frequency penalty supplied on the request, if any.
/// </summary>
public float? FrequencyPenalty { get; set; }
/// <summary>
/// Gets or sets the presence penalty supplied on the request, if any.
/// </summary>
public float? PresencePenalty { get; set; }
/// <summary>
/// Gets or sets the deterministic sampling seed supplied on the request, if any.
/// </summary>
public long? Seed { get; set; }
/// <summary>
/// Gets or sets the stop sequences supplied on the request, if any.
/// </summary>
public IReadOnlyList<string>? StopSequences { get; set; }
/// <summary>
/// Gets or sets the response format supplied on the request, if any.
/// </summary>
public ChatResponseFormat? ResponseFormat { get; set; }
/// <summary>
/// Gets or sets the model identifier supplied on the request, if any.
/// </summary>
/// <remarks>
/// This value is informational. It is not applied to local agent execution (the agent runs with
/// its own <see cref="IChatClient"/>), and the OpenAI ChatCompletions
/// wire format requires it on every request, so it is intentionally excluded from the default
/// <see cref="OpenAIChatCompletionsMapOptions.RejectRequestSettings"/> rejection.
/// </remarks>
public string? Model { get; set; }
/// <summary>
/// Gets or sets the tool selection mode (<c>tool_choice</c>) supplied on the request, if any.
/// </summary>
public ChatToolMode? ToolChoice { get; set; }
/// <summary>
/// Gets or sets the tools supplied on the request, if any.
/// </summary>
public IReadOnlyList<AITool>? Tools { get; set; }
}
@@ -0,0 +1,121 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Hosting.OpenAI;
/// <summary>
/// Options that control how an OpenAI ChatCompletions endpoint maps incoming requests onto the target
/// <see cref="AIAgent"/>.
/// </summary>
public sealed class OpenAIChatCompletionsMapOptions
{
/// <summary>
/// Gets or sets the callback used to produce the <see cref="AgentRunOptions"/> for a request from
/// the request-supplied generation and tool settings.
/// </summary>
/// <remarks>
/// <para>
/// By default this is set to <see cref="RejectRequestSettings"/>, which throws when the request
/// carries any setting that would otherwise be mapped onto the agent (for example
/// <c>temperature</c>, <c>tools</c> or <c>tool_choice</c>). This prevents a caller from silently
/// overriding the configuration of a self-contained agent.
/// </para>
/// <para>
/// Hosting developers that want to honor specific request settings can supply their own callback
/// that maps the desired fields onto an <see cref="AgentRunOptions"/> (or a subclass such as
/// <see cref="ChatClientAgentRunOptions"/>), and may choose to throw, map, or ignore any field.
/// Returning <see langword="null"/> runs the agent with its own configuration only.
/// </para>
/// </remarks>
public Func<OpenAIChatCompletionRequestInfo, AgentRunOptions?> RunOptionsFactory
{
get;
set
{
field = Throw.IfNull(value);
}
} = RejectRequestSettings;
/// <summary>
/// The default <see cref="RunOptionsFactory"/> implementation. Throws a <see cref="NotSupportedException"/>
/// when the request specifies any setting that would otherwise be mapped onto the agent, and otherwise
/// returns <see langword="null"/> so that the agent runs with its own configuration only.
/// </summary>
/// <param name="request">The request-supplied settings.</param>
/// <returns>Always <see langword="null"/> when no unsupported setting is present.</returns>
/// <remarks>
/// <see cref="OpenAIChatCompletionRequestInfo.Model"/> is intentionally not treated as an unsupported
/// setting: it is informational, is not applied to local execution, and is a required field of the
/// OpenAI ChatCompletions wire format (present on every request).
/// </remarks>
/// <exception cref="NotSupportedException">One or more request settings are not supported.</exception>
public static AgentRunOptions? RejectRequestSettings(OpenAIChatCompletionRequestInfo request)
{
Throw.IfNull(request);
List<string>? unsupported = null;
void LocalAdd(string name) => (unsupported ??= []).Add(name);
if (request.Temperature is not null)
{
LocalAdd("temperature");
}
if (request.TopP is not null)
{
LocalAdd("top_p");
}
if (request.MaxOutputTokens is not null)
{
LocalAdd("max_completion_tokens");
}
if (request.FrequencyPenalty is not null)
{
LocalAdd("frequency_penalty");
}
if (request.PresencePenalty is not null)
{
LocalAdd("presence_penalty");
}
if (request.Seed is not null)
{
LocalAdd("seed");
}
if (request.StopSequences is { Count: > 0 })
{
LocalAdd("stop");
}
if (request.ResponseFormat is not null)
{
LocalAdd("response_format");
}
if (request.Tools is { Count: > 0 })
{
LocalAdd("tools");
}
if (request.ToolChoice is not null)
{
LocalAdd("tool_choice");
}
if (unsupported is not null)
{
throw new NotSupportedException(
$"The following request setting(s) are not supported by this agent endpoint: {string.Join(", ", unsupported)}. " +
"Configure an OpenAIChatCompletionsMapOptions.RunOptionsFactory to map these settings onto the agent if they should be honored.");
}
return null;
}
}
@@ -0,0 +1,77 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hosting.OpenAI;
/// <summary>
/// Exposes the request-supplied generation and tool settings of an OpenAI Responses
/// <c>create response</c> request that a hosting developer may choose to map onto the
/// <see cref="AgentRunOptions"/> used to run the target <see cref="AIAgent"/>.
/// </summary>
/// <remarks>
/// <para>
/// This type is passed to <see cref="OpenAIResponsesMapOptions.RunOptionsFactory"/>. By default no
/// request setting is mapped onto the agent, because an agent is typically self-contained and
/// allowing callers to override its configuration (for example its instructions or which tools it
/// may invoke) can cause it to behave in ways its author did not intend.
/// </para>
/// <para>
/// Only the subset of request fields that are meaningful to map onto a local agent run are exposed.
/// The raw wire model is intentionally not surfaced.
/// </para>
/// </remarks>
public sealed class OpenAIResponseRequestInfo
{
/// <summary>
/// Gets or sets the sampling temperature supplied on the request, if any.
/// </summary>
public double? Temperature { get; set; }
/// <summary>
/// Gets or sets the nucleus sampling value (<c>top_p</c>) supplied on the request, if any.
/// </summary>
public double? TopP { get; set; }
/// <summary>
/// Gets or sets the maximum number of output tokens supplied on the request, if any.
/// </summary>
public int? MaxOutputTokens { get; set; }
/// <summary>
/// Gets or sets the instructions supplied on the request, if any.
/// </summary>
public string? Instructions { get; set; }
/// <summary>
/// Gets or sets the model identifier supplied on the request, if any.
/// </summary>
/// <remarks>
/// This value is informational. It is not applied to local agent execution (the agent runs with
/// its own <see cref="IChatClient"/>), so it is intentionally excluded
/// from the default <see cref="OpenAIResponsesMapOptions.RejectRequestSettings"/> rejection.
/// </remarks>
public string? Model { get; set; }
/// <summary>
/// Gets or sets the raw <c>tools</c> array supplied on the request, if any.
/// </summary>
/// <remarks>
/// The OpenAI Responses wire format represents tools as JSON tool declarations rather than
/// executable functions, so they are surfaced here as the raw <see cref="JsonElement"/> values.
/// </remarks>
public IReadOnlyList<JsonElement>? Tools { get; set; }
/// <summary>
/// Gets or sets the tool selection mode (<c>tool_choice</c>) supplied on the request, if any.
/// </summary>
/// <remarks>
/// The OpenAI Responses <c>tool_choice</c> value is mapped onto its
/// <see cref="ChatToolMode"/> equivalent (<c>none</c>, <c>auto</c>,
/// <c>required</c>, or a specific function). Values that have no equivalent are surfaced as
/// <see langword="null"/>.
/// </remarks>
public ChatToolMode? ToolChoice { get; set; }
}
@@ -0,0 +1,100 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Hosting.OpenAI;
/// <summary>
/// Options that control how an OpenAI Responses endpoint maps incoming requests onto the target
/// <see cref="AIAgent"/>.
/// </summary>
public sealed class OpenAIResponsesMapOptions
{
/// <summary>
/// Gets or sets the callback used to produce the <see cref="AgentRunOptions"/> for a request from
/// the request-supplied generation and tool settings.
/// </summary>
/// <remarks>
/// <para>
/// By default this is set to <see cref="RejectRequestSettings"/>, which throws when the request
/// carries any setting that would otherwise be mapped onto the agent (for example
/// <c>temperature</c>, <c>instructions</c>, <c>tools</c> or <c>tool_choice</c>). This prevents a
/// caller from silently overriding the configuration of a self-contained agent.
/// </para>
/// <para>
/// Hosting developers that want to honor specific request settings can supply their own callback
/// that maps the desired fields onto an <see cref="AgentRunOptions"/> (or a subclass such as
/// <see cref="ChatClientAgentRunOptions"/>), and may choose to throw, map, or ignore any field.
/// Returning <see langword="null"/> runs the agent with its own configuration only.
/// </para>
/// </remarks>
public Func<OpenAIResponseRequestInfo, AgentRunOptions?> RunOptionsFactory
{
get;
set
{
field = Throw.IfNull(value);
}
} = RejectRequestSettings;
/// <summary>
/// The default <see cref="RunOptionsFactory"/> implementation. Throws a <see cref="NotSupportedException"/>
/// when the request specifies any setting that would otherwise be mapped onto the agent, and otherwise
/// returns <see langword="null"/> so that the agent runs with its own configuration only.
/// </summary>
/// <param name="request">The request-supplied settings.</param>
/// <returns>Always <see langword="null"/> when no unsupported setting is present.</returns>
/// <remarks>
/// <see cref="OpenAIResponseRequestInfo.Model"/> is intentionally not treated as an unsupported
/// setting: it is informational and is not applied to local execution.
/// </remarks>
/// <exception cref="NotSupportedException">One or more request settings are not supported.</exception>
public static AgentRunOptions? RejectRequestSettings(OpenAIResponseRequestInfo request)
{
ArgumentNullException.ThrowIfNull(request);
List<string>? unsupported = null;
void LocalAdd(string name) => (unsupported ??= []).Add(name);
if (request.Temperature is not null)
{
LocalAdd("temperature");
}
if (request.TopP is not null)
{
LocalAdd("top_p");
}
if (request.MaxOutputTokens is not null)
{
LocalAdd("max_output_tokens");
}
if (request.Instructions is not null)
{
LocalAdd("instructions");
}
if (request.Tools is { Count: > 0 })
{
LocalAdd("tools");
}
if (request.ToolChoice is not null)
{
LocalAdd("tool_choice");
}
if (unsupported is not null)
{
throw new NotSupportedException(
$"The following request setting(s) are not supported by this agent endpoint: {string.Join(", ", unsupported)}. " +
"Configure an OpenAIResponsesMapOptions.RunOptionsFactory to map these settings onto the agent if they should be honored.");
}
return null;
}
}
@@ -17,16 +17,38 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;
internal sealed class AIAgentResponseExecutor : IResponseExecutor
{
private readonly AIAgent _agent;
private readonly Func<OpenAIResponseRequestInfo, AgentRunOptions?> _runOptionsFactory;
public AIAgentResponseExecutor(AIAgent agent)
public AIAgentResponseExecutor(AIAgent agent, OpenAIResponsesMapOptions? mapOptions = null)
{
ArgumentNullException.ThrowIfNull(agent);
this._agent = agent;
this._runOptionsFactory = (mapOptions ?? new OpenAIResponsesMapOptions()).RunOptionsFactory;
}
public ValueTask<ResponseError?> ValidateRequestAsync(
CreateResponse request,
CancellationToken cancellationToken = default) => ValueTask.FromResult<ResponseError?>(null);
CancellationToken cancellationToken = default)
=> ValueTask.FromResult(this.ValidateRunOptions(request));
internal ResponseError? ValidateRunOptions(CreateResponse request)
{
try
{
// Invoke the factory during validation so that unsupported request settings are surfaced
// as a clean request error rather than an unhandled exception during execution.
_ = this._runOptionsFactory(request.ToRequestInfo());
return null;
}
catch (NotSupportedException ex)
{
return new ResponseError
{
Code = "unsupported_parameter",
Message = ex.Message
};
}
}
public async IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
AgentInvocationContext context,
@@ -34,23 +56,9 @@ internal sealed class AIAgentResponseExecutor : IResponseExecutor
IReadOnlyList<ChatMessage>? conversationHistory = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
// Create options with properties from the request
var chatOptions = new ChatOptions
{
// Note: We intentionally do NOT set ConversationId on ChatOptions here.
// The conversation ID from the client request is used by the hosting layer
// to manage conversation storage, but should not be forwarded to the underlying
// IChatClient as it has its own concept of conversations (or none at all).
// ---
// ConversationId = request.Conversation?.Id,
Temperature = (float?)request.Temperature,
TopP = (float?)request.TopP,
MaxOutputTokens = request.MaxOutputTokens,
Instructions = request.Instructions,
ModelId = request.Model,
};
var options = new ChatClientAgentRunOptions(chatOptions);
// The hosting developer controls, via OpenAIResponsesMapOptions.RunOptionsFactory, which (if any)
// request settings are mapped onto the agent run. By default no request setting is mapped.
AgentRunOptions? options = this._runOptionsFactory(request.ToRequestInfo());
// Convert input to chat messages, prepending conversation history if available
var messages = new List<ChatMessage>();
@@ -21,21 +21,25 @@ internal sealed class HostedAgentResponseExecutor : IResponseExecutor
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<HostedAgentResponseExecutor> _logger;
private readonly Func<OpenAIResponseRequestInfo, AgentRunOptions?> _runOptionsFactory;
/// <summary>
/// Initializes a new instance of the <see cref="HostedAgentResponseExecutor"/> class.
/// </summary>
/// <param name="serviceProvider">The service provider used to resolve hosted agents.</param>
/// <param name="logger">The logger instance.</param>
/// <param name="mapOptions">Options controlling how incoming requests are mapped onto the agent run.</param>
public HostedAgentResponseExecutor(
IServiceProvider serviceProvider,
ILogger<HostedAgentResponseExecutor> logger)
ILogger<HostedAgentResponseExecutor> logger,
OpenAIResponsesMapOptions? mapOptions = null)
{
ArgumentNullException.ThrowIfNull(serviceProvider);
ArgumentNullException.ThrowIfNull(logger);
this._serviceProvider = serviceProvider;
this._logger = logger;
this._runOptionsFactory = (mapOptions ?? new OpenAIResponsesMapOptions()).RunOptionsFactory;
}
/// <inheritdoc/>
@@ -75,6 +79,21 @@ internal sealed class HostedAgentResponseExecutor : IResponseExecutor
});
}
// Surface unsupported request settings as a clean request error rather than an unhandled
// exception during execution.
try
{
_ = this._runOptionsFactory(request.ToRequestInfo());
}
catch (NotSupportedException ex)
{
return ValueTask.FromResult<ResponseError?>(new ResponseError
{
Code = "unsupported_parameter",
Message = ex.Message
});
}
return ValueTask.FromResult<ResponseError?>(null);
}
@@ -88,22 +107,9 @@ internal sealed class HostedAgentResponseExecutor : IResponseExecutor
string agentName = GetAgentName(request)!;
AIAgent agent = this._serviceProvider.GetRequiredKeyedService<AIAgent>(agentName);
var chatOptions = new ChatOptions
{
// Note: We intentionally do NOT set ConversationId on ChatOptions here.
// The conversation ID from the client request is used by the hosting layer
// to manage conversation storage, but should not be forwarded to the underlying
// IChatClient as it has its own concept of conversations (or none at all).
// ---
// ConversationId = request.Conversation?.Id,
Temperature = (float?)request.Temperature,
TopP = (float?)request.TopP,
MaxOutputTokens = request.MaxOutputTokens,
Instructions = request.Instructions,
ModelId = request.Model,
};
var options = new ChatClientAgentRunOptions(chatOptions);
// The hosting developer controls, via OpenAIResponsesMapOptions.RunOptionsFactory, which (if any)
// request settings are mapped onto the agent run. By default no request setting is mapped.
AgentRunOptions? options = this._runOptionsFactory(request.ToRequestInfo());
var messages = new List<ChatMessage>();
if (conversationHistory is not null)
@@ -0,0 +1,62 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json;
using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;
internal static class OpenAIResponseRequestInfoBuilder
{
public static OpenAIResponseRequestInfo ToRequestInfo(this CreateResponse request) => new()
{
Temperature = request.Temperature,
TopP = request.TopP,
MaxOutputTokens = request.MaxOutputTokens,
Instructions = request.Instructions,
Model = request.Model,
Tools = request.Tools is { Count: > 0 } tools ? new List<JsonElement>(tools) : null,
ToolChoice = request.ToolChoice?.ToChatToolMode(),
};
/// <summary>
/// Maps an OpenAI Responses <c>tool_choice</c> value onto its <see cref="ChatToolMode"/> equivalent.
/// </summary>
/// <remarks>
/// The Responses <c>tool_choice</c> is either a string (<c>none</c>, <c>auto</c> or <c>required</c>)
/// or an object identifying a specific tool (for example <c>{ "type": "function", "name": "..." }</c>).
/// Values that have no <see cref="ChatToolMode"/> equivalent are mapped to <see langword="null"/>.
/// </remarks>
private static ChatToolMode? ToChatToolMode(this JsonElement toolChoice)
{
switch (toolChoice.ValueKind)
{
case JsonValueKind.String:
return toolChoice.GetString() switch
{
"none" => ChatToolMode.None,
"auto" => ChatToolMode.Auto,
"required" => ChatToolMode.RequireAny,
_ => null
};
case JsonValueKind.Object:
// Only a function tool selection (for example { "type": "function", "name": "..." })
// has a ChatToolMode equivalent. Other object shapes (e.g. hosted tool selections) are
// not mapped so that they are not mistaken for a specific function.
if (toolChoice.TryGetProperty("type", out JsonElement type) && type.ValueKind == JsonValueKind.String &&
type.GetString() == "function" &&
toolChoice.TryGetProperty("name", out JsonElement name) && name.ValueKind == JsonValueKind.String &&
name.GetString() is { Length: > 0 } functionName)
{
return ChatToolMode.RequireSpecific(functionName);
}
return null;
default:
return null;
}
}
}
@@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text.Json;
using System.Threading;
@@ -38,6 +39,29 @@ namespace Microsoft.Agents.AI;
/// If <c>skill://index.json</c> is absent, unreadable, empty, or fails to parse, this source returns an
/// empty list.
/// </para>
/// <para>
/// <b>Thread safety and archive reconciliation.</b> For <c>archive</c>-type skills, every call to
/// <see cref="GetSkillsAsync"/> reconciles a shared on-disk directory: it extracts newly advertised
/// skills, re-extracts existing ones, and prunes those the server no longer advertises. Because that
/// work mutates files (and internal state) that concurrent calls would also touch, running it from
/// multiple threads at once could corrupt the directory or surface partially-extracted skills. To
/// prevent this, the archive reconciliation is guarded by a per-instance lock: only one call performs
/// it at a time, and other concurrent callers wait and then run sequentially.
/// </para>
/// <para>
/// This keeps the directory consistent but means concurrent (and repeated) calls do not share work —
/// each one re-contacts the MCP server and re-reconciles. To avoid that redundant work, place a caching
/// layer in front of this source (for example, via <see cref="AgentSkillsProviderBuilder"/>, which adds
/// one by default):
/// <list type="bullet">
/// <item><description>When the server's skills do not change at runtime, cache with no isolation key
/// (the default <see cref="CachingAgentSkillsSourceOptions.CacheIsolationKeySelector"/>), so a single
/// fetch and reconciliation is shared by all callers for the lifetime of the cache.</description></item>
/// <item><description>When the server's skills can change, additionally set
/// <see cref="CachingAgentSkillsSourceOptions.RefreshInterval"/> so the cache periodically re-fetches
/// and reconciles instead of doing so on every call.</description></item>
/// </list>
/// </para>
/// </remarks>
internal sealed partial class AgentMcpSkillsSource : AgentSkillsSource
{
@@ -46,19 +70,15 @@ internal sealed partial class AgentMcpSkillsSource : AgentSkillsSource
/// </summary>
private const string IndexUri = "skill://index.json";
[SuppressMessage("Usage", "CA2213:Disposable fields should be disposed", Justification = "The MCP client is supplied and owned by the caller, who is responsible for disposing it.")]
private readonly McpClient _client;
private readonly ILogger _logger;
private readonly Dictionary<string, IMcpSkillEntryLoader> _loaders;
private readonly TimeSpan? _refreshInterval;
private IList<AgentSkill>? _cachedSkills;
private DateTime _lastRefreshedUtc;
private Task<IList<AgentSkill>>? _refreshTask;
/// <summary>
/// Initializes a new instance of the <see cref="AgentMcpSkillsSource"/> class.
/// </summary>
/// <param name="client">An MCP client connected to a server that exposes Agent Skills resources.</param>
/// <param name="client">An MCP client connected to a server that exposes Agent Skills resources. The caller retains ownership of the client and is responsible for disposing it.</param>
/// <param name="options">Optional options that control archive-distributed skill handling.</param>
/// <param name="loggerFactory">Optional logger factory.</param>
public AgentMcpSkillsSource(McpClient client, AgentMcpSkillsSourceOptions? options = null, ILoggerFactory? loggerFactory = null)
@@ -74,88 +94,10 @@ internal sealed partial class AgentMcpSkillsSource : AgentSkillsSource
];
this._loaders = loaders.ToDictionary(l => l.EntryType, StringComparer.OrdinalIgnoreCase);
this._refreshInterval = options?.RefreshInterval;
}
/// <inheritdoc/>
public override async Task<IList<AgentSkill>> GetSkillsAsync(AgentSkillsSourceContext context, CancellationToken cancellationToken = default)
{
if (this.TryGetCachedSkills() is { } cached)
{
return cached;
}
// Use CAS to ensure only one concurrent refresh runs; other callers await the same task.
var tcs = new TaskCompletionSource<IList<AgentSkill>>(TaskCreationOptions.RunContinuationsAsynchronously);
if (Interlocked.CompareExchange(ref this._refreshTask, tcs.Task, null) is { } existing)
{
// Wait for the in-flight refresh but let this caller cancel its own wait independently
// without aborting the shared refresh work.
return await existing.WaitAsync(cancellationToken).ConfigureAwait(false);
}
try
{
// The refresh owner uses CancellationToken.None so that a single caller's cancellation
// does not abort the shared refresh for all concurrent waiters.
var skills = await this.GetCoreSkillsAsync(context, CancellationToken.None).ConfigureAwait(false);
this.UpdateCache(skills);
tcs.SetResult(skills);
// Allow the current caller to observe cancellation without impacting other awaiters.
cancellationToken.ThrowIfCancellationRequested();
return skills;
}
catch (Exception ex)
{
tcs.TrySetException(ex);
throw;
}
finally
{
this._refreshTask = null;
}
}
/// <summary>
/// Returns the cached skill list if caching is enabled and the cache is still fresh;
/// otherwise returns <see langword="null"/>.
/// </summary>
private IList<AgentSkill>? TryGetCachedSkills()
{
if (this._refreshInterval is null || this._cachedSkills is null)
{
return null;
}
TimeSpan cacheAge = DateTime.UtcNow - this._lastRefreshedUtc;
if (cacheAge >= this._refreshInterval.Value)
{
return null;
}
return this._cachedSkills;
}
/// <summary>
/// Stores the skill list and records the refresh timestamp for cache freshness checks.
/// </summary>
private void UpdateCache(IList<AgentSkill> skills)
{
this._cachedSkills = skills;
this._lastRefreshedUtc = DateTime.UtcNow;
}
/// <summary>
/// Reads the skill index from the MCP server, dispatches entries to registered loaders, and
/// returns the aggregated skill list.
/// </summary>
private async Task<IList<AgentSkill>> GetCoreSkillsAsync(AgentSkillsSourceContext context, CancellationToken cancellationToken)
{
McpSkillIndex? index = await this.TryReadIndexAsync(cancellationToken).ConfigureAwait(false);
@@ -193,6 +135,20 @@ internal sealed partial class AgentMcpSkillsSource : AgentSkillsSource
return skills;
}
/// <inheritdoc/>
protected override void Dispose(bool disposing)
{
if (disposing)
{
foreach (var loader in this._loaders.Values)
{
(loader as IDisposable)?.Dispose();
}
}
base.Dispose(disposing);
}
private async Task<McpSkillIndex?> TryReadIndexAsync(CancellationToken cancellationToken)
{
ReadResourceResult result;
@@ -1,6 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
namespace Microsoft.Agents.AI;
@@ -79,17 +78,4 @@ public sealed class AgentMcpSkillsSourceOptions
/// skipped.
/// </remarks>
public long? ArchiveMaxUncompressedSizeBytes { get; set; }
/// <summary>
/// Gets or sets the interval at which cached skills are considered fresh. When a caller invokes
/// <see cref="AgentMcpSkillsSource.GetSkillsAsync"/> and the cached result is younger than this
/// interval, the cached list is returned without contacting the MCP server.
/// </summary>
/// <remarks>
/// When <see langword="null"/> (the default), caching is disabled and every call fetches from
/// the MCP server. Set to a positive <see cref="TimeSpan"/> to enable caching. Values of
/// <see cref="TimeSpan.Zero"/> or negative durations effectively disable caching because the
/// cache age will always be greater than or equal to the interval.
/// </remarks>
public TimeSpan? RefreshInterval { get; set; }
}
@@ -25,7 +25,7 @@ namespace Microsoft.Agents.AI;
/// inside an archive are surfaced as readable resources only; they are never discovered as
/// executable scripts.
/// </remarks>
internal sealed partial class ArchiveEntryLoader : IMcpSkillEntryLoader
internal sealed partial class ArchiveEntryLoader : IMcpSkillEntryLoader, IDisposable
{
/// <summary>
/// The default maximum size, in bytes, of a downloaded archive resource.
@@ -36,6 +36,11 @@ internal sealed partial class ArchiveEntryLoader : IMcpSkillEntryLoader
private readonly AgentMcpSkillsSourceOptions? _options;
private readonly ILoggerFactory _loggerFactory;
private readonly ILogger _logger;
// Serializes the reconcile -> extract -> read sequence so concurrent loads never mutate the
// shared on-disk directory (or the _archiveSkillsDirectory field) at the same time.
private readonly SemaphoreSlim _reconcileGate = new(1, 1);
private string? _archiveSkillsDirectory;
public ArchiveEntryLoader(McpClient client, AgentMcpSkillsSourceOptions? options, ILoggerFactory loggerFactory)
@@ -55,33 +60,52 @@ internal sealed partial class ArchiveEntryLoader : IMcpSkillEntryLoader
// Filter out entries that are missing required fields or have invalid names.
var archiveEntries = this.FilterValidEntries(entries);
// Determine the target directory from prior state or caller-supplied options.
var archiveSkillsDirectory = this._archiveSkillsDirectory ?? this._options?.ArchiveSkillsDirectory;
// Reconcile on-disk state with the current set of advertised skills.
this.ReconcileArchiveSkillDirectories(archiveSkillsDirectory, archiveEntries);
if (archiveEntries.Count == 0)
// The reconcile -> extract -> read sequence mutates a shared on-disk directory and the
// _archiveSkillsDirectory field, so it must run as a single critical section. Concurrent
// callers wait here and execute one at a time, keeping the directory consistent.
await this._reconcileGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
return [];
// Determine the target directory from prior state or caller-supplied options.
var archiveSkillsDirectory = this._archiveSkillsDirectory ?? this._options?.ArchiveSkillsDirectory;
// Reconcile on-disk state with the current set of advertised skills.
this.ReconcileArchiveSkillDirectories(archiveSkillsDirectory, archiveEntries);
if (archiveEntries.Count == 0)
{
return [];
}
// Resolve or generate the skills directory and ensure it exists on disk.
this._archiveSkillsDirectory = archiveSkillsDirectory ?? Path.Combine(Directory.GetCurrentDirectory(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(this._archiveSkillsDirectory);
// Download and extract each archive entry into its own subdirectory.
var skillDirectories = new List<string>(archiveEntries.Count);
foreach (var entry in archiveEntries)
{
skillDirectories.AddRange(await this.TryDownloadAndExtractSkillAsync(entry, this._archiveSkillsDirectory, cancellationToken).ConfigureAwait(false));
}
// Delegate discovery of extracted content to a file-based skills source.
AgentFileSkillsSource fileSource = this.CreateFileSkillsSource(skillDirectories);
return await fileSource.GetSkillsAsync(context, cancellationToken).ConfigureAwait(false);
}
// Resolve or generate the skills directory and ensure it exists on disk.
this._archiveSkillsDirectory = archiveSkillsDirectory ?? Path.Combine(Directory.GetCurrentDirectory(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(this._archiveSkillsDirectory);
// Download and extract each archive entry into its own subdirectory.
var skillDirectories = new List<string>(archiveEntries.Count);
foreach (var entry in archiveEntries)
finally
{
skillDirectories.AddRange(await this.TryDownloadAndExtractSkillAsync(entry, this._archiveSkillsDirectory, cancellationToken).ConfigureAwait(false));
this._reconcileGate.Release();
}
}
// Delegate discovery of extracted content to a file-based skills source.
AgentFileSkillsSource fileSource = this.CreateFileSkillsSource(skillDirectories);
return await fileSource.GetSkillsAsync(context, cancellationToken).ConfigureAwait(false);
/// <summary>
/// Releases the resources used by this loader.
/// </summary>
public void Dispose()
{
this._reconcileGate.Dispose();
}
/// <summary>
@@ -96,6 +96,10 @@ internal static partial class AgentJsonUtilities
[JsonSerializable(typeof(List<FileSearchMatch>), TypeInfoPropertyName = "FileSearchMatchList")]
[JsonSerializable(typeof(FileListEntry))]
[JsonSerializable(typeof(List<FileListEntry>), TypeInfoPropertyName = "FileListEntryList")]
[JsonSerializable(typeof(FileStoreEntry))]
[JsonSerializable(typeof(List<FileStoreEntry>), TypeInfoPropertyName = "FileStoreEntryList")]
[JsonSerializable(typeof(FileLineEdit))]
[JsonSerializable(typeof(List<FileLineEdit>), TypeInfoPropertyName = "FileLineEditList")]
// BackgroundAgentsProvider types
[JsonSerializable(typeof(BackgroundAgentState))]
@@ -78,6 +78,13 @@
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkillsProvider.#ctor(Microsoft.Agents.AI.AgentSkillsSource,Microsoft.Agents.AI.AgentSkillsProviderOptions,Microsoft.Extensions.Logging.ILoggerFactory)</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkillsProvider.#ctor(System.Collections.Generic.IEnumerable{Microsoft.Agents.AI.AgentInlineSkill},Microsoft.Agents.AI.AgentSkillsProviderOptions,Microsoft.Extensions.Logging.ILoggerFactory)</Target>
@@ -211,6 +218,13 @@
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkillsProvider.#ctor(Microsoft.Agents.AI.AgentSkillsSource,Microsoft.Agents.AI.AgentSkillsProviderOptions,Microsoft.Extensions.Logging.ILoggerFactory)</Target>
<Left>lib/net472/Microsoft.Agents.AI.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkillsProvider.#ctor(System.Collections.Generic.IEnumerable{Microsoft.Agents.AI.AgentInlineSkill},Microsoft.Agents.AI.AgentSkillsProviderOptions,Microsoft.Extensions.Logging.ILoggerFactory)</Target>
@@ -344,6 +358,13 @@
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkillsProvider.#ctor(Microsoft.Agents.AI.AgentSkillsSource,Microsoft.Agents.AI.AgentSkillsProviderOptions,Microsoft.Extensions.Logging.ILoggerFactory)</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkillsProvider.#ctor(System.Collections.Generic.IEnumerable{Microsoft.Agents.AI.AgentInlineSkill},Microsoft.Agents.AI.AgentSkillsProviderOptions,Microsoft.Extensions.Logging.ILoggerFactory)</Target>
@@ -477,6 +498,13 @@
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkillsProvider.#ctor(Microsoft.Agents.AI.AgentSkillsSource,Microsoft.Agents.AI.AgentSkillsProviderOptions,Microsoft.Extensions.Logging.ILoggerFactory)</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkillsProvider.#ctor(System.Collections.Generic.IEnumerable{Microsoft.Agents.AI.AgentInlineSkill},Microsoft.Agents.AI.AgentSkillsProviderOptions,Microsoft.Extensions.Logging.ILoggerFactory)</Target>
@@ -610,6 +638,13 @@
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkillsProvider.#ctor(Microsoft.Agents.AI.AgentSkillsSource,Microsoft.Agents.AI.AgentSkillsProviderOptions,Microsoft.Extensions.Logging.ILoggerFactory)</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkillsProvider.#ctor(System.Collections.Generic.IEnumerable{Microsoft.Agents.AI.AgentInlineSkill},Microsoft.Agents.AI.AgentSkillsProviderOptions,Microsoft.Extensions.Logging.ILoggerFactory)</Target>
@@ -4,9 +4,11 @@ using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.FileSystemGlobbing;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
@@ -14,7 +16,7 @@ namespace Microsoft.Agents.AI;
/// <summary>
/// An <see cref="AIContextProvider"/> that provides file access tools to an agent
/// for saving, reading, deleting, listing, and searching files.
/// for writing, reading, deleting, listing, searching, and editing files.
/// </summary>
/// <remarks>
/// <para>
@@ -32,27 +34,33 @@ namespace Microsoft.Agents.AI;
/// <para>
/// This provider exposes the following tools to the agent:
/// <list type="bullet">
/// <item><description><c>file_access_save_file</c> — Save a file with the given name and content.</description></item>
/// <item><description><c>file_access_read_file</c> — Read the content of a file by name.</description></item>
/// <item><description><c>file_access_delete_file</c> — Delete a file by name.</description></item>
/// <item><description><c>file_access_list_files</c> — List the direct child file names in a directory.</description></item>
/// <item><description><c>file_access_list_subdirectories</c> — List the direct child subdirectory names in a directory.</description></item>
/// <item><description><c>file_access_search_files</c> — Recursively search file contents using a regular expression pattern.</description></item>
/// <item><description><c>file_access_write</c> — Write a file with the given name and content.</description></item>
/// <item><description><c>file_access_read</c> — Read the content of a file by name.</description></item>
/// <item><description><c>file_access_delete</c> — Delete a file by name.</description></item>
/// <item><description><c>file_access_ls</c> — List the direct child files and subdirectories of a directory.</description></item>
/// <item><description><c>file_access_grep</c> — Recursively search file contents using a regular expression pattern.</description></item>
/// <item><description><c>file_access_replace</c> — Replace occurrences of a substring within a file.</description></item>
/// <item><description><c>file_access_replace_lines</c> — Replace whole lines within a file.</description></item>
/// </list>
/// When <see cref="FileAccessProviderOptions.DisableWriteTools"/> is set, only the read-only tools
/// (<c>file_access_read</c>, <c>file_access_ls</c>, and <c>file_access_grep</c>) are exposed.
/// </para>
/// <para>
/// All of these tools always require approval: each is exposed as an <see cref="ApprovalRequiredAIFunction"/>.
/// By default, all of these tools require approval: each is exposed as an <see cref="ApprovalRequiredAIFunction"/>.
/// Approval can be disabled per group via <see cref="FileAccessProviderOptions.DisableReadOnlyToolApproval"/>
/// (read, ls, and grep) and <see cref="FileAccessProviderOptions.DisableWriteToolApproval"/>
/// (write, delete, replace, and replace_lines).
/// </para>
/// <para>
/// To auto-approve these tools without prompting, use the <see cref="ToolApprovalAgent"/> and add one of the provided rules to
/// <see cref="ToolApprovalAgentOptions.AutoApprovalRules"/>:
/// <list type="bullet">
/// <item><description>
/// <see cref="ReadOnlyToolsAutoApprovalRule"/> — auto-approves only the read-only tools (read, list, list subdirectories,
/// and search), while still prompting for the tools that modify the store (save and delete).
/// <see cref="ReadOnlyToolsAutoApprovalRule"/> — auto-approves only the read-only tools (read, ls,
/// and grep), while still prompting for the tools that modify the store (write, delete, replace, and replace_lines).
/// </description></item>
/// <item><description>
/// <see cref="AllToolsAutoApprovalRule"/> — auto-approves every file access tool, including save and delete.
/// <see cref="AllToolsAutoApprovalRule"/> — auto-approves every file access tool, including the tools that modify the store.
/// </description></item>
/// </list>
/// For example, to auto-approve all file access tools:
@@ -65,44 +73,47 @@ namespace Microsoft.Agents.AI;
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class FileAccessProvider : AIContextProvider
public sealed class FileAccessProvider : AIContextProvider, IDisposable
{
/// <summary>The name of the tool that saves a file.</summary>
public const string SaveFileToolName = "file_access_save_file";
/// <summary>The name of the tool that writes a file.</summary>
public const string WriteToolName = "file_access_write";
/// <summary>The name of the tool that reads a file.</summary>
public const string ReadFileToolName = "file_access_read_file";
public const string ReadFileToolName = "file_access_read";
/// <summary>The name of the tool that deletes a file.</summary>
public const string DeleteFileToolName = "file_access_delete_file";
public const string DeleteFileToolName = "file_access_delete";
/// <summary>The name of the tool that lists the files in a directory.</summary>
public const string ListFilesToolName = "file_access_list_files";
/// <summary>The name of the tool that lists the subdirectories of a directory.</summary>
public const string ListSubdirectoriesToolName = "file_access_list_subdirectories";
/// <summary>The name of the tool that lists the files and subdirectories in a directory.</summary>
public const string LsToolName = "file_access_ls";
/// <summary>The name of the tool that searches file contents.</summary>
public const string SearchFilesToolName = "file_access_search_files";
public const string GrepToolName = "file_access_grep";
/// <summary>The name of the tool that replaces occurrences of a substring within a file.</summary>
public const string ReplaceToolName = "file_access_replace";
/// <summary>The name of the tool that replaces whole lines within a file.</summary>
public const string ReplaceLinesToolName = "file_access_replace_lines";
/// <summary>The names of the tools that only read from (never modify) the file store.</summary>
private static readonly HashSet<string> s_readOnlyToolNames = new(StringComparer.Ordinal)
{
ReadFileToolName,
ListFilesToolName,
ListSubdirectoriesToolName,
SearchFilesToolName,
LsToolName,
GrepToolName,
};
/// <summary>The names of all tools exposed by this provider.</summary>
private static readonly HashSet<string> s_allToolNames = new(StringComparer.Ordinal)
{
SaveFileToolName,
WriteToolName,
ReadFileToolName,
DeleteFileToolName,
ListFilesToolName,
ListSubdirectoriesToolName,
SearchFilesToolName,
LsToolName,
GrepToolName,
ReplaceToolName,
ReplaceLinesToolName,
};
private const string DefaultInstructions =
@@ -113,12 +124,18 @@ public sealed class FileAccessProvider : AIContextProvider
Use these tools to read input data provided by the user, write output artifacts, and manage any files the user has asked you to work with.
- Never delete or overwrite existing files unless the user has explicitly asked you to do so.
- Files may be organized into subdirectories. Use `file_access_list_files` and `file_access_list_subdirectories` to explore the tree level by level,
or `file_access_search_files` to search file contents recursively across the whole store.
- Files may be organized into subdirectories. Use `file_access_ls` to explore the tree level by level,
or `file_access_grep` to search file contents recursively across the whole store.
- To make small edits to an existing file, prefer `file_access_replace` (substring replacement) or
`file_access_replace_lines` (whole-line replacement) over rewriting the whole file.
""";
private readonly AgentFileStore _fileStore;
private readonly string _instructions;
private readonly bool _disableWriteTools;
private readonly bool _disableReadOnlyToolApproval;
private readonly bool _disableWriteToolApproval;
private readonly SemaphoreSlim _writeLock = new(1, 1);
private AITool[]? _tools;
/// <summary>
@@ -136,19 +153,22 @@ public sealed class FileAccessProvider : AIContextProvider
this._fileStore = fileStore;
this._instructions = options?.Instructions ?? DefaultInstructions;
this._disableWriteTools = options?.DisableWriteTools ?? false;
this._disableReadOnlyToolApproval = options?.DisableReadOnlyToolApproval ?? false;
this._disableWriteToolApproval = options?.DisableWriteToolApproval ?? false;
}
/// <summary>
/// Gets an auto-approval rule that approves the read-only file access tools
/// (<see cref="ReadFileToolName"/>, <see cref="ListFilesToolName"/>,
/// <see cref="ListSubdirectoriesToolName"/>, and <see cref="SearchFilesToolName"/>).
/// (<see cref="ReadFileToolName"/>, <see cref="LsToolName"/>, and <see cref="GrepToolName"/>).
/// </summary>
/// <remarks>
/// <para>
/// The tools exposed by <see cref="FileAccessProvider"/> always require approval. Add this rule to
/// By default, the tools exposed by <see cref="FileAccessProvider"/> require approval. Add this rule to
/// <see cref="ToolApprovalAgentOptions.AutoApprovalRules"/> to automatically approve only the tools
/// that read from the file store, while still prompting for tools that modify it
/// (<see cref="SaveFileToolName"/> and <see cref="DeleteFileToolName"/>).
/// (<see cref="WriteToolName"/>, <see cref="DeleteFileToolName"/>, <see cref="ReplaceToolName"/>,
/// and <see cref="ReplaceLinesToolName"/>).
/// </para>
/// <para>
/// The rule matches on the tool name, returning <see langword="true"/> for read-only file access tools
@@ -160,11 +180,12 @@ public sealed class FileAccessProvider : AIContextProvider
/// <summary>
/// Gets an auto-approval rule that approves all file access tools, including the tools that modify the
/// file store (<see cref="SaveFileToolName"/> and <see cref="DeleteFileToolName"/>).
/// file store (<see cref="WriteToolName"/>, <see cref="DeleteFileToolName"/>, <see cref="ReplaceToolName"/>,
/// and <see cref="ReplaceLinesToolName"/>).
/// </summary>
/// <remarks>
/// <para>
/// The tools exposed by <see cref="FileAccessProvider"/> always require approval. Add this rule to
/// By default, the tools exposed by <see cref="FileAccessProvider"/> require approval. Add this rule to
/// <see cref="ToolApprovalAgentOptions.AutoApprovalRules"/> to automatically approve every file access
/// tool without prompting the user.
/// </para>
@@ -179,6 +200,14 @@ public sealed class FileAccessProvider : AIContextProvider
/// <inheritdoc />
public override IReadOnlyList<string> StateKeys => [];
/// <summary>
/// Releases the resources used by the <see cref="FileAccessProvider"/>.
/// </summary>
public void Dispose()
{
this._writeLock.Dispose();
}
/// <inheritdoc />
protected override ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
@@ -190,25 +219,34 @@ public sealed class FileAccessProvider : AIContextProvider
}
/// <summary>
/// Save a file with the given name and content. By default, does not overwrite an existing file unless overwrite is set to true.
/// Write a file with the given name and content. By default, does not overwrite an existing file unless overwrite is set to true.
/// </summary>
/// <param name="fileName">The name of the file to save.</param>
/// <param name="fileName">The name of the file to write.</param>
/// <param name="content">The content to write to the file.</param>
/// <param name="overwrite">Whether to overwrite the file if it already exists.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A confirmation message.</returns>
[Description("Save a file with the given name and content. By default, does not overwrite an existing file unless overwrite is set to true.")]
private async Task<string> SaveFileAsync(string fileName, string content, bool overwrite = false, CancellationToken cancellationToken = default)
[Description("Write a file with the given name and content. By default, does not overwrite an existing file unless overwrite is set to true.")]
private async Task<string> WriteAsync(string fileName, string content, bool overwrite = false, CancellationToken cancellationToken = default)
{
string path = StorePaths.NormalizeRelativePath(fileName);
if (!overwrite && await this._fileStore.FileExistsAsync(path, cancellationToken).ConfigureAwait(false))
await this._writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
return $"File '{fileName}' already exists. To replace it, save again with overwrite set to true.";
if (!overwrite && await this._fileStore.FileExistsAsync(path, cancellationToken).ConfigureAwait(false))
{
return $"File '{fileName}' already exists. To replace it, write again with overwrite set to true.";
}
await this._fileStore.WriteAsync(path, content, cancellationToken).ConfigureAwait(false);
}
finally
{
this._writeLock.Release();
}
await this._fileStore.WriteFileAsync(path, content, cancellationToken).ConfigureAwait(false);
return $"File '{fileName}' saved.";
return $"File '{fileName}' written.";
}
/// <summary>
@@ -218,10 +256,10 @@ public sealed class FileAccessProvider : AIContextProvider
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>The file content or a not-found message.</returns>
[Description("Read the content of a file by name. Returns the file content or a message indicating the file was not found.")]
private async Task<string> ReadFileAsync(string fileName, CancellationToken cancellationToken = default)
private async Task<string> ReadAsync(string fileName, CancellationToken cancellationToken = default)
{
string path = StorePaths.NormalizeRelativePath(fileName);
string? content = await this._fileStore.ReadFileAsync(path, cancellationToken).ConfigureAwait(false);
string? content = await this._fileStore.ReadAsync(path, cancellationToken).ConfigureAwait(false);
return content ?? $"File '{fileName}' not found.";
}
@@ -232,82 +270,178 @@ public sealed class FileAccessProvider : AIContextProvider
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A confirmation or not-found message.</returns>
[Description("Delete a file by name.")]
private async Task<string> DeleteFileAsync(string fileName, CancellationToken cancellationToken = default)
private async Task<string> DeleteAsync(string fileName, CancellationToken cancellationToken = default)
{
string path = StorePaths.NormalizeRelativePath(fileName);
bool deleted = await this._fileStore.DeleteFileAsync(path, cancellationToken).ConfigureAwait(false);
return deleted ? $"File '{fileName}' deleted." : $"File '{fileName}' not found.";
await this._writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
bool deleted = await this._fileStore.DeleteAsync(path, cancellationToken).ConfigureAwait(false);
return deleted ? $"File '{fileName}' deleted." : $"File '{fileName}' not found.";
}
finally
{
this._writeLock.Release();
}
}
/// <summary>
/// List the direct child file names of a directory. Omit <paramref name="directory"/> (or pass an empty string)
/// to list the store root. To enumerate files in a subdirectory, pass its relative path.
/// List the direct child files and subdirectories of a directory. Omit <paramref name="directory"/> (or pass an empty string)
/// to list the store root. Optionally filter entries with a glob pattern.
/// </summary>
/// <param name="directory">The relative directory path to list. Omit or pass an empty string to list the store root.</param>
/// <param name="globPattern">An optional glob pattern (e.g., "*.md") matched against entry names to filter the listing.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A list of file names.</returns>
[Description("List the direct child file names of a directory. Omit the directory (or pass an empty string) to list the root. To enumerate files in a subdirectory, pass its relative path, for example \"reports\" or \"reports/2024\".")]
private async Task<List<string>> ListFilesAsync(string? directory = null, CancellationToken cancellationToken = default)
/// <returns>A list of entries, each with a name and a type of "file" or "directory" (subdirectories first).</returns>
[Description("List the direct child files and subdirectories of a directory. Omit the directory (or pass an empty string) to list the root. To enumerate a subdirectory, pass its relative path, for example \"reports\" or \"reports/2024\". Optionally filter entries with a glob_pattern (e.g. \"*.md\"). Subdirectories are listed before files, and each entry has a name and a type of \"file\" or \"directory\".")]
private async Task<List<FileStoreEntry>> LsAsync(string? directory = null, string? globPattern = null, CancellationToken cancellationToken = default)
{
string target = string.IsNullOrWhiteSpace(directory) ? string.Empty : directory;
IReadOnlyList<string> fileNames = await this._fileStore.ListFilesAsync(target, cancellationToken).ConfigureAwait(false);
return new List<string>(fileNames);
string target = string.IsNullOrWhiteSpace(directory) ? string.Empty : directory!;
IReadOnlyList<FileStoreEntry> entries = await this._fileStore.ListChildrenAsync(target, cancellationToken).ConfigureAwait(false);
Matcher? matcher = string.IsNullOrWhiteSpace(globPattern) ? null : StorePaths.CreateGlobMatcher(globPattern!);
return entries.Where(entry => StorePaths.MatchesGlob(entry.Name, matcher)).ToList();
}
/// <summary>
/// List the direct child subdirectory names of a directory. Omit <paramref name="directory"/> (or pass an empty string)
/// to list the store root. To enumerate subdirectories of a subdirectory, pass its relative path.
/// Replace occurrences of <paramref name="oldString"/> with <paramref name="newString"/> in a file.
/// </summary>
/// <param name="directory">The relative directory path to list. Omit or pass an empty string to list the store root.</param>
/// <param name="fileName">The name of the file to modify.</param>
/// <param name="oldString">The substring to find and replace.</param>
/// <param name="newString">The replacement text.</param>
/// <param name="replaceAll">When <see langword="true"/>, replace every occurrence; otherwise fail unless exactly one occurrence exists.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A list of subdirectory names.</returns>
[Description("List the direct child subdirectory names of a directory. Omit the directory (or pass an empty string) to list the root. To enumerate subdirectories of a subdirectory, pass its relative path, for example \"reports\" or \"reports/2024\". Use this together with file_access_list_files to explore the directory tree level by level.")]
private async Task<List<string>> ListSubdirectoriesAsync(string? directory = null, CancellationToken cancellationToken = default)
/// <returns>A confirmation message including the number of occurrences replaced, or a failure message.</returns>
[Description("Replace occurrences of old_string with new_string in a file. Fails if old_string is not found, or if it occurs more than once and replace_all is false. Returns the number of occurrences replaced.")]
private async Task<string> ReplaceAsync(string fileName, string oldString, string newString, bool replaceAll = false, CancellationToken cancellationToken = default)
{
string target = string.IsNullOrWhiteSpace(directory) ? string.Empty : directory;
IReadOnlyList<string> directoryNames = await this._fileStore.ListDirectoriesAsync(target, cancellationToken).ConfigureAwait(false);
return new List<string>(directoryNames);
await this._writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
string path = StorePaths.NormalizeRelativePath(fileName);
string? content = await this._fileStore.ReadAsync(path, cancellationToken).ConfigureAwait(false);
if (content is null)
{
return $"File '{fileName}' not found.";
}
(string newContent, int count) = FileEditor.ApplyReplace(content, oldString, newString, replaceAll);
await this._fileStore.WriteAsync(path, newContent, cancellationToken).ConfigureAwait(false);
return $"Replaced {count} occurrence(s) in '{fileName}'.";
}
finally
{
this._writeLock.Release();
}
}
/// <summary>
/// Search the contents of all files in the store (recursively) using a regular expression pattern (case-insensitive).
/// Optionally filter which files to search using a glob pattern.
/// Replace lines in a file. Provide a list of edits, each with a 1-based line number and the literal
/// replacement text; an empty replacement deletes the line.
/// </summary>
/// <param name="fileName">The name of the file to modify.</param>
/// <param name="edits">The list of 1-based line numbers and their literal replacement text.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A confirmation message including the number of lines replaced, or a failure message.</returns>
[Description("Replace lines in a file. Provide a list of edits, each with a 1-based line_number and a literal new_line (include your own trailing newline); an empty new_line deletes the line, including its line break. Fails on out-of-range or duplicate line numbers.")]
private async Task<string> ReplaceLinesAsync(string fileName, List<FileLineEdit> edits, CancellationToken cancellationToken = default)
{
await this._writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
string path = StorePaths.NormalizeRelativePath(fileName);
string? content = await this._fileStore.ReadAsync(path, cancellationToken).ConfigureAwait(false);
if (content is null)
{
return $"File '{fileName}' not found.";
}
string newContent = FileEditor.ApplyReplaceLines(content, edits);
await this._fileStore.WriteAsync(path, newContent, cancellationToken).ConfigureAwait(false);
return $"Replaced {edits.Count} line(s) in '{fileName}'.";
}
finally
{
this._writeLock.Release();
}
}
/// <summary>
/// Search the contents of files in the store (recursively) using a regular expression pattern (case-insensitive).
/// Optionally restrict to a base directory and/or filter which files to search using a glob pattern.
/// </summary>
/// <param name="regexPattern">A regular expression pattern to match against file contents (case-insensitive).</param>
/// <param name="filePattern">An optional glob pattern to filter which files to search, matched against each file's path relative to the store root. Use <c>**</c> to match across subdirectories (e.g., "**/*.md"). Leave empty or omit to search all files.</param>
/// <param name="globPattern">An optional glob pattern to filter which files to search, matched against each file's path relative to the search directory. Leave empty or omit to search all files.</param>
/// <param name="directory">An optional base directory (relative path) to restrict the search to. Leave empty or omit to search the whole store.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A list of search results whose file names are paths relative to the store root.</returns>
[Description(
"""
Search the contents of all files in the store (recursively, across all subdirectories) using a regular expression pattern (case-insensitive).
Optionally filter which files to search using a glob pattern matched against each file's path relative to the store root:
Search the contents of files in the store (recursively, across all subdirectories) using a regular expression pattern (case-insensitive).
Optionally restrict the search to a base directory (relative path), and filter which files to search using a glob pattern matched against each file's path relative to that directory:
- '*' matches within a single path segment
- '**' matches across subdirectories, so use \"**/*.md\" to match markdown files at any depth, or \"reports/**\" to restrict the search to the 'reports' subtree.
Returns matching results whose file names are paths relative to the store root (usable with file_access_read_file), along with snippets and matching lines with line numbers.
Returns matching results whose file names are paths relative to the store root (usable with file_access_read), along with snippets and matching lines with line numbers.
""")]
private async Task<List<FileSearchResult>> SearchFilesAsync(string regexPattern, string? filePattern = null, CancellationToken cancellationToken = default)
private async Task<List<FileSearchResult>> GrepAsync(string regexPattern, string? globPattern = null, string? directory = null, CancellationToken cancellationToken = default)
{
string? pattern = string.IsNullOrWhiteSpace(filePattern) ? null : filePattern;
IReadOnlyList<FileSearchResult> results = await this._fileStore.SearchFilesAsync(string.Empty, regexPattern, pattern, recursive: true, cancellationToken).ConfigureAwait(false);
return new List<FileSearchResult>(results);
string? pattern = string.IsNullOrWhiteSpace(globPattern) ? null : globPattern;
string target = StorePaths.NormalizeRelativePath(directory ?? string.Empty, isDirectory: true);
IReadOnlyList<FileSearchResult> results = await this._fileStore.SearchAsync(target, regexPattern, pattern, recursive: true, cancellationToken).ConfigureAwait(false);
// store.SearchAsync returns FileName relative to the searched directory; re-root each result to the
// store root so the names compose directly with file_access_read/replace/delete.
string prefix = target;
if (prefix.Length == 0)
{
return new List<FileSearchResult>(results);
}
var rerooted = new List<FileSearchResult>(results.Count);
foreach (FileSearchResult result in results)
{
rerooted.Add(new FileSearchResult
{
FileName = $"{prefix}/{result.FileName}",
Snippet = result.Snippet,
MatchingLines = result.MatchingLines,
});
}
return rerooted;
}
private AITool[] CreateTools()
{
var serializerOptions = AgentJsonUtilities.DefaultOptions;
// All file access tools always require approval. Callers can use the
// ReadOnlyToolsAutoApprovalRule or AllToolsAutoApprovalRule with the ToolApprovalAgent
// to automatically approve these tools.
return
[
new ApprovalRequiredAIFunction(AIFunctionFactory.Create(this.SaveFileAsync, new AIFunctionFactoryOptions { Name = SaveFileToolName, SerializerOptions = serializerOptions })),
new ApprovalRequiredAIFunction(AIFunctionFactory.Create(this.ReadFileAsync, new AIFunctionFactoryOptions { Name = ReadFileToolName, SerializerOptions = serializerOptions })),
new ApprovalRequiredAIFunction(AIFunctionFactory.Create(this.DeleteFileAsync, new AIFunctionFactoryOptions { Name = DeleteFileToolName, SerializerOptions = serializerOptions })),
new ApprovalRequiredAIFunction(AIFunctionFactory.Create(this.ListFilesAsync, new AIFunctionFactoryOptions { Name = ListFilesToolName, SerializerOptions = serializerOptions })),
new ApprovalRequiredAIFunction(AIFunctionFactory.Create(this.ListSubdirectoriesAsync, new AIFunctionFactoryOptions { Name = ListSubdirectoriesToolName, SerializerOptions = serializerOptions })),
new ApprovalRequiredAIFunction(AIFunctionFactory.Create(this.SearchFilesAsync, new AIFunctionFactoryOptions { Name = SearchFilesToolName, SerializerOptions = serializerOptions })),
];
// Read-only and store-modifying tools require approval by default. Approval can be disabled
// per group via FileAccessProviderOptions.DisableReadOnlyToolApproval and DisableWriteToolApproval;
// otherwise callers can use the ReadOnlyToolsAutoApprovalRule or AllToolsAutoApprovalRule with the
// ToolApprovalAgent to automatically approve these tools.
bool readOnlyRequiresApproval = !this._disableReadOnlyToolApproval;
bool writeRequiresApproval = !this._disableWriteToolApproval;
var tools = new List<AITool>
{
WrapWithApprovalIfRequired(AIFunctionFactory.Create(this.ReadAsync, new AIFunctionFactoryOptions { Name = ReadFileToolName, SerializerOptions = serializerOptions }), readOnlyRequiresApproval),
WrapWithApprovalIfRequired(AIFunctionFactory.Create(this.LsAsync, new AIFunctionFactoryOptions { Name = LsToolName, SerializerOptions = serializerOptions }), readOnlyRequiresApproval),
WrapWithApprovalIfRequired(AIFunctionFactory.Create(this.GrepAsync, new AIFunctionFactoryOptions { Name = GrepToolName, SerializerOptions = serializerOptions }), readOnlyRequiresApproval),
};
if (!this._disableWriteTools)
{
tools.Add(WrapWithApprovalIfRequired(AIFunctionFactory.Create(this.WriteAsync, new AIFunctionFactoryOptions { Name = WriteToolName, SerializerOptions = serializerOptions }), writeRequiresApproval));
tools.Add(WrapWithApprovalIfRequired(AIFunctionFactory.Create(this.DeleteAsync, new AIFunctionFactoryOptions { Name = DeleteFileToolName, SerializerOptions = serializerOptions }), writeRequiresApproval));
tools.Add(WrapWithApprovalIfRequired(AIFunctionFactory.Create(this.ReplaceAsync, new AIFunctionFactoryOptions { Name = ReplaceToolName, SerializerOptions = serializerOptions }), writeRequiresApproval));
tools.Add(WrapWithApprovalIfRequired(AIFunctionFactory.Create(this.ReplaceLinesAsync, new AIFunctionFactoryOptions { Name = ReplaceLinesToolName, SerializerOptions = serializerOptions }), writeRequiresApproval));
}
return tools.ToArray();
}
private static AITool WrapWithApprovalIfRequired(AIFunction function, bool requireApproval)
=> requireApproval ? new ApprovalRequiredAIFunction(function) : function;
}
@@ -19,4 +19,49 @@ public sealed class FileAccessProviderOptions
/// that guide the agent on how to use file storage effectively.
/// </value>
public string? Instructions { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the tools that modify the file store are disabled.
/// </summary>
/// <value>
/// When <see langword="false"/> (the default), all tools are exposed. When <see langword="true"/>,
/// only the read-only tools (<c>file_access_read</c>, <c>file_access_ls</c>, and <c>file_access_grep</c>)
/// are exposed; the tools that modify the store (<c>file_access_write</c>, <c>file_access_delete</c>,
/// <c>file_access_replace</c>, and <c>file_access_replace_lines</c>) are hidden.
/// </value>
public bool DisableWriteTools { get; set; }
/// <summary>
/// Gets or sets a value indicating whether approval is disabled for the read-only file access tools
/// (<see cref="FileAccessProvider.ReadFileToolName"/>, <see cref="FileAccessProvider.LsToolName"/>,
/// and <see cref="FileAccessProvider.GrepToolName"/>).
/// </summary>
/// <remarks>
/// When <see langword="false"/> (the default), these tools require approval before invocation.
/// When <see langword="true"/>, they can be invoked without approval.
/// If any other tool in the same response still requires approval, set
/// <see cref="ChatClientAgentOptions.EnableNonApprovalRequiredFunctionBypassing"/>
/// to <see langword="true"/> so these tools are not surfaced as approval requests.
/// When approval is required, auto-approval rules (e.g. <see cref="FileAccessProvider.ReadOnlyToolsAutoApprovalRule"/>
/// or <see cref="FileAccessProvider.AllToolsAutoApprovalRule"/>) can be used to automatically approve calls.
/// </remarks>
public bool DisableReadOnlyToolApproval { get; set; }
/// <summary>
/// Gets or sets a value indicating whether approval is disabled for the tools that modify the file store
/// (<see cref="FileAccessProvider.WriteToolName"/>, <see cref="FileAccessProvider.DeleteFileToolName"/>,
/// <see cref="FileAccessProvider.ReplaceToolName"/>, and <see cref="FileAccessProvider.ReplaceLinesToolName"/>).
/// </summary>
/// <remarks>
/// When <see langword="false"/> (the default), these tools require approval before invocation.
/// When <see langword="true"/>, they can be invoked without approval.
/// If any other tool in the same response still requires approval, set
/// <see cref="ChatClientAgentOptions.EnableNonApprovalRequiredFunctionBypassing"/>
/// to <see langword="true"/> so these tools are not surfaced as approval requests.
/// When approval is required, the <see cref="FileAccessProvider.AllToolsAutoApprovalRule"/> can be used
/// to automatically approve calls.
/// This setting has no effect when <see cref="DisableWriteTools"/> is <see langword="true"/>, since the
/// tools that modify the store are not exposed in that case.
/// </remarks>
public bool DisableWriteToolApproval { get; set; }
}
@@ -7,8 +7,8 @@ using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents a file entry returned by the <see cref="FileMemoryProvider"/> list files tool,
/// containing the file name and an optional description.
/// Represents a file entry returned by the <see cref="FileMemoryProvider"/> list (ls) tool,
/// containing the file name, its entry type, and an optional description.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class FileListEntry
@@ -16,8 +16,14 @@ public sealed class FileListEntry
/// <summary>
/// Gets or sets the name of the file.
/// </summary>
[JsonPropertyName("fileName")]
public string FileName { get; set; } = string.Empty;
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the entry type. Memory entries are always <see cref="FileStoreEntry.File"/>.
/// </summary>
[JsonPropertyName("type")]
public string Type { get; set; } = FileStoreEntry.File;
/// <summary>
/// Gets or sets the description of the file, or <see langword="null"/> if no description is available.
@@ -31,17 +31,40 @@ namespace Microsoft.Agents.AI;
/// <para>
/// This provider exposes the following tools to the agent:
/// <list type="bullet">
/// <item><description><c>SaveFile</c> — Save a memory file with the given name, content, and an optional description.</description></item>
/// <item><description><c>ReadFile</c> — Read the content of a file by name.</description></item>
/// <item><description><c>DeleteFile</c> — Delete a file by name.</description></item>
/// <item><description><c>ListFiles</c> — List all files with their descriptions (if available).</description></item>
/// <item><description><c>SearchFiles</c> — Search file contents using a regular expression pattern.</description></item>
/// <item><description><c>file_memory_write</c> — Write a memory file with the given name, content, and an optional description.</description></item>
/// <item><description><c>file_memory_read</c> — Read the content of a file by name.</description></item>
/// <item><description><c>file_memory_delete</c> — Delete a file by name.</description></item>
/// <item><description><c>file_memory_ls</c> — List all files with their descriptions (if available).</description></item>
/// <item><description><c>file_memory_grep</c> — Search file contents using a regular expression pattern.</description></item>
/// <item><description><c>file_memory_replace</c> — Replace occurrences of a substring within a memory file.</description></item>
/// <item><description><c>file_memory_replace_lines</c> — Replace whole lines within a memory file.</description></item>
/// </list>
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class FileMemoryProvider : AIContextProvider, IDisposable
{
/// <summary>The name of the tool that writes a memory file.</summary>
public const string WriteToolName = "file_memory_write";
/// <summary>The name of the tool that reads a memory file.</summary>
public const string ReadFileToolName = "file_memory_read";
/// <summary>The name of the tool that deletes a memory file.</summary>
public const string DeleteFileToolName = "file_memory_delete";
/// <summary>The name of the tool that lists the memory files.</summary>
public const string LsToolName = "file_memory_ls";
/// <summary>The name of the tool that searches memory file contents.</summary>
public const string GrepToolName = "file_memory_grep";
/// <summary>The name of the tool that replaces occurrences of a substring within a memory file.</summary>
public const string ReplaceToolName = "file_memory_replace";
/// <summary>The name of the tool that replaces whole lines within a memory file.</summary>
public const string ReplaceLinesToolName = "file_memory_replace_lines";
private const string DescriptionSuffix = "_description.md";
private const string MemoryIndexFileName = "memories.md";
private const int MaxIndexEntries = 50;
@@ -54,11 +77,11 @@ public sealed class FileMemoryProvider : AIContextProvider, IDisposable
Use these tools to store plans, memories, processing results, or downloaded data.
- Use descriptive file names (e.g., "projectarchitecture.md", "userpreferences.md").
- Include a description when saving a file to help with future discovery.
- Before starting new tasks, use file_memory_list_files and file_memory_search_files to check for relevant existing memories to avoid duplicate work.
- Keep memories up-to-date by overwriting files when information changes.
- Include a description when writing a file to help with future discovery.
- Before starting new tasks, use file_memory_ls and file_memory_grep to check for relevant existing memories to avoid duplicate work.
- Keep memories up-to-date by overwriting files when information changes, or by using file_memory_replace and file_memory_replace_lines to make small edits.
- When you receive large amounts of data (e.g., downloaded web pages, API responses, research results),
save them to files if they will be required later, so that they are not lost when older context is compacted or truncated.
write them to files if they will be required later, so that they are not lost when older context is compacted or truncated.
This ensures important data remains accessible across long-running sessions.
""";
@@ -122,14 +145,14 @@ public sealed class FileMemoryProvider : AIContextProvider, IDisposable
// Inject the memory index as a user message so the agent knows what memories are available.
string indexPath = CombinePaths(state.WorkingFolder, MemoryIndexFileName);
string? indexContent = await this._fileStore.ReadFileAsync(indexPath, cancellationToken).ConfigureAwait(false);
string? indexContent = await this._fileStore.ReadAsync(indexPath, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(indexContent))
{
aiContext.Messages =
[
new ChatMessage(ChatRole.User,
"The following is your memory index — a list of files you have previously saved. " +
"You can read any of these files using the file_memory_read_file tool.\n\n" +
"The following is your memory index — a list of files you have previously written. " +
"You can read any of these files using the file_memory_read tool.\n\n" +
indexContent),
];
}
@@ -138,46 +161,45 @@ public sealed class FileMemoryProvider : AIContextProvider, IDisposable
}
/// <summary>
/// Save a memory file with the given name and content.
/// Write a memory file with the given name and content.
/// Overwrites the file if it already exists.
/// Include a description for large files to provide a summary that helps with discovery.
/// </summary>
/// <param name="fileName">The name of the file to save.</param>
/// <param name="fileName">The name of the file to write.</param>
/// <param name="content">The content to write to the file.</param>
/// <param name="description">An optional description of the file contents for discovery. Leave empty or omit to skip.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A confirmation message.</returns>
[Description("Save a memory file with the given name and content. Overwrites the file if it already exists. Include a description for large files to provide a summary that helps with discovery.")]
private async Task<string> SaveFileAsync(string fileName, string content, string? description = null, CancellationToken cancellationToken = default)
[Description("Write a memory file with the given name and content. Overwrites the file if it already exists. Include a description for large files to provide a summary that helps with future discovery.")]
private async Task<string> WriteAsync(string fileName, string content, string? description = null, CancellationToken cancellationToken = default)
{
if (IsInternalFile(fileName))
{
throw new ArgumentException("The provided file name is reserved by the system for internal use. Please choose a different file name.", nameof(fileName));
}
string normalized = StorePaths.NormalizeRelativePath(fileName);
ValidateMemoryFileName(normalized, fileName);
FileMemoryState state = this._sessionState.GetOrInitializeState(AIAgent.CurrentRunContext?.Session);
string path = ResolvePath(state.WorkingFolder, fileName);
string path = ResolvePath(state.WorkingFolder, normalized);
await this._writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await this._fileStore.WriteFileAsync(path, content, cancellationToken).ConfigureAwait(false);
await this._fileStore.WriteAsync(path, content, cancellationToken).ConfigureAwait(false);
string descPath = ResolvePath(state.WorkingFolder, GetDescriptionFileName(fileName));
string descPath = ResolvePath(state.WorkingFolder, GetDescriptionFileName(normalized));
if (!string.IsNullOrWhiteSpace(description))
{
await this._fileStore.WriteFileAsync(descPath, description, cancellationToken).ConfigureAwait(false);
await this._fileStore.WriteAsync(descPath, description!, cancellationToken).ConfigureAwait(false);
}
else
{
// Remove any stale description file when no description is provided.
await this._fileStore.DeleteFileAsync(descPath, cancellationToken).ConfigureAwait(false);
await this._fileStore.DeleteAsync(descPath, cancellationToken).ConfigureAwait(false);
}
string result = string.IsNullOrWhiteSpace(description)
? $"File '{fileName}' saved."
: $"File '{fileName}' saved with description.";
? $"File '{fileName}' written."
: $"File '{fileName}' written with description.";
await this.RebuildMemoryIndexAsync(state, cancellationToken).ConfigureAwait(false);
return result;
@@ -196,11 +218,15 @@ public sealed class FileMemoryProvider : AIContextProvider, IDisposable
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>The file content or a not-found message.</returns>
[Description("Read the content of a memory file by name. Returns the file content or a message indicating the file was not found.")]
private async Task<string> ReadFileAsync(string fileName, CancellationToken cancellationToken = default)
private async Task<string> ReadAsync(string fileName, CancellationToken cancellationToken = default)
{
string normalized = StorePaths.NormalizeRelativePath(fileName);
ValidateMemoryFileName(normalized, fileName);
FileMemoryState state = this._sessionState.GetOrInitializeState(AIAgent.CurrentRunContext?.Session);
string path = ResolvePath(state.WorkingFolder, fileName);
string? content = await this._fileStore.ReadFileAsync(path, cancellationToken).ConfigureAwait(false);
string path = ResolvePath(state.WorkingFolder, normalized);
string? content = await this._fileStore.ReadAsync(path, cancellationToken).ConfigureAwait(false);
return content ?? $"File '{fileName}' not found.";
}
@@ -211,19 +237,23 @@ public sealed class FileMemoryProvider : AIContextProvider, IDisposable
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A confirmation or not-found message.</returns>
[Description("Delete a memory file by name. Also removes its companion description file if one exists.")]
private async Task<string> DeleteFileAsync(string fileName, CancellationToken cancellationToken = default)
private async Task<string> DeleteAsync(string fileName, CancellationToken cancellationToken = default)
{
string normalized = StorePaths.NormalizeRelativePath(fileName);
ValidateMemoryFileName(normalized, fileName);
FileMemoryState state = this._sessionState.GetOrInitializeState(AIAgent.CurrentRunContext?.Session);
string path = ResolvePath(state.WorkingFolder, fileName);
string path = ResolvePath(state.WorkingFolder, normalized);
await this._writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
bool deleted = await this._fileStore.DeleteFileAsync(path, cancellationToken).ConfigureAwait(false);
bool deleted = await this._fileStore.DeleteAsync(path, cancellationToken).ConfigureAwait(false);
// Also delete companion description file if it exists.
string descPath = ResolvePath(state.WorkingFolder, GetDescriptionFileName(fileName));
await this._fileStore.DeleteFileAsync(descPath, cancellationToken).ConfigureAwait(false);
string descPath = ResolvePath(state.WorkingFolder, GetDescriptionFileName(normalized));
await this._fileStore.DeleteAsync(descPath, cancellationToken).ConfigureAwait(false);
await this.RebuildMemoryIndexAsync(state, cancellationToken).ConfigureAwait(false);
return deleted ? $"File '{fileName}' deleted." : $"File '{fileName}' not found.";
@@ -237,32 +267,32 @@ public sealed class FileMemoryProvider : AIContextProvider, IDisposable
/// <summary>
/// List all memory files with their descriptions (if available). Description files are not shown separately.
/// </summary>
/// <param name="globPattern">An optional glob pattern (e.g., "*.md") matched against file names to filter the listing.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A list of file entries with names and optional descriptions.</returns>
[Description("List all memory files with their descriptions (if available). Description files are not shown separately.")]
private async Task<List<FileListEntry>> ListFilesAsync(CancellationToken cancellationToken = default)
[Description("List all memory files with their descriptions (if available). Optionally filter file names with a glob_pattern (e.g. \"*.md\"). Internal files (description sidecars and the memory index) are not shown.")]
private async Task<List<FileListEntry>> LsAsync(string? globPattern = null, CancellationToken cancellationToken = default)
{
FileMemoryState state = this._sessionState.GetOrInitializeState(AIAgent.CurrentRunContext?.Session);
IReadOnlyList<string> fileNames = await this._fileStore.ListFilesAsync(state.WorkingFolder, cancellationToken).ConfigureAwait(false);
IReadOnlyList<FileStoreEntry> children = await this._fileStore.ListChildrenAsync(state.WorkingFolder, cancellationToken).ConfigureAwait(false);
var descriptionFileSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (string file in fileNames)
{
if (file.EndsWith(DescriptionSuffix, StringComparison.OrdinalIgnoreCase))
{
descriptionFileSet.Add(file);
}
}
var fileNames = children
.Where(c => string.Equals(c.Type, FileStoreEntry.File, StringComparison.Ordinal))
.Select(c => c.Name)
.ToList();
var availableFiles = new HashSet<string>(fileNames, StringComparer.OrdinalIgnoreCase);
var matcher = string.IsNullOrWhiteSpace(globPattern) ? null : StorePaths.CreateGlobMatcher(globPattern!);
var entries = new List<FileListEntry>();
foreach (string file in fileNames)
{
if (descriptionFileSet.Contains(file))
if (IsInternalFile(file))
{
continue;
}
if (IsInternalFile(file))
if (!StorePaths.MatchesGlob(file, matcher))
{
continue;
}
@@ -270,33 +300,108 @@ public sealed class FileMemoryProvider : AIContextProvider, IDisposable
string? fileDescription = null;
string descFileName = GetDescriptionFileName(file);
if (descriptionFileSet.Contains(descFileName))
if (availableFiles.Contains(descFileName))
{
string descPath = CombinePaths(state.WorkingFolder, descFileName);
fileDescription = await this._fileStore.ReadFileAsync(descPath, cancellationToken).ConfigureAwait(false);
fileDescription = await this._fileStore.ReadAsync(descPath, cancellationToken).ConfigureAwait(false);
}
entries.Add(new FileListEntry { FileName = file, Description = fileDescription });
entries.Add(new FileListEntry { Name = file, Type = FileStoreEntry.File, Description = fileDescription });
}
return entries;
}
/// <summary>
/// Replace occurrences of <paramref name="oldString"/> with <paramref name="newString"/> in a memory file.
/// </summary>
/// <param name="fileName">The name of the file to modify.</param>
/// <param name="oldString">The substring to find and replace.</param>
/// <param name="newString">The replacement text.</param>
/// <param name="replaceAll">When <see langword="true"/>, replace every occurrence; otherwise fail unless exactly one occurrence exists.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A confirmation message including the number of occurrences replaced, or a failure message.</returns>
[Description("Replace occurrences of old_string with new_string in a memory file. Fails if old_string is not found, or if it occurs more than once and replace_all is false. Returns the number of occurrences replaced.")]
private async Task<string> ReplaceAsync(string fileName, string oldString, string newString, bool replaceAll = false, CancellationToken cancellationToken = default)
{
string normalized = StorePaths.NormalizeRelativePath(fileName);
ValidateMemoryFileName(normalized, fileName);
FileMemoryState state = this._sessionState.GetOrInitializeState(AIAgent.CurrentRunContext?.Session);
string path = ResolvePath(state.WorkingFolder, normalized);
await this._writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
string? content = await this._fileStore.ReadAsync(path, cancellationToken).ConfigureAwait(false);
if (content is null)
{
return $"File '{fileName}' not found.";
}
(string newContent, int count) = FileEditor.ApplyReplace(content, oldString, newString, replaceAll);
await this._fileStore.WriteAsync(path, newContent, cancellationToken).ConfigureAwait(false);
return $"Replaced {count} occurrence(s) in '{fileName}'.";
}
finally
{
this._writeLock.Release();
}
}
/// <summary>
/// Replace lines in a memory file. Provide a list of edits, each with a 1-based line number and the
/// literal replacement text; an empty replacement deletes the line.
/// </summary>
/// <param name="fileName">The name of the file to modify.</param>
/// <param name="edits">The list of 1-based line numbers and their literal replacement text.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A confirmation message including the number of lines replaced, or a failure message.</returns>
[Description("Replace lines in a memory file. Provide a list of edits, each with a 1-based line_number and a literal new_line (include your own trailing newline); an empty new_line deletes the line, including its line break. Fails on out-of-range or duplicate line numbers.")]
private async Task<string> ReplaceLinesAsync(string fileName, List<FileLineEdit> edits, CancellationToken cancellationToken = default)
{
string normalized = StorePaths.NormalizeRelativePath(fileName);
ValidateMemoryFileName(normalized, fileName);
FileMemoryState state = this._sessionState.GetOrInitializeState(AIAgent.CurrentRunContext?.Session);
string path = ResolvePath(state.WorkingFolder, normalized);
await this._writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
string? content = await this._fileStore.ReadAsync(path, cancellationToken).ConfigureAwait(false);
if (content is null)
{
return $"File '{fileName}' not found.";
}
string newContent = FileEditor.ApplyReplaceLines(content, edits);
await this._fileStore.WriteAsync(path, newContent, cancellationToken).ConfigureAwait(false);
return $"Replaced {edits.Count} line(s) in '{fileName}'.";
}
finally
{
this._writeLock.Release();
}
}
/// <summary>
/// Search memory file contents using a regular expression pattern (case-insensitive).
/// Optionally filter which files to search using a glob pattern.
/// Returns matching file names, content snippets, and matching lines with line numbers.
/// </summary>
/// <param name="regexPattern">A regular expression pattern to match against file contents (case-insensitive).</param>
/// <param name="filePattern">An optional glob pattern to filter which files to search (e.g., "*.md", "research*"). Leave empty or omit to search all files.</param>
/// <param name="globPattern">An optional glob pattern to filter which files to search (e.g., "*.md", "research*"). Leave empty or omit to search all files.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A list of search results with matching file names, snippets, and matching lines.</returns>
[Description("Search memory file contents using a regular expression pattern (case-insensitive). Optionally filter which files to search using a glob pattern (e.g., \"*.md\", \"research*\"). Returns matching file names, content snippets, and matching lines with line numbers.")]
private async Task<List<FileSearchResult>> SearchFilesAsync(string regexPattern, string? filePattern = null, CancellationToken cancellationToken = default)
[Description("Search memory file contents using a regular expression pattern (case-insensitive). Optionally filter which files to search using a glob_pattern (e.g., \"*.md\", \"research*\"). Returns matching file names, content snippets, and matching lines with line numbers.")]
private async Task<List<FileSearchResult>> GrepAsync(string regexPattern, string? globPattern = null, CancellationToken cancellationToken = default)
{
FileMemoryState state = this._sessionState.GetOrInitializeState(AIAgent.CurrentRunContext?.Session);
string? pattern = string.IsNullOrWhiteSpace(filePattern) ? null : filePattern;
IReadOnlyList<FileSearchResult> results = await this._fileStore.SearchFilesAsync(state.WorkingFolder, regexPattern, pattern, recursive: false, cancellationToken).ConfigureAwait(false);
string? pattern = string.IsNullOrWhiteSpace(globPattern) ? null : globPattern;
IReadOnlyList<FileSearchResult> results = await this._fileStore.SearchAsync(state.WorkingFolder, regexPattern, pattern, recursive: false, cancellationToken).ConfigureAwait(false);
// Filter out internal files (description sidecars and memory index) so they stay hidden.
var filtered = new List<FileSearchResult>(results.Count);
@@ -319,11 +424,13 @@ public sealed class FileMemoryProvider : AIContextProvider, IDisposable
return
[
AIFunctionFactory.Create(this.SaveFileAsync, new AIFunctionFactoryOptions { Name = "file_memory_save_file", SerializerOptions = serializerOptions }),
AIFunctionFactory.Create(this.ReadFileAsync, new AIFunctionFactoryOptions { Name = "file_memory_read_file", SerializerOptions = serializerOptions }),
AIFunctionFactory.Create(this.DeleteFileAsync, new AIFunctionFactoryOptions { Name = "file_memory_delete_file", SerializerOptions = serializerOptions }),
AIFunctionFactory.Create(this.ListFilesAsync, new AIFunctionFactoryOptions { Name = "file_memory_list_files", SerializerOptions = serializerOptions }),
AIFunctionFactory.Create(this.SearchFilesAsync, new AIFunctionFactoryOptions { Name = "file_memory_search_files", SerializerOptions = serializerOptions }),
AIFunctionFactory.Create(this.WriteAsync, new AIFunctionFactoryOptions { Name = WriteToolName, SerializerOptions = serializerOptions }),
AIFunctionFactory.Create(this.ReadAsync, new AIFunctionFactoryOptions { Name = ReadFileToolName, SerializerOptions = serializerOptions }),
AIFunctionFactory.Create(this.DeleteAsync, new AIFunctionFactoryOptions { Name = DeleteFileToolName, SerializerOptions = serializerOptions }),
AIFunctionFactory.Create(this.LsAsync, new AIFunctionFactoryOptions { Name = LsToolName, SerializerOptions = serializerOptions }),
AIFunctionFactory.Create(this.GrepAsync, new AIFunctionFactoryOptions { Name = GrepToolName, SerializerOptions = serializerOptions }),
AIFunctionFactory.Create(this.ReplaceAsync, new AIFunctionFactoryOptions { Name = ReplaceToolName, SerializerOptions = serializerOptions }),
AIFunctionFactory.Create(this.ReplaceLinesAsync, new AIFunctionFactoryOptions { Name = ReplaceLinesToolName, SerializerOptions = serializerOptions }),
];
}
@@ -333,10 +440,14 @@ public sealed class FileMemoryProvider : AIContextProvider, IDisposable
/// </summary>
private async Task RebuildMemoryIndexAsync(FileMemoryState state, CancellationToken cancellationToken)
{
IReadOnlyList<string> fileNames = await this._fileStore.ListFilesAsync(state.WorkingFolder, cancellationToken).ConfigureAwait(false);
IReadOnlyList<FileStoreEntry> children = await this._fileStore.ListChildrenAsync(state.WorkingFolder, cancellationToken).ConfigureAwait(false);
// Sort deterministically so the index is stable across runs and platforms.
var sortedFiles = fileNames.OrderBy(f => f, StringComparer.OrdinalIgnoreCase).ToList();
var sortedFiles = children
.Where(c => string.Equals(c.Type, FileStoreEntry.File, StringComparison.Ordinal))
.Select(c => c.Name)
.OrderBy(f => f, StringComparer.OrdinalIgnoreCase)
.ToList();
var sb = new System.Text.StringBuilder();
sb.AppendLine("# Memory Index");
@@ -356,10 +467,9 @@ public sealed class FileMemoryProvider : AIContextProvider, IDisposable
break;
}
string? description = null;
string descFileName = GetDescriptionFileName(file);
string descPath = CombinePaths(state.WorkingFolder, descFileName);
description = await this._fileStore.ReadFileAsync(descPath, cancellationToken).ConfigureAwait(false);
string? description = await this._fileStore.ReadAsync(descPath, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(description))
{
@@ -374,7 +484,7 @@ public sealed class FileMemoryProvider : AIContextProvider, IDisposable
}
string indexPath = CombinePaths(state.WorkingFolder, MemoryIndexFileName);
await this._fileStore.WriteFileAsync(indexPath, sb.ToString(), cancellationToken).ConfigureAwait(false);
await this._fileStore.WriteAsync(indexPath, sb.ToString(), cancellationToken).ConfigureAwait(false);
}
private static string GetDescriptionFileName(string fileName)
@@ -398,6 +508,37 @@ public sealed class FileMemoryProvider : AIContextProvider, IDisposable
fileName.EndsWith(DescriptionSuffix, StringComparison.OrdinalIgnoreCase) ||
fileName.Equals(MemoryIndexFileName, StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Returns <see langword="true"/> if the normalized file name points into a subdirectory.
/// File memory is a flat, session-scoped space, so nested names are rejected up front.
/// </summary>
private static bool IsNestedPath(string normalizedFileName) =>
normalizedFileName.IndexOf('/') >= 0;
/// <summary>
/// Validates that a normalized memory file name is acceptable for write operations,
/// throwing <see cref="ArgumentException"/> when it points into a subdirectory or is
/// reserved for internal use.
/// </summary>
/// <param name="normalized">The normalized file name.</param>
/// <param name="fileName">The original file name, used for the <see cref="ArgumentException"/> parameter name.</param>
private static void ValidateMemoryFileName(string normalized, string fileName)
{
if (IsNestedPath(normalized))
{
throw new ArgumentException(
"Memory files must not be written into a subdirectory. Please choose a flat file name without path separators.",
nameof(fileName));
}
if (IsInternalFile(normalized))
{
throw new ArgumentException(
"The provided file name is reserved by the system for internal use. Please choose a different file name.",
nameof(fileName));
}
}
private static string ResolvePath(string workingFolder, string fileName)
{
// Validate and normalize the file name (rejects rooted, traversal, empty, etc.).
@@ -32,7 +32,7 @@ public abstract class AgentFileStore
/// <param name="content">The content to write to the file.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A task representing the asynchronous operation.</returns>
public abstract Task WriteFileAsync(string path, string content, CancellationToken cancellationToken = default);
public abstract Task WriteAsync(string path, string content, CancellationToken cancellationToken = default);
/// <summary>
/// Reads the content of a file.
@@ -40,7 +40,7 @@ public abstract class AgentFileStore
/// <param name="path">The relative path of the file to read.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>The file content, or <see langword="null"/> if the file does not exist.</returns>
public abstract Task<string?> ReadFileAsync(string path, CancellationToken cancellationToken = default);
public abstract Task<string?> ReadAsync(string path, CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a file.
@@ -48,23 +48,18 @@ public abstract class AgentFileStore
/// <param name="path">The relative path of the file to delete.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns><see langword="true"/> if the file was deleted; <see langword="false"/> if it did not exist.</returns>
public abstract Task<bool> DeleteFileAsync(string path, CancellationToken cancellationToken = default);
public abstract Task<bool> DeleteAsync(string path, CancellationToken cancellationToken = default);
/// <summary>
/// Lists files in a directory.
/// Lists the direct children (files and subdirectories) of a directory.
/// </summary>
/// <param name="directory">The relative path of the directory to list. Use an empty string for the root.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A list of file names in the specified directory (direct children only).</returns>
public abstract Task<IReadOnlyList<string>> ListFilesAsync(string directory, CancellationToken cancellationToken = default);
/// <summary>
/// Lists the direct child subdirectories of a directory.
/// </summary>
/// <param name="directory">The relative path of the directory to list. Use an empty string for the root.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A list of subdirectory names in the specified directory (direct children only).</returns>
public abstract Task<IReadOnlyList<string>> ListDirectoriesAsync(string directory, CancellationToken cancellationToken = default);
/// <returns>
/// A list of the direct children of the specified directory as <see cref="FileStoreEntry"/> instances.
/// Subdirectories are listed before files.
/// </returns>
public abstract Task<IReadOnlyList<FileStoreEntry>> ListChildrenAsync(string directory, CancellationToken cancellationToken = default);
/// <summary>
/// Checks whether a file exists.
@@ -82,7 +77,7 @@ public abstract class AgentFileStore
/// A regular expression pattern to match against file contents. The pattern is matched case-insensitively.
/// For example, <c>"error|warning"</c> matches lines containing "error" or "warning".
/// </param>
/// <param name="filePattern">
/// <param name="globPattern">
/// An optional glob pattern to filter which files are searched (e.g., <c>"*.md"</c>, <c>"research*"</c>).
/// When <see langword="null"/>, all files are searched.
/// Uses standard glob syntax from <see cref="Matcher"/>, matched against each file's path relative to
@@ -97,7 +92,7 @@ public abstract class AgentFileStore
/// A list of search results. Each result's <see cref="FileSearchResult.FileName"/> is the matching file's
/// path relative to <paramref name="directory"/>.
/// </returns>
public abstract Task<IReadOnlyList<FileSearchResult>> SearchFilesAsync(string directory, string regexPattern, string? filePattern = null, bool recursive = false, CancellationToken cancellationToken = default);
public abstract Task<IReadOnlyList<FileSearchResult>> SearchAsync(string directory, string regexPattern, string? globPattern = null, bool recursive = false, CancellationToken cancellationToken = default);
/// <summary>
/// Ensures a directory exists, creating it if necessary.
@@ -0,0 +1,141 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
namespace Microsoft.Agents.AI;
/// <summary>
/// Internal helpers shared by <see cref="FileAccessProvider"/> and <see cref="FileMemoryProvider"/>
/// for the <c>replace</c> and <c>replace_lines</c> tools.
/// </summary>
internal static class FileEditor
{
/// <summary>
/// Replaces occurrences of <paramref name="oldString"/> with <paramref name="newString"/> in
/// <paramref name="content"/>, returning the new content and the number of replacements made.
/// </summary>
/// <exception cref="ArgumentException">
/// Thrown when <paramref name="oldString"/> is empty, is not found, or occurs more than once
/// while <paramref name="replaceAll"/> is <see langword="false"/>.
/// </exception>
internal static (string Content, int Count) ApplyReplace(string content, string oldString, string newString, bool replaceAll)
{
if (string.IsNullOrEmpty(oldString))
{
throw new ArgumentException("old_string must not be empty.");
}
int count = CountOccurrences(content, oldString);
if (count == 0)
{
throw new ArgumentException($"old_string not found: '{oldString}'.");
}
if (count > 1 && !replaceAll)
{
throw new ArgumentException(
$"old_string occurs {count} times; pass replace_all=true to replace all, " +
"or provide a more specific old_string.");
}
#if NET8_0_OR_GREATER
return (content.Replace(oldString, newString, StringComparison.Ordinal), count);
#else
return (content.Replace(oldString, newString), count);
#endif
}
/// <summary>
/// Applies literal (1-based) line replacements to <paramref name="content"/>.
/// </summary>
/// <remarks>
/// Each edit's <see cref="FileLineEdit.NewLine"/> is treated as the literal replacement text for the
/// targeted line, including any trailing newline the caller wants to keep — the editor does not add
/// one. An empty <see cref="FileLineEdit.NewLine"/> deletes the line entirely, including its line break.
/// </remarks>
/// <exception cref="ArgumentException">
/// Thrown when <paramref name="edits"/> is empty, any line number is out of range, or a line number
/// is targeted more than once.
/// </exception>
internal static string ApplyReplaceLines(string content, IReadOnlyList<FileLineEdit> edits)
{
if (edits.Count == 0)
{
throw new ArgumentException("At least one line edit must be provided.");
}
List<string> lines = SplitLinesKeepEnds(content);
var seen = new HashSet<int>();
foreach (FileLineEdit edit in edits)
{
if (!seen.Add(edit.LineNumber))
{
throw new ArgumentException($"Duplicate line_number {edit.LineNumber} in edits.");
}
if (edit.LineNumber < 1 || edit.LineNumber > lines.Count)
{
throw new ArgumentException(
$"line_number {edit.LineNumber} is out of range (file has {lines.Count} lines).");
}
}
foreach (FileLineEdit edit in edits)
{
// An empty replacement removes the line (content and its line break); otherwise the
// replacement is written verbatim, so the caller controls any trailing newline.
lines[edit.LineNumber - 1] = edit.NewLine;
}
return string.Concat(lines);
}
private static int CountOccurrences(string content, string value)
{
int count = 0;
int index = 0;
while ((index = content.IndexOf(value, index, StringComparison.Ordinal)) >= 0)
{
count++;
index += value.Length;
}
return count;
}
/// <summary>
/// Splits content into lines, keeping each line's trailing newline (<c>\r\n</c>, <c>\n</c>, or a lone
/// <c>\r</c>) attached. The final line has no terminator when the content does not end with a newline.
/// </summary>
private static List<string> SplitLinesKeepEnds(string content)
{
var lines = new List<string>();
int start = 0;
for (int i = 0; i < content.Length; i++)
{
char c = content[i];
if (c == '\n')
{
lines.Add(content.Substring(start, i - start + 1));
start = i + 1;
}
else if (c == '\r')
{
// Treat "\r\n" as a single terminator; a lone "\r" also terminates a line.
int end = (i + 1 < content.Length && content[i + 1] == '\n') ? i + 2 : i + 1;
lines.Add(content.Substring(start, end - start));
i = end - 1;
start = end;
}
}
if (start < content.Length)
{
lines.Add(content.Substring(start));
}
return lines;
}
}
@@ -0,0 +1,31 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents a single whole-line replacement used by the file access and file memory
/// <c>replace_lines</c> tools.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class FileLineEdit
{
/// <summary>
/// Gets or sets the 1-based line number to replace.
/// </summary>
[JsonPropertyName("line_number")]
[Description("1-based line number to replace.")]
public int LineNumber { get; set; }
/// <summary>
/// Gets or sets the literal replacement text for the line, including any trailing newline to keep.
/// An empty string deletes the line entirely (its content and its line break).
/// </summary>
[JsonPropertyName("new_line")]
[Description("Literal replacement text for the line, including any trailing newline you want to keep (the editor does not add one). Set to an empty string to delete the line entirely, including its line break.")]
public string NewLine { get; set; } = string.Empty;
}
@@ -0,0 +1,51 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents a single direct child of a directory in an <see cref="AgentFileStore"/>,
/// returned by <see cref="AgentFileStore.ListChildrenAsync"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class FileStoreEntry
{
/// <summary>The <see cref="Type"/> value for a regular file.</summary>
public const string File = "file";
/// <summary>The <see cref="Type"/> value for a subdirectory.</summary>
public const string Directory = "directory";
/// <summary>
/// Initializes a new instance of the <see cref="FileStoreEntry"/> class.
/// </summary>
public FileStoreEntry()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="FileStoreEntry"/> class.
/// </summary>
/// <param name="name">The name of the entry (a single path segment, not a full path).</param>
/// <param name="type">Either <see cref="File"/> or <see cref="Directory"/>.</param>
public FileStoreEntry(string name, string type)
{
this.Name = name;
this.Type = type;
}
/// <summary>
/// Gets or sets the name of the entry (a single path segment relative to the listed directory).
/// </summary>
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the entry type, either <see cref="File"/> or <see cref="Directory"/>.
/// </summary>
[JsonPropertyName("type")]
public string Type { get; set; } = File;
}
@@ -4,7 +4,6 @@ using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
@@ -60,7 +59,7 @@ public sealed class FileSystemAgentFileStore : AgentFileStore
}
/// <inheritdoc />
public override async Task WriteFileAsync(string path, string content, CancellationToken cancellationToken = default)
public override async Task WriteAsync(string path, string content, CancellationToken cancellationToken = default)
{
string fullPath = this.ResolveSafePath(path);
@@ -80,7 +79,7 @@ public sealed class FileSystemAgentFileStore : AgentFileStore
}
/// <inheritdoc />
public override async Task<string?> ReadFileAsync(string path, CancellationToken cancellationToken = default)
public override async Task<string?> ReadAsync(string path, CancellationToken cancellationToken = default)
{
string fullPath = this.ResolveSafePath(path);
@@ -98,7 +97,7 @@ public sealed class FileSystemAgentFileStore : AgentFileStore
}
/// <inheritdoc />
public override Task<bool> DeleteFileAsync(string path, CancellationToken cancellationToken = default)
public override Task<bool> DeleteAsync(string path, CancellationToken cancellationToken = default)
{
string fullPath = this.ResolveSafePath(path);
@@ -112,22 +111,47 @@ public sealed class FileSystemAgentFileStore : AgentFileStore
}
/// <inheritdoc />
public override Task<IReadOnlyList<string>> ListFilesAsync(string directory, CancellationToken cancellationToken = default)
public override Task<IReadOnlyList<FileStoreEntry>> ListChildrenAsync(string directory, CancellationToken cancellationToken = default)
{
string fullDir = this.ResolveSafeDirectoryPath(directory);
if (!Directory.Exists(fullDir))
{
return Task.FromResult<IReadOnlyList<string>>([]);
return Task.FromResult<IReadOnlyList<FileStoreEntry>>([]);
}
var files = Directory.GetFiles(fullDir)
.Where(f => (File.GetAttributes(f) & FileAttributes.ReparsePoint) == 0)
.Select(Path.GetFileName)
.Where(name => name is not null)
.ToList();
// Subdirectories first, then files. Skip symlinks/reparse points for both.
var entries = new List<FileStoreEntry>();
return Task.FromResult<IReadOnlyList<string>>(files!);
foreach (string dir in Directory.GetDirectories(fullDir))
{
if ((File.GetAttributes(dir) & FileAttributes.ReparsePoint) != 0)
{
continue;
}
string? name = Path.GetFileName(dir);
if (name is not null)
{
entries.Add(new FileStoreEntry(name, FileStoreEntry.Directory));
}
}
foreach (string file in Directory.GetFiles(fullDir))
{
if ((File.GetAttributes(file) & FileAttributes.ReparsePoint) != 0)
{
continue;
}
string? name = Path.GetFileName(file);
if (name is not null)
{
entries.Add(new FileStoreEntry(name, FileStoreEntry.File));
}
}
return Task.FromResult<IReadOnlyList<FileStoreEntry>>(entries);
}
/// <inheritdoc />
@@ -138,10 +162,10 @@ public sealed class FileSystemAgentFileStore : AgentFileStore
}
/// <inheritdoc />
public override async Task<IReadOnlyList<FileSearchResult>> SearchFilesAsync(
public override async Task<IReadOnlyList<FileSearchResult>> SearchAsync(
string directory,
string regexPattern,
string? filePattern = null,
string? globPattern = null,
bool recursive = false,
CancellationToken cancellationToken = default)
{
@@ -154,7 +178,7 @@ public sealed class FileSystemAgentFileStore : AgentFileStore
// Compile the regex with a timeout to guard against catastrophic backtracking (ReDoS).
var regex = new Regex(regexPattern, RegexOptions.IgnoreCase, TimeSpan.FromSeconds(5));
Matcher? matcher = filePattern is not null ? StorePaths.CreateGlobMatcher(filePattern) : null;
Matcher? matcher = globPattern is not null ? StorePaths.CreateGlobMatcher(globPattern) : null;
var results = new List<FileSearchResult>();
foreach (string filePath in EnumerateFiles(fullDir, recursive))
@@ -220,25 +244,6 @@ public sealed class FileSystemAgentFileStore : AgentFileStore
return results;
}
/// <inheritdoc />
public override Task<IReadOnlyList<string>> ListDirectoriesAsync(string directory, CancellationToken cancellationToken = default)
{
string fullDir = this.ResolveSafeDirectoryPath(directory);
if (!Directory.Exists(fullDir))
{
return Task.FromResult<IReadOnlyList<string>>([]);
}
var directories = Directory.GetDirectories(fullDir)
.Where(d => (File.GetAttributes(d) & FileAttributes.ReparsePoint) == 0)
.Select(Path.GetFileName)
.Where(name => name is not null)
.ToList();
return Task.FromResult<IReadOnlyList<string>>(directories!);
}
/// <summary>
/// Enumerates the files directly under <paramref name="directory"/> (or all descendant files when
/// <paramref name="recursive"/> is <see langword="true"/>), skipping symlinks/reparse points for both
@@ -26,7 +26,7 @@ public sealed class InMemoryAgentFileStore : AgentFileStore
private readonly ConcurrentDictionary<string, string> _files = new(StringComparer.OrdinalIgnoreCase);
/// <inheritdoc />
public override Task WriteFileAsync(string path, string content, CancellationToken cancellationToken = default)
public override Task WriteAsync(string path, string content, CancellationToken cancellationToken = default)
{
path = StorePaths.NormalizeRelativePath(path);
this._files[path] = content;
@@ -34,7 +34,7 @@ public sealed class InMemoryAgentFileStore : AgentFileStore
}
/// <inheritdoc />
public override Task<string?> ReadFileAsync(string path, CancellationToken cancellationToken = default)
public override Task<string?> ReadAsync(string path, CancellationToken cancellationToken = default)
{
path = StorePaths.NormalizeRelativePath(path);
this._files.TryGetValue(path, out string? content);
@@ -42,32 +42,14 @@ public sealed class InMemoryAgentFileStore : AgentFileStore
}
/// <inheritdoc />
public override Task<bool> DeleteFileAsync(string path, CancellationToken cancellationToken = default)
public override Task<bool> DeleteAsync(string path, CancellationToken cancellationToken = default)
{
path = StorePaths.NormalizeRelativePath(path);
return Task.FromResult(this._files.TryRemove(path, out _));
}
/// <inheritdoc />
public override Task<IReadOnlyList<string>> ListFilesAsync(string directory, CancellationToken cancellationToken = default)
{
string prefix = StorePaths.NormalizeRelativePath(directory, isDirectory: true);
if (prefix.Length > 0 && !prefix.EndsWith("/", StringComparison.Ordinal))
{
prefix += "/";
}
var files = this._files.Keys
.Where(k => k.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
.Select(k => k.Substring(prefix.Length))
.Where(k => k.IndexOf("/", StringComparison.Ordinal) < 0)
.ToList();
return Task.FromResult<IReadOnlyList<string>>(files);
}
/// <inheritdoc />
public override Task<IReadOnlyList<string>> ListDirectoriesAsync(string directory, CancellationToken cancellationToken = default)
public override Task<IReadOnlyList<FileStoreEntry>> ListChildrenAsync(string directory, CancellationToken cancellationToken = default)
{
string prefix = StorePaths.NormalizeRelativePath(directory, isDirectory: true);
if (prefix.Length > 0 && !prefix.EndsWith("/", StringComparison.Ordinal))
@@ -78,7 +60,9 @@ public sealed class InMemoryAgentFileStore : AgentFileStore
// A subdirectory is the first path segment of any key whose remainder (after the prefix)
// still contains a separator. Collect distinct first segments, preserving original casing.
var directories = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var seenDirectories = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var files = new List<string>();
foreach (string key in this._files.Keys)
{
if (!key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
@@ -88,19 +72,28 @@ public sealed class InMemoryAgentFileStore : AgentFileStore
string remainder = key.Substring(prefix.Length);
int separatorIndex = remainder.IndexOf("/", StringComparison.Ordinal);
if (separatorIndex <= 0)
if (separatorIndex < 0)
{
continue;
files.Add(remainder);
}
string segment = remainder.Substring(0, separatorIndex);
if (seen.Add(segment))
else if (separatorIndex > 0)
{
directories.Add(segment);
string segment = remainder.Substring(0, separatorIndex);
if (seenDirectories.Add(segment))
{
directories.Add(segment);
}
}
}
return Task.FromResult<IReadOnlyList<string>>(directories);
// Subdirectories first, then files.
FileStoreEntry[] entries =
[
.. directories.Select(d => new FileStoreEntry(d, FileStoreEntry.Directory)),
.. files.Select(f => new FileStoreEntry(f, FileStoreEntry.File)),
];
return Task.FromResult<IReadOnlyList<FileStoreEntry>>(entries);
}
/// <inheritdoc />
@@ -111,7 +104,7 @@ public sealed class InMemoryAgentFileStore : AgentFileStore
}
/// <inheritdoc />
public override Task<IReadOnlyList<FileSearchResult>> SearchFilesAsync(string directory, string regexPattern, string? filePattern = null, bool recursive = false, CancellationToken cancellationToken = default)
public override Task<IReadOnlyList<FileSearchResult>> SearchAsync(string directory, string regexPattern, string? globPattern = null, bool recursive = false, CancellationToken cancellationToken = default)
{
// Normalize the directory prefix for path matching.
string prefix = StorePaths.NormalizeRelativePath(directory, isDirectory: true);
@@ -122,7 +115,7 @@ public sealed class InMemoryAgentFileStore : AgentFileStore
// Compile the regex with a timeout to guard against catastrophic backtracking (ReDoS).
var regex = new Regex(regexPattern, RegexOptions.IgnoreCase, TimeSpan.FromSeconds(5));
Matcher? matcher = filePattern is not null ? StorePaths.CreateGlobMatcher(filePattern) : null;
Matcher? matcher = globPattern is not null ? StorePaths.CreateGlobMatcher(globPattern) : null;
var results = new List<FileSearchResult>();
foreach (var kvp in this._files)
@@ -1,11 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
@@ -13,8 +11,7 @@ namespace Microsoft.Agents.AI;
/// <summary>
/// A skill source that holds <see cref="AgentSkill"/> instances in memory.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
internal sealed class AgentInMemorySkillsSource : AgentSkillsSource
public sealed class AgentInMemorySkillsSource : AgentSkillsSource
{
private readonly List<AgentSkill> _skills;
@@ -1,9 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
@@ -20,7 +18,6 @@ namespace Microsoft.Agents.AI;
/// Skill metadata follows the <see href="https://agentskills.io/specification">Agent Skills specification</see>.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public abstract class AgentSkill
{
/// <summary>
@@ -4,7 +4,6 @@ using System;
using System.Diagnostics.CodeAnalysis;
using System.Text.RegularExpressions;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
@@ -23,7 +22,6 @@ namespace Microsoft.Agents.AI;
/// and throws <see cref="ArgumentException"/> if either value is invalid.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class AgentSkillFrontmatter
{
/// <summary>
@@ -1,10 +1,8 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
@@ -12,7 +10,6 @@ namespace Microsoft.Agents.AI;
/// <summary>
/// Abstract base class for skill resources. A resource provides supplementary content (references, assets) to a skill.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public abstract class AgentSkillResource
{
/// <summary>
@@ -1,11 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
@@ -13,7 +11,6 @@ namespace Microsoft.Agents.AI;
/// <summary>
/// Abstract base class for skill scripts. A script represents an executable action associated with a skill.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public abstract class AgentSkillScript
{
/// <summary>
@@ -2,7 +2,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Security;
using System.Text;
@@ -12,7 +11,6 @@ using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
@@ -31,9 +29,16 @@ namespace Microsoft.Agents.AI;
/// <item><description><strong>Read resources</strong> — supplementary content is read on demand via the <c>read_skill_resource</c> tool.</description></item>
/// <item><description><strong>Run scripts</strong> — scripts are executed via the <c>run_skill_script</c> tool (when scripts exist).</description></item>
/// </list>
/// <para>
/// The provider can optionally own the lifetime of its underlying <see cref="AgentSkillsSource"/>. When
/// constructed via one of the convenience constructors (skill paths or in-memory skills) or via
/// <see cref="AgentSkillsProviderBuilder"/>, the source pipeline is created internally and owned by the
/// provider, so disposing the provider disposes the pipeline. When constructed from a caller-supplied
/// <see cref="AgentSkillsSource"/>, ownership is controlled by the <c>ownsSource</c> constructor
/// parameter and defaults to the caller retaining ownership.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed partial class AgentSkillsProvider : AIContextProvider
public sealed partial class AgentSkillsProvider : AIContextProvider, IDisposable
{
/// <summary>The name of the tool that loads a skill.</summary>
public const string LoadSkillToolName = "load_skill";
@@ -65,10 +70,11 @@ public sealed partial class AgentSkillsProvider : AIContextProvider
/// </summary>
/// <remarks>
/// <para>
/// The tools exposed by <see cref="AgentSkillsProvider"/> always require approval. Add this rule to
/// This rule only applies when approval is enabled for the matching tools in
/// <see cref="AgentSkillsProviderOptions"/>. When the read-only skill tools require approval, add this rule to
/// <see cref="ToolApprovalAgentOptions.AutoApprovalRules"/> to automatically approve only the tools
/// that read skill content, while still prompting for script execution
/// (<see cref="RunSkillScriptToolName"/>).
/// (<see cref="RunSkillScriptToolName"/>) if it also requires approval.
/// </para>
/// <para>
/// The rule matches on the tool name, returning <see langword="true"/> for read-only skill tools
@@ -84,9 +90,10 @@ public sealed partial class AgentSkillsProvider : AIContextProvider
/// </summary>
/// <remarks>
/// <para>
/// The tools exposed by <see cref="AgentSkillsProvider"/> always require approval. Add this rule to
/// This rule only applies when approval is enabled for the matching tools in
/// <see cref="AgentSkillsProviderOptions"/>. When skill tools require approval, add this rule to
/// <see cref="ToolApprovalAgentOptions.AutoApprovalRules"/> to automatically approve every skill
/// tool without prompting the user.
/// tool that requires approval without prompting the user.
/// </para>
/// <para>
/// The rule matches on the tool name, returning <see langword="true"/> for any skill tool
@@ -120,8 +127,10 @@ public sealed partial class AgentSkillsProvider : AIContextProvider
""";
private readonly AgentSkillsSource _source;
private readonly bool _ownsSource;
private readonly AgentSkillsProviderOptions? _options;
private readonly ILogger<AgentSkillsProvider> _logger;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="AgentSkillsProvider"/> class
@@ -165,7 +174,8 @@ public sealed partial class AgentSkillsProvider : AIContextProvider
new AgentFileSkillsSource(skillPaths, scriptRunner, fileOptions, loggerFactory)),
loggerFactory),
options,
loggerFactory)
loggerFactory,
ownsSource: true)
{
}
@@ -196,7 +206,8 @@ public sealed partial class AgentSkillsProvider : AIContextProvider
new AgentInMemorySkillsSource(Throw.IfNull(skills))),
loggerFactory),
options,
loggerFactory)
loggerFactory,
ownsSource: true)
{
}
@@ -208,9 +219,15 @@ public sealed partial class AgentSkillsProvider : AIContextProvider
/// <param name="source">The skill source providing skills.</param>
/// <param name="options">Optional configuration.</param>
/// <param name="loggerFactory">Optional logger factory.</param>
public AgentSkillsProvider(AgentSkillsSource source, AgentSkillsProviderOptions? options = null, ILoggerFactory? loggerFactory = null)
/// <param name="ownsSource">
/// <see langword="true"/> to transfer ownership of <paramref name="source"/> to the provider so that it is
/// disposed when the provider is disposed; <see langword="false"/> (the default) to leave ownership with the
/// caller. Set this to <see langword="true"/> only when the provider is the sole owner of the source.
/// </param>
public AgentSkillsProvider(AgentSkillsSource source, AgentSkillsProviderOptions? options = null, ILoggerFactory? loggerFactory = null, bool ownsSource = false)
{
this._source = Throw.IfNull(source);
this._ownsSource = ownsSource;
this._options = options;
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<AgentSkillsProvider>();
@@ -236,27 +253,55 @@ public sealed partial class AgentSkillsProvider : AIContextProvider
};
}
/// <summary>
/// Releases the resources used by this provider. When the provider owns its underlying
/// <see cref="AgentSkillsSource"/> (see the <c>ownsSource</c> constructor parameter), the source is
/// disposed as well.
/// </summary>
public void Dispose()
{
if (this._disposed)
{
return;
}
this._disposed = true;
if (this._ownsSource)
{
this._source.Dispose();
}
}
private IList<AIFunction> BuildTools(IList<AgentSkill> skills)
{
return
[
new ApprovalRequiredAIFunction(AIFunctionFactory.Create(
this.WrapWithApprovalIfRequired(AIFunctionFactory.Create(
(string skillName, CancellationToken cancellationToken) => this.LoadSkillAsync(skills, skillName, cancellationToken),
name: LoadSkillToolName,
description: "Loads the full content of a specific skill")),
new ApprovalRequiredAIFunction(AIFunctionFactory.Create(
description: "Loads the full content of a specific skill"),
this._options?.DisableLoadSkillApproval is not true),
this.WrapWithApprovalIfRequired(AIFunctionFactory.Create(
(string skillName, string resourceName, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default) =>
this.ReadSkillResourceAsync(skills, skillName, resourceName, serviceProvider, cancellationToken),
name: ReadSkillResourceToolName,
description: "Reads a resource associated with a skill, such as references, assets, or dynamic data.")),
new ApprovalRequiredAIFunction(AIFunctionFactory.Create(
description: "Reads a resource associated with a skill, such as references, assets, or dynamic data."),
this._options?.DisableReadSkillResourceApproval is not true),
this.WrapWithApprovalIfRequired(AIFunctionFactory.Create(
(string skillName, string scriptName, JsonElement? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) =>
this.RunSkillScriptAsync(skills, skillName, scriptName, arguments, serviceProvider, cancellationToken),
name: RunSkillScriptToolName,
description: "Runs a script associated with a skill.")),
description: "Runs a script associated with a skill."),
this._options?.DisableRunSkillScriptApproval is not true),
];
}
private AIFunction WrapWithApprovalIfRequired(AIFunction function, bool requireApproval)
{
return requireApproval ? new ApprovalRequiredAIFunction(function) : function;
}
private string? BuildSkillsInstructions(IList<AgentSkill> skills)
{
string promptTemplate = this._options?.SkillsInstructionPrompt ?? DefaultSkillsInstructionPrompt;
@@ -2,9 +2,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
@@ -39,7 +37,6 @@ namespace Microsoft.Agents.AI;
/// .Build();
/// </code>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class AgentSkillsProviderBuilder
{
private readonly List<Func<AgentFileSkillScriptRunner?, ILoggerFactory?, AgentSkillsSource>> _sourceFactories = [];
@@ -124,6 +121,16 @@ public sealed class AgentSkillsProviderBuilder
/// <summary>
/// Adds a custom skill source.
/// </summary>
/// <remarks>
/// The provider returned by <see cref="Build"/> takes ownership of <paramref name="source"/> and
/// disposes it when the provider is disposed. Because the same instance is reused on every
/// <see cref="Build"/> call, do not build more than one provider from a builder that captures a
/// shared <paramref name="source"/>; otherwise disposing one provider would dispose the source out
/// from under the others. To build multiple providers, use the
/// <see cref="UseSource(Func{ILoggerFactory?, AgentSkillsSource})"/> overload, which creates a fresh
/// source per build, or pass the source directly to an <see cref="AgentSkillsProvider"/> constructor
/// with <c>ownsSource: false</c> to retain ownership.
/// </remarks>
/// <param name="source">The custom skill source.</param>
/// <returns>This builder instance for chaining.</returns>
public AgentSkillsProviderBuilder UseSource(AgentSkillsSource source)
@@ -236,6 +243,17 @@ public sealed class AgentSkillsProviderBuilder
/// <summary>
/// Builds the <see cref="AgentSkillsProvider"/>.
/// </summary>
/// <remarks>
/// The returned provider owns the source pipeline constructed by this builder, so disposing the
/// provider disposes the pipeline (including any sources added to this builder).
/// <para>
/// Build more than one provider from the same builder only when every source it produces is
/// independent per build (for example, sources added via
/// <see cref="UseSource(Func{ILoggerFactory?, AgentSkillsSource})"/>). A source captured as a shared
/// instance through <see cref="UseSource(AgentSkillsSource)"/> is reused across builds and would be
/// disposed by whichever provider is disposed first; build only one provider in that case.
/// </para>
/// </remarks>
/// <returns>A configured <see cref="AgentSkillsProvider"/>.</returns>
public AgentSkillsProvider Build()
{
@@ -268,7 +286,7 @@ public sealed class AgentSkillsProviderBuilder
source = new DeduplicatingAgentSkillsSource(source, this._loggerFactory);
return new AgentSkillsProvider(source, this._options, this._loggerFactory);
return new AgentSkillsProvider(source, this._options, this._loggerFactory, ownsSource: true);
}
private AgentSkillsProviderOptions GetOrCreateOptions()
@@ -1,14 +1,10 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Configuration options for <see cref="AgentSkillsProvider"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class AgentSkillsProviderOptions
{
/// <summary>
@@ -35,4 +31,46 @@ public sealed class AgentSkillsProviderOptions
/// Only enable this when the skills and their scripts come from a trusted source.
/// </remarks>
public bool IncludeDetailedErrors { get; set; }
/// <summary>
/// Gets or sets a value indicating whether approval is disabled for the <see cref="AgentSkillsProvider.LoadSkillToolName"/> tool.
/// </summary>
/// <remarks>
/// When <see langword="false"/> (the default), the tool requires approval before invocation.
/// When <see langword="true"/>, the tool can be invoked without approval.
/// If any other tool in the same response still requires approval, set
/// <see cref="ChatClientAgentOptions.EnableNonApprovalRequiredFunctionBypassing"/> to <see langword="true"/>
/// so this tool is not surfaced as an approval request.
/// When approval is required, auto-approval rules (e.g. <see cref="AgentSkillsProvider.ReadOnlyToolsAutoApprovalRule"/>
/// or <see cref="AgentSkillsProvider.AllToolsAutoApprovalRule"/>) can be used to automatically approve calls.
/// </remarks>
public bool DisableLoadSkillApproval { get; set; }
/// <summary>
/// Gets or sets a value indicating whether approval is disabled for the <see cref="AgentSkillsProvider.ReadSkillResourceToolName"/> tool.
/// </summary>
/// <remarks>
/// When <see langword="false"/> (the default), the tool requires approval before invocation.
/// When <see langword="true"/>, the tool can be invoked without approval.
/// If any other tool in the same response still requires approval, set
/// <see cref="ChatClientAgentOptions.EnableNonApprovalRequiredFunctionBypassing"/> to <see langword="true"/>
/// so this tool is not surfaced as an approval request.
/// When approval is required, auto-approval rules (e.g. <see cref="AgentSkillsProvider.ReadOnlyToolsAutoApprovalRule"/>
/// or <see cref="AgentSkillsProvider.AllToolsAutoApprovalRule"/>) can be used to automatically approve calls.
/// </remarks>
public bool DisableReadSkillResourceApproval { get; set; }
/// <summary>
/// Gets or sets a value indicating whether approval is disabled for the <see cref="AgentSkillsProvider.RunSkillScriptToolName"/> tool.
/// </summary>
/// <remarks>
/// When <see langword="false"/> (the default), the tool requires approval before invocation.
/// When <see langword="true"/>, the tool can be invoked without approval.
/// If any other tool in the same response still requires approval, set
/// <see cref="ChatClientAgentOptions.EnableNonApprovalRequiredFunctionBypassing"/> to <see langword="true"/>
/// so this tool is not surfaced as an approval request.
/// When approval is required, auto-approval rules (e.g. <see cref="AgentSkillsProvider.ReadOnlyToolsAutoApprovalRule"/>
/// or <see cref="AgentSkillsProvider.AllToolsAutoApprovalRule"/>) can be used to automatically approve calls.
/// </remarks>
public bool DisableRunSkillScriptApproval { get; set; }
}
@@ -1,10 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
@@ -12,8 +11,12 @@ namespace Microsoft.Agents.AI;
/// Abstract base class for skill sources. A skill source provides skills from a specific origin
/// (filesystem, remote server, database, in-memory, etc.).
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public abstract class AgentSkillsSource
/// <remarks>
/// Sources are <see cref="IDisposable"/> so that pipelines built from decorators can release any
/// resources they own. The default <see cref="Dispose(bool)"/> implementation does nothing; sources
/// that hold disposable resources override it. Decorators dispose the source they wrap.
/// </remarks>
public abstract class AgentSkillsSource : IDisposable
{
/// <summary>
/// Gets the skills provided by this source.
@@ -22,4 +25,25 @@ public abstract class AgentSkillsSource
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A collection of skills from this source.</returns>
public abstract Task<IList<AgentSkill>> GetSkillsAsync(AgentSkillsSourceContext context, CancellationToken cancellationToken = default);
/// <summary>
/// Releases all resources used by this source.
/// </summary>
public void Dispose()
{
this.Dispose(disposing: true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Releases the unmanaged resources used by this source and optionally releases the managed resources.
/// </summary>
/// <param name="disposing">
/// <see langword="true"/> to release both managed and unmanaged resources;
/// <see langword="false"/> to release only unmanaged resources.
/// </param>
/// <remarks>The default implementation does nothing. Override to release owned resources.</remarks>
protected virtual void Dispose(bool disposing)
{
}
}
@@ -1,7 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
@@ -10,7 +8,6 @@ namespace Microsoft.Agents.AI;
/// Provides contextual information about the agent and session to an <see cref="AgentSkillsSource"/>
/// when retrieving skills.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class AgentSkillsSourceContext
{
/// <summary>
@@ -1,10 +1,8 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
@@ -16,8 +14,7 @@ namespace Microsoft.Agents.AI;
/// Skills from each child source are returned in the order the sources were registered,
/// with each source's skills appended sequentially. No deduplication or filtering is applied.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
internal sealed class AggregatingAgentSkillsSource : AgentSkillsSource
public sealed class AggregatingAgentSkillsSource : AgentSkillsSource
{
private readonly IEnumerable<AgentSkillsSource> _sources;
@@ -42,4 +39,18 @@ internal sealed class AggregatingAgentSkillsSource : AgentSkillsSource
return allSkills;
}
/// <inheritdoc/>
protected override void Dispose(bool disposing)
{
if (disposing)
{
foreach (var source in this._sources)
{
source.Dispose();
}
}
base.Dispose(disposing);
}
}
@@ -13,23 +13,36 @@ namespace Microsoft.Agents.AI;
/// call, returning the cached list on subsequent invocations.
/// </summary>
/// <remarks>
/// The cache uses a lock-free, thread-safe pattern so that concurrent callers share
/// a single in-flight fetch and all receive the same cached result.
/// If the initial fetch fails, the cache is not populated and subsequent calls will retry.
/// <para>
/// Concurrent callers are serialized per cache key so that only one underlying fetch runs at a time.
/// Once a fetch succeeds, the result is cached and shared by all subsequent callers.
/// </para>
/// <para>
/// When <see cref="CachingAgentSkillsSourceOptions.RefreshInterval"/> is set, a cached result is
/// returned only while it is younger than the interval; once it expires, the next caller re-invokes
/// the inner source and replaces the cached result. When the interval is <see langword="null"/>, the
/// cached result never expires.
/// </para>
/// <para>
/// The fetch observes the initiating caller's cancellation token. If that caller cancels, the fetch is
/// cancelled and the result is not cached; the next waiting caller starts a fresh fetch. Likewise, a fetch
/// that fails is not cached and subsequent calls will retry.
/// </para>
/// </remarks>
internal sealed class CachingAgentSkillsSource : DelegatingAgentSkillsSource
public sealed class CachingAgentSkillsSource : DelegatingAgentSkillsSource
{
private const string SharedCacheKey = "CachingAgentSkillsSource-SharedCacheKey";
private readonly ConcurrentDictionary<string, Task<IList<AgentSkill>>> _cachedTasks = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, CacheEntry> _cachedEntries = new(StringComparer.Ordinal);
private readonly CachingAgentSkillsSourceOptions? _options;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="CachingAgentSkillsSource"/> class.
/// </summary>
/// <param name="innerSource">The inner source whose results will be cached.</param>
/// <param name="options">Optional cache configuration.</param>
internal CachingAgentSkillsSource(AgentSkillsSource innerSource, CachingAgentSkillsSourceOptions? options = null)
public CachingAgentSkillsSource(AgentSkillsSource innerSource, CachingAgentSkillsSourceOptions? options = null)
: base(innerSource)
{
this._options = options;
@@ -38,35 +51,99 @@ internal sealed class CachingAgentSkillsSource : DelegatingAgentSkillsSource
/// <inheritdoc/>
public override async Task<IList<AgentSkill>> GetSkillsAsync(AgentSkillsSourceContext context, CancellationToken cancellationToken = default)
{
this.ThrowIfDisposed();
var cacheKey = this._options?.CacheIsolationKeySelector?.Invoke(context) ?? SharedCacheKey;
var tcs = new TaskCompletionSource<IList<AgentSkill>>(TaskCreationOptions.RunContinuationsAsynchronously);
var entry = this._cachedEntries.GetOrAdd(cacheKey, _ => new CacheEntry());
while (!this._cachedTasks.TryAdd(cacheKey, tcs.Task))
// Fast path: a fresh result has already been fetched and cached.
if (this.TryGetFreshResult(entry) is { } cached)
{
if (this._cachedTasks.TryGetValue(cacheKey, out var existing))
{
return await existing.ConfigureAwait(false);
}
return cached;
}
// Only one caller fetches at a time for a given cache key; the rest queue here.
await entry.Gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
// Another caller may have populated (or refreshed) the cache while we waited on the gate.
if (this.TryGetFreshResult(entry) is { } existing)
{
return existing;
}
// The fetch uses the caller's token. If the caller cancels (or the fetch fails),
// the result is not cached and the next waiting caller starts a fresh fetch.
var result = await this.InnerSource.GetSkillsAsync(context, cancellationToken).ConfigureAwait(false);
tcs.SetResult(result);
entry.Result = result;
entry.LastRefreshedUtc = DateTime.UtcNow;
return result;
}
catch (OperationCanceledException)
finally
{
this._cachedTasks.TryRemove(cacheKey, out _);
tcs.TrySetCanceled(cancellationToken);
throw;
}
catch (Exception ex)
{
this._cachedTasks.TryRemove(cacheKey, out _);
tcs.TrySetException(ex);
throw;
entry.Gate.Release();
}
}
/// <summary>
/// Returns the cached result for the entry when it exists and is still fresh; otherwise <see langword="null"/>.
/// </summary>
private IList<AgentSkill>? TryGetFreshResult(CacheEntry entry)
{
if (entry.Result is not { } result)
{
return null;
}
if (this._options?.RefreshInterval is { } interval &&
DateTime.UtcNow - entry.LastRefreshedUtc >= interval)
{
return null;
}
return result;
}
private void ThrowIfDisposed()
{
#pragma warning disable CA1513 // Use ObjectDisposedException.ThrowIf - not available on all target frameworks
if (this._disposed)
{
throw new ObjectDisposedException(this.GetType().FullName);
}
#pragma warning restore CA1513
}
/// <inheritdoc/>
protected override void Dispose(bool disposing)
{
if (disposing && !this._disposed)
{
this._disposed = true;
foreach (var entry in this._cachedEntries.Values)
{
entry.Gate.Dispose();
}
base.Dispose(disposing);
}
}
/// <summary>
/// A single cache slot: a gate that serializes fetches for one cache key, plus the cached result.
/// </summary>
private sealed class CacheEntry
{
/// <summary>Gets the gate that ensures only one fetch runs at a time for this cache key.</summary>
public SemaphoreSlim Gate { get; } = new(1, 1);
/// <summary>Gets or sets the cached result, or <see langword="null"/> if it has not been fetched yet.</summary>
public IList<AgentSkill>? Result { get; set; }
/// <summary>Gets or sets the UTC time at which <see cref="Result"/> was last refreshed.</summary>
public DateTime LastRefreshedUtc { get; set; }
}
}
@@ -1,15 +1,12 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Options for configuring <see cref="CachingAgentSkillsSource"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class CachingAgentSkillsSourceOptions
{
/// <summary>
@@ -27,4 +24,16 @@ public sealed class CachingAgentSkillsSourceOptions
/// </para>
/// </remarks>
public Func<AgentSkillsSourceContext, string?>? CacheIsolationKeySelector { get; set; }
/// <summary>
/// Gets or sets the interval after which a cached skill list is considered stale and is refreshed
/// from the inner source on the next request.
/// </summary>
/// <remarks>
/// When <see langword="null"/> (the default), cached results never expire and the inner source is
/// invoked only once per cache key. Set to a positive <see cref="TimeSpan"/> to re-invoke the inner
/// source once the cached result is older than the interval. Values of <see cref="TimeSpan.Zero"/> or
/// negative durations effectively disable caching because the cached result is always considered stale.
/// </remarks>
public TimeSpan? RefreshInterval { get; set; }
}
@@ -12,7 +12,7 @@ namespace Microsoft.Agents.AI;
/// <summary>
/// A skill source decorator that removes duplicate skills by name, keeping only the first occurrence.
/// </summary>
internal sealed partial class DeduplicatingAgentSkillsSource : DelegatingAgentSkillsSource
public sealed partial class DeduplicatingAgentSkillsSource : DelegatingAgentSkillsSource
{
private readonly ILogger<DeduplicatingAgentSkillsSource> _logger;
@@ -16,7 +16,7 @@ namespace Microsoft.Agents.AI;
/// enabling the creation of source pipelines where each layer can add functionality (caching, deduplication,
/// filtering, etc.) while delegating core operations to an underlying source.
/// </remarks>
internal abstract class DelegatingAgentSkillsSource : AgentSkillsSource
public abstract class DelegatingAgentSkillsSource : AgentSkillsSource
{
/// <summary>
/// Initializes a new instance of the <see cref="DelegatingAgentSkillsSource"/> class with the specified inner source.
@@ -35,4 +35,15 @@ internal abstract class DelegatingAgentSkillsSource : AgentSkillsSource
/// <inheritdoc/>
public override Task<IList<AgentSkill>> GetSkillsAsync(AgentSkillsSourceContext context, CancellationToken cancellationToken = default)
=> this.InnerSource.GetSkillsAsync(context, cancellationToken);
/// <inheritdoc/>
protected override void Dispose(bool disposing)
{
if (disposing)
{
this.InnerSource.Dispose();
}
base.Dispose(disposing);
}
}
@@ -17,7 +17,7 @@ namespace Microsoft.Agents.AI;
/// Skills for which the predicate returns <see langword="true"/> are included in the result;
/// skills for which it returns <see langword="false"/> are excluded and logged at debug level.
/// </remarks>
internal sealed partial class FilteringAgentSkillsSource : DelegatingAgentSkillsSource
public sealed partial class FilteringAgentSkillsSource : DelegatingAgentSkillsSource
{
private readonly Func<AgentSkill, AgentSkillsSourceContext, bool> _predicate;
private readonly ILogger<FilteringAgentSkillsSource> _logger;
@@ -1,11 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
@@ -13,7 +11,6 @@ namespace Microsoft.Agents.AI;
/// <summary>
/// An <see cref="AgentSkill"/> discovered from a filesystem directory backed by a SKILL.md file.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class AgentFileSkill : AgentSkill
{
private readonly IReadOnlyList<AgentSkillResource> _resources;
@@ -1,7 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
@@ -11,7 +9,6 @@ namespace Microsoft.Agents.AI;
/// <see cref="AgentFileSkillsSourceOptions.ScriptFilter"/> and
/// <see cref="AgentFileSkillsSourceOptions.ResourceFilter"/> predicates.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class AgentFileSkillFilterContext
{
/// <summary>
@@ -1,11 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
@@ -13,7 +11,6 @@ namespace Microsoft.Agents.AI;
/// <summary>
/// A file-path-backed skill script. Represents a script file on disk that requires an external runner to run.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class AgentFileSkillScript : AgentSkillScript
{
/// <summary>
@@ -1,11 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
@@ -23,7 +21,6 @@ namespace Microsoft.Agents.AI;
/// <param name="serviceProvider">Optional service provider for dependency injection.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The script execution result.</returns>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public delegate Task<object?> AgentFileSkillScriptRunner(
AgentFileSkill skill,
AgentFileSkillScript script,

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