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] 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 714ce4747d..2dcbe8e87a 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 @@ internal static class AgentProviderExtensions { 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 1b92235eee..2f57b4494a 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 @@ internal static class IWorkflowContextExtensions 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 @@ internal static class IWorkflowContextExtensions 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) {