Compare commits

..
Author SHA1 Message Date
Peter Ibekwe d83bc07582 Fix CI failures. 2026-06-11 10:51:06 -07:00
Peter Ibekwe 4b0aeb76a5 Address PR comments. 2026-06-11 10:35:34 -07:00
Peter Ibekwe c0ea099bd8 Address PR comments 2026-06-10 17:43:42 -07:00
Peter Ibekwe 3498f9dc66 Remove unnecessary comment 2026-06-10 15:43:36 -07:00
Peter Ibekwe 564259a4aa Fix declarative object parsing bug 2026-06-10 14:13:52 -07:00
Peter IbekweGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
3753d938f5 .NET: Bug fixes for declarative workflows (#6427)
* declarative workflow approval flow fix

* Update mcp handler cache construction

* fix method argument.

* Update dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* Fix identation

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-10 18:08:32 +00:00
60cc5ee4e4 .NET: Make GitHub.Copilot.SDK build targets reach transitive consumers (#6455) (#6457)
* .NET: Make GitHub.Copilot.SDK build targets reach transitive consumers (#6455)

Microsoft.Agents.AI.GitHub.Copilot now ships a buildTransitive/ bridge so
consumers who only reference this package (the normal use case) get the
GitHub.Copilot.SDK's CLI binary-download MSBuild targets executed at build
time. Without this, the SDK shipped its targets under build/ which NuGet
only auto-imports for projects with a direct PackageReference to the SDK,
so consumers of the adapter package got only the managed .dll, no
copilot.exe in their output, and a runtime InvalidOperationException on
the first RunAsync.

The bridge consists of two files under buildTransitive/:

* Microsoft.Agents.AI.GitHub.Copilot.props is generated at this package's
  pack time and pins the SDK version (from PackageVersion items in
  Directory.Packages.props) into _MicrosoftAgentsAICopilotSdkVersion.

* Microsoft.Agents.AI.GitHub.Copilot.targets is static and imports the
  SDK's own build/GitHub.Copilot.SDK.targets from the NuGet cache using
  the pinned version. The version-pin condition no-ops gracefully if the
  resolved SDK differs from what was baked in (e.g. consumer overrides
  the SDK version directly), so this is purely additive.

Verified by packing locally, restoring from a flat local feed, and
building a transitive-only consumer (PackageReference to MAF only, no
direct SDK ref). copilot.exe lands at bin/{cfg}/{tfm}/runtimes/{rid}/
native/copilot.exe as expected, matching the path the SDK's runtime
CopilotClient looks at.

Fixes #6455

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

* Address Copilot review feedback (#6457)

- buildTransitive/.targets: compute the full SDK targets path with a single
  Path.Combine call into one property (_MicrosoftAgentsAICopilotSdkTargetsPath),
  used in both Project= and Exists() — no more split between Path.Combine for
  the directory and inline / separator for the file name.

- Split the version-defaulting Condition between the two files: the generated
  .props now just bakes the packaged SDK version into a dedicated property
  (_MicrosoftAgentsAICopilotSdkPackagedVersion), and the static .targets file
  is the single place that defaults _MicrosoftAgentsAICopilotSdkVersion to it.
  Removes the need for any MSBuild escape gymnastics in the pack-time string
  construction, and keeps the consumer override path the same.

- _GenerateBuildTransitiveProps now hangs off public BeforeTargets (Build, Pack)
  in addition to _GetPackageFiles, so the file is generated even without a
  full pack, and we're not solely dependent on an underscore-prefixed internal
  target. The <None Pack=true /> items live in a top-level ItemGroup so they
  are collected at evaluation time instead of being added from inside the
  Target.

End-to-end retested with a transitive-only consumer (PackageReference to MAF
only, no direct GitHub.Copilot.SDK ref): copilot.exe lands at
bin/Debug/net10.0/runtimes/win-x64/native/copilot.exe (141.8 MB) as before.

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

---------

Co-authored-by: Tamir Dresher <tamirdresher@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-10 18:07:18 +00:00
10 changed files with 1238 additions and 86 deletions
@@ -32,4 +32,52 @@
<Description>Provides Microsoft Agent Framework support for GitHub Copilot SDK.</Description>
</PropertyGroup>
<!--
buildTransitive bridge for GitHub.Copilot.SDK's CLI binary-download targets.
GitHub.Copilot.SDK ships its CLI download targets under build/, which NuGet
only auto-imports for projects with a DIRECT PackageReference to the SDK.
Consumers of this package (who reference Microsoft.Agents.AI.GitHub.Copilot
instead of GitHub.Copilot.SDK directly) would otherwise get only the managed
adapter .dll, no copilot.exe in their output, and a runtime failure at the
first RunAsync call.
The targets file in buildTransitive/ is static and contains all of the
Condition / Import logic. The .props file generated here just bakes the
SDK version this package was built against into a dedicated property
($(_MicrosoftAgentsAICopilotSdkPackagedVersion)) — no Condition or
inner $(...) reference in the generated file, so we avoid any MSBuild
escape gymnastics during string construction. The static targets file
then defaults $(_MicrosoftAgentsAICopilotSdkVersion) to the packaged
version unless the consumer overrides it.
-->
<ItemGroup>
<None Include="buildTransitive\Microsoft.Agents.AI.GitHub.Copilot.targets" Pack="true" PackagePath="buildTransitive\" />
<None Include="buildTransitive\Microsoft.Agents.AI.GitHub.Copilot.props" Pack="true" PackagePath="buildTransitive\" />
</ItemGroup>
<Target Name="_GenerateBuildTransitiveProps" BeforeTargets="Build;Pack;_GetPackageFiles">
<ItemGroup>
<_CopilotSdkPackageVersion Include="@(PackageVersion)" Condition="'%(Identity)' == 'GitHub.Copilot.SDK'" />
</ItemGroup>
<PropertyGroup>
<_CopilotSdkResolvedVersion>@(_CopilotSdkPackageVersion->'%(Version)')</_CopilotSdkResolvedVersion>
</PropertyGroup>
<Error Condition="'$(_CopilotSdkResolvedVersion)' == ''"
Text="Could not resolve GitHub.Copilot.SDK version from PackageVersion items. Ensure the central package version is declared in Directory.Packages.props." />
<PropertyGroup>
<_BuildTransitivePropsContent>
<![CDATA[<Project>
<PropertyGroup>
<_MicrosoftAgentsAICopilotSdkPackagedVersion>$(_CopilotSdkResolvedVersion)</_MicrosoftAgentsAICopilotSdkPackagedVersion>
</PropertyGroup>
</Project>]]>
</_BuildTransitivePropsContent>
</PropertyGroup>
<WriteLinesToFile File="$(MSBuildThisFileDirectory)buildTransitive\Microsoft.Agents.AI.GitHub.Copilot.props"
Lines="$(_BuildTransitivePropsContent)"
Overwrite="true"
WriteOnlyWhenDifferent="true" />
</Target>
</Project>
@@ -0,0 +1,3 @@
# Auto-generated at pack time by _GenerateBuildTransitiveProps in the csproj.
Microsoft.Agents.AI.GitHub.Copilot.props
@@ -0,0 +1,34 @@
<Project>
<!--
Bridge GitHub.Copilot.SDK's build/ targets to transitive consumers.
GitHub.Copilot.SDK ships its CLI download / binary-copy MSBuild targets under
build/, which means they only auto-import for projects with a DIRECT
PackageReference to GitHub.Copilot.SDK. Consumers of this package
(Microsoft.Agents.AI.GitHub.Copilot) would otherwise get only the managed
adapter .dll, no copilot CLI binary in their output, and the SDK would fail
at runtime with:
Copilot CLI not found at 'bin/{config}/{tfm}/runtimes/{rid}/native/copilot.exe'
This file ships under buildTransitive/ so NuGet auto-imports it for every
transitive consumer, locates the SDK in the NuGet package cache, and imports
the SDK's build/ targets so the CLI binary gets downloaded and copied to the
consumer's output as expected.
The companion .props file is generated at this package's pack time and sets
$(_MicrosoftAgentsAICopilotSdkPackagedVersion) to the SDK version this
package was built against. The Condition below uses that as the default for
$(_MicrosoftAgentsAICopilotSdkVersion), so consumers may override the SDK
version path by setting $(_MicrosoftAgentsAICopilotSdkVersion) before this
file is imported. Consumers may also opt out of the binary download via the
SDK's own $(CopilotSkipCliDownload)=true, which the SDK targets honor.
-->
<PropertyGroup>
<_MicrosoftAgentsAICopilotSdkVersion Condition="'$(_MicrosoftAgentsAICopilotSdkVersion)' == ''">$(_MicrosoftAgentsAICopilotSdkPackagedVersion)</_MicrosoftAgentsAICopilotSdkVersion>
<_MicrosoftAgentsAICopilotSdkTargetsPath Condition="'$(_MicrosoftAgentsAICopilotSdkVersion)' != ''">$([System.IO.Path]::Combine('$(NuGetPackageRoot)', 'github.copilot.sdk', '$(_MicrosoftAgentsAICopilotSdkVersion)', 'build', 'GitHub.Copilot.SDK.targets'))</_MicrosoftAgentsAICopilotSdkTargetsPath>
</PropertyGroup>
<Import Project="$(_MicrosoftAgentsAICopilotSdkTargetsPath)"
Condition="'$(_MicrosoftAgentsAICopilotSdkTargetsPath)' != '' And Exists('$(_MicrosoftAgentsAICopilotSdkTargetsPath)')" />
</Project>
@@ -2,10 +2,10 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
@@ -39,7 +39,7 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
private static readonly JsonWriterOptions s_toolListJsonWriterOptions = new() { Indented = true };
private readonly Func<string, CancellationToken, Task<HttpClient?>>? _httpClientProvider;
private readonly Dictionary<string, McpClient> _clients = [];
private readonly Dictionary<(string Url, string Label, string Connection, string HeadersHash), McpClient> _clients = [];
private readonly Dictionary<string, HttpClient> _ownedHttpClients = [];
private readonly SemaphoreSlim _clientLock = new(1, 1);
@@ -66,16 +66,15 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
string? connectionName,
CancellationToken cancellationToken = default)
{
// TODO: Handle connectionName and server label appropriately when Hosted scenario supports them. For now, ignore
if (IsListToolsToolName(toolName))
{
ThrowIfListToolsArgumentsSpecified(arguments);
McpClient listToolsClient = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, cancellationToken).ConfigureAwait(false);
McpClient listToolsClient = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, connectionName, cancellationToken).ConfigureAwait(false);
IList<McpClientTool> tools = await listToolsClient.ListToolsAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
return CreateListToolsResultContent(tools.Select(tool => tool.ProtocolTool));
}
McpClient client = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, cancellationToken).ConfigureAwait(false);
McpClient client = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, connectionName, cancellationToken).ConfigureAwait(false);
McpServerToolResultContent resultContent = new(Guid.NewGuid().ToString());
@@ -145,10 +144,11 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
string serverUrl,
string? serverLabel,
IDictionary<string, string>? headers,
string? connectionName,
CancellationToken cancellationToken)
{
string normalizedUrl = serverUrl.Trim().ToUpperInvariant();
string clientCacheKey = $"{normalizedUrl}|{ComputeHeadersHash(headers)}";
string trimmedUrl = serverUrl.Trim();
var clientCacheKey = BuildCacheKey(trimmedUrl, serverLabel, connectionName, headers);
await this._clientLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
@@ -158,7 +158,7 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
return existingClient;
}
McpClient newClient = await this.CreateClientAsync(serverUrl, serverLabel, headers, normalizedUrl, cancellationToken).ConfigureAwait(false);
McpClient newClient = await this.CreateClientAsync(trimmedUrl, serverLabel, headers, trimmedUrl, cancellationToken).ConfigureAwait(false);
this._clients[clientCacheKey] = newClient;
return newClient;
}
@@ -168,6 +168,19 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
}
}
/// <summary>
/// Builds the per-client cache key as a 4-tuple of
/// (trimmed serverUrl, serverLabel, connectionName, headers hash). All four components
/// participate so that callers using different labels/connections/headers receive
/// distinct <see cref="McpClient"/> instances even when targeting the same URL.
/// </summary>
internal static (string Url, string Label, string Connection, string HeadersHash) BuildCacheKey(
string trimmedUrl,
string? serverLabel,
string? connectionName,
IDictionary<string, string>? headers) =>
(trimmedUrl, serverLabel ?? string.Empty, connectionName ?? string.Empty, ComputeHeadersHash(headers));
private async Task<McpClient> CreateClientAsync(
string serverUrl,
string? serverLabel,
@@ -185,7 +198,12 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
if (httpClient is null && !this._ownedHttpClients.TryGetValue(httpClientCacheKey, out httpClient))
{
httpClient = new HttpClient();
// Disable cookies so handler-level state (cookie jar) cannot cross the cache-key
// isolation boundary established by GetOrCreateClientAsync. The actual MCP auth
// travels via AdditionalHeaders (set per-transport below), not session cookies.
// CheckCertificateRevocationList satisfies CA5399 since we're explicitly constructing the handler.
HttpClientHandler handler = new() { UseCookies = false, CheckCertificateRevocationList = true };
httpClient = new HttpClient(handler);
this._ownedHttpClients[httpClientCacheKey] = httpClient;
}
@@ -202,26 +220,50 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
return await McpClient.CreateAsync(transport, cancellationToken: cancellationToken).ConfigureAwait(false);
}
private static string ComputeHeadersHash(IDictionary<string, string>? headers)
/// <summary>
/// Computes a deterministic, order-independent hash of the header set.
/// Header names are lower-cased for case-insensitive matching (RFC 7230 §3.2).
/// Header values remain case-sensitive (RFC 7235 — credentials are case-sensitive).
/// </summary>
#pragma warning disable CA1308 // RFC 7230 §3.2 requires lower-cased header names for case-insensitive comparison; CA1308's uppercase preference does not apply here
internal static string ComputeHeadersHash(IDictionary<string, string>? headers)
{
if (headers is null || headers.Count == 0)
{
return string.Empty;
}
// Build a deterministic, sorted representation of the headers
// Within a single process lifetime, the hashcodes are consistent.
// This will ensure that the same set of headers always produces the same hash, regardless of order.
SortedDictionary<string, string> sorted = new(headers.ToDictionary(h => h.Key.ToUpperInvariant(), h => h.Value.ToUpperInvariant()));
int hashCode = 17;
foreach (KeyValuePair<string, string> kvp in sorted)
// Sort by lower-cased key for deterministic ordering, preserving value case.
SortedDictionary<string, string> sorted = new(StringComparer.Ordinal);
foreach (KeyValuePair<string, string> header in headers)
{
hashCode = (hashCode * 31) + StringComparer.OrdinalIgnoreCase.GetHashCode(kvp.Key);
hashCode = (hashCode * 31) + StringComparer.OrdinalIgnoreCase.GetHashCode(kvp.Value);
sorted[header.Key.ToLowerInvariant()] = header.Value;
}
return hashCode.ToString(CultureInfo.InvariantCulture);
StringBuilder payload = new();
foreach (KeyValuePair<string, string> kvp in sorted)
{
payload.Append(kvp.Key).Append(':').Append(kvp.Value).Append('\n');
}
byte[] inputBytes = Encoding.UTF8.GetBytes(payload.ToString());
#if NET5_0_OR_GREATER
byte[] hashBytes = SHA256.HashData(inputBytes);
#else
using SHA256 sha256 = SHA256.Create();
byte[] hashBytes = sha256.ComputeHash(inputBytes);
#endif
// Convert to hex string (compatible with net472/netstandard2.0)
StringBuilder hex = new(hashBytes.Length * 2);
foreach (byte b in hashBytes)
{
hex.Append(b.ToString("X2", System.Globalization.CultureInfo.InvariantCulture));
}
return hex.ToString();
}
#pragma warning restore CA1308
private static void ThrowIfListToolsArgumentsSpecified(IDictionary<string, object?>? arguments)
{
@@ -13,6 +13,7 @@ using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.ObjectModel;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
@@ -27,6 +28,13 @@ internal sealed class InvokeFunctionToolExecutor(
WorkflowFormulaState state) :
DeclarativeActionExecutor<InvokeFunctionTool>(model, state)
{
private const string ApprovalSnapshotStateKey = nameof(_approvalSnapshot);
/// <summary>
/// Snapshot of evaluated parameters at approval-request time.
/// </summary>
private ApprovalSnapshot? _approvalSnapshot;
/// <summary>
/// Step identifiers for the function tool invocation workflow.
/// </summary>
@@ -69,6 +77,10 @@ internal sealed class InvokeFunctionToolExecutor(
// If approval is required, add user input request content
if (requireApproval)
{
// Snapshot the evaluated parameters.
// If state mutates during the approval window, the approved values are used on resume.
this._approvalSnapshot = new ApprovalSnapshot(functionName, arguments);
requestMessage.Contents.Add(new ToolApprovalRequestContent(this.Id, functionCall));
}
@@ -155,6 +167,31 @@ internal sealed class InvokeFunctionToolExecutor(
// Completes the action after processing the function result.
await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false);
// Clear the approval snapshot after the action completes so a subsequent
// execution of the same executor instance doesn't reuse stale data.
this._approvalSnapshot = null;
await context.QueueStateUpdateAsync<ApprovalSnapshot?>(ApprovalSnapshotStateKey, null, null, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
/// <remarks>
/// Persists the approval snapshot to workflow state so it survives checkpoint/restore cycles.
/// </remarks>
protected override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
await context.QueueStateUpdateAsync(ApprovalSnapshotStateKey, this._approvalSnapshot, null, cancellationToken).ConfigureAwait(false);
await base.OnCheckpointingAsync(context, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
/// <remarks>
/// Restores the approval snapshot from workflow state after a checkpoint restore.
/// </remarks>
protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false);
this._approvalSnapshot = await context.ReadStateAsync<ApprovalSnapshot>(ApprovalSnapshotStateKey, null, cancellationToken).ConfigureAwait(false);
}
/// <summary>
@@ -262,7 +299,24 @@ internal sealed class InvokeFunctionToolExecutor(
private async ValueTask<FunctionResultContent?> InvokeRegisteredFunctionAsync(CancellationToken cancellationToken)
{
string functionName = this.GetFunctionName();
string functionName;
Dictionary<string, object?>? arguments;
if (this._approvalSnapshot is { } snapshot)
{
// Use the snapshot captured at approval-request time so we invoke exactly what
// the user approved, even if Power Fx state has mutated during the approval window.
functionName = snapshot.FunctionName;
arguments = snapshot.Arguments;
}
else
{
// Fallback for checkpoints created before approval snapshots were introduced.
this.Logger.LogWarning("Approval snapshot missing for '{ActionId}'; falling back to expression re-evaluation.", this.Id);
functionName = this.GetFunctionName();
arguments = this.GetArguments();
}
AIFunction? function = agentProvider.Functions?.FirstOrDefault(
f => string.Equals(f.Name, functionName, StringComparison.Ordinal));
@@ -275,8 +329,7 @@ internal sealed class InvokeFunctionToolExecutor(
};
}
Dictionary<string, object?>? arguments = this.GetArguments();
AIFunctionArguments? functionArguments = arguments is null ? null : new AIFunctionArguments(arguments);
AIFunctionArguments? functionArguments = arguments is null ? null : new AIFunctionArguments(arguments.NormalizePortableValues());
object? result;
try
@@ -341,4 +394,13 @@ internal sealed class InvokeFunctionToolExecutor(
return result;
}
/// <summary>
/// Stores the evaluated parameters at approval-request time so that
/// <see cref="CaptureResponseAsync"/> uses the values the user reviewed,
/// even if <see cref="WorkflowFormulaState"/> mutates during the approval window.
/// </summary>
internal sealed record ApprovalSnapshot(
string FunctionName,
Dictionary<string, object?>? Arguments);
}
@@ -321,6 +321,189 @@ public sealed class DefaultMcpToolHandlerTests
#endregion
#region ComputeHeadersHash Tests
[Fact]
public void ComputeHeadersHash_WithNullHeaders_ReturnsEmptyString()
{
// Act
string result = DefaultMcpToolHandler.ComputeHeadersHash(null);
// Assert
result.Should().BeEmpty();
}
[Fact]
public void ComputeHeadersHash_WithEmptyHeaders_ReturnsEmptyString()
{
// Act
string result = DefaultMcpToolHandler.ComputeHeadersHash(new Dictionary<string, string>());
// Assert
result.Should().BeEmpty();
}
[Fact]
public void ComputeHeadersHash_SameHeadersDifferentOrder_ReturnsSameHash()
{
// Arrange
Dictionary<string, string> headers1 = new()
{
["Authorization"] = "Bearer token123",
["X-Custom"] = "value1"
};
Dictionary<string, string> headers2 = new()
{
["X-Custom"] = "value1",
["Authorization"] = "Bearer token123"
};
// Act
string hash1 = DefaultMcpToolHandler.ComputeHeadersHash(headers1);
string hash2 = DefaultMcpToolHandler.ComputeHeadersHash(headers2);
// Assert
hash1.Should().Be(hash2);
}
[Fact]
public void ComputeHeadersHash_SameKeysDifferentCaseKeys_ReturnsSameHash()
{
// Arrange — RFC 7230: header names are case-insensitive
Dictionary<string, string> headers1 = new() { ["Authorization"] = "Bearer token" };
Dictionary<string, string> headers2 = new() { ["authorization"] = "Bearer token" };
// Act
string hash1 = DefaultMcpToolHandler.ComputeHeadersHash(headers1);
string hash2 = DefaultMcpToolHandler.ComputeHeadersHash(headers2);
// Assert
hash1.Should().Be(hash2);
}
[Fact]
public void ComputeHeadersHash_SameKeysDifferentCaseValues_ReturnsDifferentHash()
{
// Arrange — RFC 7235: credentials are case-sensitive
Dictionary<string, string> headers1 = new() { ["Authorization"] = "Bearer ABC" };
Dictionary<string, string> headers2 = new() { ["Authorization"] = "Bearer abc" };
// Act
string hash1 = DefaultMcpToolHandler.ComputeHeadersHash(headers1);
string hash2 = DefaultMcpToolHandler.ComputeHeadersHash(headers2);
// Assert
hash1.Should().NotBe(hash2);
}
[Fact]
public void ComputeHeadersHash_DifferentHeaders_ReturnsDifferentHash()
{
// Arrange
Dictionary<string, string> headers1 = new() { ["Authorization"] = "Bearer token1" };
Dictionary<string, string> headers2 = new() { ["Authorization"] = "Bearer token2" };
// Act
string hash1 = DefaultMcpToolHandler.ComputeHeadersHash(headers1);
string hash2 = DefaultMcpToolHandler.ComputeHeadersHash(headers2);
// Assert
hash1.Should().NotBe(hash2);
}
#endregion
#region Cache Key Discrimination Tests
// These tests exercise BuildCacheKey directly because the integration path
// (InvokeToolAsync against a fake server) doesn't surface cache-hit behavior
// without standing up a real MCP server — McpClient.CreateAsync fails before
// _clients[key] = newClient runs, so nothing ever gets cached.
// Tuple equality on the returned 4-tuple verifies that the dimensions
// collectively discriminate cache entries.
[Fact]
public void BuildCacheKey_SameInputs_ReturnsEqualKeys()
{
// Arrange
Dictionary<string, string> headers = new() { ["Authorization"] = "Bearer token" };
// Act
var key1 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label", "conn", headers);
var key2 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label", "conn", headers);
// Assert
key1.Should().Be(key2);
}
[Fact]
public void BuildCacheKey_DifferentConnectionName_ReturnsDifferentKeys()
{
// Act
var key1 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label", "connection-a", null);
var key2 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label", "connection-b", null);
// Assert
key1.Should().NotBe(key2);
key1.Connection.Should().Be("connection-a");
key2.Connection.Should().Be("connection-b");
}
[Fact]
public void BuildCacheKey_DifferentServerLabel_ReturnsDifferentKeys()
{
// Act
var key1 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label-a", null, null);
var key2 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label-b", null, null);
// Assert
key1.Should().NotBe(key2);
key1.Label.Should().Be("label-a");
key2.Label.Should().Be("label-b");
}
[Fact]
public void BuildCacheKey_CaseSensitiveUrlPath_ReturnsDifferentKeys()
{
// Arrange — RFC 3986: URL path is case-sensitive
// Act
var key1 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/Tools", null, null, null);
var key2 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/tools", null, null, null);
// Assert
key1.Should().NotBe(key2);
}
[Fact]
public void BuildCacheKey_HeaderValuesCaseSensitive_ReturnsDifferentKeys()
{
// Arrange — RFC 7235: credentials are case-sensitive
Dictionary<string, string> headers1 = new() { ["Authorization"] = "Bearer ABC" };
Dictionary<string, string> headers2 = new() { ["Authorization"] = "Bearer abc" };
// Act
var key1 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", null, null, headers1);
var key2 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", null, null, headers2);
// Assert — header value case must propagate into the cache key
key1.Should().NotBe(key2);
key1.HeadersHash.Should().NotBe(key2.HeadersHash);
}
[Fact]
public void BuildCacheKey_NullLabelAndConnection_NormalizesToEmptyString()
{
// Act
var key = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", null, null, null);
// Assert — verifies null-safety contract callers rely on
key.Label.Should().BeEmpty();
key.Connection.Should().BeEmpty();
key.HeadersHash.Should().BeEmpty();
}
#endregion
#region Reserved Tools/List Tests
[Fact]
@@ -1,11 +1,21 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.Events;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.ObjectModel;
using Microsoft.Extensions.AI;
using Microsoft.PowerFx.Types;
using Moq;
using ApprovalSnapshot = Microsoft.Agents.AI.Workflows.Declarative.ObjectModel.InvokeFunctionToolExecutor.ApprovalSnapshot;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
@@ -261,6 +271,323 @@ public sealed class InvokeFunctionToolExecutorTest(ITestOutputHelper output) : W
#endregion
#region Approval Snapshot Security Tests
/// <summary>
/// Verifies that mutating the function-name variable after approval does not change
/// which function is actually invoked. The originally-approved name must be used.
/// </summary>
[Fact]
public async Task InvokeFunctionToolCaptureResponseUsesApprovedFunctionNameNotMutatedAsync()
{
// Arrange
const string ApprovedFunctionName = "safe_readonly_query";
const string MutatedFunctionName = "dangerous_admin_tool";
this.State.Set("TargetFunction", FormulaValue.New(ApprovedFunctionName));
this.State.InitializeSystem();
this.State.Bind();
InvokeFunctionTool model = this.CreateModelWithVariableFunctionName(
displayName: nameof(InvokeFunctionToolCaptureResponseUsesApprovedFunctionNameNotMutatedAsync),
variableName: "TargetFunction");
string? capturedFunctionName = null;
TestFunctionAgentProvider testAgentProvider = new(
[
AIFunctionFactory.Create(() => "safe-result", name: ApprovedFunctionName),
AIFunctionFactory.Create(() => "dangerous-result", name: MutatedFunctionName),
],
onInvoke: name => capturedFunctionName = name);
InvokeFunctionToolExecutor action = new(model, testAgentProvider, this.State);
// Act - trigger ExecuteAsync to store the approval snapshot
Mock<IWorkflowContext> mockContext = CreateMockWorkflowContext();
await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None);
// Simulate parallel branch mutating state during the approval window
this.State.Set("TargetFunction", FormulaValue.New(MutatedFunctionName));
this.State.Bind();
// User clicks approve (they saw "safe_readonly_query" in the approval UI)
ExternalInputResponse response = CreateApprovalResponse(action.Id, approved: true);
// Resume after approval
await action.CaptureResponseAsync(mockContext.Object, response, CancellationToken.None);
// Assert - the originally-approved function must be invoked, not the mutated one
Assert.NotNull(capturedFunctionName);
Assert.Equal(ApprovedFunctionName, capturedFunctionName);
}
/// <summary>
/// Verifies that mutating an argument variable after approval does not change
/// the arguments actually passed to the invoked function.
/// </summary>
[Fact]
public async Task InvokeFunctionToolCaptureResponseUsesApprovedArgumentsNotMutatedAsync()
{
// Arrange
const string FunctionName = "process_query";
const string ArgumentKey = "query";
const string ApprovedQuery = "SELECT * FROM users LIMIT 10";
const string MutatedQuery = "DROP TABLE users CASCADE; --";
this.State.Set("SqlQuery", FormulaValue.New(ApprovedQuery));
this.State.InitializeSystem();
this.State.Bind();
InvokeFunctionTool model = this.CreateModelWithVariableArgument(
displayName: nameof(InvokeFunctionToolCaptureResponseUsesApprovedArgumentsNotMutatedAsync),
functionName: FunctionName,
argumentKey: ArgumentKey,
variableName: "SqlQuery");
AIFunctionArguments? capturedArguments = null;
TestFunctionAgentProvider testAgentProvider = new(
[AIFunctionFactory.Create((string query) => $"executed:{query}", name: FunctionName)],
onInvokeArguments: args => capturedArguments = args);
InvokeFunctionToolExecutor action = new(model, testAgentProvider, this.State);
// Act - trigger ExecuteAsync to store the approval snapshot
Mock<IWorkflowContext> mockContext = CreateMockWorkflowContext();
await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None);
// Simulate parallel branch mutating state during the approval window
this.State.Set("SqlQuery", FormulaValue.New(MutatedQuery));
this.State.Bind();
// User clicks approve
ExternalInputResponse response = CreateApprovalResponse(action.Id, approved: true);
// Resume after approval
await action.CaptureResponseAsync(mockContext.Object, response, CancellationToken.None);
// Assert - the originally-approved argument must be used, not the mutated one
Assert.NotNull(capturedArguments);
Assert.Equal(ApprovedQuery, capturedArguments[ArgumentKey]?.ToString());
}
/// <summary>
/// Verifies that the approval snapshot survives a checkpoint/restore cycle.
/// After restore, the originally-approved function must still be used even if state was mutated.
/// </summary>
[Fact]
public async Task InvokeFunctionToolCaptureResponseUsesSnapshotAfterCheckpointRestoreAsync()
{
// Arrange
const string ApprovedFunctionName = "safe_readonly_query";
const string MutatedFunctionName = "dangerous_admin_tool";
this.State.Set("TargetFunction", FormulaValue.New(ApprovedFunctionName));
this.State.InitializeSystem();
this.State.Bind();
InvokeFunctionTool model = this.CreateModelWithVariableFunctionName(
displayName: nameof(InvokeFunctionToolCaptureResponseUsesSnapshotAfterCheckpointRestoreAsync),
variableName: "TargetFunction");
string? capturedFunctionName = null;
TestFunctionAgentProvider testAgentProvider = new(
[
AIFunctionFactory.Create(() => "safe-result", name: ApprovedFunctionName),
AIFunctionFactory.Create(() => "dangerous-result", name: MutatedFunctionName),
],
onInvoke: name => capturedFunctionName = name);
InvokeFunctionToolExecutor action = new(model, testAgentProvider, this.State);
// Act - trigger ExecuteAsync to store the approval snapshot
Mock<IWorkflowContext> mockContext = CreateMockWorkflowContextWithStateStore();
await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None);
// Simulate checkpoint: persist to state store
await InvokeProtectedMethodAsync(action, "OnCheckpointingAsync", mockContext.Object, CancellationToken.None);
// Simulate restore on a "new" executor instance by clearing the in-memory field via reflection
// (In production, a new executor instance would be created with _approvalSnapshot == null)
typeof(InvokeFunctionToolExecutor)
.GetField("_approvalSnapshot", BindingFlags.NonPublic | BindingFlags.Instance)!
.SetValue(action, null);
// Restore from state store
await InvokeProtectedMethodAsync(action, "OnCheckpointRestoredAsync", mockContext.Object, CancellationToken.None);
// Mutate state after restore (simulating parallel branch)
this.State.Set("TargetFunction", FormulaValue.New(MutatedFunctionName));
this.State.Bind();
// User clicks approve
ExternalInputResponse response = CreateApprovalResponse(action.Id, approved: true);
// Resume after approval
await action.CaptureResponseAsync(mockContext.Object, response, CancellationToken.None);
// Assert - the originally-approved function must be invoked, not the mutated one
Assert.NotNull(capturedFunctionName);
Assert.Equal(ApprovedFunctionName, capturedFunctionName);
}
/// <summary>
/// Verifies that the approval snapshot is cleared after a completed approval cycle,
/// both in-memory and in the persisted state store. This prevents stale data from
/// influencing a subsequent execution of the same executor instance.
/// </summary>
[Fact]
public async Task InvokeFunctionToolCaptureResponseClearsSnapshotAfterCompletionAsync()
{
// Arrange
const string FunctionName = "any_function";
this.State.InitializeSystem();
this.State.Bind();
InvokeFunctionTool model = this.CreateModel(
displayName: nameof(InvokeFunctionToolCaptureResponseClearsSnapshotAfterCompletionAsync),
functionName: FunctionName,
requireApproval: true);
TestFunctionAgentProvider testAgentProvider = new(
[AIFunctionFactory.Create(() => "result", name: FunctionName)]);
InvokeFunctionToolExecutor action = new(model, testAgentProvider, this.State);
// Act - run the full approval cycle
Dictionary<string, object?> stateStore = [];
Mock<IWorkflowContext> mockContext = CreateMockWorkflowContextWithStateStore(stateStore);
await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None);
// Sanity: snapshot was captured
FieldInfo snapshotField = typeof(InvokeFunctionToolExecutor)
.GetField("_approvalSnapshot", BindingFlags.NonPublic | BindingFlags.Instance)!;
Assert.NotNull(snapshotField.GetValue(action));
ExternalInputResponse response = CreateApprovalResponse(action.Id, approved: true);
await action.CaptureResponseAsync(mockContext.Object, response, CancellationToken.None);
// Assert - both in-memory field and persisted state are cleared
Assert.Null(snapshotField.GetValue(action));
Assert.True(stateStore.ContainsKey("_approvalSnapshot"));
Assert.Null(stateStore["_approvalSnapshot"]);
}
private static ExternalInputResponse CreateApprovalResponse(string actionId, bool approved)
{
FunctionCallContent functionCall = new(callId: actionId, name: "ignored");
ToolApprovalRequestContent approvalRequest = new(actionId, functionCall);
ToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved);
return new ExternalInputResponse(new ChatMessage(ChatRole.User, [approvalResponse]));
}
private static Mock<IWorkflowContext> CreateMockWorkflowContext()
{
Mock<IWorkflowContext> mockContext = new();
mockContext.Setup(c => c.AddEventAsync(It.IsAny<WorkflowEvent>(), It.IsAny<CancellationToken>()))
.Returns(default(ValueTask));
mockContext.Setup(c => c.QueueStateUpdateAsync(It.IsAny<string>(), It.IsAny<object?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.Returns(default(ValueTask));
mockContext.Setup(c => c.SendMessageAsync(It.IsAny<object>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.Returns(default(ValueTask));
return mockContext;
}
/// <summary>
/// Creates a mock workflow context that actually stores state values (for checkpoint/restore tests).
/// Optionally accepts an externally-owned dictionary so callers can inspect the persisted state.
/// </summary>
private static Mock<IWorkflowContext> CreateMockWorkflowContextWithStateStore(Dictionary<string, object?>? stateStore = null)
{
stateStore ??= [];
Mock<IWorkflowContext> mockContext = new();
mockContext.Setup(c => c.AddEventAsync(It.IsAny<WorkflowEvent>(), It.IsAny<CancellationToken>()))
.Returns(default(ValueTask));
mockContext.Setup(c => c.QueueStateUpdateAsync(It.IsAny<string>(), It.IsAny<ApprovalSnapshot?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.Callback<string, ApprovalSnapshot?, string?, CancellationToken>((key, value, _, _) => stateStore[key] = value)
.Returns(default(ValueTask));
mockContext.Setup(c => c.SendMessageAsync(It.IsAny<object>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.Returns(default(ValueTask));
mockContext.Setup(c => c.ReadStateAsync<ApprovalSnapshot>(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.Returns<string, string?, CancellationToken>((key, _, _) =>
new ValueTask<ApprovalSnapshot?>(stateStore.TryGetValue(key, out object? val) ? val as ApprovalSnapshot : null));
mockContext.Setup(c => c.ReadStateKeysAsync(It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new HashSet<string>());
return mockContext;
}
/// <summary>
/// Invokes a protected method on the executor via reflection (for testing checkpoint hooks).
/// </summary>
private static async ValueTask InvokeProtectedMethodAsync(InvokeFunctionToolExecutor action, string methodName, IWorkflowContext context, CancellationToken cancellationToken)
{
MethodInfo method = typeof(InvokeFunctionToolExecutor)
.GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Instance)!;
ValueTask result = (ValueTask)method.Invoke(action, [context, cancellationToken])!;
await result.ConfigureAwait(false);
}
/// <summary>
/// Minimal concrete <see cref="ResponseAgentProvider"/> that exposes an injected
/// <see cref="AIFunction"/> registry and records which function got invoked.
/// Used by the framework-invoke approval branch (<c>InvokeRegisteredFunctionAsync</c>).
/// </summary>
private sealed class TestFunctionAgentProvider : ResponseAgentProvider
{
private readonly Action<string>? _onInvoke;
private readonly Action<AIFunctionArguments>? _onInvokeArguments;
public TestFunctionAgentProvider(
IEnumerable<AIFunction> functions,
Action<string>? onInvoke = null,
Action<AIFunctionArguments>? onInvokeArguments = null)
{
this._onInvoke = onInvoke;
this._onInvokeArguments = onInvokeArguments;
this.Functions = functions.Select(f => (AIFunction)new RecordingAIFunction(f, this)).ToList();
}
internal void RecordInvocation(string name, AIFunctionArguments? arguments)
{
this._onInvoke?.Invoke(name);
if (arguments is not null)
{
this._onInvokeArguments?.Invoke(arguments);
}
}
public override Task<string> CreateConversationAsync(CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public override Task<ChatMessage> CreateMessageAsync(string conversationId, ChatMessage conversationMessage, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public override Task<ChatMessage> GetMessageAsync(string conversationId, string messageId, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public override IAsyncEnumerable<AgentResponseUpdate> InvokeAgentAsync(
string agentId, string? agentVersion, string? conversationId,
IEnumerable<ChatMessage>? messages, IDictionary<string, object?>? inputArguments,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public override IAsyncEnumerable<ChatMessage> GetMessagesAsync(
string conversationId, int? limit = null, string? after = null, string? before = null,
bool newestFirst = false, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
private sealed class RecordingAIFunction(AIFunction inner, TestFunctionAgentProvider owner) : AIFunction
{
public override string Name => inner.Name;
public override string Description => inner.Description;
public override JsonElement JsonSchema => inner.JsonSchema;
protected override ValueTask<object?> InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken)
{
owner.RecordInvocation(inner.Name, arguments);
return inner.InvokeAsync(arguments, cancellationToken);
}
}
}
#endregion
#region Helper Methods
private async Task ExecuteTestAsync(InvokeFunctionTool model)
@@ -318,5 +645,33 @@ public sealed class InvokeFunctionToolExecutorTest(ITestOutputHelper output) : W
return AssignParent<InvokeFunctionTool>(builder);
}
private InvokeFunctionTool CreateModelWithVariableFunctionName(string displayName, string variableName)
{
InvokeFunctionTool.Builder builder = new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
FunctionName = new StringExpression.Builder(
StringExpression.Variable(PropertyPath.TopicVariable(variableName))),
RequireApproval = new BoolExpression.Builder(BoolExpression.Literal(true)),
};
return AssignParent<InvokeFunctionTool>(builder);
}
private InvokeFunctionTool CreateModelWithVariableArgument(
string displayName, string functionName, string argumentKey, string variableName)
{
InvokeFunctionTool.Builder builder = new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
FunctionName = new StringExpression.Builder(StringExpression.Literal(functionName)),
RequireApproval = new BoolExpression.Builder(BoolExpression.Literal(true)),
};
builder.Arguments.Add(argumentKey,
ValueExpression.Variable(PropertyPath.TopicVariable(variableName)));
return AssignParent<InvokeFunctionTool>(builder);
}
#endregion
}
@@ -63,6 +63,9 @@ logger = logging.getLogger(__name__)
_ENV_REFERENCE_RE = re.compile(r"\bEnv\.([A-Za-z_][A-Za-z0-9_]*)")
# Allowed identifier shape for object-attribute steps in declarative state paths
_SAFE_PATH_SEGMENT_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_]*$")
@dataclass(frozen=True)
class DeclarativeEnvConfig:
@@ -266,6 +269,9 @@ class DeclarativeWorkflowState:
- Conversation: Conversation history
"""
# Sentinel marking "no prior value" for temporary-key bookkeeping.
_MISSING: Any = object()
def __init__(self, state: State, env_config: DeclarativeEnvConfig | None = None):
"""Initialize with a State instance.
@@ -331,16 +337,21 @@ class DeclarativeWorkflowState:
def get(self, path: str, default: Any = None) -> Any:
"""Get a value from the state using a dot-notated path.
Dict-keyed segments may use arbitrary string keys (e.g. UUIDs in
``System.conversations.<id>.messages``). Segments that would resolve
via object-attribute access must be valid declarative identifiers
(``[A-Za-z][A-Za-z0-9_]*``); other shapes return ``default``.
Args:
path: Dot-notated path like 'Local.results' or 'Workflow.Inputs.query'
default: Default value if path doesn't exist
Returns:
The value at the path, or default if not found
The value at the path, or default if not found or unreachable.
"""
state_data = self.get_state_data()
parts = path.split(".")
if not parts:
if not parts or any(not p for p in parts):
return default
namespace = parts[0]
@@ -377,10 +388,19 @@ class DeclarativeWorkflowState:
obj = obj.get(part, default) # type: ignore[union-attr]
if obj is default:
return default
elif hasattr(obj, part): # type: ignore[arg-type]
obj = getattr(obj, part) # type: ignore[arg-type]
else:
return default
# Attribute access is only allowed for safe declarative identifiers.
if not _SAFE_PATH_SEGMENT_RE.match(part):
logger.warning(
"DeclarativeWorkflowState.get: rejecting attribute segment %r in path %r",
part,
path,
)
return default
if hasattr(obj, part): # type: ignore[arg-type]
obj = getattr(obj, part) # type: ignore[arg-type]
else:
return default
return obj # type: ignore[return-value]
@@ -392,12 +412,14 @@ class DeclarativeWorkflowState:
value: The value to set
Raises:
ValueError: If attempting to set Workflow.Inputs (which is read-only)
ValueError: If ``path`` is empty or contains empty segments
(e.g. ``"Local."``, ``"Local..foo"``), or if attempting to set
``Workflow.Inputs`` (which is read-only).
"""
state_data = self.get_state_data()
parts = path.split(".")
if not parts:
return
if not parts or any(not p for p in parts):
raise ValueError(f"Invalid path {path!r}: empty segments are not allowed")
namespace = parts[0]
remaining = parts[1:]
@@ -453,7 +475,16 @@ class DeclarativeWorkflowState:
Args:
path: Dot-notated path to a list
value: The value to append
Raises:
ValueError: If ``path`` is empty or contains empty segments
(e.g. ``"Local."``, ``"Local..foo"``), or if the existing
value at ``path`` is not a list.
"""
parts = path.split(".")
if not parts or any(not p for p in parts):
raise ValueError(f"Invalid path {path!r}: empty segments are not allowed")
existing = self.get(path)
if existing is None:
self.set(path, [value])
@@ -464,6 +495,15 @@ class DeclarativeWorkflowState:
else:
raise ValueError(f"Cannot append to non-list at path '{path}'")
def _clear_local_path(self, name: str) -> None:
"""Remove ``name`` from the ``Local`` namespace, if present."""
state_data = self.get_state_data()
local = state_data.get("Local")
if local is None or name not in local:
return
local.pop(name, None)
self.set_state_data(state_data)
def eval(self, expression: str) -> Any:
"""Evaluate a PowerFx expression with the current state.
@@ -504,53 +544,64 @@ class DeclarativeWorkflowState:
return result
# Pre-process nested custom functions (e.g., Upper(MessageText(...)))
# Replace them with their evaluated results before sending to PowerFx
formula = self._preprocess_custom_functions(formula)
# and run PowerFx. The finally below restores any temporary state
# written during preprocessing, regardless of where execution exits.
temp_writes: list[tuple[str, Any]] = []
if Engine is None:
raise RuntimeError(
f"PowerFx is not available (dotnet runtime not installed). "
f"Expression '={formula[:80]}' cannot be evaluated. "
f"Install dotnet and the powerfx package for full PowerFx support."
)
symbols = self._to_powerfx_symbols()
# Use setlocale(category) query form so we can restore the exact prior value.
# getlocale() returns a normalized tuple and is not always a lossless
# round-trip for setlocale across platforms/locales.
original_numeric_locale = locale.setlocale(locale.LC_NUMERIC)
try:
for locale_candidate in _POWERFX_NUMERIC_LOCALE_CANDIDATES:
try:
locale.setlocale(locale.LC_NUMERIC, locale_candidate)
break
except locale.Error:
continue
formula = self._preprocess_custom_functions(formula, temp_writes)
engine = Engine()
try:
from System.Globalization import ( # pyright: ignore[reportMissingImports]
CultureInfo, # pyright: ignore[reportUnknownVariableType]
if Engine is None:
raise RuntimeError(
f"PowerFx is not available (dotnet runtime not installed). "
f"Expression '={formula[:80]}' cannot be evaluated. "
f"Install dotnet and the powerfx package for full PowerFx support."
)
except ImportError:
return engine.eval(formula, symbols=symbols, locale=_POWERFX_EVAL_LOCALE)
original_culture = cast(Any, CultureInfo.CurrentCulture) # pyright: ignore[reportUnknownMemberType]
symbols = self._to_powerfx_symbols()
# Use setlocale(category) query form so we can restore the exact prior value.
# getlocale() returns a normalized tuple and is not always a lossless
# round-trip for setlocale across platforms/locales.
original_numeric_locale = locale.setlocale(locale.LC_NUMERIC)
try:
CultureInfo.CurrentCulture = CultureInfo(_POWERFX_EVAL_LOCALE) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
return engine.eval(formula, symbols=symbols, locale=_POWERFX_EVAL_LOCALE)
for locale_candidate in _POWERFX_NUMERIC_LOCALE_CANDIDATES:
try:
locale.setlocale(locale.LC_NUMERIC, locale_candidate)
break
except locale.Error:
continue
engine = Engine()
try:
from System.Globalization import ( # pyright: ignore[reportMissingImports]
CultureInfo, # pyright: ignore[reportUnknownVariableType]
)
except ImportError:
return engine.eval(formula, symbols=symbols, locale=_POWERFX_EVAL_LOCALE)
original_culture = cast(Any, CultureInfo.CurrentCulture) # pyright: ignore[reportUnknownMemberType]
try:
CultureInfo.CurrentCulture = CultureInfo(_POWERFX_EVAL_LOCALE) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
return engine.eval(formula, symbols=symbols, locale=_POWERFX_EVAL_LOCALE)
finally:
CultureInfo.CurrentCulture = original_culture # pyright: ignore[reportUnknownMemberType]
except ValueError as e:
error_msg = str(e)
# Handle undefined variable errors gracefully by returning None
# This matches the behavior of the legacy fallback parser
if "isn't recognized" in error_msg or "Name isn't valid" in error_msg:
logger.debug(f"PowerFx: undefined variable in expression '{formula}', returning None")
return None
raise
finally:
CultureInfo.CurrentCulture = original_culture # pyright: ignore[reportUnknownMemberType]
except ValueError as e:
error_msg = str(e)
# Handle undefined variable errors gracefully by returning None
# This matches the behavior of the legacy fallback parser
if "isn't recognized" in error_msg or "Name isn't valid" in error_msg:
logger.debug(f"PowerFx: undefined variable in expression '{formula}', returning None")
return None
raise
locale.setlocale(locale.LC_NUMERIC, original_numeric_locale)
finally:
locale.setlocale(locale.LC_NUMERIC, original_numeric_locale)
# Restore each temporary key to its prior value (or remove it).
for path, previous in reversed(temp_writes):
if previous is self._MISSING:
self._clear_local_path(path.removeprefix("Local."))
else:
self.set(path, previous)
def _eval_custom_function(self, formula: str) -> Any | None:
"""Handle custom functions not supported by the Python PowerFx library.
@@ -609,7 +660,7 @@ class DeclarativeWorkflowState:
return None
def _preprocess_custom_functions(self, formula: str) -> str:
def _preprocess_custom_functions(self, formula: str, temp_writes: list[tuple[str, Any]]) -> str:
"""Pre-process custom functions nested inside other PowerFx functions.
Custom functions like MessageText() are not supported by the PowerFx engine.
@@ -624,9 +675,14 @@ class DeclarativeWorkflowState:
Args:
formula: The PowerFx formula to pre-process
temp_writes: Caller-owned list. Each write to a temporary key
appends a ``(path, previous_value)`` entry where
``previous_value`` is the value at ``path`` before the write
or :attr:`_MISSING` if none. The caller must restore every
entry, including when this method raises mid-write.
Returns:
The formula with custom function calls replaced by their evaluated results
The rewritten formula.
"""
import re
@@ -635,7 +691,6 @@ class DeclarativeWorkflowState:
# We use 500 to leave room for the rest of the expression around the replaced value.
MAX_INLINE_LENGTH = 500
# Counter for generating unique temp variable names
temp_var_counter = 0
# Custom functions that need pre-processing: (regex pattern, handler)
@@ -691,11 +746,14 @@ class DeclarativeWorkflowState:
# Replace in formula
if isinstance(replacement, str):
if len(replacement) > MAX_INLINE_LENGTH:
# Store long strings in a temp variable to avoid PowerFx expression limit
# Store long results in an underscore-prefixed temp key;
# record the prior value so eval() can restore it.
temp_var_name = f"_TempMessageText{temp_var_counter}"
temp_var_counter += 1
self.set(f"Local.{temp_var_name}", replacement)
replacement_str = f"Local.{temp_var_name}"
temp_var_path = f"Local.{temp_var_name}"
temp_writes.append((temp_var_path, self.get(temp_var_path, default=self._MISSING)))
self.set(temp_var_path, replacement)
replacement_str = temp_var_path
logger.debug(
f"Stored long MessageText result ({len(replacement)} chars) "
f"in temp variable {temp_var_name}"
@@ -847,11 +905,13 @@ class DeclarativeWorkflowState:
return value
def interpolate_string(self, text: str) -> str:
"""Interpolate {Variable.Path} references in a string.
"""Interpolate ``{Variable.Path}`` references in a string.
This handles template-style variable substitution like:
- "Created ticket #{Local.TicketParameters.TicketId}"
- "Routing to {Local.RoutingParameters.TeamName}"
Captures brace-delimited tokens whose root segment is an identifier
(``[A-Za-z][A-Za-z0-9_]*``) followed by zero or more ``.`` separated
dict-key segments. Resolution is delegated to :meth:`get`; unresolved
tokens are replaced with the empty string. Tokens that do not look
like state paths (e.g. ``{foo-bar}``, ``{Ctrl+C}``) are left literal.
Args:
text: Text that may contain {Variable.Path} references
@@ -866,10 +926,11 @@ class DeclarativeWorkflowState:
value = self.get(var_path)
return str(value) if value is not None else ""
# Match {Variable.Path} patterns
pattern = r"\{([A-Za-z][A-Za-z0-9_.]*)\}"
# Root segment must be an identifier; follow-on segments accept any
# non-empty dict-key (e.g. ``_id``, ``1``, UUIDs). ``get()`` enforces
# per-segment safety on attribute traversal.
pattern = r"\{([A-Za-z][A-Za-z0-9_]*(?:\.[^{}\s.]+)*)\}"
# Replace all matches
result = text
for match in re.finditer(pattern, text):
replacement = replace_var(match)
@@ -0,0 +1,364 @@
# Copyright (c) Microsoft. All rights reserved.
# pyright: reportUnknownParameterType=false, reportUnknownArgumentType=false
# pyright: reportMissingParameterType=false, reportUnknownMemberType=false
# pyright: reportPrivateUsage=false, reportUnknownVariableType=false
# pyright: reportGeneralTypeIssues=false
"""Path-segment validation tests for DeclarativeWorkflowState.
Path segments handed to ``get``/``set``/``append`` and ``{Variable.Path}``
placeholders in ``interpolate_string`` are subject to three distinct rules
that this module pins:
- **Empty segments** (e.g. ``""``, ``"Local."``, ``"Local..foo"``) are rejected
by all of ``get``/``set``/``append`` and ``interpolate_string``. ``get`` and
``interpolate_string`` return their default / leave the placeholder literal;
``set`` and ``append`` raise ``ValueError``.
- **Object-attribute segments** — segments that ``get`` would resolve via
``getattr`` because the parent is a non-dict object — must match the safe
identifier shape ``[A-Za-z][A-Za-z0-9_]*``. Other shapes are rejected with a
warning log and the default is returned.
- **Dict-keyed segments** — segments that resolve via dict lookup because the
parent is a ``dict`` — may use arbitrary non-empty string keys (e.g. UUIDs
or hyphenated identifiers like ``System.conversations.<uuid>.messages``).
"""
import logging
from dataclasses import dataclass
from typing import Any
from unittest.mock import MagicMock
import pytest
from agent_framework_declarative._workflows import DeclarativeWorkflowState
try:
import powerfx # noqa: F401
_powerfx_available = True
except (ImportError, RuntimeError):
_powerfx_available = False
_requires_powerfx = pytest.mark.skipif(not _powerfx_available, reason="PowerFx engine not available")
@pytest.fixture
def mock_state() -> MagicMock:
"""In-memory mock for the underlying State."""
ms = MagicMock()
ms._data = {}
def get(key: str, default: Any = None) -> Any:
return ms._data.get(key, default)
def set_(key: str, value: Any) -> None:
ms._data[key] = value
def has(key: str) -> bool:
return key in ms._data
def delete(key: str) -> None:
ms._data.pop(key, None)
ms.get = MagicMock(side_effect=get)
ms.set = MagicMock(side_effect=set_)
ms.has = MagicMock(side_effect=has)
ms.delete = MagicMock(side_effect=delete)
return ms
@pytest.fixture
def state(mock_state: MagicMock) -> DeclarativeWorkflowState:
s = DeclarativeWorkflowState(mock_state)
s.initialize()
return s
@dataclass
class _PlainObj:
"""Non-dict object so ``get`` falls through to attribute access."""
text: str = "hi"
# ---------------------------------------------------------------------------
# get(): invalid paths return default
# ---------------------------------------------------------------------------
class TestGetRejectsInvalidPaths:
def test_rejects_dunder_segment_via_attribute_access(self, state: DeclarativeWorkflowState) -> None:
state.set("Local.obj", _PlainObj())
assert state.get("Local.obj.__class__") is None
assert state.get("Local.obj.__class__", default="DEF") == "DEF"
def test_rejects_full_env_exfil_chain(self, state: DeclarativeWorkflowState, monkeypatch) -> None:
sentinel = "agent-framework-path-safety-sentinel"
monkeypatch.setenv("AF_PATH_SAFETY_SENTINEL", sentinel)
state.set("Local.obj", _PlainObj())
result = state.get("Local.obj.__class__.__init__.__globals__.os.environ")
assert result is None
assert sentinel not in str(result)
def test_rejects_leading_underscore_via_attribute_access(self, state: DeclarativeWorkflowState) -> None:
state.set("Local.obj", _PlainObj())
assert state.get("Local.obj._private") is None
def test_rejects_invalid_chars_via_attribute_access(self, state: DeclarativeWorkflowState) -> None:
state.set("Local.obj", _PlainObj())
assert state.get("Local.obj.text bar") is None
assert state.get("Local.obj.text-bar") is None
def test_rejects_empty_path_and_empty_segments(self, state: DeclarativeWorkflowState) -> None:
assert state.get("") is None
assert state.get(".") is None
assert state.get("Local.") is None
assert state.get(".Local") is None
def test_warning_logged_on_rejected_attribute_segment(
self,
state: DeclarativeWorkflowState,
caplog: pytest.LogCaptureFixture,
) -> None:
state.set("Local.obj", _PlainObj())
with caplog.at_level(logging.WARNING, logger="agent_framework_declarative._workflows._declarative_base"):
state.get("Local.obj.__class__")
assert any("rejecting attribute segment" in r.message for r in caplog.records)
def test_dict_keyed_dunder_is_not_attribute_access(self, state: DeclarativeWorkflowState) -> None:
"""A literal dunder dict key is harmless because dict lookup never reaches getattr."""
state.set("Local.bag", {"__class__": "harmless-string"})
assert state.get("Local.bag.__class__") == "harmless-string"
# ---------------------------------------------------------------------------
# get(): legitimate paths continue to work
# ---------------------------------------------------------------------------
class TestGetAllowsValidPaths:
def test_underscore_inside_identifier(self, state: DeclarativeWorkflowState) -> None:
state.set("Local.user_input", "ok")
assert state.get("Local.user_input") == "ok"
def test_mixed_case_identifiers(self, state: DeclarativeWorkflowState) -> None:
state.set("Local.UserInput", "u1")
state.set("Local.userInput", "u2")
assert state.get("Local.UserInput") == "u1"
assert state.get("Local.userInput") == "u2"
def test_object_attribute_traversal_still_works(self, state: DeclarativeWorkflowState) -> None:
state.set("Local.msg", _PlainObj(text="hello"))
assert state.get("Local.msg.text") == "hello"
def test_nested_dict_traversal_still_works(self, state: DeclarativeWorkflowState) -> None:
state.set("Local.params", {"team": {"name": "alpha"}})
assert state.get("Local.params.team.name") == "alpha"
def test_uuid_and_hyphenated_dict_keys_are_allowed(self, state: DeclarativeWorkflowState) -> None:
"""Conversation-id style paths use arbitrary dict keys (UUIDs / hyphens)."""
conv_id = "eb815014-06f1-4db6-b7c1-304ea135424f"
state.set(f"System.conversations.{conv_id}.messages", ["m1", "m2"])
assert state.get(f"System.conversations.{conv_id}.messages") == ["m1", "m2"]
# ---------------------------------------------------------------------------
# set() / append(): dict-keyed operations accept arbitrary string keys
# ---------------------------------------------------------------------------
class TestSetAndAppend:
def test_set_allows_underscore_inside_identifier(self, state: DeclarativeWorkflowState) -> None:
state.set("Local.user_input", "ok")
assert state.get("Local.user_input") == "ok"
def test_set_allows_uuid_and_hyphenated_dict_keys(self, state: DeclarativeWorkflowState) -> None:
conv_id = "conv-test-1"
state.set(f"System.conversations.{conv_id}.messages", [])
assert state.get(f"System.conversations.{conv_id}.messages") == []
def test_append_allows_uuid_and_hyphenated_dict_keys(self, state: DeclarativeWorkflowState) -> None:
conv_id = "conv-42"
state.append(f"System.conversations.{conv_id}.messages", {"role": "user", "text": "hi"})
msgs = state.get(f"System.conversations.{conv_id}.messages")
assert msgs == [{"role": "user", "text": "hi"}]
def test_workflow_inputs_still_read_only(self, state: DeclarativeWorkflowState) -> None:
with pytest.raises(ValueError, match="read-only"):
state.set("Workflow.Inputs.x", 1)
# ---------------------------------------------------------------------------
# set() / append(): malformed paths (empty segments) raise ValueError
# ---------------------------------------------------------------------------
class TestSetRejectsInvalidPaths:
@pytest.mark.parametrize("bad_path", ["", "Local.", "Local..foo", ".Local"])
def test_set_rejects_empty_segment(self, state: DeclarativeWorkflowState, bad_path: str) -> None:
with pytest.raises(ValueError, match="empty segments are not allowed"):
state.set(bad_path, "x")
@pytest.mark.parametrize("bad_path", ["", "Local.", "Local..foo", ".Local"])
def test_append_rejects_empty_segment(self, state: DeclarativeWorkflowState, bad_path: str) -> None:
with pytest.raises(ValueError, match="empty segments are not allowed"):
state.append(bad_path, "x")
def test_set_rejection_makes_no_partial_write(self, state: DeclarativeWorkflowState) -> None:
"""Rejected set() must not create an unreachable entry in the state."""
state.set("Local.user_input", "pre")
with pytest.raises(ValueError):
state.set("Local.", "value")
local = state.get_state_data().get("Local", {})
assert "" not in local
assert local == {"user_input": "pre"}
assert state.get("Local.") is None
assert state.get("Local.user_input") == "pre"
def test_append_rejection_makes_no_partial_write(self, state: DeclarativeWorkflowState) -> None:
"""Rejected append() must not create an unreachable entry in the state."""
state.set("Local.items", ["a"])
with pytest.raises(ValueError):
state.append("Local.", "value")
local = state.get_state_data().get("Local", {})
assert "" not in local
assert local == {"items": ["a"]}
# ---------------------------------------------------------------------------
# interpolate_string(): permissive matcher; get() enforces safety
# ---------------------------------------------------------------------------
class TestInterpolateString:
def test_ignores_dunder_payload(self, state: DeclarativeWorkflowState, monkeypatch) -> None:
sentinel = "agent-framework-interp-sentinel"
monkeypatch.setenv("AF_INTERP_SENTINEL", sentinel)
state.set("Local.obj", _PlainObj())
out = state.interpolate_string("X={Local.obj.__class__.__init__.__globals__.os.environ}")
assert sentinel not in out
assert out == "X="
def test_unknown_path_reduces_to_empty(self, state: DeclarativeWorkflowState) -> None:
assert state.interpolate_string("v={Local._private}") == "v="
@pytest.mark.parametrize(
"literal",
["{foo-bar}", "{Ctrl+C}", "{not:a:path}", "{Local.}", "{}"],
)
def test_non_state_braced_tokens_left_literal(self, state: DeclarativeWorkflowState, literal: str) -> None:
assert state.interpolate_string(f"v={literal}") == f"v={literal}"
def test_allows_underscore_inside_identifier(self, state: DeclarativeWorkflowState) -> None:
state.set("Local.user_input", "hello")
assert state.interpolate_string("v={Local.user_input}") == "v=hello"
def test_resolves_nested_dict_path(self, state: DeclarativeWorkflowState) -> None:
state.set("Local.params", {"team": "alpha"})
assert state.interpolate_string("team={Local.params.team}") == "team=alpha"
@pytest.mark.parametrize(
("key", "value"),
[
("_id", "abc123"),
("1", "one"),
("2025", "year-bucket"),
],
)
def test_resolves_dict_keyed_segments(self, state: DeclarativeWorkflowState, key: str, value: str) -> None:
state.set("Local.bag", {key: value})
assert state.interpolate_string(f"v={{Local.bag.{key}}}") == f"v={value}"
def test_resolves_uuid_conversation_key(self, state: DeclarativeWorkflowState) -> None:
conv_id = "eb815014-06f1-4db6-b7c1-304ea135424f"
state.set(f"System.conversations.{conv_id}.messages", ["hello"])
out = state.interpolate_string(f"m={{System.conversations.{conv_id}.messages}}")
assert out == "m=['hello']"
def test_end_to_end_send_activity_payload_neutralized(
self,
state: DeclarativeWorkflowState,
monkeypatch,
) -> None:
sentinel = "agent-framework-e2e-sentinel"
monkeypatch.setenv("AF_E2E_SENTINEL", sentinel)
state.set("Local.toolResult", _PlainObj())
payload = "{Local.toolResult.__class__.__init__.__globals__.os.environ}"
evaluated = state.eval_if_expression(payload)
rendered = state.interpolate_string(evaluated) if isinstance(evaluated, str) else str(evaluated)
assert sentinel not in rendered
assert rendered == ""
# ---------------------------------------------------------------------------
# Regressions: PowerFx and internal temp-variable handling still work
# ---------------------------------------------------------------------------
@_requires_powerfx
class TestPowerFxStillWorks:
def test_simple_powerfx_expression_evaluates(self, state: DeclarativeWorkflowState) -> None:
state.set("Local.x", 6)
state.set("Local.y", 7)
assert state.eval("=Local.x * Local.y") == 42
def test_internal_temp_message_text_still_works(self, state: DeclarativeWorkflowState) -> None:
"""Long MessageText() results round-trip and the temp key is removed after eval."""
long_text = "A" * 600
state.set(
"Local.Messages",
[{"text": long_text, "contents": [{"type": "text", "text": long_text}]}],
)
result = state.eval("=Upper(MessageText(Local.Messages))")
assert result == "A" * 600
local = state.get_state_data().get("Local", {})
remaining = sorted(k for k in local if k.startswith("_TempMessageText"))
assert not remaining, f"Temporary keys remain in Local: {remaining}"
def test_message_text_eval_preserves_user_temp_value(self, state: DeclarativeWorkflowState) -> None:
"""User state at the temp key path survives a long MessageText eval."""
long_text = "A" * 600
state.set("Local._TempMessageText0", "user-important-value")
state.set(
"Local.Messages",
[{"text": long_text, "contents": [{"type": "text", "text": long_text}]}],
)
result = state.eval("=Upper(MessageText(Local.Messages))")
assert result == "A" * 600
assert state.get("Local._TempMessageText0") == "user-important-value"
def test_message_text_eval_cleans_up_on_powerfx_failure(
self,
state: DeclarativeWorkflowState,
monkeypatch,
) -> None:
"""Temp key is removed even when PowerFx evaluation raises."""
from agent_framework_declarative._workflows import _declarative_base as base
class _FailingEngine:
def eval(self, *args: Any, **kwargs: Any) -> Any:
raise RuntimeError("boom")
monkeypatch.setattr(base, "Engine", _FailingEngine)
long_text = "A" * 600
state.set(
"Local.Messages",
[{"text": long_text, "contents": [{"type": "text", "text": long_text}]}],
)
with pytest.raises(RuntimeError, match="boom"):
state.eval("=Upper(MessageText(Local.Messages))")
local = state.get_state_data().get("Local", {})
remaining = sorted(k for k in local if k.startswith("_TempMessageText"))
assert not remaining, f"Temporary keys remain in Local after PowerFx failure: {remaining}"
@@ -2765,7 +2765,7 @@ class TestLongMessageTextHandling:
assert temp_var is None
async def test_long_message_text_stored_in_temp_variable(self, mock_state):
"""Test that long MessageText results are stored in temp variables."""
"""Long MessageText results round-trip and the temp key is removed after eval."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
@@ -2777,9 +2777,9 @@ class TestLongMessageTextHandling:
result = state.eval("=Upper(MessageText(Local.Messages))")
assert result == "A" * 600 # Upper on 'A' is still 'A'
# A temp variable should have been created
temp_var = state.get("Local._TempMessageText0")
assert temp_var == long_text
local = state.get_state_data().get("Local", {})
remaining = sorted(k for k in local if k.startswith("_TempMessageText"))
assert not remaining, f"Temporary keys remain in Local: {remaining}"
async def test_find_with_long_message_text(self, mock_state):
"""Test Find function works with long MessageText stored in temp variable."""