.NET: Fix declarative workflow regressions for hosted agents (#5905)

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* Set Foundry ProductContext per-executor instead of via PropertyPath workaround

ObjectModel 2026.2.4.1 resolves PropertyPath.VariableName / NamespaceAlias and VariableScopeNames.IsValidName against AsyncLocal<ProductContext> 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>

* 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>

* Honor AutoSendIsDefaultValue when computing autoSend

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>

---------

Co-authored-by: Ben Thomas <25218250+alliscode@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Ben Thomas
2026-05-21 18:06:38 -07:00
committed by GitHub
Unverified
parent 8e54f0b0e7
commit b559545fa4
7 changed files with 248 additions and 17 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 = [];
@@ -71,6 +71,13 @@ internal abstract class DeclarativeActionExecutor : Executor<ActionExecutorResul
[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<ProductContext>; 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}]");
@@ -192,13 +192,16 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, ResponseA
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<bool> autoSendResult = this.Evaluator.GetValue(this.AgentOutput.AutoSend);
return autoSendResult.Value;
return true;
}
}
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
@@ -103,6 +104,24 @@ internal sealed class InvokeFunctionToolExecutor(
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<ToolApprovalResponseContent>()
.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 +260,48 @@ internal sealed class InvokeFunctionToolExecutor(
return conversationIdValue.Length == 0 ? null : conversationIdValue;
}
private async ValueTask<FunctionResultContent?> InvokeRegisteredFunctionAsync(CancellationToken cancellationToken)
{
string functionName = this.GetFunctionName();
AIFunction? function = agentProvider.Functions?.FirstOrDefault(
f => string.Equals(f.Name, functionName, StringComparison.Ordinal));
if (function is null)
{
return new FunctionResultContent(this.Id, result: null)
{
Exception = new InvalidOperationException(
$"Function '{functionName}' is not registered with the agent provider."),
};
}
Dictionary<string, object?>? arguments = this.GetArguments();
AIFunctionArguments? functionArguments = arguments is null ? null : new AIFunctionArguments(arguments);
object? result;
try
{
result = await function.InvokeAsync(functionArguments, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
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,
_ => JsonSerializer.Serialize(result, AIJsonUtilities.DefaultOptions.GetTypeInfo(result.GetType())),
};
return new FunctionResultContent(this.Id, serialized);
}
private bool GetRequireApproval()
{
if (this.Model.RequireApproval is null)
@@ -253,12 +314,16 @@ internal sealed class InvokeFunctionToolExecutor(
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<string, object?>? GetArguments()
@@ -311,12 +311,16 @@ internal sealed class InvokeMcpToolExecutor(
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()
@@ -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);
}
@@ -0,0 +1,139 @@
// 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;
/// <summary>
/// Tests for <see cref="AgentProviderExtensions.InvokeAgentAsync"/>.
/// </summary>
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);
[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(
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<string?>(),
It.IsAny<string?>(),
It.IsAny<IEnumerable<ChatMessage>?>(),
It.IsAny<IDictionary<string, object?>?>(),
It.IsAny<CancellationToken>()))
.Returns(ToAsyncEnumerableAsync(updates));
List<(string ConversationId, ChatMessage Message)> copiedMessages = [];
mockProvider
.Setup(p => p.CreateMessageAsync(
It.IsAny<string>(),
It.IsAny<ChatMessage>(),
It.IsAny<CancellationToken>()))
.Returns<string, ChatMessage, CancellationToken>(
(convId, msg, _) =>
{
copiedMessages.Add((convId, msg));
return Task.FromResult(msg);
});
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<AgentResponseUpdateEvent>().Count();
int responseEventCount = events.OfType<AgentResponseEvent>().Count();
if (expectResponseEvents)
{
Assert.Equal(updates.Length, updateEventCount);
Assert.Equal(1, responseEventCount);
}
else
{
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<AgentResponseUpdate> ToAsyncEnumerableAsync(IEnumerable<AgentResponseUpdate> updates)
{
foreach (AgentResponseUpdate update in updates)
{
yield return update;
}
await Task.CompletedTask;
}
}