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..2be060b1318 100644 --- a/dotnet/src/Microsoft.Agents.AI.Declarative/ChatClient/ChatClientPromptAgentFactory.cs +++ b/dotnet/src/Microsoft.Agents.AI.Declarative/ChatClient/ChatClientPromptAgentFactory.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.ObjectModel; @@ -20,13 +21,57 @@ 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, + new ChatClientPromptAgentFactoryOptions() + { + Engine = engine, + Configuration = configuration, + AllowedConfigurationVariables = configuration?.AsEnumerable().Select(static pair => pair.Key), + LoggerFactory = loggerFactory, + }, + isValidated: true) { + } + + /// + /// Creates a new instance of the class. + /// + /// The chat client used by created agents. + /// Options used to configure the created agents and declarative expression evaluation. + /// Optional functions exposed as tools to created agents. + /// The configured instance. + public static ChatClientPromptAgentFactory Create( + IChatClient chatClient, + ChatClientPromptAgentFactoryOptions options, + IList? functions = null) => + new(chatClient, functions, ValidateOptions(options), isValidated: true); + + private ChatClientPromptAgentFactory( + IChatClient chatClient, + IList? functions, + ChatClientPromptAgentFactoryOptions options, + bool isValidated) : + base(options.Engine, options.Configuration, options.AllowedConfigurationVariables, options.MaximumExpressionLength, options.MaximumCallDepth) + { + _ = isValidated; Throw.IfNull(chatClient); this._chatClient = chatClient; this._functions = functions; - this._loggerFactory = loggerFactory; + this._loggerFactory = options.LoggerFactory; } /// @@ -34,6 +79,8 @@ public ChatClientPromptAgentFactory(IChatClient chatClient, IList? f { Throw.IfNull(promptAgent); + this.InitializeConfigurationVariables(promptAgent); + var options = new ChatClientAgentOptions() { Name = promptAgent.Name, @@ -51,5 +98,44 @@ public ChatClientPromptAgentFactory(IChatClient chatClient, IList? f private readonly IChatClient _chatClient; private readonly IList? _functions; private readonly ILoggerFactory? _loggerFactory; + + private static ChatClientPromptAgentFactoryOptions ValidateOptions(ChatClientPromptAgentFactoryOptions? options) => + Throw.IfNull(options); #endregion } + +/// +/// Options for configuring . +/// +public sealed class ChatClientPromptAgentFactoryOptions +{ + /// + /// Gets or sets configuration keys that may be exposed to Power Fx when the agent definition references them through Env. + /// + public IEnumerable? AllowedConfigurationVariables { get; init; } + + /// + /// Gets or sets an optional Power Fx engine used to evaluate declarative expressions. + /// + public RecalcEngine? Engine { get; init; } + + /// + /// Gets or sets optional configuration used to resolve explicitly allowed environment variables referenced by the agent definition. + /// + public IConfiguration? Configuration { get; init; } + + /// + /// Gets or sets an optional logger factory used by created agents. + /// + public ILoggerFactory? LoggerFactory { get; init; } + + /// + /// Gets or sets an optional maximum length for Power Fx expressions evaluated by the factory-created engine. + /// + public int? MaximumExpressionLength { get; init; } + + /// + /// Gets or sets an optional maximum nested call depth for Power Fx expressions evaluated by the factory-created engine. + /// + public int? MaximumCallDepth { get; init; } +} 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..d16424c4411 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,83 @@ 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 . + /// 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, allowedConfigurationVariables: null) { - this.Engine = engine ?? new RecalcEngine(); + } + + /// + /// 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(variableName => this._allowedConfigurationVariables.Contains(variableName))) + { + 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 . /// 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..a41196de29c 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. /// @@ -52,6 +63,11 @@ public sealed class DeclarativeWorkflowOptions(ResponseAgentProvider agentProvid /// public int? MaximumExpressionLength { get; init; } + /// + /// Gets a value indicating whether the Power Fx Set function is enabled. + /// + public bool EnableSetFunction { get; init; } + /// /// Gets the used to create loggers for workflow components. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/DeclarativeWorkflowOptionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/DeclarativeWorkflowOptionsExtensions.cs index 1e1c52ab887..b4ce4587c2b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/DeclarativeWorkflowOptionsExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/DeclarativeWorkflowOptionsExtensions.cs @@ -10,5 +10,5 @@ internal static class DeclarativeWorkflowOptionsExtensions private const int DefaultMaximumExpressionLength = 10000; public static RecalcEngine CreateRecalcEngine(this DeclarativeWorkflowOptions? context) => - RecalcEngineFactory.Create(context?.MaximumExpressionLength ?? DefaultMaximumExpressionLength, context?.MaximumCallDepth); + RecalcEngineFactory.Create(context?.MaximumExpressionLength ?? DefaultMaximumExpressionLength, context?.MaximumCallDepth, context?.EnableSetFunction ?? false); } 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..93beb091ab2 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/IWorkflowContextExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/IWorkflowContextExtensions.cs @@ -58,7 +58,9 @@ public static async ValueTask FormatTemplateAsync(this IWorkflowContext StringBuilder builder = new(); foreach (string line in lines) { - builder.AppendLine(state.Engine.Format(TemplateLine.Parse(line))); + EvaluationResult result = state.Evaluator.Format(TemplateLine.Parse(line)); + ThrowIfSensitive(result.Sensitivity); + builder.AppendLine(result.Value); } return builder.ToString(); @@ -86,6 +88,7 @@ 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 (TValue?)result.Value.ToObject(); } @@ -103,10 +106,75 @@ 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, scopeName, 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; + } + + await context.QueueStateUpdateAsync(key, value.Value, scopeName, cancellationToken).ConfigureAwait(false); + + string plainScopeName = scopeName ?? WorkflowFormulaState.DefaultScopeName; + 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 +195,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) @@ -142,7 +210,7 @@ public static async ValueTask FormatTemplateAsync(this IWorkflowContext /// 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 +232,17 @@ 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 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..d5c7273a3f5 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/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/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..7eb2b583a89 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/RecalcEngineFactory.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/RecalcEngineFactory.cs @@ -11,7 +11,8 @@ internal static class RecalcEngineFactory { public static RecalcEngine Create( int? maximumExpressionLength = null, - int? maximumCallDepth = null) + int? maximumCallDepth = null, + bool enableSetFunction = false) { RecalcEngine engine = new(CreateConfig()); @@ -37,7 +38,10 @@ PowerFxConfig CreateConfig() config.MaxCallDepth = maximumCallDepth.Value; } - config.EnableSetFunction(); + if (enableSetFunction) + { + 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..39dfc7efa54 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.OrdinalIgnoreCase); 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..b5c15104323 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,11 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.PowerFx; internal sealed class WorkflowExpressionEngine { - private readonly RecalcEngine _engine; + private readonly WorkflowFormulaState _state; - public WorkflowExpressionEngine(RecalcEngine engine) + public WorkflowExpressionEngine(WorkflowFormulaState state) { - this._engine = engine; + this._state = state; } public EvaluationResult GetValue(BoolExpression boolean) => this.Evaluate(boolean); @@ -41,6 +43,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 +325,131 @@ private EvaluationResult EvaluateScope(ExpressionBase expression) expression.VariableReference?.ToString() : expression.ExpressionText; - FormulaValue result = this._engine.Eval(expressionText); + FormulaValue result = this._state.Engine.Eval(expressionText); 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 this._state.GetSensitivity(variableName, expression.VariableReference.NamespaceAlias); + } + + string? expressionText = expression.ExpressionText; + if (string.IsNullOrWhiteSpace(expressionText)) + { + return SensitivityLevel.None; + } + + CheckResult checkResult = this._state.Engine.Check(expressionText); + checkResult.ThrowOnErrors(); + + SensitivityLevel sensitivity = SensitivityLevel.None; + foreach ((string? ScopeName, string VariableName) reference in GetVariableReferences(checkResult.Parse.Root)) + { + sensitivity = MaxSensitivity(sensitivity, this._state.GetSensitivity(reference.VariableName, reference.ScopeName)); + } + + return sensitivity; } + + 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..a172b43d6a1 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowFormulaState.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowFormulaState.cs @@ -27,6 +27,8 @@ internal sealed class WorkflowFormulaState VariableScopeNames.System, ]; + private const string SensitivityScopePrefix = "__Microsoft_Agents_AI_Workflows_Declarative_Sensitivity:"; + private readonly Dictionary _scopes; private Dictionary _initialScopes; @@ -43,7 +45,7 @@ public WorkflowFormulaState(RecalcEngine engine) this._initialScopes = this.CreateScopeSnapshot(); this.Engine = engine; - this.Evaluator = new WorkflowExpressionEngine(engine); + this.Evaluator = new WorkflowExpressionEngine(this); this.Bind(); } @@ -59,8 +61,26 @@ 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 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 +116,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 +140,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 +184,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 +214,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; } = []; } } 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..47f9ea9f7f2 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,9 @@ #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 +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.EnableSetFunction.get -> bool +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.EnableSetFunction.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!> 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..47f9ea9f7f2 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,9 @@ #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 +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.EnableSetFunction.get -> bool +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.EnableSetFunction.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!> 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..47f9ea9f7f2 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,9 @@ #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 +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.EnableSetFunction.get -> bool +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.EnableSetFunction.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!> 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..47f9ea9f7f2 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,9 @@ #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 +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.EnableSetFunction.get -> bool +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.EnableSetFunction.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!> 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..47f9ea9f7f2 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,9 @@ #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 +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.EnableSetFunction.get -> bool +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.EnableSetFunction.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!> 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..a0cfdc94acd 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/AgentBotElementYamlTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/AgentBotElementYamlTests.cs @@ -234,7 +234,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); 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..56ddda7a89c 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,12 @@ // Copyright (c) Microsoft. All rights reserved. +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 +109,114 @@ 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 = ChatClientPromptAgentFactory.Create( + this._mockChatClient.Object, + options: new ChatClientPromptAgentFactoryOptions() + { + 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_LoadsReferencedConfigurationAsync() + { + // 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 + 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_OnlyLoadsAllowedReferencedConfigurationAsync() + { + // Arrange + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Temperature"] = "0.9", + ["SOME_SECRET"] = "secret-value", + }) + .Build(); + GptComponentMetadata promptAgent = AgentBotElementYaml.FromYaml(PromptAgents.AgentWithVariableReferences); + InspectingPromptAgentFactory factory = new(configuration, ["Temperature"]); + + // Act + await factory.TryCreateAsync(promptAgent); + + // Assert + StringValue temperature = Assert.IsType(factory.Evaluate("Temperature")); + Assert.Equal("0.9", temperature.Value); + Assert.False(factory.CanEvaluate("SOME_SECRET")); + } + + private sealed class InspectingPromptAgentFactory(IConfiguration configuration, IEnumerable allowedConfigurationVariables) + : PromptAgentFactory(engine: null, configuration: configuration, allowedConfigurationVariables: allowedConfigurationVariables) + { + public FormulaValue Evaluate(string expression) => this.Engine.Eval(expression); + + 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); + } + } } 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..529779325c5 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Kit/IWorkflowContextExtensionsTests.cs @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft. All rights reserved. + +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 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(); + } +} 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/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..611953e55b1 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,64 @@ 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 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/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; }