mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
* 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>
373 lines
13 KiB
C#
373 lines
13 KiB
C#
// Copyright (c) Microsoft. All rights reserved.
|
|
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Text.Json;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Agents.AI.Workflows.Declarative.Events;
|
|
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
|
|
using Microsoft.Agents.AI.Workflows.Declarative.Interpreter;
|
|
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
|
|
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
|
|
using Microsoft.Agents.ObjectModel;
|
|
using Microsoft.Extensions.AI;
|
|
using Microsoft.Shared.Diagnostics;
|
|
|
|
namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
|
|
|
|
/// <summary>
|
|
/// Executor for the <see cref="InvokeMcpTool"/> action.
|
|
/// This executor invokes MCP tools on remote servers and handles approval flows.
|
|
/// </summary>
|
|
internal sealed class InvokeMcpToolExecutor(
|
|
InvokeMcpTool model,
|
|
IMcpToolHandler mcpToolHandler,
|
|
ResponseAgentProvider agentProvider,
|
|
WorkflowFormulaState state) :
|
|
DeclarativeActionExecutor<InvokeMcpTool>(model, state)
|
|
{
|
|
/// <summary>
|
|
/// Step identifiers for the MCP tool invocation workflow.
|
|
/// </summary>
|
|
public static class Steps
|
|
{
|
|
/// <summary>
|
|
/// Step for waiting for external input (approval or direct response).
|
|
/// </summary>
|
|
public static string ExternalInput(string id) => $"{id}_{nameof(ExternalInput)}";
|
|
|
|
/// <summary>
|
|
/// Step for resuming after receiving external input.
|
|
/// </summary>
|
|
public static string Resume(string id) => $"{id}_{nameof(Resume)}";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Determines if the message indicates external input is required.
|
|
/// </summary>
|
|
public static bool RequiresInput(object? message) =>
|
|
message is ExternalInputRequest || (message is PortableValue pv && pv.IsType(out ExternalInputRequest? _));
|
|
|
|
/// <summary>
|
|
/// Determines if the message indicates no external input is required.
|
|
/// </summary>
|
|
public static bool RequiresNothing(object? message) =>
|
|
message is ActionExecutorResult || (message is PortableValue pv && pv.IsType(out ActionExecutorResult? _));
|
|
|
|
/// <inheritdoc/>
|
|
protected override bool EmitResultEvent => false;
|
|
|
|
/// <inheritdoc/>
|
|
protected override bool IsDiscreteAction => false;
|
|
|
|
/// <inheritdoc/>
|
|
[SendsMessage(typeof(ExternalInputRequest))]
|
|
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
|
{
|
|
string serverUrl = this.GetServerUrl();
|
|
string? serverLabel = this.GetServerLabel();
|
|
string toolName = this.GetToolName();
|
|
bool requireApproval = this.GetRequireApproval();
|
|
Dictionary<string, object?>? arguments = this.GetArguments();
|
|
Dictionary<string, string>? headers = this.GetHeaders();
|
|
string? connectionName = this.GetConnectionName();
|
|
|
|
if (requireApproval)
|
|
{
|
|
// Create tool call content for approval request
|
|
McpServerToolCallContent toolCall = new(this.Id, toolName, serverLabel ?? serverUrl)
|
|
{
|
|
Arguments = arguments
|
|
};
|
|
|
|
if (headers != null)
|
|
{
|
|
toolCall.AdditionalProperties ??= [];
|
|
toolCall.AdditionalProperties.Add(headers);
|
|
}
|
|
|
|
ToolApprovalRequestContent approvalRequest = new(this.Id, toolCall);
|
|
|
|
ChatMessage requestMessage = new(ChatRole.Assistant, [approvalRequest]);
|
|
AgentResponse agentResponse = new([requestMessage]);
|
|
|
|
// Yield to the caller for approval
|
|
ExternalInputRequest inputRequest = new(agentResponse);
|
|
await context.SendMessageAsync(inputRequest, cancellationToken).ConfigureAwait(false);
|
|
|
|
return default;
|
|
}
|
|
|
|
// No approval required - invoke the tool directly
|
|
McpServerToolResultContent resultContent = await mcpToolHandler.InvokeToolAsync(
|
|
serverUrl,
|
|
serverLabel,
|
|
toolName,
|
|
arguments,
|
|
headers,
|
|
connectionName,
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
await this.ProcessResultAsync(context, resultContent, cancellationToken).ConfigureAwait(false);
|
|
|
|
// Signal completion so the workflow routes via RequiresNothing
|
|
await context.SendResultMessageAsync(this.Id, result: null, cancellationToken).ConfigureAwait(false);
|
|
|
|
return default;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Captures the external input response and processes the MCP tool result.
|
|
/// </summary>
|
|
/// <param name="context">The workflow context.</param>
|
|
/// <param name="response">The external input response.</param>
|
|
/// <param name="cancellationToken">A cancellation token.</param>
|
|
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
|
|
public async ValueTask CaptureResponseAsync(
|
|
IWorkflowContext context,
|
|
ExternalInputResponse response,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ToolApprovalResponseContent? approvalResponse = response.Messages
|
|
.SelectMany(m => m.Contents)
|
|
.OfType<ToolApprovalResponseContent>()
|
|
.FirstOrDefault(r => r.RequestId == this.Id);
|
|
|
|
if (approvalResponse?.Approved != true)
|
|
{
|
|
// Tool call was rejected
|
|
await this.AssignErrorAsync(context, "MCP tool invocation was not approved by user.").ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
// Approved - now invoke the tool
|
|
string serverUrl = this.GetServerUrl();
|
|
string? serverLabel = this.GetServerLabel();
|
|
string toolName = this.GetToolName();
|
|
Dictionary<string, object?>? arguments = this.GetArguments();
|
|
Dictionary<string, string>? headers = this.GetHeaders();
|
|
string? connectionName = this.GetConnectionName();
|
|
|
|
McpServerToolResultContent resultContent = await mcpToolHandler.InvokeToolAsync(
|
|
serverUrl,
|
|
serverLabel,
|
|
toolName,
|
|
arguments,
|
|
headers,
|
|
connectionName,
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
await this.ProcessResultAsync(context, resultContent, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Completes the MCP tool invocation by raising the completion event.
|
|
/// </summary>
|
|
public async ValueTask CompleteAsync(IWorkflowContext context, ActionExecutorResult message, CancellationToken cancellationToken)
|
|
{
|
|
await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
private async ValueTask ProcessResultAsync(IWorkflowContext context, McpServerToolResultContent resultContent, CancellationToken cancellationToken)
|
|
{
|
|
bool autoSend = this.GetAutoSendValue();
|
|
string? conversationId = this.GetConversationId();
|
|
|
|
await this.AssignResultAsync(context, resultContent).ConfigureAwait(false);
|
|
ChatMessage resultMessage = new(ChatRole.Tool, resultContent.Outputs);
|
|
|
|
// Store messages if output path is configured
|
|
if (this.Model.Output?.Messages is not null)
|
|
{
|
|
await this.AssignAsync(this.Model.Output.Messages?.Path, resultMessage.ToFormula(), context).ConfigureAwait(false);
|
|
}
|
|
|
|
// Auto-send the result if configured
|
|
if (autoSend)
|
|
{
|
|
AgentResponse resultResponse = new([resultMessage]);
|
|
await context.AddEventAsync(new AgentResponseEvent(this.Id, resultResponse), cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
// Add messages to conversation if conversationId is provided
|
|
if (conversationId is not null)
|
|
{
|
|
ChatMessage assistantMessage = new(ChatRole.Assistant, resultContent.Outputs);
|
|
await agentProvider.CreateMessageAsync(conversationId, assistantMessage, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
private async ValueTask AssignResultAsync(IWorkflowContext context, McpServerToolResultContent toolResult)
|
|
{
|
|
if (this.Model.Output?.Result is null || toolResult.Outputs is null || toolResult.Outputs.Count == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
List<object?> parsedResults = [];
|
|
foreach (AIContent resultContent in toolResult.Outputs)
|
|
{
|
|
object? resultValue = resultContent switch
|
|
{
|
|
TextContent text => text.Text,
|
|
DataContent data => data.Uri,
|
|
_ => resultContent.ToString(),
|
|
};
|
|
|
|
// Convert JsonElement to its raw JSON string for processing
|
|
if (resultValue is JsonElement jsonElement)
|
|
{
|
|
resultValue = jsonElement.GetRawText();
|
|
}
|
|
|
|
// Attempt to parse as JSON if it's a string (or was converted from JsonElement)
|
|
if (resultValue is string jsonString)
|
|
{
|
|
try
|
|
{
|
|
using JsonDocument jsonDocument = JsonDocument.Parse(jsonString);
|
|
|
|
// Handle different JSON value kinds
|
|
object? parsedValue = jsonDocument.RootElement.ValueKind switch
|
|
{
|
|
JsonValueKind.Object => jsonDocument.ParseRecord(VariableType.RecordType),
|
|
JsonValueKind.Array => jsonDocument.ParseList(jsonDocument.RootElement.GetListTypeFromJson()),
|
|
JsonValueKind.String => jsonDocument.RootElement.GetString(),
|
|
JsonValueKind.Number => jsonDocument.RootElement.TryGetInt64(out long l) ? l : jsonDocument.RootElement.GetDouble(),
|
|
JsonValueKind.True => true,
|
|
JsonValueKind.False => false,
|
|
JsonValueKind.Null => null,
|
|
_ => jsonString,
|
|
};
|
|
|
|
parsedResults.Add(parsedValue);
|
|
continue;
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
// Not a valid JSON
|
|
}
|
|
}
|
|
|
|
parsedResults.Add(resultValue);
|
|
}
|
|
|
|
await this.AssignAsync(this.Model.Output.Result?.Path, parsedResults.ToFormula(), context).ConfigureAwait(false);
|
|
}
|
|
|
|
private async ValueTask AssignErrorAsync(IWorkflowContext context, string errorMessage)
|
|
{
|
|
// Store error in result if configured (as a simple string)
|
|
if (this.Model.Output?.Result is not null)
|
|
{
|
|
await this.AssignAsync(this.Model.Output.Result?.Path, $"Error: {errorMessage}".ToFormula(), context).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
private string GetServerUrl() =>
|
|
this.Evaluator.GetValue(
|
|
Throw.IfNull(
|
|
this.Model.ServerUrl,
|
|
$"{nameof(this.Model)}.{nameof(this.Model.ServerUrl)}")).Value;
|
|
|
|
private string? GetServerLabel()
|
|
{
|
|
if (this.Model.ServerLabel is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
string value = this.Evaluator.GetValue(this.Model.ServerLabel).Value;
|
|
return value.Length == 0 ? null : value;
|
|
}
|
|
|
|
private string GetToolName() =>
|
|
this.Evaluator.GetValue(
|
|
Throw.IfNull(
|
|
this.Model.ToolName,
|
|
$"{nameof(this.Model)}.{nameof(this.Model.ToolName)}")).Value;
|
|
|
|
private string? GetConversationId()
|
|
{
|
|
if (this.Model.ConversationId is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
string value = this.Evaluator.GetValue(this.Model.ConversationId).Value;
|
|
return value.Length == 0 ? null : value;
|
|
}
|
|
|
|
private bool GetRequireApproval()
|
|
{
|
|
if (this.Model.RequireApproval is null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return this.Evaluator.GetValue(this.Model.RequireApproval).Value;
|
|
}
|
|
|
|
private bool GetAutoSendValue()
|
|
{
|
|
// 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 this.Evaluator.GetValue(output.AutoSend).Value;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private string? GetConnectionName()
|
|
{
|
|
if (this.Model.Connection?.Name is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
string value = this.Evaluator.GetValue(this.Model.Connection.Name).Value;
|
|
return value.Length == 0 ? null : value;
|
|
}
|
|
|
|
private Dictionary<string, object?>? GetArguments()
|
|
{
|
|
if (this.Model.Arguments is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
Dictionary<string, object?> result = [];
|
|
foreach (KeyValuePair<string, ValueExpression> argument in this.Model.Arguments)
|
|
{
|
|
result[argument.Key] = this.Evaluator.GetValue(argument.Value).Value.ToObject();
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private Dictionary<string, string>? GetHeaders()
|
|
{
|
|
if (this.Model.Headers is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
Dictionary<string, string> result = [];
|
|
foreach (KeyValuePair<string, StringExpression> header in this.Model.Headers)
|
|
{
|
|
string value = this.Evaluator.GetValue(header.Value).Value;
|
|
if (!string.IsNullOrEmpty(value))
|
|
{
|
|
result[header.Key] = value;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
}
|