diff --git a/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/Program.cs b/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/Program.cs index 7c3ecbc7e96..d6d9396fd4c 100644 --- a/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/Program.cs +++ b/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/Program.cs @@ -100,6 +100,7 @@ public static async Task Main(string[] args) WorkflowFactory workflowFactory = new("InvokeFoundryToolboxMcp.yaml", foundryEndpoint) { Configuration = workflowConfiguration, + AllowedEnvironmentVariables = [ToolboxMcpServerUrlSetting, DocsServerLabelSetting, WebSearchToolNameSetting], McpToolHandler = mcpToolHandler }; diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/AgentBotElementYaml.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/AgentBotElementYaml.cs index 89eacdf8fa9..4e62030ccd8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Declarative/AgentBotElementYaml.cs +++ b/dotnet/src/Microsoft.Agents.AI.Declarative/AgentBotElementYaml.cs @@ -1,11 +1,14 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using Microsoft.Agents.ObjectModel; using Microsoft.Agents.ObjectModel.Abstractions; +using Microsoft.Agents.ObjectModel.Analysis; +using Microsoft.Agents.ObjectModel.PowerFx; using Microsoft.Agents.ObjectModel.Yaml; using Microsoft.Extensions.Configuration; using Microsoft.Shared.Diagnostics; @@ -22,8 +25,9 @@ internal static class AgentBotElementYaml /// /// YAML representation of the to use to create the prompt function. /// Optional instance which provides environment variables to the template. + /// Configuration keys that may be exposed when the YAML references them through Env. [RequiresDynamicCode("Calls YamlDotNet.Serialization.DeserializerBuilder.DeserializerBuilder()")] - public static GptComponentMetadata FromYaml(string text, IConfiguration? configuration = null) + public static GptComponentMetadata FromYaml(string text, IConfiguration? configuration = null, IEnumerable? allowedConfigurationVariables = null) { Throw.IfNullOrEmpty(text); @@ -35,7 +39,7 @@ public static GptComponentMetadata FromYaml(string text, IConfiguration? configu throw new InvalidDataException($"Unsupported root element: {rootElement.GetType().Name}. Expected an {nameof(GptComponentMetadata)}."); } - var botDefinition = WrapPromptAgentWithBot(promptAgent, configuration); + var botDefinition = WrapPromptAgentWithBot(promptAgent, configuration, allowedConfigurationVariables); return botDefinition.Descendants().OfType().First(); } @@ -52,7 +56,7 @@ private sealed class AgentFeatureConfiguration : IFeatureConfiguration public bool IsTenantFeatureEnabled(string featureName, bool defaultValue) => defaultValue; } - public static BotDefinition WrapPromptAgentWithBot(this GptComponentMetadata element, IConfiguration? configuration = null) + public static BotDefinition WrapPromptAgentWithBot(this GptComponentMetadata element, IConfiguration? configuration = null, IEnumerable? allowedConfigurationVariables = null) { var botBuilder = new BotDefinition.Builder @@ -67,19 +71,26 @@ public static BotDefinition WrapPromptAgentWithBot(this GptComponentMetadata ele } }; - if (configuration is not null) + if (configuration is not null && allowedConfigurationVariables is not null) { - foreach (var kvp in configuration.AsEnumerable().Where(kvp => kvp.Value is not null)) + HashSet allowedVariables = new(allowedConfigurationVariables, StringComparer.OrdinalIgnoreCase); + foreach (string variableName in GetReferencedEnvironmentVariableNames(element).Where(allowedVariables.Contains)) { + string? configurationValue = configuration[variableName]; + if (configurationValue is null) + { + continue; + } + botBuilder.EnvironmentVariables.Add(new EnvironmentVariableDefinition.Builder() { - SchemaName = kvp.Key, + SchemaName = variableName, Id = Guid.NewGuid(), - DisplayName = kvp.Key, + DisplayName = variableName, ValueComponent = new EnvironmentVariableValue.Builder() { Id = Guid.NewGuid(), - Value = kvp.Value!, + Value = configurationValue, }, }); } @@ -87,5 +98,13 @@ public static BotDefinition WrapPromptAgentWithBot(this GptComponentMetadata ele return botBuilder.Build(); } + + internal static ISet GetReferencedEnvironmentVariableNames(GptComponentMetadata element) + { + var botDefinition = WrapPromptAgentWithBot(element); + SemanticModel semanticModel = botDefinition.GetSemanticModel(new PowerFxExpressionChecker(new AgentFeatureConfiguration()), new AgentFeatureConfiguration()); + + return semanticModel.GetAllEnvironmentVariablesReferencedInTheBot(); + } #endregion } diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/ChatClient/ChatClientPromptAgentFactory.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/ChatClient/ChatClientPromptAgentFactory.cs index 28f0c47fbb6..69a598d2d34 100644 --- a/dotnet/src/Microsoft.Agents.AI.Declarative/ChatClient/ChatClientPromptAgentFactory.cs +++ b/dotnet/src/Microsoft.Agents.AI.Declarative/ChatClient/ChatClientPromptAgentFactory.cs @@ -20,36 +20,69 @@ public sealed class ChatClientPromptAgentFactory : PromptAgentFactory /// /// Creates a new instance of the class. /// - public ChatClientPromptAgentFactory(IChatClient chatClient, IList? functions = null, RecalcEngine? engine = null, IConfiguration? configuration = null, ILoggerFactory? loggerFactory = null) : base(engine, configuration) + /// The chat client used by created agents. + /// Optional functions exposed as tools to created agents. + /// Optional Power Fx engine used to evaluate declarative expressions. + /// Optional configuration used to resolve explicitly allowed environment variables referenced by the agent definition. + /// Optional logger factory used by created agents. + public ChatClientPromptAgentFactory( + IChatClient chatClient, + IList? functions = null, + RecalcEngine? engine = null, + IConfiguration? configuration = null, + ILoggerFactory? loggerFactory = null) + : this(chatClient, functions, engine, configuration, loggerFactory, null) + { + // BINARY COMPAT CONSTRUCTOR + } + /// + /// Creates a new instance of the class. + /// + /// The chat client used by created agents. + /// Optional functions exposed as tools to created agents. + /// Optional Power Fx engine used to evaluate declarative expressions. + /// Optional configuration used to resolve explicitly allowed environment variables referenced by the agent definition. + /// Optional logger factory used by created agents. + /// Optional explicitly allowed environment variables referenced by the agent definition. + public ChatClientPromptAgentFactory( + IChatClient chatClient, + IList? functions, + RecalcEngine? engine, + IConfiguration? configuration, + ILoggerFactory? loggerFactory, + IEnumerable? allowedConfigurationVariables) + : base(engine, configuration, allowedConfigurationVariables) { Throw.IfNull(chatClient); - this._chatClient = chatClient; this._functions = functions; this._loggerFactory = loggerFactory; } /// - public override Task TryCreateAsync(GptComponentMetadata promptAgent, CancellationToken cancellationToken = default) + public override async Task TryCreateAsync(GptComponentMetadata promptAgent, CancellationToken cancellationToken = default) { Throw.IfNull(promptAgent); + this.InitializeConfigurationVariables(promptAgent); + var options = new ChatClientAgentOptions() { Name = promptAgent.Name, Description = promptAgent.Description, - ChatOptions = promptAgent.GetChatOptions(this.Engine, this._functions), + ChatOptions = await promptAgent.GetChatOptionsAsync(this.Engine, this._functions, cancellationToken: cancellationToken).ConfigureAwait(false), }; var agent = new ChatClientAgent(this._chatClient, options, this._loggerFactory); Declarative.FeatureUsageMarker.MarkUsed(); - return Task.FromResult(agent); + return agent; } #region private private readonly IChatClient _chatClient; private readonly IList? _functions; private readonly ILoggerFactory? _loggerFactory; + #endregion } diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/BoolExpressionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/BoolExpressionExtensions.cs index 9b12ea19fdd..a114f28ddcd 100644 --- a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/BoolExpressionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/BoolExpressionExtensions.cs @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Threading; +using System.Threading.Tasks; using Microsoft.PowerFx; using Microsoft.PowerFx.Types; @@ -15,8 +17,9 @@ internal static class BoolExpressionExtensions /// /// Expression to evaluate. /// Recalc engine to use for evaluation. + /// Cancellation token to observe while evaluating the expression. /// The evaluated boolean value, or null if the expression is null or cannot be evaluated. - internal static bool? Eval(this BoolExpression? expression, RecalcEngine? engine) + internal static async Task EvalAsync(this BoolExpression? expression, RecalcEngine? engine, CancellationToken cancellationToken = default) { if (expression is null) { @@ -35,11 +38,11 @@ internal static class BoolExpressionExtensions if (expression.IsExpression) { - return engine.Eval(expression.ExpressionText!).AsBoolean(); + return (await engine.EvalAsync(expression.ExpressionText!, cancellationToken).ConfigureAwait(false)).AsBoolean(); } else if (expression.IsVariableReference) { - var formulaValue = engine.Eval(expression.VariableReference!.VariableName); + var formulaValue = await engine.EvalAsync(expression.VariableReference!.VariableName, cancellationToken).ConfigureAwait(false); if (formulaValue is BooleanValue booleanValue) { return booleanValue.Value; diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/IntExpressionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/IntExpressionExtensions.cs index dbc6ff4dda4..3d24cb19fbe 100644 --- a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/IntExpressionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/IntExpressionExtensions.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. using System.Globalization; +using System.Threading; +using System.Threading.Tasks; using Microsoft.PowerFx; using Microsoft.PowerFx.Types; @@ -16,8 +18,9 @@ internal static class IntExpressionExtensions /// /// Expression to evaluate. /// Recalc engine to use for evaluation. + /// Cancellation token to observe while evaluating the expression. /// The evaluated integer value, or null if the expression is null or cannot be evaluated. - internal static long? Eval(this IntExpression? expression, RecalcEngine? engine) + internal static async Task EvalAsync(this IntExpression? expression, RecalcEngine? engine, CancellationToken cancellationToken = default) { if (expression is null) { @@ -36,11 +39,11 @@ internal static class IntExpressionExtensions if (expression.IsExpression) { - return (long)engine.Eval(expression.ExpressionText!).AsDouble(); + return (long)(await engine.EvalAsync(expression.ExpressionText!, cancellationToken).ConfigureAwait(false)).AsDouble(); } else if (expression.IsVariableReference) { - var formulaValue = engine.Eval(expression.VariableReference!.VariableName); + var formulaValue = await engine.EvalAsync(expression.VariableReference!.VariableName, cancellationToken).ConfigureAwait(false); if (formulaValue is NumberValue numberValue) { return (long)numberValue.Value; diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/NumberExpressionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/NumberExpressionExtensions.cs index b4f59a015a1..aa6710025b8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/NumberExpressionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/NumberExpressionExtensions.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. using System.Globalization; +using System.Threading; +using System.Threading.Tasks; using Microsoft.PowerFx; using Microsoft.PowerFx.Types; @@ -16,8 +18,9 @@ internal static class NumberExpressionExtensions /// /// Expression to evaluate. /// Recalc engine to use for evaluation. + /// Cancellation token to observe while evaluating the expression. /// The evaluated number value, or null if the expression is null or cannot be evaluated. - internal static double? Eval(this NumberExpression? expression, RecalcEngine? engine) + internal static async Task EvalAsync(this NumberExpression? expression, RecalcEngine? engine, CancellationToken cancellationToken = default) { if (expression is null) { @@ -36,11 +39,11 @@ internal static class NumberExpressionExtensions if (expression.IsExpression) { - return engine.Eval(expression.ExpressionText!).AsDouble(); + return (await engine.EvalAsync(expression.ExpressionText!, cancellationToken).ConfigureAwait(false)).AsDouble(); } else if (expression.IsVariableReference) { - var formulaValue = engine.Eval(expression.VariableReference!.VariableName); + var formulaValue = await engine.EvalAsync(expression.VariableReference!.VariableName, cancellationToken).ConfigureAwait(false); if (formulaValue is NumberValue numberValue) { return numberValue.Value; diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/PromptAgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/PromptAgentExtensions.cs index 0da3f18f85a..4a9153c94cc 100644 --- a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/PromptAgentExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/PromptAgentExtensions.cs @@ -1,7 +1,10 @@ // Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; +using System.ComponentModel; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using Microsoft.Extensions.AI; using Microsoft.PowerFx; using Microsoft.Shared.Diagnostics; @@ -19,7 +22,20 @@ public static class PromptAgentExtensions /// Instance of /// Instance of /// Instance of + [Obsolete("Use GetChatOptionsAsync instead. This method calls into async methods and might cause deadlocks")] + [EditorBrowsable(EditorBrowsableState.Never)] public static ChatOptions? GetChatOptions(this GptComponentMetadata promptAgent, RecalcEngine? engine, IList? functions) +#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits + => promptAgent.GetChatOptionsAsync(engine, functions).GetAwaiter().GetResult(); +#pragma warning restore VSTHRD002 // Avoid problematic synchronous waits + /// + /// Retrieves the 'options' property from a as a instance. + /// + /// Instance of + /// Instance of + /// Instance of + /// Cancellation token to observe while retrieving chat options. + public static async Task GetChatOptionsAsync(this GptComponentMetadata promptAgent, RecalcEngine? engine, IList? functions, CancellationToken cancellationToken = default) { Throw.IfNull(promptAgent); @@ -36,17 +52,17 @@ public static class PromptAgentExtensions return new ChatOptions() { Instructions = promptAgent.Instructions?.ToTemplateString(), - Temperature = (float?)modelOptions?.Temperature?.Eval(engine), - MaxOutputTokens = (int?)modelOptions?.MaxOutputTokens?.Eval(engine), - TopP = (float?)modelOptions?.TopP?.Eval(engine), - TopK = (int?)modelOptions?.TopK?.Eval(engine), - FrequencyPenalty = (float?)modelOptions?.FrequencyPenalty?.Eval(engine), - PresencePenalty = (float?)modelOptions?.PresencePenalty?.Eval(engine), - Seed = modelOptions?.Seed?.Eval(engine), + Temperature = modelOptions?.Temperature is { } temperature ? (float?)await temperature.EvalAsync(engine, cancellationToken: cancellationToken).ConfigureAwait(false) : default, + MaxOutputTokens = modelOptions?.MaxOutputTokens is { } maxOutputTokens ? (int?)await maxOutputTokens.EvalAsync(engine, cancellationToken: cancellationToken).ConfigureAwait(false) : default, + TopP = modelOptions?.TopP is { } topP ? (float?)await topP.EvalAsync(engine, cancellationToken: cancellationToken).ConfigureAwait(false) : default, + TopK = modelOptions?.TopK is { } topK ? (int?)await topK.EvalAsync(engine, cancellationToken: cancellationToken).ConfigureAwait(false) : default, + FrequencyPenalty = modelOptions?.FrequencyPenalty is { } frequencyPenalty ? (float?)await frequencyPenalty.EvalAsync(engine, cancellationToken: cancellationToken).ConfigureAwait(false) : default, + PresencePenalty = modelOptions?.PresencePenalty is { } presencePenalty ? (float?)await presencePenalty.EvalAsync(engine, cancellationToken: cancellationToken).ConfigureAwait(false) : default, + Seed = modelOptions?.Seed is { } seed ? (int?)await seed.EvalAsync(engine, cancellationToken: cancellationToken).ConfigureAwait(false) : default, ResponseFormat = outputSchema?.AsChatResponseFormat(), ModelId = promptAgent.Model?.ModelNameHint, StopSequences = modelOptions?.StopSequences, - AllowMultipleToolCalls = modelOptions?.AllowMultipleToolCalls?.Eval(engine), + AllowMultipleToolCalls = modelOptions?.AllowMultipleToolCalls is { } allowMultipleToolCalls ? await allowMultipleToolCalls.EvalAsync(engine, cancellationToken: cancellationToken).ConfigureAwait(false) : default, ToolMode = modelOptions?.AsChatToolMode(), Tools = tools, AdditionalProperties = modelOptions?.GetAdditionalProperties(s_chatOptionProperties), diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/StringExpressionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/StringExpressionExtensions.cs index 2a9b42e0873..7a01b66a14e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/StringExpressionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/StringExpressionExtensions.cs @@ -1,5 +1,9 @@ // Copyright (c) Microsoft. All rights reserved. +using System; +using System.ComponentModel; +using System.Threading; +using System.Threading.Tasks; using Microsoft.PowerFx; using Microsoft.PowerFx.Types; @@ -16,7 +20,20 @@ public static class StringExpressionExtensions /// Expression to evaluate. /// Recalc engine to use for evaluation. /// The evaluated string value, or null if the expression is null or cannot be evaluated. + [Obsolete("Use EvalAsync instead. This method calls into async methods and might cause deadlocks")] + [EditorBrowsable(EditorBrowsableState.Never)] public static string? Eval(this StringExpression? expression, RecalcEngine? engine) +#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits + => EvalAsync(expression, engine).GetAwaiter().GetResult(); +#pragma warning restore VSTHRD002 // Avoid problematic synchronous waits + /// + /// Evaluates the given using the provided . + /// + /// Expression to evaluate. + /// Recalc engine to use for evaluation. + /// Cancellation token to use for the asynchronous operation. + /// The evaluated string value, or null if the expression is null or cannot be evaluated. + public static async Task EvalAsync(this StringExpression? expression, RecalcEngine? engine, CancellationToken cancellationToken = default) { if (expression is null) { @@ -35,11 +52,11 @@ public static class StringExpressionExtensions if (expression.IsExpression) { - return engine.Eval(expression.ExpressionText!).ToString(); + return (await engine.EvalAsync(expression.ExpressionText!, cancellationToken: cancellationToken).ConfigureAwait(false)).ToString(); } else if (expression.IsVariableReference) { - var stringValue = engine.Eval(expression.VariableReference!.VariableName) as StringValue; + var stringValue = await engine.EvalAsync(expression.VariableReference!.VariableName, cancellationToken: cancellationToken).ConfigureAwait(false) as StringValue; return stringValue?.Value; } diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/YamlAgentFactoryExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/YamlAgentFactoryExtensions.cs index 1cc24055d90..b7a0e2da26a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/YamlAgentFactoryExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/YamlAgentFactoryExtensions.cs @@ -24,7 +24,7 @@ public static Task CreateFromYamlAsync(this PromptAgentFactory agentFac Throw.IfNull(agentFactory); Throw.IfNullOrEmpty(agentYaml); - var agentDefinition = AgentBotElementYaml.FromYaml(agentYaml); + var agentDefinition = agentFactory.FromYaml(agentYaml); return agentFactory.CreateAsync( agentDefinition, diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/PromptAgentFactory.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/PromptAgentFactory.cs index 22d55178ba0..f9ea1f2deda 100644 --- a/dotnet/src/Microsoft.Agents.AI.Declarative/PromptAgentFactory.cs +++ b/dotnet/src/Microsoft.Agents.AI.Declarative/PromptAgentFactory.cs @@ -1,6 +1,9 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.ObjectModel; @@ -15,30 +18,85 @@ namespace Microsoft.Agents.AI; /// public abstract class PromptAgentFactory { + private const int DefaultMaximumExpressionLength = 10000; + + private readonly IConfiguration? _configuration; + private readonly HashSet _allowedConfigurationVariables; + /// /// Initializes a new instance of the class. /// /// Optional , if none is provided a default instance will be created. - /// Optional configuration to be added as variables to the . - protected PromptAgentFactory(RecalcEngine? engine = null, IConfiguration? configuration = null) + /// Optional configuration used to resolve explicitly allowed environment variables referenced by the agent definition. + protected PromptAgentFactory(RecalcEngine? engine = null, + IConfiguration? configuration = null) : this(engine, configuration, null, null, null) { - this.Engine = engine ?? new RecalcEngine(); + // BINARY COMPAT CONSTRUCTOR + } + + /// + /// Initializes a new instance of the class. + /// + /// Optional , if none is provided a default instance will be created. + /// Optional configuration used to resolve explicitly allowed environment variables referenced by the agent definition. + /// Configuration keys that may be exposed to Power Fx when the agent definition references them through Env. + /// Optional maximum length for Power Fx expressions evaluated by the factory-created engine. + /// Optional maximum nested call depth for Power Fx expressions evaluated by the factory-created engine. + protected PromptAgentFactory(RecalcEngine? engine, + IConfiguration? configuration, + IEnumerable? allowedConfigurationVariables, + int? maximumExpressionLength = null, + int? maximumCallDepth = null) + { + this.Engine = engine ?? new RecalcEngine(CreateConfig(maximumExpressionLength, maximumCallDepth)); + this._configuration = configuration; + this._allowedConfigurationVariables = new( + allowedConfigurationVariables ?? [], + StringComparer.OrdinalIgnoreCase); + } + + private static PowerFxConfig CreateConfig(int? maximumExpressionLength, int? maximumCallDepth) + { + PowerFxConfig config = new(Features.PowerFxV1) + { + MaximumExpressionLength = maximumExpressionLength ?? DefaultMaximumExpressionLength, + }; - if (configuration is not null) + if (maximumCallDepth is not null) { - foreach (var kvp in configuration.AsEnumerable()) - { - this.Engine.UpdateVariable(kvp.Key, kvp.Value ?? string.Empty); - } + config.MaxCallDepth = maximumCallDepth.Value; } + + return config; } /// /// Gets the Power Fx recalculation engine used to evaluate expressions in agent definitions. - /// This engine is configured with variables from the provided during construction. + /// This engine is configured with only explicitly allowed variables from the provided during construction. /// protected RecalcEngine Engine { get; } + /// + /// Adds allowed configuration values referenced through Env by the agent definition to the Power Fx engine. + /// + /// Definition of the agent to inspect. + protected void InitializeConfigurationVariables(GptComponentMetadata promptAgent) + { + if (this._configuration is null || this._allowedConfigurationVariables.Count == 0) + { + return; + } + + foreach (string variableName in AgentBotElementYaml.GetReferencedEnvironmentVariableNames(promptAgent).Where(this._allowedConfigurationVariables.Contains)) + { + this.Engine.UpdateVariable(variableName, this._configuration[variableName] ?? string.Empty); + } + } + + [RequiresDynamicCode("Calls YamlDotNet.Serialization.DeserializerBuilder.DeserializerBuilder()")] + internal GptComponentMetadata FromYaml(string text) => + AgentBotElementYaml.FromYaml(text, this._configuration, this._allowedConfigurationVariables); + /// /// Create a from the specified . /// @@ -49,6 +107,7 @@ public async Task CreateAsync(GptComponentMetadata promptAgent, Cancell { Throw.IfNull(promptAgent); + this.InitializeConfigurationVariables(promptAgent); var agent = await this.TryCreateAsync(promptAgent, cancellationToken).ConfigureAwait(false) ?? throw new NotSupportedException($"Agent type {promptAgent.Kind} is not supported."); Declarative.FeatureUsageMarker.MarkUsed(); return agent; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowBuilder.cs index 054aa38b237..e12370a53ca 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowBuilder.cs @@ -73,7 +73,11 @@ public static Workflow Build( string rootId = WorkflowActionVisitor.Steps.Root(workflowElement); WorkflowFormulaState state = new(options.CreateRecalcEngine()); - state.Initialize(workflowElement.WrapWithBot(), options.Configuration); + state.Initialize( + workflowElement.WrapWithBot(), + options.Configuration, + options.AllowedEnvironmentVariables, + options.AllowProcessEnvironmentVariableFallback); state.CaptureInitialState(); DeclarativeWorkflowExecutor rootExecutor = new(rootId, diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs index 90439402dbd..b5b05b910d3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Generic; using System.Diagnostics; using Microsoft.Agents.AI.Workflows.Observability; using Microsoft.Extensions.Configuration; @@ -37,6 +38,16 @@ public sealed class DeclarativeWorkflowOptions(ResponseAgentProvider agentProvid /// public IConfiguration? Configuration { get; init; } + /// + /// Gets the configuration or process environment variable names that may be exposed through the workflow Env scope. + /// + public IEnumerable? AllowedEnvironmentVariables { get; init; } + + /// + /// Gets a value indicating whether the workflow may fall back to process environment variables for allowed Env names missing from . + /// + public bool AllowProcessEnvironmentVariableFallback { get; init; } + /// /// Optionally identifies a continued workflow conversation. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs index 1b92235eee3..f7b2bfce695 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs @@ -38,17 +38,38 @@ public static ValueTask QueueStateResetAsync(this IWorkflowContext context, Prop public static ValueTask QueueStateUpdateAsync(this IWorkflowContext context, PropertyPath variablePath, TValue? value, CancellationToken cancellationToken = default) => context.QueueStateUpdateAsync(Throw.IfNull(variablePath.VariableName), value, Throw.IfNull(variablePath.NamespaceAlias), cancellationToken); + public static ValueTask QueueStateUpdateAsync( + this IWorkflowContext context, + PropertyPath variablePath, + TValue? value, + SensitivityLevel sensitivity, + CancellationToken cancellationToken = default) + { + string variableName = Throw.IfNull(variablePath.VariableName); + string namespaceAlias = Throw.IfNull(variablePath.NamespaceAlias); + + return context is DeclarativeWorkflowContext declarativeContext + ? declarativeContext.QueueStateUpdateAsync(variableName, value, namespaceAlias, sensitivity, cancellationToken) + : context.QueueStateUpdateAsync(variableName, value, namespaceAlias, cancellationToken); + } + public static async ValueTask QueueEnvironmentUpdateAsync(this IWorkflowContext context, string key, TValue? value, CancellationToken cancellationToken = default) { DeclarativeWorkflowContext declarativeContext = DeclarativeContext(context); - await declarativeContext.UpdateStateAsync(key, value, VariableScopeNames.Environment, allowSystem: true, cancellationToken).ConfigureAwait(false); + await declarativeContext.UpdateStateAsync( + key, + value, + VariableScopeNames.Environment, + allowSystem: true, + sensitivity: SensitivityLevel.Sensitive, + cancellationToken: cancellationToken).ConfigureAwait(false); declarativeContext.State.Bind(); } public static async ValueTask QueueSystemUpdateAsync(this IWorkflowContext context, string key, TValue? value, CancellationToken cancellationToken = default) { DeclarativeWorkflowContext declarativeContext = DeclarativeContext(context); - await declarativeContext.UpdateStateAsync(key, value, VariableScopeNames.System, allowSystem: true, cancellationToken).ConfigureAwait(false); + await declarativeContext.UpdateStateAsync(key, value, VariableScopeNames.System, allowSystem: true, cancellationToken: cancellationToken).ConfigureAwait(false); declarativeContext.State.Bind(); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs index 5d052c64d3d..597ea831296 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs @@ -24,8 +24,6 @@ internal abstract class DeclarativeActionExecutor(TAction model, Workfl internal abstract class DeclarativeActionExecutor : Executor, IResettableExecutor, IModeledAction { - private readonly WorkflowFormulaState _state; - protected DeclarativeActionExecutor(DialogAction model, WorkflowFormulaState state) : base(model.Id.Value) { @@ -34,7 +32,7 @@ protected DeclarativeActionExecutor(DialogAction model, WorkflowFormulaState sta throw new DeclarativeModelException($"Missing required properties for element: {model.GetId()} ({model.GetType().Name})."); } - this._state = state; + this.State = state; this.Model = model; } @@ -51,9 +49,11 @@ protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBui public string ParentId { get => field ??= this.Model.GetParentId() ?? WorkflowActionVisitor.Steps.Root(); } - public RecalcEngine Engine => this._state.Engine; + public RecalcEngine Engine => this.State.Engine; + + public WorkflowExpressionEngine Evaluator => this.State.Evaluator; - public WorkflowExpressionEngine Evaluator => this._state.Evaluator; + protected WorkflowFormulaState State { get; } internal ILogger Logger { get; set; } = NullLogger.Instance; @@ -64,7 +64,7 @@ protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBui /// public virtual ValueTask ResetAsync() { - this._state.Reset(); + this.State.Reset(); return default; } @@ -89,7 +89,7 @@ public override async ValueTask HandleAsync(ActionExecutorResult message, IWorkf try { - object? result = await this.ExecuteAsync(new DeclarativeWorkflowContext(context, this._state), cancellationToken).ConfigureAwait(false); + object? result = await this.ExecuteAsync(new DeclarativeWorkflowContext(context, this.State), cancellationToken).ConfigureAwait(false); Debug.WriteLine($"RESULT #{this.Id} - {result ?? "(null)"}"); if (this.EmitResultEvent) @@ -123,19 +123,21 @@ public override async ValueTask HandleAsync(ActionExecutorResult message, IWorkf /// This must be overridden to restore any state that was saved during checkpointing. /// protected override ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default) => - this._state.RestoreAsync(context, cancellationToken); + this.State.RestoreAsync(context, cancellationToken); - protected async ValueTask AssignAsync(PropertyPath? targetPath, FormulaValue result, IWorkflowContext context) + protected async ValueTask AssignAsync(PropertyPath? targetPath, FormulaValue result, IWorkflowContext context, SensitivityLevel sensitivity = SensitivityLevel.None) { if (targetPath is null) { return; } - await context.QueueStateUpdateAsync(targetPath, result).ConfigureAwait(false); + await context.QueueStateUpdateAsync(targetPath, result, sensitivity).ConfigureAwait(false); + string variableName = targetPath.VariableName ?? throw new DeclarativeActionException($"Invalid variable reference: '{targetPath}'."); + this.State.SetSensitivity(variableName, targetPath.NamespaceAlias, sensitivity); #if DEBUG - string? resultValue = result.Format(); + string? resultValue = sensitivity == SensitivityLevel.Sensitive ? "" : result.Format(); string valuePosition = (resultValue?.IndexOf('\n') ?? -1) >= 0 ? Environment.NewLine : " "; Debug.WriteLine( $""" diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowContext.cs index 6616aa5d00c..183b2e785e7 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowContext.cs @@ -58,7 +58,7 @@ public async ValueTask QueueClearScopeAsync(string? scopeName = null, Cancellati // Copy keys to array to avoid modifying collection during enumeration. foreach (string key in this.State.Keys(scopeName).ToArray()) { - await this.UpdateStateAsync(key, UnassignedValue.Instance, scopeName, allowSystem: false, cancellationToken).ConfigureAwait(false); + await this.UpdateStateAsync(key, UnassignedValue.Instance, scopeName, allowSystem: false, cancellationToken: cancellationToken).ConfigureAwait(false); } } else @@ -73,7 +73,18 @@ public async ValueTask QueueClearScopeAsync(string? scopeName = null, Cancellati /// public async ValueTask QueueStateUpdateAsync(string key, T? value, string? scopeName = null, CancellationToken cancellationToken = default) { - await this.UpdateStateAsync(key, value, scopeName, allowSystem: false, cancellationToken).ConfigureAwait(false); + await this.UpdateStateAsync(key, value, scopeName, allowSystem: false, cancellationToken: cancellationToken).ConfigureAwait(false); + this.State.Bind(); + } + + internal async ValueTask QueueStateUpdateAsync( + string key, + T? value, + string? scopeName, + SensitivityLevel sensitivity, + CancellationToken cancellationToken = default) + { + await this.UpdateStateAsync(key, value, scopeName, allowSystem: false, sensitivity: sensitivity, cancellationToken: cancellationToken).ConfigureAwait(false); this.State.Bind(); } @@ -137,7 +148,13 @@ public ValueTask> ReadStateKeysAsync(string? scopeName = null, C public ValueTask SendMessageAsync(object message, string? targetId = null, CancellationToken cancellationToken = default) => this.Source.SendMessageAsync(message, targetId, cancellationToken); - public ValueTask UpdateStateAsync(string key, T? value, string? scopeName, bool allowSystem, CancellationToken cancellationToken = default) + public ValueTask UpdateStateAsync( + string key, + T? value, + string? scopeName, + bool allowSystem, + SensitivityLevel sensitivity = SensitivityLevel.None, + CancellationToken cancellationToken = default) { bool isManagedScope = scopeName is not null && // null scope cannot be managed @@ -165,47 +182,61 @@ scopeName is not null && // null scope cannot be managed _ => QueueNativeStateAsync(value), }; - ValueTask QueueEmptyStateAsync() + async ValueTask QueueEmptyStateAsync() { if (isManagedScope) { - this.State.Set(key, FormulaValue.NewBlank(), scopeName); + this.State.Set(key, FormulaValue.NewBlank(), scopeName, sensitivity); } - return this.Source.QueueStateUpdateAsync(key, UnassignedValue.Instance, scopeName, cancellationToken); + await this.Source.QueueStateUpdateAsync(key, UnassignedValue.Instance, scopeName, cancellationToken).ConfigureAwait(false); + await this.QueueSensitivityUpdateAsync(key, scopeName, sensitivity, cancellationToken).ConfigureAwait(false); } - ValueTask QueueFormulaStateAsync(FormulaValue formulaValue) + async ValueTask QueueFormulaStateAsync(FormulaValue formulaValue) { if (isManagedScope) { - this.State.Set(key, formulaValue, scopeName); + this.State.Set(key, formulaValue, scopeName, sensitivity); } - return this.Source.QueueStateUpdateAsync(key, formulaValue.AsPortable(), scopeName, cancellationToken); + await this.Source.QueueStateUpdateAsync(key, formulaValue.AsPortable(), scopeName, cancellationToken).ConfigureAwait(false); + await this.QueueSensitivityUpdateAsync(key, scopeName, sensitivity, cancellationToken).ConfigureAwait(false); } - ValueTask QueueDataValueStateAsync(DataValue dataValue) + async ValueTask QueueDataValueStateAsync(DataValue dataValue) { FormulaValue formulaValue = dataValue.ToFormula(); if (isManagedScope) { - this.State.Set(key, formulaValue, scopeName); + this.State.Set(key, formulaValue, scopeName, sensitivity); } - return this.Source.QueueStateUpdateAsync(key, formulaValue.AsPortable(), scopeName, cancellationToken); + await this.Source.QueueStateUpdateAsync(key, formulaValue.AsPortable(), scopeName, cancellationToken).ConfigureAwait(false); + await this.QueueSensitivityUpdateAsync(key, scopeName, sensitivity, cancellationToken).ConfigureAwait(false); } - ValueTask QueueNativeStateAsync(object rawValue) + async ValueTask QueueNativeStateAsync(object rawValue) { FormulaValue formulaValue = rawValue.ToFormula(); if (isManagedScope) { - this.State.Set(key, formulaValue, scopeName); + this.State.Set(key, formulaValue, scopeName, sensitivity); } - return this.Source.QueueStateUpdateAsync(key, formulaValue.AsPortable(), scopeName, cancellationToken); + await this.Source.QueueStateUpdateAsync(key, formulaValue.AsPortable(), scopeName, cancellationToken).ConfigureAwait(false); + await this.QueueSensitivityUpdateAsync(key, scopeName, sensitivity, cancellationToken).ConfigureAwait(false); + } + } + + private ValueTask QueueSensitivityUpdateAsync(string key, string? scopeName, SensitivityLevel sensitivity, CancellationToken cancellationToken) + { + if (scopeName is null || (!ManagedScopes.Contains(scopeName) && scopeName != VariableScopeNames.Environment)) + { + return default; } + + return this.Source.QueueStateUpdateAsync(key, sensitivity, WorkflowFormulaState.GetSensitivityScopeName(scopeName), cancellationToken); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/IWorkflowContextExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/IWorkflowContextExtensions.cs index 69cca12fafd..3217ea774af 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/IWorkflowContextExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/IWorkflowContextExtensions.cs @@ -36,6 +36,27 @@ public static class IWorkflowContextExtensions public static ValueTask FormatTemplateAsync(this IWorkflowContext context, string line, CancellationToken cancellationToken = default) => context.FormatTemplateAsync([line], cancellationToken); + /// + /// Formats a template line using the workflow's declarative state + /// and evaluating any embedded expressions (e.g., Power Fx) contained within the line. + /// + /// The workflow execution context used to restore persisted state prior to formatting. + /// The template line to format. + /// The formatted line and its sensitivity metadata. + public static ValueTask> FormatTemplateWithSensitivityAsync(this IWorkflowContext context, string line) => + context.FormatTemplateWithSensitivityAsync(line, default); + + /// + /// Formats a template line using the workflow's declarative state + /// and evaluating any embedded expressions (e.g., Power Fx) contained within the line. + /// + /// The workflow execution context used to restore persisted state prior to formatting. + /// The template line to format. + /// A token that propagates notification when operation should be canceled. + /// The formatted line and its sensitivity metadata. + public static ValueTask> FormatTemplateWithSensitivityAsync(this IWorkflowContext context, string line, CancellationToken cancellationToken) => + context.FormatTemplateWithSensitivityAsync([line], cancellationToken); + /// /// Formats a template lines using the workflow's declarative state /// and evaluating any embedded expressions (e.g., Power Fx) contained within each line. @@ -52,16 +73,44 @@ public static ValueTask FormatTemplateAsync(this IWorkflowContext contex /// var text = await context.FormatAsync("Hello @{User.Name}", "Count: @{Metrics.Count}"); /// public static async ValueTask FormatTemplateAsync(this IWorkflowContext context, IEnumerable lines, CancellationToken cancellationToken = default) + { + EvaluationResult result = await context.FormatTemplateWithSensitivityAsync(lines, cancellationToken).ConfigureAwait(false); + ThrowIfSensitive(result.Sensitivity); + return result.Value; + } + + /// + /// Formats a template lines using the workflow's declarative state + /// and evaluating any embedded expressions (e.g., Power Fx) contained within each line. + /// + /// The workflow execution context used to restore persisted state prior to formatting. + /// The template lines to format. + /// The formatted lines and their sensitivity metadata. + public static ValueTask> FormatTemplateWithSensitivityAsync(this IWorkflowContext context, IEnumerable lines) => + context.FormatTemplateWithSensitivityAsync(lines, default); + + /// + /// Formats a template lines using the workflow's declarative state + /// and evaluating any embedded expressions (e.g., Power Fx) contained within each line. + /// + /// The workflow execution context used to restore persisted state prior to formatting. + /// The template lines to format. + /// A token that propagates notification when operation should be canceled. + /// The formatted lines and their sensitivity metadata. + public static async ValueTask> FormatTemplateWithSensitivityAsync(this IWorkflowContext context, IEnumerable lines, CancellationToken cancellationToken) { WorkflowFormulaState state = await context.GetStateAsync(cancellationToken).ConfigureAwait(false); StringBuilder builder = new(); + SensitivityLevel sensitivity = SensitivityLevel.None; foreach (string line in lines) { - builder.AppendLine(state.Engine.Format(TemplateLine.Parse(line))); + EvaluationResult result = state.Evaluator.Format(TemplateLine.Parse(line)); + sensitivity = MaxSensitivity(sensitivity, result.Sensitivity); + builder.AppendLine(result.Value); } - return builder.ToString(); + return new(builder.ToString(), sensitivity); } /// @@ -82,12 +131,27 @@ public static async ValueTask FormatTemplateAsync(this IWorkflowContext /// A token that propagates notification when operation should be canceled. /// The evaluated expression value public static async ValueTask EvaluateValueAsync(this IWorkflowContext context, string expression, CancellationToken cancellationToken = default) + { + EvaluationResult result = await context.EvaluateValueWithSensitivityAsync(expression, cancellationToken).ConfigureAwait(false); + ThrowIfSensitive(result.Sensitivity); + return result.Value; + } + + /// + /// Evaluate an expression using the workflow's declarative state. + /// + /// The type of the evaluated value. + /// The workflow execution context used to restore persisted state prior to formatting. + /// The expression to evaluate. + /// A token that propagates notification when operation should be canceled. + /// The evaluated expression value and its sensitivity metadata. + public static async ValueTask> EvaluateValueWithSensitivityAsync(this IWorkflowContext context, string expression, CancellationToken cancellationToken = default) { WorkflowFormulaState state = await context.GetStateAsync(cancellationToken).ConfigureAwait(false); EvaluationResult result = state.Evaluator.GetValue(ValueExpression.Expression(expression)); - return (TValue?)result.Value.ToObject(); + return new((TValue?)result.Value.ToObject(), result.Sensitivity); } /// @@ -103,10 +167,74 @@ public static async ValueTask FormatTemplateAsync(this IWorkflowContext WorkflowFormulaState state = await context.GetStateAsync(cancellationToken).ConfigureAwait(false); EvaluationResult result = state.Evaluator.GetValue(ValueExpression.Expression(expression)); + ThrowIfSensitive(result.Sensitivity); return result.Value.AsList(); } + /// + /// Reads a state value together with its sensitivity metadata. + /// + /// The type of the state value. + /// The workflow execution context used to read state. + /// The key of the state value. + /// An optional name that specifies the scope to read. If null, the default scope is used. + /// A token that propagates notification when operation should be canceled. + /// The state value and its sensitivity metadata. + public static async ValueTask> ReadStateWithSensitivityAsync( + this IWorkflowContext context, + string key, + string? scopeName = null, + CancellationToken cancellationToken = default) + { + if (context is DeclarativeWorkflowContext declarativeContext) + { + string effectiveScopeName = scopeName ?? WorkflowFormulaState.DefaultScopeName; + TValue? declarativeValue = await context.ReadStateAsync(key, effectiveScopeName, cancellationToken).ConfigureAwait(false); + SensitivityLevel declarativeSensitivity = declarativeContext.State.GetSensitivity(key, effectiveScopeName); + return new(declarativeValue, declarativeSensitivity); + } + + string plainScopeName = scopeName ?? WorkflowFormulaState.DefaultScopeName; + TValue? value = await context.ReadStateAsync(key, plainScopeName, cancellationToken).ConfigureAwait(false); + SensitivityLevel sensitivity = ShouldPersistSensitivity(plainScopeName) + ? await context.ReadStateAsync(key, WorkflowFormulaState.GetSensitivityScopeName(plainScopeName), cancellationToken).ConfigureAwait(false) + : SensitivityLevel.None; + return new(value, sensitivity); + } + + /// + /// Queues a state update using sensitivity metadata carried with the value. + /// + /// The type of the state value. + /// The workflow execution context used to queue state updates. + /// The key of the state value. + /// The value and sensitivity metadata to store. + /// An optional name that specifies the scope to update. If null, the default scope is used. + /// A token that propagates notification when operation should be canceled. + /// A task representing the queued state update. + public static async ValueTask QueueStateUpdateWithSensitivityAsync( + this IWorkflowContext context, + string key, + EvaluationResult value, + string? scopeName = null, + CancellationToken cancellationToken = default) + { + if (context is DeclarativeWorkflowContext declarativeContext) + { + string effectiveScopeName = scopeName ?? WorkflowFormulaState.DefaultScopeName; + await declarativeContext.QueueStateUpdateAsync(key, value.Value, effectiveScopeName, value.Sensitivity, cancellationToken).ConfigureAwait(false); + return; + } + + string plainScopeName = scopeName ?? WorkflowFormulaState.DefaultScopeName; + await context.QueueStateUpdateAsync(key, value.Value, plainScopeName, cancellationToken).ConfigureAwait(false); + if (ShouldPersistSensitivity(plainScopeName)) + { + await context.QueueStateUpdateAsync(key, value.Sensitivity, WorkflowFormulaState.GetSensitivityScopeName(plainScopeName), cancellationToken).ConfigureAwait(false); + } + } + /// /// Convert the result of an expression to the specified target type. /// @@ -127,7 +255,7 @@ public static async ValueTask FormatTemplateAsync(this IWorkflowContext /// The workflow execution context used to restore persisted state prior to formatting. /// Describes the target type for the value conversion. /// The key of the state value. - /// An optional name that specifies the scope to read.If null, the default scope is used. + /// An optional name that specifies the scope to read. If null, the default scope is used. /// A token that propagates notification when operation should be canceled. /// The converted value public static async ValueTask ConvertValueAsync(this IWorkflowContext context, VariableType targetType, string key, string? scopeName = null, CancellationToken cancellationToken = default) @@ -136,13 +264,34 @@ public static async ValueTask FormatTemplateAsync(this IWorkflowContext return sourceValue.ConvertType(targetType); } + /// + /// Convert the variable value to the specified target type while preserving sensitivity metadata. + /// + /// The workflow execution context used to restore persisted state prior to formatting. + /// Describes the target type for the value conversion. + /// The key of the state value. + /// An optional name that specifies the scope to read. If null, the default scope is used. + /// A token that propagates notification when operation should be canceled. + /// The converted value and its sensitivity metadata. + public static async ValueTask> ConvertValueWithSensitivityAsync( + this IWorkflowContext context, + VariableType targetType, + string key, + string? scopeName = null, + CancellationToken cancellationToken = default) + { + EvaluationResult sourceValue = await context.ReadStateWithSensitivityAsync(key, scopeName, cancellationToken).ConfigureAwait(false); + object? convertedValue = sourceValue.Value is null ? null : sourceValue.Value.ToFormula().ToObject().ConvertType(targetType); + return new(convertedValue, sourceValue.Sensitivity); + } + /// /// Evaluate an expression using the workflow's declarative state. /// /// The type of the list element. /// The workflow execution context used to restore persisted state prior to formatting. /// The key of the state value. - /// An optional name that specifies the scope to read.If null, the default scope is used. + /// An optional name that specifies the scope to read. If null, the default scope is used. /// A token that propagates notification when operation should be canceled. /// The evaluated list expression public static async ValueTask?> ReadListAsync(this IWorkflowContext context, string key, string? scopeName = null, CancellationToken cancellationToken = default) @@ -164,4 +313,20 @@ private static async Task GetStateAsync(this IWorkflowCont return state; } + + private static void ThrowIfSensitive(SensitivityLevel sensitivity) + { + if (sensitivity == SensitivityLevel.Sensitive) + { + throw new DeclarativeActionException("Cannot return sensitive workflow expression value."); + } + } + + private static SensitivityLevel MaxSensitivity(SensitivityLevel left, SensitivityLevel right) => + left == SensitivityLevel.Sensitive || right == SensitivityLevel.Sensitive ? SensitivityLevel.Sensitive : SensitivityLevel.None; + + private static bool ShouldPersistSensitivity(string scopeName) => + DeclarativeWorkflowContext.ManagedScopes.Contains(scopeName) || + scopeName == VariableScopeNames.Environment || + scopeName == VariableScopeNames.System; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/RootExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/RootExecutor.cs index 80f6e69b60d..3a18a06c948 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/RootExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/RootExecutor.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Frozen; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Declarative.Extensions; @@ -21,6 +23,8 @@ public abstract class RootExecutor : Executor, IResettableExecut private readonly ResponseAgentProvider _agentProvider; private readonly WorkflowFormulaState _state; private readonly Func? _inputTransform; + private readonly bool _allowProcessEnvironmentVariableFallback; + private readonly FrozenSet _allowedEnvironmentVariables; private string? _conversationId; @@ -42,6 +46,8 @@ protected RootExecutor(string id, DeclarativeWorkflowOptions options, Func - /// Initializes the specified variables from if available; - /// otherwise falls back to the process environment variables. + /// Initializes the specified variables from if available. + /// Only names included in are initialized. + /// Process environment variables are used only when enabled by . /// /// The workflow execution context providing messaging and state services. /// The set of variable names to initialize. /// A representing the asynchronous execution operation. protected async ValueTask InitializeEnvironmentAsync(IWorkflowContext context, params string[] variableNames) { - foreach (string variableName in variableNames) + foreach (string variableName in variableNames.Where(this._allowedEnvironmentVariables.Contains)) { await context.QueueEnvironmentUpdateAsync(variableName, GetEnvironmentVariable(variableName)).ConfigureAwait(false); } string GetEnvironmentVariable(string name) { - if (this._configuration is not null) - { - return this._configuration[name] ?? string.Empty; - } - - return Environment.GetEnvironmentVariable(name) ?? string.Empty; + string? configurationValue = this._configuration?[name]; + return configurationValue ?? (this._allowProcessEnvironmentVariableFallback ? Environment.GetEnvironmentVariable(name) ?? string.Empty : string.Empty); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/AddConversationMessageExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/AddConversationMessageExecutor.cs index 21c14de546f..16740053de6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/AddConversationMessageExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/AddConversationMessageExecutor.cs @@ -7,6 +7,7 @@ using Microsoft.Agents.AI.Workflows.Declarative.Interpreter; using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; using Microsoft.Agents.ObjectModel; +using Microsoft.Agents.ObjectModel.Abstractions; using Microsoft.Extensions.AI; using Microsoft.Shared.Diagnostics; @@ -42,7 +43,13 @@ private IEnumerable GetContent() { foreach (AddConversationMessageContent content in this.Model.Content) { - AIContent? messageContent = content.Type.Value.ToContent(this.Engine.Format(content.Value), content.MediaType); + EvaluationResult contentResult = this.Evaluator.Format(content.Value); + if (contentResult.Sensitivity == SensitivityLevel.Sensitive) + { + throw new DeclarativeActionException($"Cannot send sensitive conversation message content: {this.Id}."); + } + + AIContent? messageContent = content.Type.Value.ToContent(contentResult.Value, content.MediaType); if (messageContent is not null) { yield return messageContent; @@ -57,8 +64,12 @@ private IEnumerable GetContent() return null; } - RecordDataValue? metadataValue = this.Evaluator.GetValue(this.Model.Metadata).Value; + EvaluationResult metadataResult = this.Evaluator.GetValue(this.Model.Metadata); + if (metadataResult.Sensitivity == SensitivityLevel.Sensitive) + { + throw new DeclarativeActionException($"Cannot send sensitive conversation message metadata: {this.Id}."); + } - return metadataValue.ToMetadata(); + return metadataResult.Value.ToMetadata(); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/CopyConversationMessagesExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/CopyConversationMessagesExecutor.cs index 381abcb84dc..3b63ffeb26a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/CopyConversationMessagesExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/CopyConversationMessagesExecutor.cs @@ -45,6 +45,11 @@ internal sealed class CopyConversationMessagesExecutor(CopyConversationMessages Throw.IfNull(this.Model.Messages, $"{nameof(this.Model)}.{nameof(this.Model.Messages)}"); EvaluationResult expressionResult = this.Evaluator.GetValue(this.Model.Messages); + if (expressionResult.Sensitivity == SensitivityLevel.Sensitive) + { + throw new DeclarativeActionException($"Cannot send sensitive conversation messages: {this.Id}."); + } + DataValue messages = expressionResult.Value; return messages.ToChatMessages(); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/EditTableExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/EditTableExecutor.cs index 41fd8468e0e..e110ee4272a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/EditTableExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/EditTableExecutor.cs @@ -25,6 +25,7 @@ internal sealed class EditTableExecutor(EditTable model, WorkflowFormulaState st { throw this.Exception($"Require '{variablePath}' to be a table, not: '{table.GetType().Name}'."); } + SensitivityLevel tableSensitivity = this.GetSensitivity(variablePath); TableChangeType changeType = this.Model.ChangeType.Value; switch (this.Model.ChangeType.Value) @@ -33,6 +34,7 @@ internal sealed class EditTableExecutor(EditTable model, WorkflowFormulaState st ValueExpression addItemValue = Throw.IfNull(this.Model.Value, $"{nameof(this.Model)}.{nameof(this.Model.Value)}"); EvaluationResult addResult = this.Evaluator.GetValue(addItemValue); FormulaValue addValue = addResult.Value.ToFormula(); + SensitivityLevel addSensitivity = MaxSensitivity(tableSensitivity, addResult.Sensitivity); RecordType recordType = tableValue.Type.ToRecord(); RecordValue newRecord; TableValue resultTable; @@ -47,35 +49,36 @@ internal sealed class EditTableExecutor(EditTable model, WorkflowFormulaState st await tableValue.AppendAsync(newRecord, cancellationToken).ConfigureAwait(false); resultTable = tableValue; } - await this.AssignAsync(variablePath, resultTable, context).ConfigureAwait(false); - await this.AssignAsync(this.Model.ResultVariable?.Path, newRecord, context).ConfigureAwait(false); + await this.AssignAsync(variablePath, resultTable, context, addSensitivity).ConfigureAwait(false); + await this.AssignAsync(this.Model.ResultVariable?.Path, newRecord, context, addSensitivity).ConfigureAwait(false); break; case TableChangeType.Remove: ValueExpression removeItemValue = Throw.IfNull(this.Model.Value, $"{nameof(this.Model)}.{nameof(this.Model.Value)}"); EvaluationResult removeResult = this.Evaluator.GetValue(removeItemValue); + SensitivityLevel removeSensitivity = MaxSensitivity(tableSensitivity, removeResult.Sensitivity); if (removeResult.Value is TableDataValue removeItemTable) { await tableValue.RemoveAsync(removeItemTable?.Values.Select(row => row.ToRecordValue()), all: true, cancellationToken).ConfigureAwait(false); - await this.AssignAsync(variablePath, tableValue, context).ConfigureAwait(false); - await this.AssignAsync(this.Model.ResultVariable?.Path, RecordValue.Empty(), context).ConfigureAwait(false); + await this.AssignAsync(variablePath, tableValue, context, removeSensitivity).ConfigureAwait(false); + await this.AssignAsync(this.Model.ResultVariable?.Path, RecordValue.Empty(), context, removeSensitivity).ConfigureAwait(false); } break; case TableChangeType.Clear: await tableValue.ClearAsync(cancellationToken).ConfigureAwait(false); - await this.AssignAsync(variablePath, tableValue, context).ConfigureAwait(false); - await this.AssignAsync(this.Model.ResultVariable?.Path, FormulaValue.NewBlank(), context).ConfigureAwait(false); + await this.AssignAsync(variablePath, tableValue, context, tableSensitivity).ConfigureAwait(false); + await this.AssignAsync(this.Model.ResultVariable?.Path, FormulaValue.NewBlank(), context, tableSensitivity).ConfigureAwait(false); break; case TableChangeType.TakeFirst: RecordValue? firstRow = tableValue.Rows.FirstOrDefault()?.Value; if (firstRow is not null) { await tableValue.RemoveAsync([firstRow], all: true, cancellationToken).ConfigureAwait(false); - await this.AssignAsync(variablePath, tableValue, context).ConfigureAwait(false); - await this.AssignAsync(this.Model.ResultVariable?.Path, firstRow, context).ConfigureAwait(false); + await this.AssignAsync(variablePath, tableValue, context, tableSensitivity).ConfigureAwait(false); + await this.AssignAsync(this.Model.ResultVariable?.Path, firstRow, context, tableSensitivity).ConfigureAwait(false); } else { - await this.AssignAsync(this.Model.ResultVariable?.Path, FormulaValue.NewBlank(), context).ConfigureAwait(false); + await this.AssignAsync(this.Model.ResultVariable?.Path, FormulaValue.NewBlank(), context, tableSensitivity).ConfigureAwait(false); } break; case TableChangeType.TakeLast: @@ -83,12 +86,12 @@ internal sealed class EditTableExecutor(EditTable model, WorkflowFormulaState st if (lastRow is not null) { await tableValue.RemoveAsync([lastRow], all: true, cancellationToken).ConfigureAwait(false); - await this.AssignAsync(variablePath, tableValue, context).ConfigureAwait(false); - await this.AssignAsync(this.Model.ResultVariable?.Path, lastRow, context).ConfigureAwait(false); + await this.AssignAsync(variablePath, tableValue, context, tableSensitivity).ConfigureAwait(false); + await this.AssignAsync(this.Model.ResultVariable?.Path, lastRow, context, tableSensitivity).ConfigureAwait(false); } else { - await this.AssignAsync(this.Model.ResultVariable?.Path, FormulaValue.NewBlank(), context).ConfigureAwait(false); + await this.AssignAsync(this.Model.ResultVariable?.Path, FormulaValue.NewBlank(), context, tableSensitivity).ConfigureAwait(false); } break; } @@ -120,4 +123,10 @@ IEnumerable GetValues() } } } + + private SensitivityLevel GetSensitivity(PropertyPath? path) => + path?.VariableName is string variableName ? this.State.GetSensitivity(variableName, path.NamespaceAlias) : SensitivityLevel.None; + + private static SensitivityLevel MaxSensitivity(SensitivityLevel left, SensitivityLevel right) => + left == SensitivityLevel.Sensitive || right == SensitivityLevel.Sensitive ? SensitivityLevel.Sensitive : SensitivityLevel.None; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs index 79b32428a87..7475916bf11 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs @@ -25,6 +25,7 @@ internal sealed class EditTableV2Executor(EditTableV2 model, WorkflowFormulaStat { throw this.Exception($"Require '{this.Model.ItemsVariable.Path}' to be a table, not: '{table.GetType().Name}'."); } + SensitivityLevel tableSensitivity = this.GetSensitivity(this.Model.ItemsVariable); EditTableOperation? changeType = this.Model.ChangeType; if (changeType is AddItemOperation addItemOperation) @@ -32,6 +33,7 @@ internal sealed class EditTableV2Executor(EditTableV2 model, WorkflowFormulaStat ValueExpression addItemValue = Throw.IfNull(addItemOperation.Value, $"{nameof(this.Model)}.{nameof(this.Model.ChangeType)}"); EvaluationResult expressionResult = this.Evaluator.GetValue(addItemValue); FormulaValue addValue = expressionResult.Value.ToFormula(); + SensitivityLevel mutationSensitivity = MaxSensitivity(tableSensitivity, expressionResult.Sensitivity); RecordType recordType = tableValue.Type.ToRecord(); TableValue resultTable; if (!recordType.FieldNames.Any() && !tableValue.Rows.Any()) @@ -45,21 +47,22 @@ internal sealed class EditTableV2Executor(EditTableV2 model, WorkflowFormulaStat await tableValue.AppendAsync(newRecord, cancellationToken).ConfigureAwait(false); resultTable = tableValue; } - await this.AssignAsync(this.Model.ItemsVariable, resultTable, context).ConfigureAwait(false); + await this.AssignAsync(this.Model.ItemsVariable, resultTable, context, mutationSensitivity).ConfigureAwait(false); } else if (changeType is ClearItemsOperation) { await tableValue.ClearAsync(cancellationToken).ConfigureAwait(false); - await this.AssignAsync(this.Model.ItemsVariable, tableValue, context).ConfigureAwait(false); + await this.AssignAsync(this.Model.ItemsVariable, tableValue, context, tableSensitivity).ConfigureAwait(false); } else if (changeType is RemoveItemOperation removeItemOperation) { ValueExpression removeItemValue = Throw.IfNull(removeItemOperation.Value, $"{nameof(this.Model)}.{nameof(this.Model.ChangeType)}"); EvaluationResult expressionResult = this.Evaluator.GetValue(removeItemValue); + SensitivityLevel mutationSensitivity = MaxSensitivity(tableSensitivity, expressionResult.Sensitivity); if (expressionResult.Value.ToFormula() is TableValue removeItemTable) { await tableValue.RemoveAsync(removeItemTable.Rows.Select(row => row.Value), all: true, cancellationToken).ConfigureAwait(false); - await this.AssignAsync(this.Model.ItemsVariable, tableValue, context).ConfigureAwait(false); + await this.AssignAsync(this.Model.ItemsVariable, tableValue, context, mutationSensitivity).ConfigureAwait(false); } } else if (changeType is TakeLastItemOperation takeLastOperation) @@ -68,12 +71,12 @@ internal sealed class EditTableV2Executor(EditTableV2 model, WorkflowFormulaStat if (lastRow is not null) { await tableValue.RemoveAsync([lastRow], all: true, cancellationToken).ConfigureAwait(false); - await this.AssignAsync(this.Model.ItemsVariable, tableValue, context).ConfigureAwait(false); - await this.AssignAsync(takeLastOperation.ResultVariable?.Path, lastRow, context).ConfigureAwait(false); + await this.AssignAsync(this.Model.ItemsVariable, tableValue, context, tableSensitivity).ConfigureAwait(false); + await this.AssignAsync(takeLastOperation.ResultVariable?.Path, lastRow, context, tableSensitivity).ConfigureAwait(false); } else { - await this.AssignAsync(takeLastOperation.ResultVariable?.Path, FormulaValue.NewBlank(), context).ConfigureAwait(false); + await this.AssignAsync(takeLastOperation.ResultVariable?.Path, FormulaValue.NewBlank(), context, tableSensitivity).ConfigureAwait(false); } } else if (changeType is TakeFirstItemOperation takeFirstOperation) @@ -82,12 +85,12 @@ internal sealed class EditTableV2Executor(EditTableV2 model, WorkflowFormulaStat if (firstRow is not null) { await tableValue.RemoveAsync([firstRow], all: true, cancellationToken).ConfigureAwait(false); - await this.AssignAsync(this.Model.ItemsVariable, tableValue, context).ConfigureAwait(false); - await this.AssignAsync(takeFirstOperation.ResultVariable?.Path, firstRow, context).ConfigureAwait(false); + await this.AssignAsync(this.Model.ItemsVariable, tableValue, context, tableSensitivity).ConfigureAwait(false); + await this.AssignAsync(takeFirstOperation.ResultVariable?.Path, firstRow, context, tableSensitivity).ConfigureAwait(false); } else { - await this.AssignAsync(takeFirstOperation.ResultVariable?.Path, FormulaValue.NewBlank(), context).ConfigureAwait(false); + await this.AssignAsync(takeFirstOperation.ResultVariable?.Path, FormulaValue.NewBlank(), context, tableSensitivity).ConfigureAwait(false); } } @@ -118,4 +121,10 @@ IEnumerable GetValues() } } } + + private SensitivityLevel GetSensitivity(PropertyPath? path) => + path?.VariableName is string variableName ? this.State.GetSensitivity(variableName, path.NamespaceAlias) : SensitivityLevel.None; + + private static SensitivityLevel MaxSensitivity(SensitivityLevel left, SensitivityLevel right) => + left == SensitivityLevel.Sensitive || right == SensitivityLevel.Sensitive ? SensitivityLevel.Sensitive : SensitivityLevel.None; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ForeachExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ForeachExecutor.cs index f154ad7f97b..d829f0ba8ae 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ForeachExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ForeachExecutor.cs @@ -26,9 +26,11 @@ public static class Steps private const string IndexStateKey = nameof(_index); private const string ValuesStateKey = nameof(_values); private const string HasValueStateKey = nameof(HasValue); + private const string SensitivityStateKey = nameof(_sensitivity); private int _index; private FormulaValue[] _values; + private SensitivityLevel _sensitivity; public ForeachExecutor(Foreach model, WorkflowFormulaState state) : base(model, state) @@ -55,6 +57,7 @@ public ForeachExecutor(Foreach model, WorkflowFormulaState state) { this._values = [expressionResult.Value.ToFormula()]; } + this._sensitivity = expressionResult.Sensitivity; await this.ResetStateAsync(context, cancellationToken).ConfigureAwait(false); @@ -67,7 +70,15 @@ public async ValueTask TakeNextAsync(IWorkflowContext context, object? _, Cancel { FormulaValue value = this._values[this._index]; - await context.QueueStateUpdateAsync(Throw.IfNull(this.Model.Value), value, cancellationToken).ConfigureAwait(false); + PropertyPath valuePath = Throw.IfNull(this.Model.Value); + if (context is DeclarativeWorkflowContext) + { + await this.AssignAsync(valuePath, value, context, this._sensitivity).ConfigureAwait(false); + } + else + { + await context.QueueStateUpdateAsync(valuePath, value, cancellationToken).ConfigureAwait(false); + } if (this.Model.Index is not null) { @@ -122,6 +133,7 @@ protected override async ValueTask OnCheckpointingAsync(IWorkflowContext context await context.QueueStateUpdateAsync(IndexStateKey, this._index, cancellationToken: cancellationToken).ConfigureAwait(false); await context.QueueStateUpdateAsync(ValuesStateKey, portableValues, cancellationToken: cancellationToken).ConfigureAwait(false); await context.QueueStateUpdateAsync(HasValueStateKey, this.HasValue, cancellationToken: cancellationToken).ConfigureAwait(false); + await context.QueueStateUpdateAsync(SensitivityStateKey, this._sensitivity, cancellationToken: cancellationToken).ConfigureAwait(false); await base.OnCheckpointingAsync(context, cancellationToken).ConfigureAwait(false); } @@ -147,5 +159,6 @@ protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext co this._values = [.. savedValues.Select(value => value.ToFormula())]; this._index = await context.ReadStateAsync(IndexStateKey, cancellationToken: cancellationToken).ConfigureAwait(false); this.HasValue = await context.ReadStateAsync(HasValueStateKey, cancellationToken: cancellationToken).ConfigureAwait(false); + this._sensitivity = await context.ReadStateAsync(SensitivityStateKey, cancellationToken: cancellationToken).ConfigureAwait(false); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs index 2b0378f9998..fee636b801b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs @@ -154,6 +154,11 @@ private async ValueTask InvokeAgentAsync(IWorkflowContext context, IEnumerable expressionResult = this.Evaluator.GetValue(this.AgentInput.Messages); + if (expressionResult.Sensitivity == SensitivityLevel.Sensitive) + { + throw new DeclarativeActionException($"Cannot send sensitive agent input messages: {this.Id}."); + } + userInput = expressionResult.Value; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs index 57fe319aaff..16f061f37a6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs @@ -30,7 +30,7 @@ internal sealed class ParseValueExecutor(ParseValue model, WorkflowFormulaState object? parsedResult = expressionResult.Value.ToObject().ConvertType(targetType); parsedValue = parsedResult.ToFormula(); - await this.AssignAsync(this.Model.Variable.Path, parsedValue, context).ConfigureAwait(false); + await this.AssignAsync(this.Model.Variable.Path, parsedValue, context, expressionResult.Sensitivity).ConfigureAwait(false); return default; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/QuestionExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/QuestionExecutor.cs index 4ad88dd40cb..dacdb9151f6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/QuestionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/QuestionExecutor.cs @@ -10,6 +10,7 @@ using Microsoft.Agents.AI.Workflows.Declarative.Kit; using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; using Microsoft.Agents.ObjectModel; +using Microsoft.Agents.ObjectModel.Abstractions; using Microsoft.Extensions.AI; using Microsoft.PowerFx.Types; using Microsoft.Shared.Diagnostics; @@ -161,13 +162,13 @@ private async ValueTask PromptAsync(IWorkflowContext context, int actualCount, C long repeatCount = this.Evaluator.GetValue(this.Model.RepeatCount).Value; if (actualCount >= repeatCount) { - DataValue defaultValue = DataValue.Blank(); + EvaluationResult defaultValue = new(DataValue.Blank(), SensitivityLevel.None); if (this.Model.DefaultValue is not null) { ValueExpression defaultValueExpression = Throw.IfNull(this.Model.DefaultValue); - defaultValue = this.Evaluator.GetValue(defaultValueExpression).Value; + defaultValue = this.Evaluator.GetValue(defaultValueExpression); } - await this.AssignAsync(Throw.IfNull(this.Model.Variable).Path, defaultValue.ToFormula(), context).ConfigureAwait(false); + await this.AssignAsync(Throw.IfNull(this.Model.Variable).Path, defaultValue.Value.ToFormula(), context, defaultValue.Sensitivity).ConfigureAwait(false); string defaultValueResponse = this.FormatPrompt(this.Model.DefaultValueResponse); await context.AddEventAsync(new MessageActivityEvent(defaultValueResponse.Trim()), cancellationToken).ConfigureAwait(false); // Reset for any subsequent Question turn (e.g. via GotoAction re-entry) so the next attempt starts fresh. @@ -187,6 +188,12 @@ private string FormatPrompt(ActivityTemplateBase? promptTemplate) return string.Empty; } - return this.Engine.Format(messageActivity.Text).Trim(); + EvaluationResult promptResult = this.Evaluator.Format(messageActivity.Text); + if (promptResult.Sensitivity == SensitivityLevel.Sensitive) + { + throw new DeclarativeActionException($"Cannot send sensitive question prompt: {this.Id}."); + } + + return promptResult.Value.Trim(); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs index 3b9794b197f..4763422b105 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs @@ -3,10 +3,10 @@ using System; using System.Threading; using System.Threading.Tasks; -using Microsoft.Agents.AI.Workflows.Declarative.Extensions; using Microsoft.Agents.AI.Workflows.Declarative.Interpreter; using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; using Microsoft.Agents.ObjectModel; +using Microsoft.Agents.ObjectModel.Abstractions; using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; @@ -18,7 +18,13 @@ internal sealed class SendActivityExecutor(SendActivity model, WorkflowFormulaSt { if (this.Model.Activity is MessageActivityTemplate messageActivity) { - string activityText = this.Engine.Format(messageActivity.Text).Trim(); + EvaluationResult activityResult = this.Evaluator.Format(messageActivity.Text); + if (activityResult.Sensitivity == SensitivityLevel.Sensitive) + { + throw new DeclarativeActionException($"Cannot send sensitive activity text: {this.Id}."); + } + + string activityText = activityResult.Value.Trim(); await context.AddEventAsync(new MessageActivityEvent(activityText.Trim()), cancellationToken).ConfigureAwait(false); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetMultipleVariablesExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetMultipleVariablesExecutor.cs index e81126e9a5e..1015c0a0dd1 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetMultipleVariablesExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetMultipleVariablesExecutor.cs @@ -31,7 +31,7 @@ internal sealed class SetMultipleVariablesExecutor(SetMultipleVariables model, W { EvaluationResult expressionResult = this.Evaluator.GetValue(assignment.Value); - await this.AssignAsync(assignment.Variable, expressionResult.Value.ToFormula(), context).ConfigureAwait(false); + await this.AssignAsync(assignment.Variable, expressionResult.Value.ToFormula(), context, expressionResult.Sensitivity).ConfigureAwait(false); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetTextVariableExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetTextVariableExecutor.cs index 37b8d43e8a5..8d7f2924c4e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetTextVariableExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetTextVariableExecutor.cs @@ -2,10 +2,10 @@ using System.Threading; using System.Threading.Tasks; -using Microsoft.Agents.AI.Workflows.Declarative.Extensions; using Microsoft.Agents.AI.Workflows.Declarative.Interpreter; using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; using Microsoft.Agents.ObjectModel; +using Microsoft.Agents.ObjectModel.Abstractions; using Microsoft.PowerFx.Types; using Microsoft.Shared.Diagnostics; @@ -19,9 +19,9 @@ internal sealed class SetTextVariableExecutor(SetTextVariable model, WorkflowFor Throw.IfNull(this.Model.Variable); Throw.IfNull(this.Model.Value); - FormulaValue expressionResult = FormulaValue.New(this.Engine.Format(this.Model.Value)); + EvaluationResult expressionResult = this.Evaluator.Format(this.Model.Value); - await this.AssignAsync(this.Model.Variable.Path, expressionResult, context).ConfigureAwait(false); + await this.AssignAsync(this.Model.Variable.Path, FormulaValue.New(expressionResult.Value), context, expressionResult.Sensitivity).ConfigureAwait(false); return default; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetVariableExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetVariableExecutor.cs index 6fd4002df5c..d75a47ca00a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetVariableExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetVariableExecutor.cs @@ -21,7 +21,7 @@ internal sealed class SetVariableExecutor(SetVariable model, WorkflowFormulaStat EvaluationResult expressionResult = this.Evaluator.GetValue(this.Model.Value); - await this.AssignAsync(this.Model.Variable.Path, expressionResult.Value.ToFormula(), context).ConfigureAwait(false); + await this.AssignAsync(this.Model.Variable.Path, expressionResult.Value.ToFormula(), context, expressionResult.Sensitivity).ConfigureAwait(false); return default; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/RecalcEngineFactory.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/RecalcEngineFactory.cs index 6c6fe5649fe..c6cc9cbea65 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/RecalcEngineFactory.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/RecalcEngineFactory.cs @@ -37,7 +37,6 @@ PowerFxConfig CreateConfig() config.MaxCallDepth = maximumCallDepth.Value; } - config.EnableSetFunction(); config.AddFunction(new AgentMessage()); config.AddFunction(new UserMessage()); config.AddFunction(new MessageText.StringInput()); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs index 95b8f9ab93d..c46016317f3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs @@ -37,22 +37,43 @@ public static WorkflowTypeInfo Describe(this TElement workflowElement) [.. semanticModel.GetVariables(workflowElement.SchemaName.Value).Where(x => !x.IsSystemVariable).Select(v => v.ToDiagnostic())]); } - public static void Initialize(this WorkflowFormulaState scopes, TElement workflowElement, IConfiguration? configuration) where TElement : BotElement, IDialogBase + public static void Initialize( + this WorkflowFormulaState scopes, + TElement workflowElement, + IConfiguration? configuration, + IEnumerable? allowedEnvironmentVariables, + bool allowProcessEnvironmentVariableFallback) where TElement : BotElement, IDialogBase { scopes.InitializeSystem(); SemanticModel semanticModel = workflowElement.GetSemanticModel(new PowerFxExpressionChecker(s_semanticFeatureConfig), s_semanticFeatureConfig); - scopes.InitializeEnvironment(semanticModel, configuration); + scopes.InitializeEnvironment(semanticModel, configuration, allowedEnvironmentVariables, allowProcessEnvironmentVariableFallback); scopes.InitializeDefaults(semanticModel, workflowElement.SchemaName.Value); } - private static void InitializeEnvironment(this WorkflowFormulaState scopes, SemanticModel semanticModel, IConfiguration? configuration) + private static void InitializeEnvironment( + this WorkflowFormulaState scopes, + SemanticModel semanticModel, + IConfiguration? configuration, + IEnumerable? allowedEnvironmentVariables, + bool allowProcessEnvironmentVariableFallback) { + HashSet allowedVariables = new(allowedEnvironmentVariables ?? [], StringComparer.Ordinal); foreach (string variableName in semanticModel.GetAllEnvironmentVariablesReferencedInTheBot()) { - string? environmentValue = configuration is not null ? configuration[variableName] : Environment.GetEnvironmentVariable(variableName); + if (!allowedVariables.Contains(variableName)) + { + continue; + } + + string? environmentValue = configuration?[variableName]; + if (environmentValue is null && allowProcessEnvironmentVariableFallback) + { + environmentValue = Environment.GetEnvironmentVariable(variableName); + } + FormulaValue variableValue = string.IsNullOrEmpty(environmentValue) ? FormulaType.String.NewBlank() : FormulaValue.New(environmentValue); - scopes.Set(variableName, variableValue, VariableScopeNames.Environment); + scopes.Set(variableName, variableValue, VariableScopeNames.Environment, SensitivityLevel.Sensitive); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs index a22a857635a..eb0232d1b0f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs @@ -3,11 +3,13 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Linq; using Microsoft.Agents.AI.Workflows.Declarative.Extensions; using Microsoft.Agents.ObjectModel; using Microsoft.Agents.ObjectModel.Abstractions; using Microsoft.Agents.ObjectModel.Exceptions; using Microsoft.PowerFx; +using Microsoft.PowerFx.Syntax; using Microsoft.PowerFx.Types; using Microsoft.Shared.Diagnostics; @@ -15,11 +17,13 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.PowerFx; internal sealed class WorkflowExpressionEngine { - private readonly RecalcEngine _engine; + private readonly WorkflowFormulaState _state; + private readonly ParserOptions? _parserOptions; - public WorkflowExpressionEngine(RecalcEngine engine) + public WorkflowExpressionEngine(WorkflowFormulaState state) { - this._engine = engine; + this._state = state; + this._parserOptions = state.AllowsSideEffects ? new ParserOptions { AllowsSideEffects = true } : null; } public EvaluationResult GetValue(BoolExpression boolean) => this.Evaluate(boolean); @@ -41,6 +45,55 @@ public WorkflowExpressionEngine(RecalcEngine engine) public EvaluationResult GetValue(EnumExpression expression) where TValue : EnumWrapper => this.Evaluate(expression); + public EvaluationResult Format(IEnumerable template) + { + Throw.IfNull(template); + + SensitivityLevel sensitivity = SensitivityLevel.None; + List segments = []; + foreach (EvaluationResult result in template.Select(this.Format)) + { + sensitivity = MaxSensitivity(sensitivity, result.Sensitivity); + segments.Add(result.Value); + } + + return new(string.Concat(segments), sensitivity); + } + + public EvaluationResult Format(TemplateLine? line) + { + if (line is null) + { + return new(string.Empty, SensitivityLevel.None); + } + + SensitivityLevel sensitivity = SensitivityLevel.None; + List segments = []; + foreach (EvaluationResult result in line.Segments.Select(this.Format)) + { + sensitivity = MaxSensitivity(sensitivity, result.Sensitivity); + segments.Add(result.Value); + } + + return new(string.Concat(segments), sensitivity); + } + + private EvaluationResult Format(TemplateSegment segment) + { + if (segment is TextSegment textSegment) + { + return new(textSegment.Value ?? string.Empty, SensitivityLevel.None); + } + + if (segment is ExpressionSegment { Expression: not null } expressionSegment) + { + EvaluationResult result = this.EvaluateScope(expressionSegment.Expression); + return new(result.Value.Format(), result.Sensitivity); + } + + throw new DeclarativeModelException($"Unsupported segment type: {segment.GetType().Name}"); + } + private EvaluationResult Evaluate(BoolExpression expression) { Throw.IfNull(expression); @@ -274,13 +327,136 @@ private EvaluationResult EvaluateScope(ExpressionBase expression) expression.VariableReference?.ToString() : expression.ExpressionText; - FormulaValue result = this._engine.Eval(expressionText); + FormulaValue result = this._state.Engine.Eval(expressionText, options: this._parserOptions); if (result is ErrorValue errorValue) { throw new DeclarativeActionException(errorValue.Format()); } - return new(result, SensitivityLevel.None); + return new(result, this.GetSensitivity(expression)); + } + + private SensitivityLevel GetSensitivity(ExpressionBase expression) + { + if (expression.VariableReference is { VariableName: string variableName }) + { + return GetReferenceSensitivity(expression.VariableReference.NamespaceAlias, variableName); + } + + string? expressionText = expression.ExpressionText; + if (string.IsNullOrWhiteSpace(expressionText)) + { + return SensitivityLevel.None; + } + + CheckResult checkResult = this._state.Engine.Check(expressionText, options: this._parserOptions); + checkResult.ThrowOnErrors(); + + SensitivityLevel sensitivity = SensitivityLevel.None; + foreach ((string? ScopeName, string VariableName) reference in GetVariableReferences(checkResult.Parse.Root)) + { + sensitivity = MaxSensitivity(sensitivity, GetReferenceSensitivity(reference.ScopeName, reference.VariableName)); + } + + return sensitivity; + + SensitivityLevel GetReferenceSensitivity(string? scopeName, string variableName) => + scopeName is null && VariableScopeNames.IsValidName(variableName) + ? this._state.GetScopeSensitivity(variableName) + : this._state.GetSensitivity(variableName, scopeName); + } + + private static IEnumerable<(string? ScopeName, string VariableName)> GetVariableReferences(TexlNode node) + { + switch (node) + { + case DottedNameNode dottedNameNode: + if (TryGetDottedReference(dottedNameNode, out (string? ScopeName, string VariableName) dottedReference)) + { + yield return dottedReference; + } + else + { + foreach ((string? ScopeName, string VariableName) reference in GetVariableReferences(dottedNameNode.Left)) + { + yield return reference; + } + } + yield break; + + case FirstNameNode firstNameNode: + yield return (null, firstNameNode.Ident.Name.Value); + yield break; + + case AsNode asNode: + foreach ((string? ScopeName, string VariableName) reference in GetVariableReferences(asNode.Left)) + { + yield return reference; + } + yield break; + + case BinaryOpNode binaryOpNode: + foreach ((string? ScopeName, string VariableName) reference in GetVariableReferences(binaryOpNode.Left)) + { + yield return reference; + } + foreach ((string? ScopeName, string VariableName) reference in GetVariableReferences(binaryOpNode.Right)) + { + yield return reference; + } + yield break; + + case UnaryOpNode unaryOpNode: + foreach ((string? ScopeName, string VariableName) reference in GetVariableReferences(unaryOpNode.Child)) + { + yield return reference; + } + yield break; + + case CallNode callNode: + foreach ((string? ScopeName, string VariableName) reference in GetVariableReferences(callNode.Args)) + { + yield return reference; + } + yield break; + + case VariadicBase variadicBase: + foreach (TexlNode childNode in variadicBase.ChildNodes) + { + foreach ((string? ScopeName, string VariableName) reference in GetVariableReferences(childNode)) + { + yield return reference; + } + } + yield break; + } } + + private static bool TryGetDottedReference(DottedNameNode dottedNameNode, out (string? ScopeName, string VariableName) reference) + { + List names = []; + TexlNode node = dottedNameNode; + while (node is DottedNameNode current) + { + names.Add(current.Right.Name.Value); + node = current.Left; + } + + if (node is not FirstNameNode firstNameNode) + { + reference = default; + return false; + } + + names.Add(firstNameNode.Ident.Name.Value); + names.Reverse(); + reference = names.Count > 1 && VariableScopeNames.IsValidName(names[0]) + ? (names[0], names[1]) + : (null, names[0]); + return true; + } + + private static SensitivityLevel MaxSensitivity(SensitivityLevel left, SensitivityLevel right) => + left == SensitivityLevel.Sensitive || right == SensitivityLevel.Sensitive ? SensitivityLevel.Sensitive : SensitivityLevel.None; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowFormulaState.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowFormulaState.cs index aaff60b08b4..87f726a77e0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowFormulaState.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowFormulaState.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Frozen; using System.Collections.Generic; using System.Diagnostics; @@ -27,6 +28,8 @@ internal sealed class WorkflowFormulaState VariableScopeNames.System, ]; + private const string SensitivityScopePrefix = "__Microsoft_Agents_AI_Workflows_Declarative_Sensitivity:"; + private readonly Dictionary _scopes; private Dictionary _initialScopes; @@ -37,13 +40,16 @@ internal sealed class WorkflowFormulaState public WorkflowExpressionEngine Evaluator { get; } - public WorkflowFormulaState(RecalcEngine engine) + public bool AllowsSideEffects { get; } + + public WorkflowFormulaState(RecalcEngine engine, bool allowsSideEffects = false) { this._scopes = VariableScopeNames.AllScopes.ToDictionary(scopeName => GetScopeName(scopeName), _ => new WorkflowScope()); this._initialScopes = this.CreateScopeSnapshot(); this.Engine = engine; - this.Evaluator = new WorkflowExpressionEngine(engine); + this.AllowsSideEffects = allowsSideEffects; + this.Evaluator = new WorkflowExpressionEngine(this); this.Bind(); } @@ -59,8 +65,38 @@ public FormulaValue Get(string variableName, string? scopeName = null) return FormulaValue.NewBlank(); } - public void Set(string variableName, FormulaValue value, string? scopeName = null) => - this.GetScope(scopeName ?? DefaultScopeName)[variableName] = value; + public void Set(string variableName, FormulaValue value, string? scopeName = null, SensitivityLevel sensitivity = SensitivityLevel.None) + { + WorkflowScope scope = this.GetScope(scopeName ?? DefaultScopeName); + scope[variableName] = value; + scope.Sensitivities[variableName] = sensitivity; + } + + public SensitivityLevel GetSensitivity(string variableName, string? scopeName = null) + { + if (scopeName is not null && !VariableScopeNames.IsValidName(scopeName)) + { + return SensitivityLevel.None; + } + + WorkflowScope scope = this.GetScope(scopeName ?? DefaultScopeName); + return scope.Sensitivities.TryGetValue(variableName, out SensitivityLevel sensitivity) ? sensitivity : SensitivityLevel.None; + } + + public SensitivityLevel GetScopeSensitivity(string scopeName) + { + if (!VariableScopeNames.IsValidName(scopeName)) + { + return SensitivityLevel.None; + } + + return this.GetScope(scopeName).Sensitivities.Values.Any(static sensitivity => sensitivity == SensitivityLevel.Sensitive) + ? SensitivityLevel.Sensitive + : SensitivityLevel.None; + } + + public void SetSensitivity(string variableName, string? scopeName, SensitivityLevel sensitivity) => + this.GetScope(scopeName ?? DefaultScopeName).Sensitivities[variableName] = sensitivity; public bool SetInitialized() => Interlocked.CompareExchange(ref this._isInitialized, 1, 0) == 0; @@ -96,13 +132,14 @@ async Task ReadScopeAsync(string scopeName) foreach (string key in keys) { PortableValue? value = await context.ReadStateAsync(key, scopeName, cancellationToken).ConfigureAwait(false); + SensitivityLevel sensitivity = await context.ReadStateAsync(key, GetSensitivityScopeName(scopeName), cancellationToken).ConfigureAwait(false); if (value is null) { - this.Set(key, FormulaValue.NewBlank(), scopeName); + this.Set(key, FormulaValue.NewBlank(), scopeName, sensitivity); continue; } FormulaValue formulaValue = value.ToFormula(); - this.Set(key, formulaValue, scopeName); + this.Set(key, formulaValue, scopeName, sensitivity); Debug.WriteLine($"RESTORED: {scopeName}.{key} => {formulaValue.Type}"); } @@ -119,10 +156,16 @@ private void RestoreInitialState() { WorkflowScope scope = this._scopes[initialScopeEntry.Key]; scope.Clear(); + scope.Sensitivities.Clear(); foreach (KeyValuePair initialValueEntry in initialScopeEntry.Value) { scope[initialValueEntry.Key] = initialValueEntry.Value; } + + foreach (KeyValuePair initialSensitivityEntry in initialScopeEntry.Value.Sensitivities) + { + scope.Sensitivities[initialSensitivityEntry.Key] = initialSensitivityEntry.Value; + } } } @@ -157,6 +200,8 @@ void Bind(string scopeName, string? targetScope = null) private WorkflowScope GetScope(string? scopeName) => this._scopes[GetScopeName(scopeName)]; + public static string GetSensitivityScopeName(string scopeName) => $"{SensitivityScopePrefix}{GetScopeName(scopeName)}"; + public static string GetScopeName(string? scopeName) { WorkflowDiagnostics.SetFoundryProduct(); @@ -185,6 +230,15 @@ public WorkflowScope() public WorkflowScope(IDictionary values) : base(values) { + if (values is WorkflowScope scope) + { + foreach (KeyValuePair sensitivity in scope.Sensitivities) + { + this.Sensitivities[sensitivity.Key] = sensitivity.Value; + } + } } + + public Dictionary Sensitivities { get; } = new(StringComparer.OrdinalIgnoreCase); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net10.0/PublicAPI.Unshipped.txt index ab058de62d4..d7ac870b3a9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net10.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net10.0/PublicAPI.Unshipped.txt @@ -1 +1,13 @@ #nullable enable +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowProcessEnvironmentVariableFallback.get -> bool +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowProcessEnvironmentVariableFallback.init -> void +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowedEnvironmentVariables.get -> System.Collections.Generic.IEnumerable? +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowedEnvironmentVariables.init -> void +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.QueueStateUpdateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! key, Microsoft.Agents.ObjectModel.Abstractions.EvaluationResult! value, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.ReadStateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! key, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.ConvertValueWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, Microsoft.Agents.AI.Workflows.Declarative.Kit.VariableType! targetType, string! key, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.EvaluateValueWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! expression, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, System.Collections.Generic.IEnumerable! lines) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, System.Collections.Generic.IEnumerable! lines, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! line) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! line, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask!> diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net472/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net472/PublicAPI.Unshipped.txt index ab058de62d4..d7ac870b3a9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net472/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net472/PublicAPI.Unshipped.txt @@ -1 +1,13 @@ #nullable enable +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowProcessEnvironmentVariableFallback.get -> bool +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowProcessEnvironmentVariableFallback.init -> void +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowedEnvironmentVariables.get -> System.Collections.Generic.IEnumerable? +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowedEnvironmentVariables.init -> void +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.QueueStateUpdateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! key, Microsoft.Agents.ObjectModel.Abstractions.EvaluationResult! value, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.ReadStateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! key, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.ConvertValueWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, Microsoft.Agents.AI.Workflows.Declarative.Kit.VariableType! targetType, string! key, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.EvaluateValueWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! expression, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, System.Collections.Generic.IEnumerable! lines) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, System.Collections.Generic.IEnumerable! lines, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! line) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! line, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask!> diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net8.0/PublicAPI.Unshipped.txt index ab058de62d4..d7ac870b3a9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net8.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net8.0/PublicAPI.Unshipped.txt @@ -1 +1,13 @@ #nullable enable +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowProcessEnvironmentVariableFallback.get -> bool +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowProcessEnvironmentVariableFallback.init -> void +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowedEnvironmentVariables.get -> System.Collections.Generic.IEnumerable? +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowedEnvironmentVariables.init -> void +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.QueueStateUpdateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! key, Microsoft.Agents.ObjectModel.Abstractions.EvaluationResult! value, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.ReadStateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! key, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.ConvertValueWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, Microsoft.Agents.AI.Workflows.Declarative.Kit.VariableType! targetType, string! key, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.EvaluateValueWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! expression, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, System.Collections.Generic.IEnumerable! lines) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, System.Collections.Generic.IEnumerable! lines, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! line) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! line, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask!> diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net9.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net9.0/PublicAPI.Unshipped.txt index ab058de62d4..d7ac870b3a9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net9.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net9.0/PublicAPI.Unshipped.txt @@ -1 +1,13 @@ #nullable enable +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowProcessEnvironmentVariableFallback.get -> bool +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowProcessEnvironmentVariableFallback.init -> void +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowedEnvironmentVariables.get -> System.Collections.Generic.IEnumerable? +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowedEnvironmentVariables.init -> void +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.QueueStateUpdateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! key, Microsoft.Agents.ObjectModel.Abstractions.EvaluationResult! value, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.ReadStateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! key, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.ConvertValueWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, Microsoft.Agents.AI.Workflows.Declarative.Kit.VariableType! targetType, string! key, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.EvaluateValueWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! expression, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, System.Collections.Generic.IEnumerable! lines) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, System.Collections.Generic.IEnumerable! lines, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! line) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! line, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask!> diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index ab058de62d4..d7ac870b3a9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -1 +1,13 @@ #nullable enable +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowProcessEnvironmentVariableFallback.get -> bool +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowProcessEnvironmentVariableFallback.init -> void +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowedEnvironmentVariables.get -> System.Collections.Generic.IEnumerable? +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowedEnvironmentVariables.init -> void +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.QueueStateUpdateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! key, Microsoft.Agents.ObjectModel.Abstractions.EvaluationResult! value, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.ReadStateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! key, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.ConvertValueWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, Microsoft.Agents.AI.Workflows.Declarative.Kit.VariableType! targetType, string! key, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.EvaluateValueWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! expression, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, System.Collections.Generic.IEnumerable! lines) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, System.Collections.Generic.IEnumerable! lines, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! line) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! line, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask!> diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs index 47167cd9e53..69b6c1e9bcc 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs @@ -423,7 +423,7 @@ await Task.WhenAll(executorNotifyTask, restoreCheckpointIndexTask.AsTask()).ConfigureAwait(false); this._lastCheckpointInfo = checkpointInfo; - this.StepTracer.Reload(this.StepTracer.StepNumber); + this.StepTracer.Reload(checkpoint.StepNumber); async ValueTask UpdateCheckpointIndexAsync() { diff --git a/dotnet/src/Shared/Workflows/Execution/WorkflowFactory.cs b/dotnet/src/Shared/Workflows/Execution/WorkflowFactory.cs index 68e4af28786..b2e0db233a6 100644 --- a/dotnet/src/Shared/Workflows/Execution/WorkflowFactory.cs +++ b/dotnet/src/Shared/Workflows/Execution/WorkflowFactory.cs @@ -16,6 +16,8 @@ internal sealed class WorkflowFactory(string workflowFile, Uri foundryEndpoint) public IConfiguration? Configuration { get; init; } + public IEnumerable? AllowedEnvironmentVariables { get; init; } + // Assign to continue an existing conversation public string? ConversationId { get; init; } @@ -46,6 +48,7 @@ public Workflow CreateWorkflow() new(agentProvider) { Configuration = this.Configuration, + AllowedEnvironmentVariables = this.AllowedEnvironmentVariables, ConversationId = this.ConversationId, LoggerFactory = this.LoggerFactory, McpToolHandler = this.McpToolHandler, diff --git a/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/AgentBotElementYamlTests.cs b/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/AgentBotElementYamlTests.cs index 418a68e25e2..52cdbf30b30 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/AgentBotElementYamlTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/AgentBotElementYamlTests.cs @@ -4,6 +4,8 @@ using System.IO; using System.Linq; using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; using Microsoft.Agents.ObjectModel; using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; @@ -220,7 +222,7 @@ public void FromYaml_RemoteConnection() } [Fact] - public void FromYaml_WithVariableReferences() + public async Task FromYaml_WithVariableReferences() { // Arrange IConfiguration configuration = new ConfigurationBuilder() @@ -234,7 +236,10 @@ public void FromYaml_WithVariableReferences() .Build(); // Act - var agent = AgentBotElementYaml.FromYaml(PromptAgents.AgentWithVariableReferences, configuration); + var agent = AgentBotElementYaml.FromYaml( + PromptAgents.AgentWithVariableReferences, + configuration, + ["OpenAIEndpoint", "OpenAIApiKey", "Temperature", "TopP"]); // Assert Assert.NotNull(agent); @@ -242,16 +247,16 @@ public void FromYaml_WithVariableReferences() CurrentModels model = (agent.Model as CurrentModels)!; Assert.NotNull(model); Assert.NotNull(model.Options); - Assert.Equal(0.9, Eval(model.Options?.Temperature, configuration)); - Assert.Equal(0.8, Eval(model.Options?.TopP, configuration)); + Assert.Equal(0.9, await EvalAsync(model.Options?.Temperature, configuration, TestContext.Current.CancellationToken)); + Assert.Equal(0.8, await EvalAsync(model.Options?.TopP, configuration, TestContext.Current.CancellationToken)); Assert.NotNull(model.Connection); Assert.IsType(model.Connection); ApiKeyConnection connection = (model.Connection as ApiKeyConnection)!; Assert.NotNull(connection); Assert.NotNull(connection.Endpoint); Assert.NotNull(connection.Key); - Assert.Equal("endpoint", Eval(connection.Endpoint, configuration)); - Assert.Equal("apiKey", Eval(connection.Key, configuration)); + Assert.Equal("endpoint", await EvalAsync(connection.Endpoint, configuration, TestContext.Current.CancellationToken)); + Assert.Equal("apiKey", await EvalAsync(connection.Key, configuration, TestContext.Current.CancellationToken)); } /// @@ -270,7 +275,7 @@ public sealed class PersonInfo public string? Occupation { get; set; } } - private static string? Eval(StringExpression? expression, IConfiguration? configuration = null) + private static async Task EvalAsync(StringExpression? expression, IConfiguration? configuration = null, CancellationToken cancellationToken = default) { if (expression is null) { @@ -286,10 +291,10 @@ public sealed class PersonInfo } } - return expression.Eval(engine); + return await expression.EvalAsync(engine, cancellationToken: cancellationToken).ConfigureAwait(false); } - private static double? Eval(NumberExpression? expression, IConfiguration? configuration = null) + private static async Task EvalAsync(NumberExpression? expression, IConfiguration? configuration = null, CancellationToken cancellationToken = default) { if (expression is null) { @@ -305,6 +310,6 @@ public sealed class PersonInfo } } - return expression.Eval(engine); + return await expression.EvalAsync(engine, cancellationToken: cancellationToken).ConfigureAwait(false); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/ChatClient/ChatClientAgentFactoryTests.cs b/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/ChatClient/ChatClientAgentFactoryTests.cs index 85906620005..ae6a4109438 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/ChatClient/ChatClientAgentFactoryTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/ChatClient/ChatClientAgentFactoryTests.cs @@ -1,7 +1,13 @@ // Copyright (c) Microsoft. All rights reserved. +using System; +using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; +using Microsoft.Agents.ObjectModel; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Configuration; +using Microsoft.PowerFx.Types; using Moq; namespace Microsoft.Agents.AI.Declarative.UnitTests.ChatClient; @@ -104,4 +110,185 @@ public async Task TryCreateAsync_Creates_ToolsAsync() var tools = chatClientAgent?.ChatOptions?.Tools; Assert.Equal(5, tools?.Count); } + + [Fact] + public async Task Constructor_WithNullFunctions_CreatesAgentAsync() + { + // Arrange + var promptAgent = PromptAgents.CreateTestPromptAgent(); + ChatClientPromptAgentFactory factory = new(this._mockChatClient.Object, null); + + // Act + AIAgent? agent = await factory.TryCreateAsync(promptAgent); + + // Assert + Assert.NotNull(agent); + } + + [Fact] + public async Task TryCreateAsync_WithOptions_LoadsAllowedConfigurationAsync() + { + // Arrange + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Temperature"] = "0.9", + ["TopP"] = "0.8", + ["OpenAIEndpoint"] = "https://example.openai.azure.com/", + ["OpenAIApiKey"] = "test-key", + }) + .Build(); + GptComponentMetadata promptAgent = AgentBotElementYaml.FromYaml(PromptAgents.AgentWithVariableReferences); + ChatClientPromptAgentFactory factory = new( + this._mockChatClient.Object, + configuration: configuration, + allowedConfigurationVariables: ["Temperature", "TopP", "OpenAIEndpoint", "OpenAIApiKey"]); + + // Act + AIAgent? agent = await factory.TryCreateAsync(promptAgent); + + // Assert + ChatClientAgent chatClientAgent = Assert.IsType(agent); + Assert.Equal(0.9F, chatClientAgent.ChatOptions?.Temperature); + Assert.Equal(0.8F, chatClientAgent.ChatOptions?.TopP); + } + + [Fact] + public async Task TryCreateAsync_WithLegacyConfiguration_ThrowsAsync() + { + // Arrange + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Temperature"] = "0.9", + ["TopP"] = "0.8", + ["OpenAIEndpoint"] = "https://example.openai.azure.com/", + ["OpenAIApiKey"] = "test-key", + }) + .Build(); + GptComponentMetadata promptAgent = AgentBotElementYaml.FromYaml(PromptAgents.AgentWithVariableReferences); + ChatClientPromptAgentFactory factory = new(this._mockChatClient.Object, configuration: configuration); + + // Act + var exception = await Assert.ThrowsAsync(async () => await factory.TryCreateAsync(promptAgent)); + + // Assert + Assert.Contains("Name isn't valid. 'Temperature' isn't recognized.", exception.Message); + } + + [Fact] + public async Task ProtectedConstructor_WithLegacyConfiguration_ThrowsAsync() + { + // Arrange + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Temperature"] = "0.9", + ["TopP"] = "0.8", + ["SOME_SECRET"] = "secret-value", + }) + .Build(); + GptComponentMetadata promptAgent = AgentBotElementYaml.FromYaml(PromptAgents.AgentWithVariableReferences); + LegacyInspectingPromptAgentFactory factory = new(configuration); + + // Act + await factory.TryCreateAsync(promptAgent); + var exception = await Assert.ThrowsAsync(async () => await factory.EvaluateAsync("Temperature")); + + // Assert + Assert.Contains("Name isn't valid. 'Temperature' isn't recognized.", exception.Message); + } + + [Fact] + public async Task CreateAsync_WithLegacyConfiguration_ThrowsCreateAsync() + { + // Arrange + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Temperature"] = "0.9", + }) + .Build(); + GptComponentMetadata promptAgent = AgentBotElementYaml.FromYaml(PromptAgents.AgentWithVariableReferences); + CreateAsyncInspectingPromptAgentFactory factory = new(configuration, this._mockChatClient.Object); + + // Act + var exception = await Assert.ThrowsAsync(async () => await factory.CreateAsync(promptAgent)); + + // Assert + Assert.Contains("Name isn't valid. 'Temperature' isn't recognized.", exception.Message); + } + + [Fact] + public async Task TryCreateAsync_OnlyLoadsAllowedReferencedConfigurationAsync() + { + // Arrange + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Temperature"] = "0.9", + ["TopP"] = "0.8", + }) + .Build(); + GptComponentMetadata promptAgent = AgentBotElementYaml.FromYaml(PromptAgents.AgentWithVariableReferences); + InspectingPromptAgentFactory factory = new(configuration, ["Temperature"]); + + // Act + await factory.TryCreateAsync(promptAgent); + + // Assert + StringValue temperature = Assert.IsType(await factory.EvaluateAsync("Temperature")); + Assert.Equal("0.9", temperature.Value); + Assert.False(factory.CanEvaluate("TopP")); + } + + private sealed class InspectingPromptAgentFactory(IConfiguration configuration, IEnumerable allowedConfigurationVariables) + : PromptAgentFactory(engine: null, configuration: configuration, allowedConfigurationVariables: allowedConfigurationVariables) + { + public Task EvaluateAsync(string expression, CancellationToken cancellationToken = default) => this.Engine.EvalAsync(expression, cancellationToken); + + public bool CanEvaluate(string expression) => this.Engine.Check(expression).IsSuccess; + + public override Task TryCreateAsync(GptComponentMetadata promptAgent, CancellationToken cancellationToken = default) + { + // Arrange + this.InitializeConfigurationVariables(promptAgent); + + // Act & Assert + return Task.FromResult(null); + } + } + + private sealed class CreateAsyncInspectingPromptAgentFactory(IConfiguration configuration, IChatClient chatClient) + : PromptAgentFactory(engine: null, configuration: configuration) + { + public string? TemperatureValue { get; private set; } + + public override async Task TryCreateAsync(GptComponentMetadata promptAgent, CancellationToken cancellationToken = default) + { + // Arrange + StringValue temperature = Assert.IsType(await this.Engine.EvalAsync("Temperature", cancellationToken)); + + // Act + this.TemperatureValue = temperature.Value; + + // Assert + return new ChatClientAgent(chatClient); + } + } + + private sealed class LegacyInspectingPromptAgentFactory(IConfiguration configuration) + : PromptAgentFactory(engine: null, configuration: configuration) + { + public Task EvaluateAsync(string expression, CancellationToken cancellationToken = default) => this.Engine.EvalAsync(expression, cancellationToken); + + public override Task TryCreateAsync(GptComponentMetadata promptAgent, CancellationToken cancellationToken = default) + { + // Arrange + this.InitializeConfigurationVariables(promptAgent); + + // Act & Assert + return Task.FromResult(null); + } + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs index 7bb92f913c2..1f702d52858 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Reflection; using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Declarative.Interpreter; @@ -11,6 +12,8 @@ using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; using Microsoft.Agents.ObjectModel; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Configuration; +using Microsoft.PowerFx.Types; using Moq; using Xunit.Sdk; @@ -125,6 +128,196 @@ public async Task HostedWorkflowAgentIsolatesDeclarativeStateForImplicitSessions Assert.NotEqual(provider.MessageConversations[0], provider.MessageConversations[1]); } + [Fact] + public async Task Build_OnlyInitializesAllowedReferencedEnvironmentVariablesAsync() + { + // Arrange + const string AllowedName = "AllowedConfig"; + const string HiddenName = "HiddenConfig"; + const string ProcessOnlyName = "ProcessOnlyConfig"; + const string ProcessOnlyValue = "process-value"; + + string? originalProcessOnlyValue = Environment.GetEnvironmentVariable(ProcessOnlyName); + Environment.SetEnvironmentVariable(ProcessOnlyName, ProcessOnlyValue); + + try + { + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [AllowedName] = "allowed-value", + [HiddenName] = "hidden-value", + }) + .Build(); + using StringReader yamlReader = new( + """ + kind: Workflow + trigger: + + kind: OnConversationStart + id: env_boundary_workflow + actions: + + - kind: ConditionGroup + id: environment_boundary_condition + conditions: + - id: environment_boundary_passed + condition: =Env.AllowedConfig = "allowed-value" + actions: + - kind: SendActivity + id: environment_boundary_passed_activity + activity: allowed-configuration-available + elseActions: + - kind: SendActivity + id: environment_boundary_failed_activity + activity: allowed-configuration-missing + + - kind: SetVariable + id: referenced_hidden_configuration + disabled: true + variable: Local.Hidden + value: =Env.HiddenConfig + + - kind: SetVariable + id: referenced_process_configuration + disabled: true + variable: Local.ProcessOnly + value: =Env.ProcessOnlyConfig + """); + Mock mockAgentProvider = CreateMockProvider("Test input message"); + DeclarativeWorkflowOptions options = + new(mockAgentProvider.Object) + { + Configuration = configuration, + AllowedEnvironmentVariables = [AllowedName, ProcessOnlyName], + LoggerFactory = this.Output, + }; + Workflow workflow = DeclarativeWorkflowBuilder.Build(yamlReader, options); + WorkflowFormulaState rootState = GetRootState(workflow); + + // Act + await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, "Test input message"); + + await foreach (WorkflowEvent workflowEvent in run.WatchStreamAsync()) + { + this.WorkflowEvents.Add(workflowEvent); + if (workflowEvent is WorkflowErrorEvent errorEvent) + { + throw errorEvent.Data as Exception ?? new XunitException("Unexpected failure..."); + } + } + + // Assert + StringValue allowedValue = Assert.IsType(rootState.Get(AllowedName, VariableScopeNames.Environment)); + Assert.Equal("allowed-value", allowedValue.Value); + Assert.IsType(rootState.Get(HiddenName, VariableScopeNames.Environment)); + Assert.IsType(rootState.Get(ProcessOnlyName, VariableScopeNames.Environment)); + this.AssertMessage("allowed-configuration-available"); + this.AssertNotMessage("allowed-configuration-missing"); + } + finally + { + Environment.SetEnvironmentVariable(ProcessOnlyName, originalProcessOnlyValue); + } + } + + [Fact] + public async Task Build_WithProcessEnvironmentFallback_LoadsAllowedMissingConfigurationFromProcessEnvironmentAsync() + { + // Arrange + const string ProcessOnlyName = "ProcessOnlyConfigForFallback"; + const string ExplicitName = "ExplicitConfigWinsForFallback"; + const string HiddenName = "HiddenConfigForFallback"; + const string ProcessOnlyValue = "process-only-value"; + const string ExplicitConfigurationValue = "configuration-value"; + const string ExplicitProcessValue = "process-value"; + const string HiddenValue = "hidden-value"; + + string? originalProcessOnlyValue = Environment.GetEnvironmentVariable(ProcessOnlyName); + string? originalExplicitValue = Environment.GetEnvironmentVariable(ExplicitName); + string? originalHiddenValue = Environment.GetEnvironmentVariable(HiddenName); + Environment.SetEnvironmentVariable(ProcessOnlyName, ProcessOnlyValue); + Environment.SetEnvironmentVariable(ExplicitName, ExplicitProcessValue); + Environment.SetEnvironmentVariable(HiddenName, HiddenValue); + + try + { + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [ExplicitName] = ExplicitConfigurationValue, + }) + .Build(); + using StringReader yamlReader = new( + """ + kind: Workflow + trigger: + + kind: OnConversationStart + id: env_fallback_workflow + actions: + + - kind: ConditionGroup + id: environment_fallback_condition + conditions: + - id: environment_fallback_passed + condition: =Env.ProcessOnlyConfigForFallback = "process-only-value" && Env.ExplicitConfigWinsForFallback = "configuration-value" + actions: + - kind: SendActivity + id: environment_fallback_passed_activity + activity: process-environment-fallback-enabled + elseActions: + - kind: SendActivity + id: environment_fallback_failed_activity + activity: process-environment-fallback-failed + + - kind: SetVariable + id: referenced_hidden_process_environment + disabled: true + variable: Local.Hidden + value: =Env.HiddenConfigForFallback + """); + Mock mockAgentProvider = CreateMockProvider("Test input message"); + DeclarativeWorkflowOptions options = + new(mockAgentProvider.Object) + { + Configuration = configuration, + AllowedEnvironmentVariables = [ProcessOnlyName, ExplicitName], + AllowProcessEnvironmentVariableFallback = true, + LoggerFactory = this.Output, + }; + Workflow workflow = DeclarativeWorkflowBuilder.Build(yamlReader, options); + WorkflowFormulaState rootState = GetRootState(workflow); + + // Act + await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, "Test input message"); + + await foreach (WorkflowEvent workflowEvent in run.WatchStreamAsync()) + { + this.WorkflowEvents.Add(workflowEvent); + if (workflowEvent is WorkflowErrorEvent errorEvent) + { + throw errorEvent.Data as Exception ?? new XunitException("Unexpected failure..."); + } + } + + // Assert + StringValue processOnlyValue = Assert.IsType(rootState.Get(ProcessOnlyName, VariableScopeNames.Environment)); + Assert.Equal(ProcessOnlyValue, processOnlyValue.Value); + StringValue explicitValue = Assert.IsType(rootState.Get(ExplicitName, VariableScopeNames.Environment)); + Assert.Equal(ExplicitConfigurationValue, explicitValue.Value); + Assert.IsType(rootState.Get(HiddenName, VariableScopeNames.Environment)); + this.AssertMessage("process-environment-fallback-enabled"); + this.AssertNotMessage("process-environment-fallback-failed"); + } + finally + { + Environment.SetEnvironmentVariable(ProcessOnlyName, originalProcessOnlyValue); + Environment.SetEnvironmentVariable(ExplicitName, originalExplicitValue); + Environment.SetEnvironmentVariable(HiddenName, originalHiddenValue); + } + } + [Fact] public async Task GotoActionAsync() { @@ -372,6 +565,17 @@ private void AssertExecuted(string executorId, bool isAction = true, bool isDisc private void AssertMessage(string message) => Assert.Contains(this.WorkflowEvents.OfType(), e => string.Equals(e.Message.Trim(), message, StringComparison.Ordinal)); + private void AssertNotMessage(string message) => + Assert.DoesNotContain(this.WorkflowEvents.OfType(), e => string.Equals(e.Message.Trim(), message, StringComparison.Ordinal)); + + private static WorkflowFormulaState GetRootState(Workflow workflow) + { + ExecutorBinding rootBinding = workflow.ReflectExecutors()[workflow.StartExecutorId]; + Executor rootExecutor = Assert.IsAssignableFrom(rootBinding.RawValue); + FieldInfo stateField = Assert.Single(rootExecutor.GetType().GetFields(BindingFlags.Instance | BindingFlags.NonPublic), field => field.FieldType == typeof(WorkflowFormulaState)); + return Assert.IsType(stateField.GetValue(rootExecutor)); + } + private Task RunWorkflowAsync(string workflowPath) => this.RunWorkflowAsync(workflowPath, "Test input message"); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Kit/IWorkflowContextExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Kit/IWorkflowContextExtensionsTests.cs new file mode 100644 index 00000000000..d26a1b6a84b --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Kit/IWorkflowContextExtensionsTests.cs @@ -0,0 +1,216 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Declarative.Extensions; +using Microsoft.Agents.AI.Workflows.Declarative.Interpreter; +using Microsoft.Agents.AI.Workflows.Declarative.Kit; +using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; +using Microsoft.Agents.ObjectModel; +using Microsoft.Agents.ObjectModel.Abstractions; +using Microsoft.PowerFx.Types; +using Moq; + +namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Kit; + +public sealed class IWorkflowContextExtensionsTests +{ + [Fact] + public async Task FormatTemplateAsync_WithSensitiveValue_ThrowsAsync() + { + // Arrange + WorkflowFormulaState state = new(RecalcEngineFactory.Create()); + state.Set("SOME_SECRET", FormulaValue.New("secret-value"), VariableScopeNames.Environment, SensitivityLevel.Sensitive); + state.Bind(); + DeclarativeWorkflowContext context = new(new Mock().Object, state); + + // Act + ValueTask FormatAsync() => context.FormatTemplateAsync("={Env.SOME_SECRET}"); + + // Assert + DeclarativeActionException exception = await Assert.ThrowsAsync(async () => await FormatAsync()); + Assert.Contains("Cannot return sensitive workflow expression value", exception.Message); + } + + [Fact] + public async Task FormatTemplateWithSensitivityAsync_WithSensitiveValue_ReturnsSensitivityAsync() + { + // Arrange + WorkflowFormulaState state = new(RecalcEngineFactory.Create()); + state.Set("SOME_SECRET", FormulaValue.New("secret-value"), VariableScopeNames.Environment, SensitivityLevel.Sensitive); + state.Bind(); + DeclarativeWorkflowContext context = new(new Mock().Object, state); + + // Act + EvaluationResult result = await context.FormatTemplateWithSensitivityAsync("={Env.SOME_SECRET}"); + + // Assert + Assert.Equal("=secret-value" + System.Environment.NewLine, result.Value); + Assert.Equal(SensitivityLevel.Sensitive, result.Sensitivity); + } + + [Fact] + public async Task EvaluateValueAsync_WithSensitiveValue_ThrowsAsync() + { + // Arrange + WorkflowFormulaState state = new(RecalcEngineFactory.Create()); + state.Set(SystemScope.Names.LastMessageText, FormulaValue.New("secret-value"), VariableScopeNames.System, SensitivityLevel.Sensitive); + state.Bind(); + DeclarativeWorkflowContext context = new(new Mock().Object, state); + + // Act + ValueTask EvaluateAsync() => context.EvaluateValueAsync("System.LastMessageText"); + + // Assert + DeclarativeActionException exception = await Assert.ThrowsAsync(async () => await EvaluateAsync()); + Assert.Contains("Cannot return sensitive workflow expression value", exception.Message); + } + + [Fact] + public async Task EvaluateValueWithSensitivityAsync_WithSensitiveValue_ReturnsSensitivityAsync() + { + // Arrange + WorkflowFormulaState state = new(RecalcEngineFactory.Create()); + state.Set(SystemScope.Names.LastMessageText, FormulaValue.New("secret-value"), VariableScopeNames.System, SensitivityLevel.Sensitive); + state.Bind(); + DeclarativeWorkflowContext context = new(new Mock().Object, state); + + // Act + EvaluationResult result = await context.EvaluateValueWithSensitivityAsync("System.LastMessageText"); + + // Assert + Assert.Equal("secret-value", result.Value); + Assert.Equal(SensitivityLevel.Sensitive, result.Sensitivity); + } + + [Fact] + public async Task QueueStateUpdateAsync_WithSensitivity_RebindsStateAsync() + { + // Arrange + WorkflowFormulaState state = new(RecalcEngineFactory.Create()); + state.Set("TestValue", FormulaValue.New("old-value")); + state.Bind(); + DeclarativeWorkflowContext context = new(new Mock().Object, state); + + // Act + await context.QueueStateUpdateAsync(PropertyPath.Create("Local.TestValue"), FormulaValue.New("new-value"), SensitivityLevel.Sensitive); + + // Assert + Assert.Equal("new-value", state.Engine.Eval("Local.TestValue").ToObject()); + Assert.Equal(SensitivityLevel.Sensitive, state.GetSensitivity("TestValue", VariableScopeNames.Local)); + } + + [Fact] + public async Task ReadStateWithSensitivityAsync_QueuesSensitiveAssignmentAsync() + { + // Arrange + WorkflowFormulaState state = new(RecalcEngineFactory.Create()); + state.Set(SystemScope.Names.LastMessageText, FormulaValue.New("secret-value"), VariableScopeNames.System, SensitivityLevel.Sensitive); + state.Bind(); + + Mock source = new(MockBehavior.Loose); + source + .Setup(c => c.ReadStateAsync(SystemScope.Names.LastMessageText, VariableScopeNames.System, default)) + .Returns(new ValueTask("secret-value")); + DeclarativeWorkflowContext context = new(source.Object, state); + + // Act + var evaluatedValue = await context.ReadStateWithSensitivityAsync(SystemScope.Names.LastMessageText, VariableScopeNames.System); + await context.QueueStateUpdateWithSensitivityAsync("TestValue", evaluatedValue, VariableScopeNames.Local); + + // Assert + Assert.Equal("secret-value", state.Engine.Eval("Local.TestValue").ToObject()); + Assert.Equal(SensitivityLevel.Sensitive, state.GetSensitivity("TestValue", VariableScopeNames.Local)); + } + + [Fact] + public async Task ReadStateWithSensitivityAsync_WithPlainContext_ReadsSensitivitySidecarAsync() + { + // Arrange + Mock context = new(MockBehavior.Loose); + context + .Setup(c => c.ReadStateAsync("TestValue", VariableScopeNames.Local, default)) + .Returns(new ValueTask("secret-value")); + context + .Setup(c => c.ReadStateAsync("TestValue", WorkflowFormulaState.GetSensitivityScopeName(VariableScopeNames.Local), default)) + .Returns(new ValueTask(SensitivityLevel.Sensitive)); + + // Act + var evaluatedValue = await context.Object.ReadStateWithSensitivityAsync("TestValue", VariableScopeNames.Local); + + // Assert + Assert.Equal("secret-value", evaluatedValue.Value); + Assert.Equal(SensitivityLevel.Sensitive, evaluatedValue.Sensitivity); + } + + [Fact] + public async Task QueueStateUpdateWithSensitivityAsync_WithPlainContext_QueuesSensitivitySidecarAsync() + { + // Arrange + Mock context = new(MockBehavior.Strict); + context + .Setup(c => c.QueueStateUpdateAsync("TestValue", "secret-value", VariableScopeNames.Local, default)) + .Returns(default(ValueTask)); + context + .Setup(c => c.QueueStateUpdateAsync("TestValue", SensitivityLevel.Sensitive, WorkflowFormulaState.GetSensitivityScopeName(VariableScopeNames.Local), default)) + .Returns(default(ValueTask)); + + // Act + await context.Object.QueueStateUpdateWithSensitivityAsync("TestValue", new EvaluationResult("secret-value", SensitivityLevel.Sensitive), VariableScopeNames.Local); + + // Assert + context.VerifyAll(); + } + + [Fact] + public async Task GeneratedForeachPattern_WithSensitiveCollection_PreservesItemSensitivityAsync() + { + // Arrange + WorkflowFormulaState state = new(RecalcEngineFactory.Create()); + state.Set( + "SensitiveItems", + FormulaValue.NewTable( + RecordType.Empty(), + FormulaValue.NewRecordFromFields(new NamedValue("Value", FormulaValue.New("first"))), + FormulaValue.NewRecordFromFields(new NamedValue("Value", FormulaValue.New("second")))), + VariableScopeNames.Environment, + SensitivityLevel.Sensitive); + state.Bind(); + DeclarativeWorkflowContext context = new(new Mock().Object, state); + + // Act + EvaluationResult evaluatedValue = await context.EvaluateValueWithSensitivityAsync("Env.SensitiveItems"); + IEnumerable values = Assert.IsAssignableFrom(evaluatedValue.Value); + object? firstValue = values.Cast().First(); + await context.QueueStateUpdateWithSensitivityAsync( + key: "LoopValue", + value: new EvaluationResult(firstValue, evaluatedValue.Sensitivity), + scopeName: VariableScopeNames.Local); + + // Assert + Assert.Equal(SensitivityLevel.Sensitive, evaluatedValue.Sensitivity); + Assert.NotNull(state.Get("LoopValue").ToObject()); + Assert.Equal(SensitivityLevel.Sensitive, state.GetSensitivity("LoopValue")); + } + + [Fact] + public async Task ConvertValueWithSensitivityAsync_WithPlainContext_PreservesSensitivitySidecarAsync() + { + // Arrange + Mock context = new(MockBehavior.Loose); + context + .Setup(c => c.ReadStateAsync("TestValue", VariableScopeNames.Local, default)) + .Returns(new ValueTask(new PortableValue("42"))); + context + .Setup(c => c.ReadStateAsync("TestValue", WorkflowFormulaState.GetSensitivityScopeName(VariableScopeNames.Local), default)) + .Returns(new ValueTask(SensitivityLevel.Sensitive)); + + // Act + EvaluationResult result = await context.Object.ConvertValueWithSensitivityAsync(typeof(decimal), "TestValue", VariableScopeNames.Local); + + // Assert + Assert.Equal(42M, result.Value); + Assert.Equal(SensitivityLevel.Sensitive, result.Sensitivity); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Kit/RootExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Kit/RootExecutorTests.cs new file mode 100644 index 00000000000..4fedeb6d0f5 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Kit/RootExecutorTests.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Declarative.Interpreter; +using Microsoft.Agents.AI.Workflows.Declarative.Kit; +using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; +using Microsoft.Agents.ObjectModel; +using Microsoft.Extensions.Configuration; +using Moq; + +namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Kit; + +public sealed class RootExecutorTests +{ + [Fact] + public async Task InitializeEnvironmentAsync_OnlyQueuesAllowedVariablesAsync() + { + // Arrange + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ALLOWED"] = "allowed-value", + ["HIDDEN"] = "hidden-value", + }) + .Build(); + DeclarativeWorkflowOptions options = + new(new MockAgentProvider().Object) + { + Configuration = configuration, + AllowedEnvironmentVariables = ["ALLOWED"], + }; + TestRootExecutor executor = new(options); + Mock sourceContext = new(MockBehavior.Strict); + sourceContext.Setup(c => c.QueueStateUpdateAsync("ALLOWED", It.IsAny(), VariableScopeNames.Environment, It.IsAny())) + .Returns(default(ValueTask)); + sourceContext.Setup(c => c.QueueStateUpdateAsync("ALLOWED", SensitivityLevel.Sensitive, WorkflowFormulaState.GetSensitivityScopeName(VariableScopeNames.Environment), It.IsAny())) + .Returns(default(ValueTask)); + + DeclarativeWorkflowContext context = new(sourceContext.Object, executor.Session.State); + + // Act + await executor.InitializeAsync(context, "ALLOWED", "HIDDEN"); + + // Assert + sourceContext.Verify(c => c.QueueStateUpdateAsync("ALLOWED", It.IsAny(), VariableScopeNames.Environment, It.IsAny()), Times.Once); + sourceContext.Verify(c => c.QueueStateUpdateAsync("ALLOWED", SensitivityLevel.Sensitive, WorkflowFormulaState.GetSensitivityScopeName(VariableScopeNames.Environment), It.IsAny()), Times.Once); + sourceContext.Verify(c => c.QueueStateUpdateAsync("HIDDEN", It.IsAny(), VariableScopeNames.Environment, It.IsAny()), Times.Never); + } + + private sealed class TestRootExecutor(DeclarativeWorkflowOptions options) : RootExecutor("test_root", options, inputTransform: null) + { + public ValueTask InitializeAsync(IWorkflowContext context, params string[] variableNames) => + this.InitializeEnvironmentAsync(context, variableNames); + + protected override ValueTask ExecuteAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) => + default; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/AddConversationMessageExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/AddConversationMessageExecutorTest.cs index 2f89de4dee5..abef7277cb0 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/AddConversationMessageExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/AddConversationMessageExecutorTest.cs @@ -70,6 +70,62 @@ await this.ExecuteTestAsync( metadata: metadataRecord); } + [Fact] + public async Task AddMessageWithSensitiveContentThrowsAsync() + { + // Arrange + this.State.Set("SOME_SECRET", FormulaValue.New("secret-value"), VariableScopeNames.Environment, SensitivityLevel.Sensitive); + MockAgentProvider mockAgentProvider = new(); + int messageCount = mockAgentProvider.TestMessages.Count; + AddConversationMessage model = + this.CreateModel( + this.FormatDisplayName(nameof(AddMessageWithSensitiveContentThrowsAsync)), + FormatVariablePath("TestMessage"), + "TestConversationId", + AgentMessageRoleWrapper.Get(AgentMessageRole.User), + "={Env.SOME_SECRET}", + metadata: null); + + AddConversationMessageExecutor action = new(model, mockAgentProvider.Object, this.State); + Task ExecuteAsync() => this.ExecuteAsync(action); + + // Act & Assert + DeclarativeActionException exception = await Assert.ThrowsAsync(ExecuteAsync); + Assert.Contains("Cannot send sensitive conversation message content", exception.Message); + Assert.Equal(messageCount, mockAgentProvider.TestMessages.Count); + } + + [Fact] + public async Task AddMessageWithSensitiveMetadataThrowsAsync() + { + // Arrange + Dictionary metadataValues = + new() + { + ["Key1"] = "secret-value", + }; + this.State.Set("SecretMetadata", metadataValues.ToRecordValue().ToFormula(), sensitivity: SensitivityLevel.Sensitive); + MockAgentProvider mockAgentProvider = new(); + int messageCount = mockAgentProvider.TestMessages.Count; + AddConversationMessage model = + this.CreateModel( + this.FormatDisplayName(nameof(AddMessageWithSensitiveMetadataThrowsAsync)), + FormatVariablePath("TestMessage"), + "TestConversationId", + AgentMessageRoleWrapper.Get(AgentMessageRole.User), + "Hello", + metadata: null, + ObjectExpression.Variable(PropertyPath.TopicVariable("SecretMetadata")).ToBuilder()); + + AddConversationMessageExecutor action = new(model, mockAgentProvider.Object, this.State); + Task ExecuteAsync() => this.ExecuteAsync(action); + + // Act & Assert + DeclarativeActionException exception = await Assert.ThrowsAsync(ExecuteAsync); + Assert.Contains("Cannot send sensitive conversation message metadata", exception.Message); + Assert.Equal(messageCount, mockAgentProvider.TestMessages.Count); + } + private async Task ExecuteTestAsync( string displayName, string variableName, @@ -112,10 +168,10 @@ private AddConversationMessage CreateModel( string conversationId, AgentMessageRoleWrapper role, string messageText, - RecordDataValue? metadata) + RecordDataValue? metadata, + ObjectExpression.Builder? metadataExpression = null) { - ObjectExpression.Builder? metadataExpression = null; - if (metadata is not null) + if (metadata is not null && metadataExpression is null) { metadataExpression = ObjectExpression.Literal(metadata).ToBuilder(); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/CopyConversationMessagesExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/CopyConversationMessagesExecutorTest.cs index c0a2fdf6598..67327f87a2f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/CopyConversationMessagesExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/CopyConversationMessagesExecutorTest.cs @@ -86,6 +86,32 @@ await this.ExecuteTestAsync( expectedMessageCount: 1); } + [Fact] + public async Task CopyMessagesWithSensitiveVariableThrowsAsync() + { + // Arrange + List testMessages = + [ + new ChatMessage(ChatRole.User, "Message from variable") + ]; + TableValue messagesTable = testMessages.ToTable(); + this.State.Set("SourceMessages", messagesTable, sensitivity: SensitivityLevel.Sensitive); + MockAgentProvider mockAgentProvider = new(); + int messageCount = mockAgentProvider.TestMessages.Count; + CopyConversationMessages model = this.CreateModel( + this.FormatDisplayName(nameof(CopyMessagesWithSensitiveVariableThrowsAsync)), + "TestConversationId", + ValueExpression.Variable(PropertyPath.TopicVariable("SourceMessages"))); + + CopyConversationMessagesExecutor action = new(model, mockAgentProvider.Object, this.State); + Task ExecuteAsync() => this.ExecuteAsync(action); + + // Act & Assert + DeclarativeActionException exception = await Assert.ThrowsAsync(ExecuteAsync); + Assert.Contains("Cannot send sensitive conversation messages", exception.Message); + Assert.Equal(messageCount, mockAgentProvider.TestMessages.Count); + } + [Fact] public async Task CopyMessagesToWorkflowConversationAsync() { diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeAzureAgentExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeAzureAgentExecutorTest.cs index b4f80f97c90..32c7e7ec8f3 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeAzureAgentExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeAzureAgentExecutorTest.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Declarative.Extensions; using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; using Microsoft.Agents.ObjectModel; @@ -102,6 +103,32 @@ public async Task RecordValuedArgumentIsBoundAsRecordAsync() Assert.Equal("beta", record["b"]); } + [Fact] + public async Task SensitiveInputMessagesThrowAsync() + { + // Arrange + this.State.InitializeSystem(); + List testMessages = + [ + new ChatMessage(ChatRole.User, "Message from variable") + ]; + this.State.Set("SourceMessages", testMessages.ToTable(), sensitivity: SensitivityLevel.Sensitive); + CapturingAgentProvider provider = new("acknowledged"); + InvokeAzureAgent model = + this.CreateModel( + displayName: nameof(SensitiveInputMessagesThrowAsync), + agentName: "BrainMessages", + messages: ValueExpression.Variable(PropertyPath.TopicVariable("SourceMessages"))); + + InvokeAzureAgentExecutor action = new(model, provider, this.State); + Task ExecuteAsync() => this.ExecuteAsync(action, isDiscrete: false); + + // Act & Assert + DeclarativeActionException exception = await Assert.ThrowsAsync(ExecuteAsync); + Assert.Contains("Cannot send sensitive agent input messages", exception.Message); + Assert.Null(provider.CapturedMessages); + } + [Fact] public async Task InlineRecordExpressionArgumentIsBoundAsRecordAsync() { @@ -291,6 +318,7 @@ private InvokeAzureAgent CreateModel( string displayName, string agentName, IReadOnlyList<(string Key, ValueExpression Value)>? arguments = null, + ValueExpression? messages = null, string? responseObjectVariable = null) { InvokeAzureAgent.Builder builder = @@ -315,6 +343,11 @@ private InvokeAzureAgent CreateModel( builder.Input = inputBuilder; } + if (messages is not null) + { + (builder.Input ??= new AzureAgentInput.Builder()).Messages = messages; + } + if (responseObjectVariable is not null) { builder.Output = @@ -360,6 +393,8 @@ private sealed class CapturingAgentProvider(string responseText) : ResponseAgent { public IDictionary? CapturedArguments { get; private set; } + public IEnumerable? CapturedMessages { get; private set; } + public override IAsyncEnumerable InvokeAgentAsync( string agentId, string? agentVersion, @@ -369,6 +404,7 @@ public override IAsyncEnumerable InvokeAgentAsync( CancellationToken cancellationToken = default) { this.CapturedArguments = inputArguments; + this.CapturedMessages = messages; return YieldAsync(responseText); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/QuestionExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/QuestionExecutorTest.cs index dbe056f891e..b5cd92c4c2c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/QuestionExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/QuestionExecutorTest.cs @@ -228,6 +228,27 @@ await this.CaptureResponseTestAsync( expectResponse: false); } + [Fact] + public async Task QuestionCaptureResponseExceedingRepeatCountPreservesDefaultValueSensitivityAsync() + { + // Arrange + this.State.Set("SOME_SECRET", FormulaValue.New("secret-value"), VariableScopeNames.Environment, SensitivityLevel.Sensitive); + this.State.Bind(); + Question model = this.CreateModel( + displayName: nameof(QuestionCaptureResponseExceedingRepeatCountPreservesDefaultValueSensitivityAsync), + variableName: "TestVariable", + repeatCount: 0, + defaultValueExpressionText: "Env.SOME_SECRET"); + + // Act & Assert + await this.CaptureResponseTestAsync( + model, + variableName: "TestVariable", + responseText: null, + expectResponse: false); + Assert.Equal(SensitivityLevel.Sensitive, this.State.GetSensitivity("TestVariable")); + } + [Fact] public async Task QuestionCaptureResponseWithAutoSendFalseAsync() { @@ -438,7 +459,8 @@ private Question CreateModel( SkipQuestionMode? skipMode = null, int? repeatCount = null, EntityReference? entity = null, - DataValue? autoSend = null) + DataValue? autoSend = null, + string? defaultValueExpressionText = null) { BoolExpression.Builder? alwaysPromptExpression = null; if (alwaysPrompt is not null) @@ -457,6 +479,10 @@ private Question CreateModel( { defaultValueExpression = ValueExpression.Literal(defaultValue).ToBuilder(); } + else if (defaultValueExpressionText is not null) + { + defaultValueExpression = ValueExpression.Expression(defaultValueExpressionText).ToBuilder(); + } EnumExpression.Builder? skipModeExpression = null; if (skipMode is not null) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/SendActivityExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/SendActivityExecutorTest.cs index bfddd1b8f0d..4a12f4528f9 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/SendActivityExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/SendActivityExecutorTest.cs @@ -5,6 +5,7 @@ using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; using Microsoft.Agents.ObjectModel; using Microsoft.Extensions.AI; +using Microsoft.PowerFx.Types; namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel; @@ -43,6 +44,25 @@ public async Task CaptureActivityAsync() Assert.Equal(message.MessageId, updateEvent.Update.MessageId); } + [Fact] + public async Task CaptureActivity_WithSensitiveEnvironmentValue_ThrowsAsync() + { + // Arrange + this.State.Set("SOME_SECRET", FormulaValue.New("secret-value"), VariableScopeNames.Environment, SensitivityLevel.Sensitive); + SendActivity model = + this.CreateModel( + this.FormatDisplayName(nameof(CaptureActivity_WithSensitiveEnvironmentValue_ThrowsAsync)), + "={Env.SOME_SECRET}"); + + // Act + SendActivityExecutor action = new(model, this.State); + Task ExecuteAsync() => this.ExecuteAsync(action); + + // Assert + DeclarativeActionException exception = await Assert.ThrowsAsync(ExecuteAsync); + Assert.Contains("Cannot send sensitive activity text", exception.Message); + } + private SendActivity CreateModel(string displayName, string activityMessage, string? summary = null) { MessageActivityTemplate.Builder activityBuilder = diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineFactoryTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineFactoryTests.cs index d158ca552b2..cb081a7ba50 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineFactoryTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineFactoryTests.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; using Microsoft.PowerFx; +using Microsoft.PowerFx.Types; namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.PowerFx; @@ -32,16 +33,17 @@ public void NewInstanceEachTime() } [Fact] - public void HasSetFunctionEnabled() + public void SetFunctionDisabledByDefault() { // Arrange RecalcEngine engine = RecalcEngineFactory.Create(); + engine.UpdateVariable("MyVariable", FormulaValue.New(0)); // Act - CheckResult result = engine.Check("1+1"); + CheckResult result = engine.Check("Set(MyVariable, 1)", options: new ParserOptions() { AllowsSideEffects = true }); // Assert - Assert.True(result.IsSuccess); + Assert.False(result.IsSuccess); } [Fact] diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/WorkflowExpressionEngineTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/WorkflowExpressionEngineTests.cs index ebaaf5d0466..87054d436f7 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/WorkflowExpressionEngineTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/WorkflowExpressionEngineTests.cs @@ -123,6 +123,80 @@ public void StringExpressionGetValueForVariable() expectedValue: "Hello World"); } + [Fact] + public void StringExpressionGetValueForEnvironmentVariableIsSensitive() + { + // Arrange + this.State.Set("SOME_SECRET", FormulaValue.New("secret-value"), VariableScopeNames.Environment, SensitivityLevel.Sensitive); + this.State.Bind(); + + // Act & Assert + this.EvaluateExpression( + StringExpression.Variable(PropertyPath.Create("Env.SOME_SECRET")), + expectedValue: "secret-value", + expectedSensitivity: SensitivityLevel.Sensitive); + } + + [Fact] + public void StringExpressionGetValueForQuotedEnvironmentVariableIsSensitive() + { + // Arrange + this.State.Set("API-KEY", FormulaValue.New("secret-value"), VariableScopeNames.Environment, SensitivityLevel.Sensitive); + this.State.Bind(); + + // Act & Assert + this.EvaluateExpression( + StringExpression.Expression("Env.'API-KEY'"), + expectedValue: "secret-value", + expectedSensitivity: SensitivityLevel.Sensitive); + } + + [Fact] + public void ValueExpressionGetValueForEnvironmentScopeIsSensitive() + { + // Arrange + this.State.Set("SOME_SECRET", FormulaValue.New("secret-value"), VariableScopeNames.Environment, SensitivityLevel.Sensitive); + this.State.Set("PUBLIC_VALUE", FormulaValue.New("public-value"), VariableScopeNames.Environment); + this.State.Bind(); + + // Act + EvaluationResult result = this.State.Evaluator.GetValue(ValueExpression.Expression("Env")); + + // Assert + Assert.Equal(SensitivityLevel.Sensitive, result.Sensitivity); + Assert.NotNull(result.Value.ToObject()); + } + + [Fact] + public void StringExpressionGetValueForComputedDottedAccessIsSensitive() + { + // Arrange + TableValue secretTable = FormulaValue.NewTable( + RecordType.Empty().Add("Value", FormulaType.String), + new RecordValue[] { FormulaValue.NewRecordFromFields(new NamedValue("Value", FormulaValue.New("secret-value"))) }); + this.State.Set("SecretTable", secretTable, VariableScopeNames.Local, SensitivityLevel.Sensitive); + this.State.Bind(); + + // Act & Assert + this.EvaluateExpression( + StringExpression.Expression("First(Local.SecretTable).Value"), + expectedValue: "secret-value", + expectedSensitivity: SensitivityLevel.Sensitive); + } + + [Fact] + public void StringExpressionGetValueForEnvironmentVariableTextLiteralIsNotSensitive() + { + // Arrange + this.State.Set("SOME_SECRET", FormulaValue.New("secret-value"), VariableScopeNames.Environment, SensitivityLevel.Sensitive); + this.State.Bind(); + + // Act & Assert + this.EvaluateExpression( + StringExpression.Expression(@"Concatenate(""Env.SOME_SECRET"", "" literal"")"), + expectedValue: "Env.SOME_SECRET literal"); + } + [Fact] public void StringExpressionGetValueForFormula() => // Arrange, Act & Assert diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/WorkflowFormulaStateTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/WorkflowFormulaStateTests.cs index 5193296db4d..0af699c501c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/WorkflowFormulaStateTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/WorkflowFormulaStateTests.cs @@ -1,8 +1,12 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; using Microsoft.Agents.ObjectModel; using Microsoft.PowerFx.Types; +using Moq; namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.PowerFx; @@ -81,4 +85,23 @@ public void SetOverwritesExistingValue() FormulaValue result = this.State.Get("key1"); Assert.Equal(newValue, result); } + + [Fact] + public async Task RestoreAsync_RestoresPersistedSensitivityAsync() + { + // Arrange + Mock context = new(MockBehavior.Strict); + context.Setup(c => c.ReadStateKeysAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((string? scopeName, CancellationToken _) => scopeName == VariableScopeNames.Local ? new HashSet { "secret" } : []); + context.Setup(c => c.ReadStateAsync("secret", VariableScopeNames.Local, It.IsAny())) + .ReturnsAsync(new PortableValue("secret-value")); + context.Setup(c => c.ReadStateAsync("secret", WorkflowFormulaState.GetSensitivityScopeName(VariableScopeNames.Local), It.IsAny())) + .ReturnsAsync(SensitivityLevel.Sensitive); + + // Act + await this.State.RestoreAsync(context.Object, CancellationToken.None); + + // Assert + Assert.Equal(SensitivityLevel.Sensitive, this.State.GetSensitivity("secret")); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/Condition.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/Condition.cs index 3dfa7b4f68a..04eb0196bc5 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/Condition.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/Condition.cs @@ -56,8 +56,8 @@ internal sealed class SetvariableTestExecutor(FormulaSession session) : ActionEx // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - object? evaluatedValue = await context.EvaluateValueAsync("Value(System.LastMessageText)").ConfigureAwait(false); - await context.QueueStateUpdateAsync(key: "TestValue", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); + var evaluatedValue = await context.EvaluateValueWithSensitivityAsync("Value(System.LastMessageText)").ConfigureAwait(false); + await context.QueueStateUpdateWithSensitivityAsync(key: "TestValue", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); return default; } @@ -71,14 +71,14 @@ internal sealed class ConditiongroupTestExecutor(FormulaSession session) : Actio // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - bool condition0 = await context.EvaluateValueAsync("Mod(Local.TestValue, 2) = 1").ConfigureAwait(false); - if (condition0) + var condition0 = await context.EvaluateValueWithSensitivityAsync("Mod(Local.TestValue, 2) = 1").ConfigureAwait(false); + if (condition0.Value) { return "conditionItem_odd"; } - bool condition1 = await context.EvaluateValueAsync("Mod(Local.TestValue, 2) = 0").ConfigureAwait(false); - if (condition1) + var condition1 = await context.EvaluateValueWithSensitivityAsync("Mod(Local.TestValue, 2) = 0").ConfigureAwait(false); + if (condition1.Value) { return "conditionItem_even"; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/ConditionElse.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/ConditionElse.cs index 2f64bdd3e54..23ccaede2b8 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/ConditionElse.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/ConditionElse.cs @@ -56,8 +56,8 @@ internal sealed class SetvariableTestExecutor(FormulaSession session) : ActionEx // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - object? evaluatedValue = await context.EvaluateValueAsync("Value(System.LastMessageText)").ConfigureAwait(false); - await context.QueueStateUpdateAsync(key: "TestValue", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); + var evaluatedValue = await context.EvaluateValueWithSensitivityAsync("Value(System.LastMessageText)").ConfigureAwait(false); + await context.QueueStateUpdateWithSensitivityAsync(key: "TestValue", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); return default; } @@ -71,8 +71,8 @@ internal sealed class ConditiongroupTestExecutor(FormulaSession session) : Actio // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - bool condition0 = await context.EvaluateValueAsync("Mod(Local.TestValue, 2) = 1").ConfigureAwait(false); - if (condition0) + var condition0 = await context.EvaluateValueWithSensitivityAsync("Mod(Local.TestValue, 2) = 1").ConfigureAwait(false); + if (condition0.Value) { return "conditionItem_odd"; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/EditTable.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/EditTable.cs index 0c612dcae99..5b2fb4eecf2 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/EditTable.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/EditTable.cs @@ -56,8 +56,8 @@ internal sealed class SetVarExecutor(FormulaSession session) : ActionExecutor(id // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - object? evaluatedValue = await context.EvaluateValueAsync("[{id: 3}]").ConfigureAwait(false); - await context.QueueStateUpdateAsync(key: "MyTable", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); + var evaluatedValue = await context.EvaluateValueWithSensitivityAsync("[{id: 3}]").ConfigureAwait(false); + await context.QueueStateUpdateWithSensitivityAsync(key: "MyTable", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); return default; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/EditTableV2.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/EditTableV2.cs index 0c612dcae99..5b2fb4eecf2 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/EditTableV2.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/EditTableV2.cs @@ -56,8 +56,8 @@ internal sealed class SetVarExecutor(FormulaSession session) : ActionExecutor(id // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - object? evaluatedValue = await context.EvaluateValueAsync("[{id: 3}]").ConfigureAwait(false); - await context.QueueStateUpdateAsync(key: "MyTable", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); + var evaluatedValue = await context.EvaluateValueWithSensitivityAsync("[{id: 3}]").ConfigureAwait(false); + await context.QueueStateUpdateWithSensitivityAsync(key: "MyTable", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); return default; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopBreak.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopBreak.cs index 04459394b09..26525b6b710 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopBreak.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopBreak.cs @@ -17,6 +17,8 @@ using Microsoft.Agents.AI.Workflows; using Microsoft.Agents.AI.Workflows.Declarative; using Microsoft.Agents.AI.Workflows.Declarative.Kit; +using Microsoft.Agents.ObjectModel; +using Microsoft.Agents.ObjectModel.Abstractions; using Microsoft.Extensions.AI; namespace Test.WorkflowProviders; @@ -55,8 +57,14 @@ protected override async ValueTask ExecuteAsync(TInput message, IWorkflowContext /// internal sealed class ForeachLoopExecutor(FormulaSession session) : ActionExecutor(id: "foreach_loop", session) { + private const string IndexStateKey = nameof(_index); + private const string ValuesStateKey = nameof(_values); + private const string HasValueStateKey = nameof(HasValue); + private const string SensitivityStateKey = nameof(_sensitivity); + private int _index; - private object[] _values = []; + private PortableValue[] _values = []; + private SensitivityLevel _sensitivity; public bool HasValue { get; private set; } @@ -64,22 +72,23 @@ internal sealed class ForeachLoopExecutor(FormulaSession session) : ActionExecut protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { this._index = 0; - object? evaluatedValue = await context.EvaluateValueAsync("""["a", "b", "c", "d", "e", "f"]""").ConfigureAwait(false); + EvaluationResult evaluatedValue = await context.EvaluateValueWithSensitivityAsync("""["a", "b", "c", "d", "e", "f"]""").ConfigureAwait(false); - if (evaluatedValue == null) + if (evaluatedValue.Value == null) { this._values = []; this.HasValue = false; } else - if (evaluatedValue is IEnumerable evaluatedList) + if (evaluatedValue.Value is IEnumerable evaluatedList) { - this._values = [.. evaluatedList]; + this._values = [.. evaluatedList.Cast().Select(ToPortableValue)]; } else { - this._values = [evaluatedValue]; + this._values = [ToPortableValue(evaluatedValue.Value)]; } + this._sensitivity = evaluatedValue.Sensitivity; await this.ResetAsync(context, cancellationToken).ConfigureAwait(false); @@ -90,10 +99,10 @@ public async ValueTask TakeNextAsync(IWorkflowContext context, object? _, Cancel { if (this.HasValue = this._index < this._values.Length) { - object value = this._values[this._index]; + object? value = this._values[this._index].NormalizePortableValue(); - await context.QueueStateUpdateAsync(key: "LoopValue", value: value, scopeName: "Local").ConfigureAwait(false); - await context.QueueStateUpdateAsync(key: "LoopIndex", value: this._index, scopeName: "Local").ConfigureAwait(false); + await context.QueueStateUpdateWithSensitivityAsync(key: "LoopValue", value: new EvaluationResult(value, this._sensitivity), scopeName: "Local").ConfigureAwait(false); + await context.QueueStateUpdateAsync(key: "LoopIndex", value: this._index, scopeName: "Local").ConfigureAwait(false); this._index++; } @@ -109,6 +118,36 @@ private async ValueTask ResetAsync(IWorkflowContext context, CancellationToken c await context.QueueStateUpdateAsync(key: "LoopValue", value: UnassignedValue.Instance, scopeName: "Local").ConfigureAwait(false); await context.QueueStateUpdateAsync(key: "LoopIndex", value: UnassignedValue.Instance, scopeName: "Local").ConfigureAwait(false); } + + protected override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default) + { + await context.QueueStateUpdateAsync(IndexStateKey, this._index, cancellationToken: cancellationToken).ConfigureAwait(false); + await context.QueueStateUpdateAsync(ValuesStateKey, this._values, cancellationToken: cancellationToken).ConfigureAwait(false); + await context.QueueStateUpdateAsync(HasValueStateKey, this.HasValue, cancellationToken: cancellationToken).ConfigureAwait(false); + await context.QueueStateUpdateAsync(SensitivityStateKey, this._sensitivity, cancellationToken: cancellationToken).ConfigureAwait(false); + + await base.OnCheckpointingAsync(context, cancellationToken).ConfigureAwait(false); + } + + protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default) + { + PortableValue[]? savedValues = + await context.ReadStateAsync(ValuesStateKey, cancellationToken: cancellationToken).ConfigureAwait(false); + if (savedValues is null) + { + return; + } + + this._values = savedValues; + this._index = await context.ReadStateAsync(IndexStateKey, cancellationToken: cancellationToken).ConfigureAwait(false); + this.HasValue = await context.ReadStateAsync(HasValueStateKey, cancellationToken: cancellationToken).ConfigureAwait(false); + this._sensitivity = await context.ReadStateAsync(SensitivityStateKey, cancellationToken: cancellationToken).ConfigureAwait(false); + + await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false); + } + + private static PortableValue ToPortableValue(object? value) => + new(value ?? UnassignedValue.Instance); } /// @@ -119,8 +158,8 @@ internal sealed class SetVariableInnerExecutor(FormulaSession session) : ActionE // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - object? evaluatedValue = await context.EvaluateValueAsync("Local.Count + 1").ConfigureAwait(false); - await context.QueueStateUpdateAsync(key: "Count", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); + var evaluatedValue = await context.EvaluateValueWithSensitivityAsync("Local.Count + 1").ConfigureAwait(false); + await context.QueueStateUpdateWithSensitivityAsync(key: "Count", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); return default; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopContinue.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopContinue.cs index d023184476a..4e6f09438a7 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopContinue.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopContinue.cs @@ -17,6 +17,8 @@ using Microsoft.Agents.AI.Workflows; using Microsoft.Agents.AI.Workflows.Declarative; using Microsoft.Agents.AI.Workflows.Declarative.Kit; +using Microsoft.Agents.ObjectModel; +using Microsoft.Agents.ObjectModel.Abstractions; using Microsoft.Extensions.AI; namespace Test.WorkflowProviders; @@ -55,8 +57,14 @@ protected override async ValueTask ExecuteAsync(TInput message, IWorkflowContext /// internal sealed class ForeachLoopExecutor(FormulaSession session) : ActionExecutor(id: "foreach_loop", session) { + private const string IndexStateKey = nameof(_index); + private const string ValuesStateKey = nameof(_values); + private const string HasValueStateKey = nameof(HasValue); + private const string SensitivityStateKey = nameof(_sensitivity); + private int _index; - private object[] _values = []; + private PortableValue[] _values = []; + private SensitivityLevel _sensitivity; public bool HasValue { get; private set; } @@ -64,22 +72,23 @@ internal sealed class ForeachLoopExecutor(FormulaSession session) : ActionExecut protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { this._index = 0; - object? evaluatedValue = await context.EvaluateValueAsync("""["a", "b", "c", "d", "e", "f"]""").ConfigureAwait(false); + EvaluationResult evaluatedValue = await context.EvaluateValueWithSensitivityAsync("""["a", "b", "c", "d", "e", "f"]""").ConfigureAwait(false); - if (evaluatedValue == null) + if (evaluatedValue.Value == null) { this._values = []; this.HasValue = false; } else - if (evaluatedValue is IEnumerable evaluatedList) + if (evaluatedValue.Value is IEnumerable evaluatedList) { - this._values = [.. evaluatedList]; + this._values = [.. evaluatedList.Cast().Select(ToPortableValue)]; } else { - this._values = [evaluatedValue]; + this._values = [ToPortableValue(evaluatedValue.Value)]; } + this._sensitivity = evaluatedValue.Sensitivity; await this.ResetAsync(context, cancellationToken).ConfigureAwait(false); @@ -90,10 +99,10 @@ public async ValueTask TakeNextAsync(IWorkflowContext context, object? _, Cancel { if (this.HasValue = this._index < this._values.Length) { - object value = this._values[this._index]; + object? value = this._values[this._index].NormalizePortableValue(); - await context.QueueStateUpdateAsync(key: "LoopValue", value: value, scopeName: "Local").ConfigureAwait(false); - await context.QueueStateUpdateAsync(key: "LoopIndex", value: this._index, scopeName: "Local").ConfigureAwait(false); + await context.QueueStateUpdateWithSensitivityAsync(key: "LoopValue", value: new EvaluationResult(value, this._sensitivity), scopeName: "Local").ConfigureAwait(false); + await context.QueueStateUpdateAsync(key: "LoopIndex", value: this._index, scopeName: "Local").ConfigureAwait(false); this._index++; } @@ -109,6 +118,36 @@ private async ValueTask ResetAsync(IWorkflowContext context, CancellationToken c await context.QueueStateUpdateAsync(key: "LoopValue", value: UnassignedValue.Instance, scopeName: "Local").ConfigureAwait(false); await context.QueueStateUpdateAsync(key: "LoopIndex", value: UnassignedValue.Instance, scopeName: "Local").ConfigureAwait(false); } + + protected override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default) + { + await context.QueueStateUpdateAsync(IndexStateKey, this._index, cancellationToken: cancellationToken).ConfigureAwait(false); + await context.QueueStateUpdateAsync(ValuesStateKey, this._values, cancellationToken: cancellationToken).ConfigureAwait(false); + await context.QueueStateUpdateAsync(HasValueStateKey, this.HasValue, cancellationToken: cancellationToken).ConfigureAwait(false); + await context.QueueStateUpdateAsync(SensitivityStateKey, this._sensitivity, cancellationToken: cancellationToken).ConfigureAwait(false); + + await base.OnCheckpointingAsync(context, cancellationToken).ConfigureAwait(false); + } + + protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default) + { + PortableValue[]? savedValues = + await context.ReadStateAsync(ValuesStateKey, cancellationToken: cancellationToken).ConfigureAwait(false); + if (savedValues is null) + { + return; + } + + this._values = savedValues; + this._index = await context.ReadStateAsync(IndexStateKey, cancellationToken: cancellationToken).ConfigureAwait(false); + this.HasValue = await context.ReadStateAsync(HasValueStateKey, cancellationToken: cancellationToken).ConfigureAwait(false); + this._sensitivity = await context.ReadStateAsync(SensitivityStateKey, cancellationToken: cancellationToken).ConfigureAwait(false); + + await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false); + } + + private static PortableValue ToPortableValue(object? value) => + new(value ?? UnassignedValue.Instance); } /// @@ -119,8 +158,8 @@ internal sealed class SetVariableInnerExecutor(FormulaSession session) : ActionE // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - object? evaluatedValue = await context.EvaluateValueAsync("Local.Count + 1").ConfigureAwait(false); - await context.QueueStateUpdateAsync(key: "Count", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); + var evaluatedValue = await context.EvaluateValueWithSensitivityAsync("Local.Count + 1").ConfigureAwait(false); + await context.QueueStateUpdateWithSensitivityAsync(key: "Count", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); return default; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopEach.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopEach.cs index a467d42f338..ffacf0fbd19 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopEach.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopEach.cs @@ -17,6 +17,8 @@ using Microsoft.Agents.AI.Workflows; using Microsoft.Agents.AI.Workflows.Declarative; using Microsoft.Agents.AI.Workflows.Declarative.Kit; +using Microsoft.Agents.ObjectModel; +using Microsoft.Agents.ObjectModel.Abstractions; using Microsoft.Extensions.AI; namespace Test.WorkflowProviders; @@ -55,8 +57,14 @@ protected override async ValueTask ExecuteAsync(TInput message, IWorkflowContext /// internal sealed class ForeachLoopExecutor(FormulaSession session) : ActionExecutor(id: "foreach_loop", session) { + private const string IndexStateKey = nameof(_index); + private const string ValuesStateKey = nameof(_values); + private const string HasValueStateKey = nameof(HasValue); + private const string SensitivityStateKey = nameof(_sensitivity); + private int _index; - private object[] _values = []; + private PortableValue[] _values = []; + private SensitivityLevel _sensitivity; public bool HasValue { get; private set; } @@ -64,22 +72,23 @@ internal sealed class ForeachLoopExecutor(FormulaSession session) : ActionExecut protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { this._index = 0; - object? evaluatedValue = await context.EvaluateValueAsync("""["a", "b", "c", "d", "e", "f"]""").ConfigureAwait(false); + EvaluationResult evaluatedValue = await context.EvaluateValueWithSensitivityAsync("""["a", "b", "c", "d", "e", "f"]""").ConfigureAwait(false); - if (evaluatedValue == null) + if (evaluatedValue.Value == null) { this._values = []; this.HasValue = false; } else - if (evaluatedValue is IEnumerable evaluatedList) + if (evaluatedValue.Value is IEnumerable evaluatedList) { - this._values = [.. evaluatedList]; + this._values = [.. evaluatedList.Cast().Select(ToPortableValue)]; } else { - this._values = [evaluatedValue]; + this._values = [ToPortableValue(evaluatedValue.Value)]; } + this._sensitivity = evaluatedValue.Sensitivity; await this.ResetAsync(context, cancellationToken).ConfigureAwait(false); @@ -90,10 +99,10 @@ public async ValueTask TakeNextAsync(IWorkflowContext context, object? _, Cancel { if (this.HasValue = this._index < this._values.Length) { - object value = this._values[this._index]; + object? value = this._values[this._index].NormalizePortableValue(); - await context.QueueStateUpdateAsync(key: "LoopValue", value: value, scopeName: "Local").ConfigureAwait(false); - await context.QueueStateUpdateAsync(key: "LoopIndex", value: this._index, scopeName: "Local").ConfigureAwait(false); + await context.QueueStateUpdateWithSensitivityAsync(key: "LoopValue", value: new EvaluationResult(value, this._sensitivity), scopeName: "Local").ConfigureAwait(false); + await context.QueueStateUpdateAsync(key: "LoopIndex", value: this._index, scopeName: "Local").ConfigureAwait(false); this._index++; } @@ -109,6 +118,36 @@ private async ValueTask ResetAsync(IWorkflowContext context, CancellationToken c await context.QueueStateUpdateAsync(key: "LoopValue", value: UnassignedValue.Instance, scopeName: "Local").ConfigureAwait(false); await context.QueueStateUpdateAsync(key: "LoopIndex", value: UnassignedValue.Instance, scopeName: "Local").ConfigureAwait(false); } + + protected override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default) + { + await context.QueueStateUpdateAsync(IndexStateKey, this._index, cancellationToken: cancellationToken).ConfigureAwait(false); + await context.QueueStateUpdateAsync(ValuesStateKey, this._values, cancellationToken: cancellationToken).ConfigureAwait(false); + await context.QueueStateUpdateAsync(HasValueStateKey, this.HasValue, cancellationToken: cancellationToken).ConfigureAwait(false); + await context.QueueStateUpdateAsync(SensitivityStateKey, this._sensitivity, cancellationToken: cancellationToken).ConfigureAwait(false); + + await base.OnCheckpointingAsync(context, cancellationToken).ConfigureAwait(false); + } + + protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default) + { + PortableValue[]? savedValues = + await context.ReadStateAsync(ValuesStateKey, cancellationToken: cancellationToken).ConfigureAwait(false); + if (savedValues is null) + { + return; + } + + this._values = savedValues; + this._index = await context.ReadStateAsync(IndexStateKey, cancellationToken: cancellationToken).ConfigureAwait(false); + this.HasValue = await context.ReadStateAsync(HasValueStateKey, cancellationToken: cancellationToken).ConfigureAwait(false); + this._sensitivity = await context.ReadStateAsync(SensitivityStateKey, cancellationToken: cancellationToken).ConfigureAwait(false); + + await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false); + } + + private static PortableValue ToPortableValue(object? value) => + new(value ?? UnassignedValue.Instance); } /// @@ -119,8 +158,8 @@ internal sealed class SetVariableInnerExecutor(FormulaSession session) : ActionE // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - object? evaluatedValue = await context.EvaluateValueAsync("Local.Count + 1").ConfigureAwait(false); - await context.QueueStateUpdateAsync(key: "Count", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); + var evaluatedValue = await context.EvaluateValueWithSensitivityAsync("Local.Count + 1").ConfigureAwait(false); + await context.QueueStateUpdateWithSensitivityAsync(key: "Count", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); return default; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/ParseValue.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/ParseValue.cs index 55d4ba1efb3..d254ab9498c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/ParseValue.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/ParseValue.cs @@ -73,8 +73,8 @@ internal sealed class ParseVarExecutor(FormulaSession session) : ActionExecutor( protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { VariableType targetType = typeof(decimal); - object? parsedValue = await context.ConvertValueAsync(targetType, key: "MySource", scopeName: "Local", cancellationToken).ConfigureAwait(false); - await context.QueueStateUpdateAsync(key: "MyVar", value: parsedValue, scopeName: "Local").ConfigureAwait(false); + var parsedValue = await context.ConvertValueWithSensitivityAsync(targetType, key: "MySource", scopeName: "Local", cancellationToken).ConfigureAwait(false); + await context.QueueStateUpdateWithSensitivityAsync(key: "MyVar", value: parsedValue, scopeName: "Local").ConfigureAwait(false); return default; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/SendActivity.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/SendActivity.cs index 05cd29c5744..b092f100023 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/SendActivity.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/SendActivity.cs @@ -56,8 +56,8 @@ internal sealed class SetInputExecutor(FormulaSession session) : ActionExecutor( // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - object? evaluatedValue = await context.ReadStateAsync(key: "LastMessageText", scopeName: "System").ConfigureAwait(false); - await context.QueueStateUpdateAsync(key: "TestValue", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); + var evaluatedValue = await context.ReadStateWithSensitivityAsync(key: "LastMessageText", scopeName: "System").ConfigureAwait(false); + await context.QueueStateUpdateWithSensitivityAsync(key: "TestValue", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); return default; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/SetTextVariable.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/SetTextVariable.cs index 98b3bf23511..20157631ee7 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/SetTextVariable.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/SetTextVariable.cs @@ -55,12 +55,12 @@ internal sealed class SetTextExecutor(FormulaSession session) : ActionExecutor(i { protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - string textValue = - await context.FormatTemplateAsync( + var textValue = + await context.FormatTemplateWithSensitivityAsync( """ Test content """); - await context.QueueStateUpdateAsync(key: "TestVar", value: textValue, scopeName: "Local").ConfigureAwait(false); + await context.QueueStateUpdateWithSensitivityAsync(key: "TestVar", value: textValue, scopeName: "Local").ConfigureAwait(false); return default; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/SetVariable.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/SetVariable.cs index 84bf8ff5f56..b1939dd0d3a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/SetVariable.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/SetVariable.cs @@ -56,8 +56,8 @@ internal sealed class SetVarExecutor(FormulaSession session) : ActionExecutor(id // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - object? evaluatedValue = await context.EvaluateValueAsync("3").ConfigureAwait(false); - await context.QueueStateUpdateAsync(key: "TestVar", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); + var evaluatedValue = await context.EvaluateValueWithSensitivityAsync("3").ConfigureAwait(false); + await context.QueueStateUpdateWithSensitivityAsync(key: "TestVar", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); return default; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/CheckpointResumeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/CheckpointResumeTests.cs index e1d7af741c6..d725927777c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/CheckpointResumeTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/CheckpointResumeTests.cs @@ -299,6 +299,65 @@ internal async Task Checkpoint_Restore_ClearsQueuedExternalResponsesBeforeImport Assert.Equal(RunStatus.Idle, finalStatus); } +#if NETFRAMEWORK + /// + /// Verifies restored runs continue superstep numbering from the checkpoint's saved step. + /// + [Theory] + [InlineData(ExecutionEnvironment.InProcess_Lockstep, false)] + [InlineData(ExecutionEnvironment.InProcess_Lockstep, true)] + internal async Task Checkpoint_Restore_ContinuesStepNumberFromCheckpointAsync( + ExecutionEnvironment environment, + bool rehydrateToRestore) + { + // Arrange + Workflow workflow = CreateSimpleRequestWorkflow(); + CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); + InProcessExecutionEnvironment env = environment.ToWorkflowExecutionEnvironment(); + + await using StreamingRun run = await env.WithCheckpointing(checkpointManager) + .RunStreamingAsync(workflow, "Hello"); + + (ExternalRequest pendingRequest, CheckpointInfo checkpoint) = await CapturePendingRequestAndCheckpointAsync(run); + + // Advance the original run so live restore must rewind the tracer. + await run.SendResponseAsync(pendingRequest.CreateResponse("World")); + List firstCompletionEvents = await ReadToHaltAsync(run); + Assert.Empty(firstCompletionEvents.OfType() ?? []); + + if (rehydrateToRestore) + { + await run.DisposeAsync(); + + await using StreamingRun resumedRun = await env.WithCheckpointing(checkpointManager) + .ResumeStreamingAsync(workflow, checkpoint); + + await AssertRestoredRunContinuesFromCheckpointAsync(resumedRun); + } + else + { + await run.RestoreCheckpointAsync(checkpoint); + + await AssertRestoredRunContinuesFromCheckpointAsync(run); + } + + static async ValueTask AssertRestoredRunContinuesFromCheckpointAsync(StreamingRun restoredRun) + { + List restoredEvents = await ReadToHaltAsync(restoredRun); + ExternalRequest replayedRequest = Assert.Single(restoredEvents.OfType() + .Select(evt => evt.Request)); + + await restoredRun.SendResponseAsync(replayedRequest.CreateResponse("Again")); + List restoredCompletionEvents = await ReadToHaltAsync(restoredRun); + + Assert.Empty(restoredCompletionEvents.OfType() ?? []); + SuperStepCompletedEvent? resumedCompletion = restoredCompletionEvents.OfType().FirstOrDefault(); + Assert.NotNull(resumedCompletion); + Assert.Equal(1, resumedCompletion.StepNumber); + } + } +#endif + /// /// Verifies that fan-in edge state buffered before a checkpoint is still present after resume. /// diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/05_Simple_Workflow_Checkpointing.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/05_Simple_Workflow_Checkpointing.cs index f203f72c58b..9795d668de1 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/05_Simple_Workflow_Checkpointing.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/05_Simple_Workflow_Checkpointing.cs @@ -62,9 +62,9 @@ await environment.WithCheckpointing(checkpointManager) Assert.NotNull(result); // Depending on the timing of the response with respect to the underlying workflow - // we may end up with an extra superstep in between. - Assert.True(checkpoints.Count >= 6); - Assert.True(checkpoints.Count <= 7); + // we may end up with extra supersteps in between. + Assert.True(checkpoints.Count >= 6, $"Expected at least 6 checkpoints, got {checkpoints.Count}."); + Assert.True(checkpoints.Count <= 12, $"Expected at most 12 checkpoints, got {checkpoints.Count}."); cancellationSource.Dispose(); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/SampleSmokeTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/SampleSmokeTest.cs index fa1ce7b974f..759c61a160b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/SampleSmokeTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/SampleSmokeTest.cs @@ -117,17 +117,7 @@ internal async Task Test_RunSample_Step5Async(ExecutionEnvironment environment) { using StringWriter writer = new(); - VerifyingPlaybackResponder responder = new( - // Iteration 1 - ("Guess the number.", 50), - ("Your guess was too high. Try again.", 23), - - // Iteration 2 - ("Your guess was too high. Try again.", 23), - ("Your guess was too low. Try again.", 42) - ); - - string guessResult = await Step5EntryPoint.RunAsync(writer, userGuessCallback: responder.InvokeNext, environment.ToWorkflowExecutionEnvironment()); + string guessResult = await Step5EntryPoint.RunAsync(writer, userGuessCallback: RespondToGuessPrompt, environment.ToWorkflowExecutionEnvironment()); Assert.Equal("You guessed correctly! You Win!", guessResult); } @@ -139,17 +129,7 @@ internal async Task Test_RunSample_Step5aAsync(ExecutionEnvironment environment) { using StringWriter writer = new(); - VerifyingPlaybackResponder responder = new( - // Iteration 1 - ("Guess the number.", 50), - ("Your guess was too high. Try again.", 23), - - // Iteration 2 - ("Your guess was too high. Try again.", 23), - ("Your guess was too low. Try again.", 42) - ); - - string guessResult = await Step5EntryPoint.RunAsync(writer, userGuessCallback: responder.InvokeNext, environment.ToWorkflowExecutionEnvironment(), rehydrateToRestore: true); + string guessResult = await Step5EntryPoint.RunAsync(writer, userGuessCallback: RespondToGuessPrompt, environment.ToWorkflowExecutionEnvironment(), rehydrateToRestore: true); Assert.Equal("You guessed correctly! You Win!", guessResult); } @@ -161,21 +141,11 @@ internal async Task Test_RunSample_Step5bAsync(ExecutionEnvironment environment) { using StringWriter writer = new(); - VerifyingPlaybackResponder responder = new( - // Iteration 1 - ("Guess the number.", 50), - ("Your guess was too high. Try again.", 23), - - // Iteration 2 - ("Your guess was too high. Try again.", 23), - ("Your guess was too low. Try again.", 42) - ); - JsonSerializerOptions options = new(SampleJsonContext.Default.Options); options.MakeReadOnly(); CheckpointManager memoryJsonManager = CheckpointManager.CreateJson(new InMemoryJsonStore(), options); - string guessResult = await Step5EntryPoint.RunAsync(writer, userGuessCallback: responder.InvokeNext, environment.ToWorkflowExecutionEnvironment(), rehydrateToRestore: true, checkpointManager: memoryJsonManager); + string guessResult = await Step5EntryPoint.RunAsync(writer, userGuessCallback: RespondToGuessPrompt, environment.ToWorkflowExecutionEnvironment(), rehydrateToRestore: true, checkpointManager: memoryJsonManager); Assert.Equal("You guessed correctly! You Win!", guessResult); } @@ -555,6 +525,15 @@ internal async Task Test_RunSample_Step14a_SharedState_IsolatedAcrossSubworkflow Assert.IsType(actualError); } + + private static int RespondToGuessPrompt(string prompt) => + prompt switch + { + "Guess the number." => 50, + "Your guess was too high. Try again." => 23, + "Your guess was too low. Try again." => 42, + _ => throw new InvalidOperationException($"Unexpected prompt: {prompt}") + }; } internal sealed class VerifyingPlaybackResponder