From fbb4c867dd8a917d45c13497d091773da666d4f5 Mon Sep 17 00:00:00 2001 From: Ben Thomas <25218250+alliscode@users.noreply.github.com> Date: Fri, 15 May 2026 14:17:20 -0700 Subject: [PATCH 1/9] Fix declarative workflow regressions for hosted agents Three regressions surfaced when running a declarative workflow as a Foundry hosted agent. Together they caused every condition group to fall through to elseActions and the raw agent JSON to leak to the caller. 1. AgentProviderExtensions.InvokeAgentAsync forced autoSend to true whenever the agent ran on the workflow conversation, which overrode the explicit autoSend: false declared in workflow.yaml and streamed the raw structured-output JSON straight to the user. Honor the caller-supplied autoSend instead. 2. IWorkflowContextExtensions.ReadState / QueueStateUpdateAsync / QueueStateResetAsync took the variable name and namespace alias directly from PropertyPath.VariableName / NamespaceAlias. Against Microsoft.Agents.ObjectModel 2026.2.4.1 those properties return null for a dotted reference such as `Local.Triage` even when SegmentCount == 2 and IsValid == true, so every assignment threw ArgumentNullException via Throw.IfNull. Fall back to Segments() to reconstruct the name and alias when the parser returns null. 3. The same ObjectModel version no longer recognizes the user-facing `Local` scope alias: VariableScopeNames.IsValidName(`Local`) returns false and GetNamespaceFromName(`Local`) returns Unknown, so the declarative interpreter's IsManagedScope check fails and the State.Set call is silently skipped. Translate the `Local` alias to its canonical `Topic` form before forwarding to QueueStateUpdateAsync; WorkflowFormulaState.Bind continues to expose it as `Local` to PowerFx. Verified end-to-end against a deployed Foundry hosted agent: the declarative triage workflow now routes Technical / Billing / General inputs correctly and only the autoSend-eligible messages reach the caller. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Extensions/AgentProviderExtensions.cs | 8 ++++-- .../Extensions/IWorkflowContextExtensions.cs | 25 ++++++++++++++++--- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/AgentProviderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/AgentProviderExtensions.cs index 714ce4747d6..2dcbe8e87a0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/AgentProviderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/AgentProviderExtensions.cs @@ -22,9 +22,13 @@ public static async ValueTask InvokeAgentAsync( { IAsyncEnumerable agentUpdates = agentProvider.InvokeAgentAsync(agentName, null, conversationId, inputMessages, inputArguments, cancellationToken); - // Enable "autoSend" behavior if this is the workflow conversation. + // Determine whether the target conversation is the workflow conversation + // (used below to decide whether to mirror messages into the workflow conversation + // when an agent runs against a different conversation). The caller's autoSend + // value is honored as-is — when the workflow.yaml specifies autoSend: false the + // raw agent output must not be streamed to the caller, even when the agent is + // running on the workflow conversation. bool isWorkflowConversation = context.IsWorkflowConversation(conversationId, out string? workflowConversationId); - autoSend |= isWorkflowConversation; // Process the agent response updates. List updates = []; 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..2f57b4494ae 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Declarative.Interpreter; @@ -21,7 +22,7 @@ public static ValueTask RaiseCompletionEventAsync(this IWorkflowContext context, context.AddEventAsync(new DeclarativeActionCompletedEvent(action), cancellationToken); public static FormulaValue ReadState(this IWorkflowContext context, PropertyPath variablePath) => - context.ReadState(Throw.IfNull(variablePath.VariableName), Throw.IfNull(variablePath.NamespaceAlias)); + context.ReadState(Throw.IfNull(GetVariableName(variablePath)), GetNamespaceAlias(variablePath)); public static FormulaValue ReadState(this IWorkflowContext context, string key, string? scopeName = null) => DeclarativeContext(context).State.Get(key, scopeName); @@ -33,10 +34,28 @@ public static ValueTask SendResultMessageAsync(this IWorkflowContext context, st context.SendMessageAsync(new ActionExecutorResult(id, result), targetId: null, cancellationToken); public static ValueTask QueueStateResetAsync(this IWorkflowContext context, PropertyPath variablePath, CancellationToken cancellationToken = default) => - context.QueueStateUpdateAsync(Throw.IfNull(variablePath.VariableName), UnassignedValue.Instance, Throw.IfNull(variablePath.NamespaceAlias), cancellationToken); + context.QueueStateUpdateAsync(Throw.IfNull(GetVariableName(variablePath)), UnassignedValue.Instance, GetNamespaceAlias(variablePath), cancellationToken); 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); + context.QueueStateUpdateAsync(Throw.IfNull(GetVariableName(variablePath)), value, GetNamespaceAlias(variablePath), cancellationToken); + + // Workaround for ObjectModel 2026.2.4.1 regression: PropertyPath built from a dotted + // reference such as "Local.Triage" returns null for both NamespaceAlias and VariableName + // even when SegmentCount==2 and IsValid==true. Reconstruct from Segments() in that case. + private static string? GetVariableName(PropertyPath variablePath) => + variablePath.VariableName ?? (variablePath.SegmentCount >= 2 ? variablePath.Segments().ElementAtOrDefault(1).PropertyName : variablePath.SegmentCount == 1 ? variablePath.Segments().ElementAtOrDefault(0).PropertyName : null); + + // Workaround for ObjectModel 2026.2.4.1 regression: in addition to the parser bug above, + // the framework's user-facing scope alias "Local" is no longer recognized by + // VariableScopeNames.IsValidName / GetNamespaceFromName (they only accept the canonical + // names "Topic", "Global", "System", "Env"). Translate the "Local" alias back to its + // canonical "Topic" form so downstream IsManagedScope checks succeed. + private static string? GetNamespaceAlias(PropertyPath variablePath) + { + string? alias = variablePath.NamespaceAlias + ?? (variablePath.SegmentCount >= 2 ? variablePath.Segments().ElementAtOrDefault(0).PropertyName : null); + return string.Equals(alias, "Local", StringComparison.Ordinal) ? VariableScopeNames.Topic : alias; + } public static async ValueTask QueueEnvironmentUpdateAsync(this IWorkflowContext context, string key, TValue? value, CancellationToken cancellationToken = default) { From 1baf4af4d8130a80ed317db0bf464badd6089619 Mon Sep 17 00:00:00 2001 From: alliscode <25218250+alliscode@users.noreply.github.com> Date: Fri, 15 May 2026 17:19:50 -0700 Subject: [PATCH 2/9] Hosted-agent HITL: persist session across previous_response_id chains; run approved local AIFunctions Two regressions hit declarative workflows that use require_approval=true when the client chains turns via previous_response_id (no conversation_id): 1. AgentFrameworkResponseHandler keyed the AgentSession store solely on conversation_id, so when only previous_response_id was present the StateBag (which holds ToolApprovalIdMap) was discarded after each turn. The next turn then threw 'No approval mapping recorded for wire id ...' in InputConverter.ConvertMcpApprovalResponse. Fix: fall back to previous_response_id on load and to context.ResponseId on save so the response-id chain becomes a valid session key. Conversation id remains preferred when present. 2. InvokeFunctionToolExecutor.CaptureResponseAsync only acted on FunctionResultContent. In the hosted Foundry path the approval response arrives as a ToolApprovalResponseContent with no FunctionResultContent, so the local AIFunction never ran and downstream PropertyPath/SendActivity consumers (e.g. {Local.RefundResult}) saw empty values. Fix: when no FunctionResultContent matches but an approved ToolApprovalResponseContent does, look up the registered AIFunction by name on agentProvider.Functions and invoke it with the evaluated arguments, surfacing the result through the existing assignment path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AgentFrameworkResponseHandler.cs | 34 +++++++++--- .../ObjectModel/InvokeFunctionToolExecutor.cs | 54 +++++++++++++++++++ 2 files changed, 81 insertions(+), 7 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs index c88fbd88c6e..22090451f77 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs @@ -56,13 +56,24 @@ public override async IAsyncEnumerable CreateAsync( var agent = this.ResolveAgent(request); var sessionStore = this.ResolveSessionStore(request); - // 2. Load or create a new session from the interaction + // 2. Load or create a new session from the interaction. + // + // The session can be keyed by either: + // - conversation_id (used by clients that explicitly thread a conversation), or + // - previous_response_id (used by clients that chain via the Responses API) + // + // Without this fallback, clients that rely on previous_response_id chaining lose + // session state (StateBag — including HITL tool-approval id mappings) between turns. var sessionConversationId = request.GetConversationId(); + var previousResponseId = request.PreviousResponseId; + var sessionLoadKey = !string.IsNullOrWhiteSpace(sessionConversationId) + ? sessionConversationId + : previousResponseId; var chatClientAgent = agent.GetService(); - AgentSession? session = !string.IsNullOrWhiteSpace(sessionConversationId) - ? await sessionStore.GetSessionAsync(agent, sessionConversationId, cancellationToken).ConfigureAwait(false) + AgentSession? session = !string.IsNullOrWhiteSpace(sessionLoadKey) + ? await sessionStore.GetSessionAsync(agent, sessionLoadKey, cancellationToken).ConfigureAwait(false) : chatClientAgent is not null ? await chatClientAgent.CreateSessionAsync(cancellationToken).ConfigureAwait(false) : await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); @@ -81,7 +92,7 @@ public override async IAsyncEnumerable CreateAsync( // (e.g. resuming a workflow paused at an external-input port), the workflow's // checkpointed state already contains the prior turns' messages — replaying history // would re-drive completed actions and break HITL resume semantics. - var isResume = !string.IsNullOrWhiteSpace(sessionConversationId) + var isResume = !string.IsNullOrWhiteSpace(sessionLoadKey) && session?.StateBag?.Count > 0; if (!isResume) { @@ -284,10 +295,19 @@ public override async IAsyncEnumerable CreateAsync( { await enumerator.DisposeAsync().ConfigureAwait(false); - // Persist session after streaming completes (successful or not) - if (session is not null && !string.IsNullOrWhiteSpace(sessionConversationId)) + // Persist session after streaming completes (successful or not). + // + // Save key precedence mirrors the load logic above: + // - conversation_id is stable across all turns of the same conversation. + // - Otherwise we save under this turn's response_id so the next request — + // which arrives with previous_response_id == this response_id — can find it. + var sessionSaveKey = !string.IsNullOrWhiteSpace(sessionConversationId) + ? sessionConversationId + : context.ResponseId; + + if (session is not null && !string.IsNullOrWhiteSpace(sessionSaveKey)) { - await sessionStore.SaveSessionAsync(agent, sessionConversationId, session, cancellationToken).ConfigureAwait(false); + await sessionStore.SaveSessionAsync(agent, sessionSaveKey, session, cancellationToken).ConfigureAwait(false); } } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs index baa6f9c6b85..b4845abcd5b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs @@ -103,6 +103,24 @@ public async ValueTask CaptureResponseAsync( FunctionResultContent? matchingResult = functionResults .FirstOrDefault(r => r.CallId == this.Id); + // When the caller approved an approval-required function call but didn't execute it + // locally (the hosted Foundry scenario, where mcp_approval_response is converted to a + // ToolApprovalResponseContent only), invoke the registered AIFunction here so that the + // declarative workflow can capture the result and continue (e.g. for downstream + // SendActivity/PropertyPath consumers like {Local.Result}). + if (matchingResult is null) + { + ToolApprovalResponseContent? approval = response.Messages + .SelectMany(m => m.Contents) + .OfType() + .FirstOrDefault(r => r.RequestId == this.Id); + + if (approval is { Approved: true }) + { + matchingResult = await this.InvokeRegisteredFunctionAsync(cancellationToken).ConfigureAwait(false); + } + } + if (matchingResult is not null) { // Store the result in output variable @@ -241,6 +259,42 @@ private string GetFunctionName() => return conversationIdValue.Length == 0 ? null : conversationIdValue; } + private async ValueTask InvokeRegisteredFunctionAsync(CancellationToken cancellationToken) + { + string functionName = this.GetFunctionName(); + AIFunction? function = agentProvider.Functions?.FirstOrDefault( + f => string.Equals(f.Name, functionName, System.StringComparison.Ordinal)); + + if (function is null) + { + return new FunctionResultContent( + this.Id, + $"Function '{functionName}' is not registered with the agent provider."); + } + + Dictionary? arguments = this.GetArguments(); + AIFunctionArguments? functionArguments = arguments is null ? null : new AIFunctionArguments(arguments); + + object? result; + try + { + result = await function.InvokeAsync(functionArguments, cancellationToken).ConfigureAwait(false); + } + catch (System.Exception ex) + { + return new FunctionResultContent(this.Id, $"Function '{functionName}' invocation failed: {ex.Message}"); + } + + string serialized = result switch + { + null => string.Empty, + string s => s, + _ => result.ToString() ?? string.Empty, + }; + + return new FunctionResultContent(this.Id, serialized); + } + private bool GetRequireApproval() { if (this.Model.RequireApproval is null) From ea5153cb9884705636348d5f67cc3062aab3d7de Mon Sep 17 00:00:00 2001 From: alliscode <25218250+alliscode@users.noreply.github.com> Date: Mon, 18 May 2026 08:22:09 -0700 Subject: [PATCH 3/9] Apply PropertyPath workaround to initialization path; share + tidy helpers Address PR #5905 review feedback: * Move the PropertyPath VariableName/NamespaceAlias fallback and 'Local' -> 'Topic' scope remap into a shared internal PropertyPathExtensions helper. Materializes Segments() once, names the magic 'Local' alias as a const, and carries a TODO referencing the tracking issue. * Apply the same helper in WorkflowDiagnostics.InitializeDefaults so a declared default for a dotted variable like 'Local.Triage' is no longer silently skipped at workflow startup (closes the gap flagged by the reviewer: runtime ReadState/QueueStateUpdateAsync worked but state.Initialize did not). * Restore the previous strict failure mode on namespace alias by wrapping GetNamespaceAlias() in Throw.IfNull at call sites so a malformed single-segment path keeps failing fast rather than silently passing null to State.Get/Set. All 821 unit tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Extensions/IWorkflowContextExtensions.cs | 25 +------- .../Extensions/PropertyPathExtensions.cs | 63 +++++++++++++++++++ .../PowerFx/WorkflowDiagnostics.cs | 27 ++++++-- 3 files changed, 87 insertions(+), 28 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/PropertyPathExtensions.cs 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 2f57b4494ae..f1667ba715b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Declarative.Interpreter; @@ -22,7 +21,7 @@ public static ValueTask RaiseCompletionEventAsync(this IWorkflowContext context, context.AddEventAsync(new DeclarativeActionCompletedEvent(action), cancellationToken); public static FormulaValue ReadState(this IWorkflowContext context, PropertyPath variablePath) => - context.ReadState(Throw.IfNull(GetVariableName(variablePath)), GetNamespaceAlias(variablePath)); + context.ReadState(Throw.IfNull(variablePath.GetVariableName()), Throw.IfNull(variablePath.GetNamespaceAlias())); public static FormulaValue ReadState(this IWorkflowContext context, string key, string? scopeName = null) => DeclarativeContext(context).State.Get(key, scopeName); @@ -34,28 +33,10 @@ public static ValueTask SendResultMessageAsync(this IWorkflowContext context, st context.SendMessageAsync(new ActionExecutorResult(id, result), targetId: null, cancellationToken); public static ValueTask QueueStateResetAsync(this IWorkflowContext context, PropertyPath variablePath, CancellationToken cancellationToken = default) => - context.QueueStateUpdateAsync(Throw.IfNull(GetVariableName(variablePath)), UnassignedValue.Instance, GetNamespaceAlias(variablePath), cancellationToken); + context.QueueStateUpdateAsync(Throw.IfNull(variablePath.GetVariableName()), UnassignedValue.Instance, Throw.IfNull(variablePath.GetNamespaceAlias()), cancellationToken); public static ValueTask QueueStateUpdateAsync(this IWorkflowContext context, PropertyPath variablePath, TValue? value, CancellationToken cancellationToken = default) => - context.QueueStateUpdateAsync(Throw.IfNull(GetVariableName(variablePath)), value, GetNamespaceAlias(variablePath), cancellationToken); - - // Workaround for ObjectModel 2026.2.4.1 regression: PropertyPath built from a dotted - // reference such as "Local.Triage" returns null for both NamespaceAlias and VariableName - // even when SegmentCount==2 and IsValid==true. Reconstruct from Segments() in that case. - private static string? GetVariableName(PropertyPath variablePath) => - variablePath.VariableName ?? (variablePath.SegmentCount >= 2 ? variablePath.Segments().ElementAtOrDefault(1).PropertyName : variablePath.SegmentCount == 1 ? variablePath.Segments().ElementAtOrDefault(0).PropertyName : null); - - // Workaround for ObjectModel 2026.2.4.1 regression: in addition to the parser bug above, - // the framework's user-facing scope alias "Local" is no longer recognized by - // VariableScopeNames.IsValidName / GetNamespaceFromName (they only accept the canonical - // names "Topic", "Global", "System", "Env"). Translate the "Local" alias back to its - // canonical "Topic" form so downstream IsManagedScope checks succeed. - private static string? GetNamespaceAlias(PropertyPath variablePath) - { - string? alias = variablePath.NamespaceAlias - ?? (variablePath.SegmentCount >= 2 ? variablePath.Segments().ElementAtOrDefault(0).PropertyName : null); - return string.Equals(alias, "Local", StringComparison.Ordinal) ? VariableScopeNames.Topic : alias; - } + context.QueueStateUpdateAsync(Throw.IfNull(variablePath.GetVariableName()), value, Throw.IfNull(variablePath.GetNamespaceAlias()), cancellationToken); public static async ValueTask QueueEnvironmentUpdateAsync(this IWorkflowContext context, string key, TValue? value, CancellationToken cancellationToken = default) { diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/PropertyPathExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/PropertyPathExtensions.cs new file mode 100644 index 00000000000..243291049a5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/PropertyPathExtensions.cs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +using Microsoft.Agents.ObjectModel; + +namespace Microsoft.Agents.AI.Workflows.Declarative.Extensions; + +// TODO(https://github.com/microsoft/agent-framework/issues/5905): remove these workarounds +// once the Microsoft.Agents.ObjectModel regression that broke PropertyPath parsing of +// dotted references such as "Local.Triage" is fixed and the "Local" scope alias is +// recognized again by VariableScopeNames.IsValidName / GetNamespaceFromName. +internal static class PropertyPathExtensions +{ + // User-facing alias for the canonical "Topic" scope. ObjectModel 2026.2.4.1 no longer + // accepts this alias in VariableScopeNames.IsValidName, which causes IsManagedScope + // checks to fail and State.Set to silently skip the update. Translate to canonical form. + internal const string LocalScopeAlias = "Local"; + + /// + /// Returns the variable name from , falling back to the + /// last segment when is null (ObjectModel + /// 2026.2.4.1 returns null for dotted refs like "Local.Triage" even when SegmentCount + /// is 2 and IsValid is true). + /// + internal static string? GetVariableName(this PropertyPath variablePath) + { + if (variablePath.VariableName is { } name) + { + return name; + } + + var segments = variablePath.Segments().ToArray(); + return segments.Length switch + { + 0 => null, + 1 => segments[0].PropertyName, + // Variable name is the segment immediately after the scope alias. Any trailing + // segments are dotted property accessors handled by the PowerFx evaluator and + // are not part of the addressable variable name. + _ => segments[1].PropertyName, + }; + } + + /// + /// Returns the namespace alias from , falling back to the + /// first segment when is null, and remapping + /// the user-facing "Local" alias to its canonical + /// form (see ). + /// + internal static string? GetNamespaceAlias(this PropertyPath variablePath) + { + string? alias = variablePath.NamespaceAlias; + if (alias is null && variablePath.SegmentCount >= 2) + { + alias = variablePath.Segments().FirstOrDefault().PropertyName; + } + + return string.Equals(alias, LocalScopeAlias, StringComparison.Ordinal) + ? VariableScopeNames.Topic + : alias; + } +} 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..85719a8b997 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs @@ -60,20 +60,35 @@ private static void InitializeDefaults(this WorkflowFormulaState scopes, Semanti { foreach (VariableInformationDiagnostic variableDiagnostic in semanticModel.GetVariables(schemaName).Where(x => !x.IsSystemVariable).Select(v => v.ToDiagnostic())) { - if (variableDiagnostic?.Path?.VariableName is null) + // Use the same PropertyPath fallback/remap as runtime reads/writes so dotted + // refs like "Local.Triage" (which ObjectModel 2026.2.4.1 returns with null + // VariableName/NamespaceAlias) and the "Local" scope alias are not silently + // dropped from the initial state. + // TODO(https://github.com/microsoft/agent-framework/issues/5905): drop the + // workaround once the ObjectModel regression is fixed. + PropertyPath? path = variableDiagnostic?.Path; + if (path is null) { continue; } - FormulaValue defaultValue = variableDiagnostic.ConstantValue?.ToFormula() ?? variableDiagnostic.Type.NewBlank(); + string? variableName = path.GetVariableName(); + if (variableName is null) + { + continue; + } + + string? scopeName = path.GetNamespaceAlias(); + + FormulaValue defaultValue = variableDiagnostic!.ConstantValue?.ToFormula() ?? variableDiagnostic.Type.NewBlank(); - if (variableDiagnostic.Path.NamespaceAlias?.Equals(VariableScopeNames.System, StringComparison.OrdinalIgnoreCase) is true && - !SystemScope.AllNames.Contains(variableDiagnostic.Path.VariableName)) + if (scopeName?.Equals(VariableScopeNames.System, StringComparison.OrdinalIgnoreCase) is true && + !SystemScope.AllNames.Contains(variableName)) { - throw new DeclarativeModelException($"Variable '{variableDiagnostic.Path.VariableName}' is not a supported system variable."); + throw new DeclarativeModelException($"Variable '{variableName}' is not a supported system variable."); } - scopes.Set(variableDiagnostic.Path.VariableName, defaultValue, variableDiagnostic.Path.NamespaceAlias ?? WorkflowFormulaState.DefaultScopeName); + scopes.Set(variableName, defaultValue, scopeName ?? WorkflowFormulaState.DefaultScopeName); } } From c6e82917e7aacdfc1a3e7830568f91db0f13468b Mon Sep 17 00:00:00 2001 From: alliscode <25218250+alliscode@users.noreply.github.com> Date: Mon, 18 May 2026 10:16:43 -0700 Subject: [PATCH 4/9] Add tests for AgentProviderExtensions.InvokeAgentAsync autoSend behavior Covers the autoSend regression fix: when the agent runs on the workflow conversation with autoSend=false, no AgentResponseUpdateEvent or AgentResponseEvent is added to the context. Also covers autoSend=true (events emitted) and autoSend=false on a non-workflow conversation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Extensions/AgentProviderExtensionsTest.cs | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/AgentProviderExtensionsTest.cs diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/AgentProviderExtensionsTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/AgentProviderExtensionsTest.cs new file mode 100644 index 00000000000..8bd51e618a0 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/AgentProviderExtensionsTest.cs @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Declarative.Extensions; +using Microsoft.Agents.AI.Workflows.Declarative.Kit; +using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; +using Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel; +using Microsoft.Agents.ObjectModel; +using Microsoft.Extensions.AI; +using Microsoft.PowerFx.Types; +using Moq; + +namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Extensions; + +/// +/// Tests for . +/// +public sealed class AgentProviderExtensionsTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output) +{ + private const string WorkflowConversationId = "workflow-conv-id"; + private const string AgentName = "test-agent"; + + [Fact] + public Task AutoSendFalseOnWorkflowConversationSuppressesResponseEventsAsync() => + this.RunAsync(autoSend: false, conversationId: WorkflowConversationId, expectResponseEvents: false); + + [Fact] + public Task AutoSendTrueOnWorkflowConversationEmitsResponseEventsAsync() => + this.RunAsync(autoSend: true, conversationId: WorkflowConversationId, expectResponseEvents: true); + + [Fact] + public Task AutoSendFalseOnExternalConversationSuppressesResponseEventsAsync() => + this.RunAsync(autoSend: false, conversationId: "other-conv-id", expectResponseEvents: false); + + private async Task RunAsync(bool autoSend, string conversationId, bool expectResponseEvents) + { + // Arrange: seed the workflow conversation id so IsWorkflowConversation can recognize it. + this.State.Set( + SystemScope.Names.ConversationId, + FormulaValue.New(WorkflowConversationId), + VariableScopeNames.System); + + MockAgentProvider mockProvider = new(); + AgentResponseUpdate[] updates = + [ + new(ChatRole.Assistant, "hello "), + new(ChatRole.Assistant, "world"), + ]; + mockProvider + .Setup(p => p.InvokeAgentAsync( + AgentName, + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny())) + .Returns(ToAsyncEnumerableAsync(updates)); + + string actionId = this.CreateActionId().Value; + + // Act + WorkflowEvent[] events = + await this.ExecuteAsync( + actionId, + async (IWorkflowContext context, ActionExecutorResult _, CancellationToken cancellationToken) => + { + await mockProvider.Object.InvokeAgentAsync( + actionId, + context, + AgentName, + conversationId, + autoSend, + cancellationToken: cancellationToken).ConfigureAwait(false); + }); + + // Assert + int updateEventCount = events.OfType().Count(); + int responseEventCount = events.OfType().Count(); + + if (expectResponseEvents) + { + Assert.Equal(updates.Length, updateEventCount); + Assert.Equal(1, responseEventCount); + } + else + { + Assert.Equal(0, updateEventCount); + Assert.Equal(0, responseEventCount); + } + } + + private static async IAsyncEnumerable ToAsyncEnumerableAsync(IEnumerable updates) + { + foreach (AgentResponseUpdate update in updates) + { + yield return update; + } + + await Task.CompletedTask; + } +} From bb1135714a378fbde50911b6161d5714452d03d3 Mon Sep 17 00:00:00 2001 From: alliscode <25218250+alliscode@users.noreply.github.com> Date: Mon, 18 May 2026 12:48:35 -0700 Subject: [PATCH 5/9] Surface SendActivity output via AgentResponseUpdateEvent SendActivityExecutor previously only emitted the activity text via YieldOutputAsync, which the runtime converts to an AgentResponseEvent. WorkflowSession gates AgentResponseEvent behind includeWorkflowOutputsInResponse, so when a host opts out of summary outputs (the default for AsAIAgent) the SendActivity reply is silently dropped. Mirror the pattern used by AgentProviderExtensions for autoSend agent invocations: also emit an AgentResponseUpdateEvent, which WorkflowSession yields unconditionally. This makes SendActivity reliably reach chat-protocol clients without requiring includeWorkflowOutputsInResponse = true (which would also duplicate autoSend agent output). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ObjectModel/SendActivityExecutor.cs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) 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 4df1c83c9d0..66af8d52e3b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs @@ -21,12 +21,21 @@ internal sealed class SendActivityExecutor(SendActivity model, WorkflowFormulaSt await context.AddEventAsync(new MessageActivityEvent(activityText.Trim()), cancellationToken).ConfigureAwait(false); + ChatMessage message = new(ChatRole.Assistant, activityText); + + // Emit an AgentResponseUpdateEvent so chat protocols (e.g. AsAIAgent) receive the + // activity text as streaming chat content. This event is yielded by WorkflowSession + // unconditionally, mirroring how AgentProviderExtensions surfaces autoSend agent + // updates — without it, SendActivity output is dropped whenever the host runs with + // includeWorkflowOutputsInResponse = false (the default). + AgentResponseUpdate update = new(ChatRole.Assistant, activityText) { AuthorName = this.Id }; + await context.AddEventAsync(new AgentResponseUpdateEvent(this.Id, update), cancellationToken).ConfigureAwait(false); + // Route through YieldOutputAsync so the activity participates in the workflow's // output-filter pipeline. The runner currently special-cases AgentResponse to - // produce an AgentResponseEvent identical to the one we'd build by hand, so this - // is behavior-preserving today and forward-compatible if filtering is ever - // applied to agent responses. - AgentResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]); + // produce an AgentResponseEvent identical to the one we'd build by hand, which + // is the gated summary surfaced only when includeWorkflowOutputsInResponse = true. + AgentResponse response = new([message]); await context.YieldOutputAsync(response, cancellationToken).ConfigureAwait(false); } From 28ee58abab01d13c3dbe1582ed16695826843a58 Mon Sep 17 00:00:00 2001 From: alliscode <25218250+alliscode@users.noreply.github.com> Date: Mon, 18 May 2026 16:45:45 -0700 Subject: [PATCH 6/9] Revert previous_response_id session-key fallback The fallback let a session be keyed by an unbroken previous_response_id chain, but conversation_id is the right way to thread state across turns: it survives shared/branched chains (e.g. when another agent generates a response in between) and is the documented model for stateful clients. Restore conversation_id as the sole session key and rely on the client to thread it. The InvokeFunctionTool approval/local-function half of 1baf4af4d remains. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AgentFrameworkResponseHandler.cs | 34 ++++--------------- 1 file changed, 7 insertions(+), 27 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs index 22090451f77..c88fbd88c6e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs @@ -56,24 +56,13 @@ public override async IAsyncEnumerable CreateAsync( var agent = this.ResolveAgent(request); var sessionStore = this.ResolveSessionStore(request); - // 2. Load or create a new session from the interaction. - // - // The session can be keyed by either: - // - conversation_id (used by clients that explicitly thread a conversation), or - // - previous_response_id (used by clients that chain via the Responses API) - // - // Without this fallback, clients that rely on previous_response_id chaining lose - // session state (StateBag — including HITL tool-approval id mappings) between turns. + // 2. Load or create a new session from the interaction var sessionConversationId = request.GetConversationId(); - var previousResponseId = request.PreviousResponseId; - var sessionLoadKey = !string.IsNullOrWhiteSpace(sessionConversationId) - ? sessionConversationId - : previousResponseId; var chatClientAgent = agent.GetService(); - AgentSession? session = !string.IsNullOrWhiteSpace(sessionLoadKey) - ? await sessionStore.GetSessionAsync(agent, sessionLoadKey, cancellationToken).ConfigureAwait(false) + AgentSession? session = !string.IsNullOrWhiteSpace(sessionConversationId) + ? await sessionStore.GetSessionAsync(agent, sessionConversationId, cancellationToken).ConfigureAwait(false) : chatClientAgent is not null ? await chatClientAgent.CreateSessionAsync(cancellationToken).ConfigureAwait(false) : await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); @@ -92,7 +81,7 @@ public override async IAsyncEnumerable CreateAsync( // (e.g. resuming a workflow paused at an external-input port), the workflow's // checkpointed state already contains the prior turns' messages — replaying history // would re-drive completed actions and break HITL resume semantics. - var isResume = !string.IsNullOrWhiteSpace(sessionLoadKey) + var isResume = !string.IsNullOrWhiteSpace(sessionConversationId) && session?.StateBag?.Count > 0; if (!isResume) { @@ -295,19 +284,10 @@ public override async IAsyncEnumerable CreateAsync( { await enumerator.DisposeAsync().ConfigureAwait(false); - // Persist session after streaming completes (successful or not). - // - // Save key precedence mirrors the load logic above: - // - conversation_id is stable across all turns of the same conversation. - // - Otherwise we save under this turn's response_id so the next request — - // which arrives with previous_response_id == this response_id — can find it. - var sessionSaveKey = !string.IsNullOrWhiteSpace(sessionConversationId) - ? sessionConversationId - : context.ResponseId; - - if (session is not null && !string.IsNullOrWhiteSpace(sessionSaveKey)) + // Persist session after streaming completes (successful or not) + if (session is not null && !string.IsNullOrWhiteSpace(sessionConversationId)) { - await sessionStore.SaveSessionAsync(agent, sessionSaveKey, session, cancellationToken).ConfigureAwait(false); + await sessionStore.SaveSessionAsync(agent, sessionConversationId, session, cancellationToken).ConfigureAwait(false); } } } From 4e65b04a34c2c308084e20455eb8fe6275c75d33 Mon Sep 17 00:00:00 2001 From: alliscode <25218250+alliscode@users.noreply.github.com> Date: Thu, 21 May 2026 11:49:49 -0700 Subject: [PATCH 7/9] Set Foundry ProductContext per-executor instead of via PropertyPath workaround ObjectModel 2026.2.4.1 resolves PropertyPath.VariableName / NamespaceAlias and VariableScopeNames.IsValidName against AsyncLocal at access time. In hosted-agent scenarios each HTTP request runs on a fresh async context where that AsyncLocal is default, so dotted refs like Local.Triage returned null and the Local scope alias was rejected. Replace the PropertyPathExtensions helper (which papered over both symptoms) with a single WorkflowDiagnostics.SetFoundryProduct() call at the entry of DeclarativeActionExecutor.HandleAsync. The set writes to the request's logical async context before any code reads PropertyPath, letting the existing parser and scope resolver work as designed. Validated: 824/824 declarative unit tests pass; technical/billing/general routes all dispatch correctly against a deployed Foundry hosted agent. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Extensions/IWorkflowContextExtensions.cs | 6 +- .../Extensions/PropertyPathExtensions.cs | 63 ------------------- .../Interpreter/DeclarativeActionExecutor.cs | 7 +++ .../PowerFx/WorkflowDiagnostics.cs | 27 ++------ 4 files changed, 16 insertions(+), 87 deletions(-) delete mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/PropertyPathExtensions.cs 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 f1667ba715b..1b92235eee3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs @@ -21,7 +21,7 @@ public static ValueTask RaiseCompletionEventAsync(this IWorkflowContext context, context.AddEventAsync(new DeclarativeActionCompletedEvent(action), cancellationToken); public static FormulaValue ReadState(this IWorkflowContext context, PropertyPath variablePath) => - context.ReadState(Throw.IfNull(variablePath.GetVariableName()), Throw.IfNull(variablePath.GetNamespaceAlias())); + context.ReadState(Throw.IfNull(variablePath.VariableName), Throw.IfNull(variablePath.NamespaceAlias)); public static FormulaValue ReadState(this IWorkflowContext context, string key, string? scopeName = null) => DeclarativeContext(context).State.Get(key, scopeName); @@ -33,10 +33,10 @@ public static ValueTask SendResultMessageAsync(this IWorkflowContext context, st context.SendMessageAsync(new ActionExecutorResult(id, result), targetId: null, cancellationToken); public static ValueTask QueueStateResetAsync(this IWorkflowContext context, PropertyPath variablePath, CancellationToken cancellationToken = default) => - context.QueueStateUpdateAsync(Throw.IfNull(variablePath.GetVariableName()), UnassignedValue.Instance, Throw.IfNull(variablePath.GetNamespaceAlias()), cancellationToken); + context.QueueStateUpdateAsync(Throw.IfNull(variablePath.VariableName), UnassignedValue.Instance, Throw.IfNull(variablePath.NamespaceAlias), cancellationToken); public static ValueTask QueueStateUpdateAsync(this IWorkflowContext context, PropertyPath variablePath, TValue? value, CancellationToken cancellationToken = default) => - context.QueueStateUpdateAsync(Throw.IfNull(variablePath.GetVariableName()), value, Throw.IfNull(variablePath.GetNamespaceAlias()), cancellationToken); + context.QueueStateUpdateAsync(Throw.IfNull(variablePath.VariableName), value, Throw.IfNull(variablePath.NamespaceAlias), cancellationToken); public static async ValueTask QueueEnvironmentUpdateAsync(this IWorkflowContext context, string key, TValue? value, CancellationToken cancellationToken = default) { diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/PropertyPathExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/PropertyPathExtensions.cs deleted file mode 100644 index 243291049a5..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/PropertyPathExtensions.cs +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Linq; -using Microsoft.Agents.ObjectModel; - -namespace Microsoft.Agents.AI.Workflows.Declarative.Extensions; - -// TODO(https://github.com/microsoft/agent-framework/issues/5905): remove these workarounds -// once the Microsoft.Agents.ObjectModel regression that broke PropertyPath parsing of -// dotted references such as "Local.Triage" is fixed and the "Local" scope alias is -// recognized again by VariableScopeNames.IsValidName / GetNamespaceFromName. -internal static class PropertyPathExtensions -{ - // User-facing alias for the canonical "Topic" scope. ObjectModel 2026.2.4.1 no longer - // accepts this alias in VariableScopeNames.IsValidName, which causes IsManagedScope - // checks to fail and State.Set to silently skip the update. Translate to canonical form. - internal const string LocalScopeAlias = "Local"; - - /// - /// Returns the variable name from , falling back to the - /// last segment when is null (ObjectModel - /// 2026.2.4.1 returns null for dotted refs like "Local.Triage" even when SegmentCount - /// is 2 and IsValid is true). - /// - internal static string? GetVariableName(this PropertyPath variablePath) - { - if (variablePath.VariableName is { } name) - { - return name; - } - - var segments = variablePath.Segments().ToArray(); - return segments.Length switch - { - 0 => null, - 1 => segments[0].PropertyName, - // Variable name is the segment immediately after the scope alias. Any trailing - // segments are dotted property accessors handled by the PowerFx evaluator and - // are not part of the addressable variable name. - _ => segments[1].PropertyName, - }; - } - - /// - /// Returns the namespace alias from , falling back to the - /// first segment when is null, and remapping - /// the user-facing "Local" alias to its canonical - /// form (see ). - /// - internal static string? GetNamespaceAlias(this PropertyPath variablePath) - { - string? alias = variablePath.NamespaceAlias; - if (alias is null && variablePath.SegmentCount >= 2) - { - alias = variablePath.Segments().FirstOrDefault().PropertyName; - } - - return string.Equals(alias, LocalScopeAlias, StringComparison.Ordinal) - ? VariableScopeNames.Topic - : alias; - } -} 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 0d64822ee34..69db1d9452f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs @@ -71,6 +71,13 @@ public ValueTask ResetAsync() [SendsMessage(typeof(ActionExecutorResult))] public override async ValueTask HandleAsync(ActionExecutorResult message, IWorkflowContext context, CancellationToken cancellationToken = default) { + // Establish the Foundry ProductContext on the current async logical context before + // running any code that reads PropertyPath.VariableName / NamespaceAlias. ObjectModel + // resolves those lazily against AsyncLocal; when the workflow is + // hosted (AsAIAgent + AddFoundryResponses) each HTTP request runs on a fresh logical + // context where the build-thread setting does not flow. + WorkflowDiagnostics.SetFoundryProduct(); + if (this.Model.Disabled) { Debug.WriteLine($"DISABLED {this.GetType().Name} [{this.Id}]"); 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 85719a8b997..95b8f9ab93d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs @@ -60,35 +60,20 @@ private static void InitializeDefaults(this WorkflowFormulaState scopes, Semanti { foreach (VariableInformationDiagnostic variableDiagnostic in semanticModel.GetVariables(schemaName).Where(x => !x.IsSystemVariable).Select(v => v.ToDiagnostic())) { - // Use the same PropertyPath fallback/remap as runtime reads/writes so dotted - // refs like "Local.Triage" (which ObjectModel 2026.2.4.1 returns with null - // VariableName/NamespaceAlias) and the "Local" scope alias are not silently - // dropped from the initial state. - // TODO(https://github.com/microsoft/agent-framework/issues/5905): drop the - // workaround once the ObjectModel regression is fixed. - PropertyPath? path = variableDiagnostic?.Path; - if (path is null) + if (variableDiagnostic?.Path?.VariableName is null) { continue; } - string? variableName = path.GetVariableName(); - if (variableName is null) - { - continue; - } - - string? scopeName = path.GetNamespaceAlias(); - - FormulaValue defaultValue = variableDiagnostic!.ConstantValue?.ToFormula() ?? variableDiagnostic.Type.NewBlank(); + FormulaValue defaultValue = variableDiagnostic.ConstantValue?.ToFormula() ?? variableDiagnostic.Type.NewBlank(); - if (scopeName?.Equals(VariableScopeNames.System, StringComparison.OrdinalIgnoreCase) is true && - !SystemScope.AllNames.Contains(variableName)) + if (variableDiagnostic.Path.NamespaceAlias?.Equals(VariableScopeNames.System, StringComparison.OrdinalIgnoreCase) is true && + !SystemScope.AllNames.Contains(variableDiagnostic.Path.VariableName)) { - throw new DeclarativeModelException($"Variable '{variableName}' is not a supported system variable."); + throw new DeclarativeModelException($"Variable '{variableDiagnostic.Path.VariableName}' is not a supported system variable."); } - scopes.Set(variableName, defaultValue, scopeName ?? WorkflowFormulaState.DefaultScopeName); + scopes.Set(variableDiagnostic.Path.VariableName, defaultValue, variableDiagnostic.Path.NamespaceAlias ?? WorkflowFormulaState.DefaultScopeName); } } From f545d775da8b51bbb2d297705cc05db2ebc00767 Mon Sep 17 00:00:00 2001 From: alliscode <25218250+alliscode@users.noreply.github.com> Date: Thu, 21 May 2026 12:48:56 -0700 Subject: [PATCH 8/9] Address review feedback on InvokeFunctionToolExecutor - Surface registered-function lookup failures and invocation exceptions via FunctionResultContent.Exception instead of returning the error text as a successful Result, so downstream {Local.X} assignments can distinguish failures from successes. - Use AIJsonUtilities.DefaultOptions to JSON-serialize non-string function results (matching FunctionInvokingChatClient / ToolBridge), so complex types stay consumable by PropertyPath consumers instead of degrading to Object.ToString(). - Drop the explicit System. prefix on StringComparison / Exception now that the file imports System. - Add AutoSendTrueOnExternalConversationEmitsResponseEventsAndCopiesMessagesAsync to cover the (autoSend: true, external conversation) quadrant, asserting that response events are emitted and that messages are mirrored to the workflow conversation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ObjectModel/InvokeFunctionToolExecutor.cs | 21 +++++++---- .../Extensions/AgentProviderExtensionsTest.cs | 37 ++++++++++++++++++- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs index b4845abcd5b..d2fa401ab37 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; using System.Linq; using System.Text.Json; @@ -263,13 +264,15 @@ private string GetFunctionName() => { string functionName = this.GetFunctionName(); AIFunction? function = agentProvider.Functions?.FirstOrDefault( - f => string.Equals(f.Name, functionName, System.StringComparison.Ordinal)); + f => string.Equals(f.Name, functionName, StringComparison.Ordinal)); if (function is null) { - return new FunctionResultContent( - this.Id, - $"Function '{functionName}' is not registered with the agent provider."); + return new FunctionResultContent(this.Id, result: null) + { + Exception = new InvalidOperationException( + $"Function '{functionName}' is not registered with the agent provider."), + }; } Dictionary? arguments = this.GetArguments(); @@ -280,16 +283,20 @@ private string GetFunctionName() => { result = await function.InvokeAsync(functionArguments, cancellationToken).ConfigureAwait(false); } - catch (System.Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException) { - return new FunctionResultContent(this.Id, $"Function '{functionName}' invocation failed: {ex.Message}"); + return new FunctionResultContent(this.Id, result: null) { Exception = ex }; } + // Match FunctionInvokingChatClient's serialization: pass strings through as-is and + // JSON-serialize anything else so structured results remain consumable by downstream + // PropertyPath consumers such as {Local.RefundResult}. Use AIJsonUtilities so the + // same trim/AOT-friendly serializer chain used elsewhere in the framework is applied. string serialized = result switch { null => string.Empty, string s => s, - _ => result.ToString() ?? string.Empty, + _ => JsonSerializer.Serialize(result, AIJsonUtilities.DefaultOptions.GetTypeInfo(result.GetType())), }; return new FunctionResultContent(this.Id, serialized); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/AgentProviderExtensionsTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/AgentProviderExtensionsTest.cs index 8bd51e618a0..0393401b481 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/AgentProviderExtensionsTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/AgentProviderExtensionsTest.cs @@ -35,7 +35,19 @@ public Task AutoSendTrueOnWorkflowConversationEmitsResponseEventsAsync() => public Task AutoSendFalseOnExternalConversationSuppressesResponseEventsAsync() => this.RunAsync(autoSend: false, conversationId: "other-conv-id", expectResponseEvents: false); - private async Task RunAsync(bool autoSend, string conversationId, bool expectResponseEvents) + [Fact] + public Task AutoSendTrueOnExternalConversationEmitsResponseEventsAndCopiesMessagesAsync() => + this.RunAsync( + autoSend: true, + conversationId: "other-conv-id", + expectResponseEvents: true, + expectCrossConversationCopy: true); + + private async Task RunAsync( + bool autoSend, + string conversationId, + bool expectResponseEvents, + bool expectCrossConversationCopy = false) { // Arrange: seed the workflow conversation id so IsWorkflowConversation can recognize it. this.State.Set( @@ -59,6 +71,19 @@ private async Task RunAsync(bool autoSend, string conversationId, bool expectRes It.IsAny())) .Returns(ToAsyncEnumerableAsync(updates)); + List<(string ConversationId, ChatMessage Message)> copiedMessages = []; + mockProvider + .Setup(p => p.CreateMessageAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns( + (convId, msg, _) => + { + copiedMessages.Add((convId, msg)); + return Task.FromResult(msg); + }); + string actionId = this.CreateActionId().Value; // Act @@ -90,6 +115,16 @@ await mockProvider.Object.InvokeAgentAsync( Assert.Equal(0, updateEventCount); Assert.Equal(0, responseEventCount); } + + if (expectCrossConversationCopy) + { + Assert.NotEmpty(copiedMessages); + Assert.All(copiedMessages, c => Assert.Equal(WorkflowConversationId, c.ConversationId)); + } + else + { + Assert.Empty(copiedMessages); + } } private static async IAsyncEnumerable ToAsyncEnumerableAsync(IEnumerable updates) From c7aa9901089d2a2a251d21a49e00b79cfcd3cb81 Mon Sep 17 00:00:00 2001 From: alliscode <25218250+alliscode@users.noreply.github.com> Date: Thu, 21 May 2026 17:46:30 -0700 Subject: [PATCH 9/9] Honor AutoSendIsDefaultValue when computing autoSend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AzureAgentOutput.AutoSend and InvokeToolOutput.AutoSend in Microsoft.Agents.ObjectModel 2026.2.4.1 are never null — they return a literal-false default when the YAML omits the field. The previous null check in Get/AutoSendValue therefore always fell through to evaluating the literal false, so every action whose YAML had any output block but no explicit autoSend was treated as autoSend = false. This was previously masked by `autoSend |= isWorkflowConversation` in AgentProviderExtensions (removed earlier in this PR to honor explicit autoSend: false), which silently re-enabled autoSend on the workflow conversation. Use AutoSendIsDefaultValue to distinguish an explicit autoSend value from the implicit default and treat the implicit default as true, restoring the historical behavior for ValidateCaseAsync InvokeAgent.yaml (3 InvokeAzureAgent actions, last one captures to Local.RatingResponse via output.messages with no autoSend specified) while keeping the hosted-agent fix that honors an explicit autoSend: false. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ObjectModel/InvokeAzureAgentExecutor.cs | 13 ++++++++----- .../ObjectModel/InvokeFunctionToolExecutor.cs | 10 +++++++--- .../ObjectModel/InvokeMcpToolExecutor.cs | 10 +++++++--- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs index 322c460ee3a..82dd0b6389f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs @@ -192,13 +192,16 @@ private string GetAgentName() => private bool GetAutoSendValue() { - if (this.AgentOutput?.AutoSend is null) + // AzureAgentOutput.AutoSend is never null — it returns a literal-false default + // when the YAML omits the field. Use AutoSendIsDefaultValue to distinguish an + // explicit autoSend value from the implicit default, and treat the implicit + // default as autoSend = true (the historical behavior for actions that omit + // autoSend or have no output block at all). + if (this.AgentOutput is { AutoSendIsDefaultValue: false } output) { - return true; + return this.Evaluator.GetValue(output.AutoSend).Value; } - EvaluationResult autoSendResult = this.Evaluator.GetValue(this.AgentOutput.AutoSend); - - return autoSendResult.Value; + return true; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs index d2fa401ab37..6ca429c648a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs @@ -314,12 +314,16 @@ private bool GetRequireApproval() private bool GetAutoSendValue() { - if (this.Model.Output?.AutoSend is null) + // InvokeToolOutput.AutoSend is never null — it returns a literal-false default + // when the YAML omits the field. Use AutoSendIsDefaultValue to distinguish an + // explicit autoSend value from the implicit default, and treat the implicit + // default as autoSend = true (the historical behavior). + if (this.Model.Output is { AutoSendIsDefaultValue: false } output) { - return true; + return this.Evaluator.GetValue(output.AutoSend).Value; } - return this.Evaluator.GetValue(this.Model.Output.AutoSend).Value; + return true; } private Dictionary? GetArguments() diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs index 7540556f64f..7796a6f4094 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs @@ -311,12 +311,16 @@ private bool GetRequireApproval() private bool GetAutoSendValue() { - if (this.Model.Output?.AutoSend is null) + // InvokeToolOutput.AutoSend is never null — it returns a literal-false default + // when the YAML omits the field. Use AutoSendIsDefaultValue to distinguish an + // explicit autoSend value from the implicit default, and treat the implicit + // default as autoSend = true (the historical behavior). + if (this.Model.Output is { AutoSendIsDefaultValue: false } output) { - return true; + return this.Evaluator.GetValue(output.AutoSend).Value; } - return this.Evaluator.GetValue(this.Model.Output.AutoSend).Value; + return true; } private string? GetConnectionName()