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>
This commit is contained in:
Ben Thomas
2026-05-15 14:17:20 -07:00
Unverified
parent 3047ad3066
commit fbb4c867dd
2 changed files with 28 additions and 5 deletions
@@ -22,9 +22,13 @@ internal static class AgentProviderExtensions
{
IAsyncEnumerable<AgentResponseUpdate> 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<AgentResponseUpdate> updates = [];
@@ -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<TValue>(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<TValue>(this IWorkflowContext context, string key, TValue? value, CancellationToken cancellationToken = default)
{