Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,24 @@ public override async IAsyncEnumerable<ResponseStreamEvent> 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<ChatClientAgent>();

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);
Expand All @@ -81,7 +92,7 @@ public override async IAsyncEnumerable<ResponseStreamEvent> 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)
{
Expand Down Expand Up @@ -284,10 +295,19 @@ public override async IAsyncEnumerable<ResponseStreamEvent> 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);
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,13 @@ public static async ValueTask<AgentResponse> InvokeAgentAsync(
{
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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing test for the key behavioral change. The removed autoSend |= isWorkflowConversation line fixes regression #1 (raw JSON leaking when autoSend: false is declared in workflow.yaml), but no test verifies this scenario. The only code exercising InvokeAgentAsync hardcodes autoSend = true. Please add a test that calls InvokeAgentAsync with autoSend: false on a workflow conversation and asserts that no AgentResponseUpdateEvent/AgentResponseEvent is added to the context — otherwise this regression can be silently reintroduced.

autoSend |= isWorkflowConversation;

// Process the agent response updates.
List<AgentResponseUpdate> updates = [];
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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));
Comment on lines 24 to +25

public static FormulaValue ReadState(this IWorkflowContext context, string key, string? scopeName = null) =>
DeclarativeContext(context).State.Get(key, scopeName);
Expand All @@ -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<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);
Comment on lines +45 to +46

Comment on lines +45 to +47
Comment on lines +41 to +47
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Initialization path not covered by this workaround. This fixes runtime ReadState/QueueStateUpdateAsync, but DeclarativeWorkflowBuilder seeds state via state.Initialize(...) (DeclarativeWorkflowBuilder.cs:76), and WorkflowDiagnostics.InitializeDefaults does if (variableDiagnostic?.Path?.VariableName is null) { continue; } (WorkflowDiagnostics.cs:61-66). A defaulted variable like Local.Triage will still be silently skipped at startup. Please carry the same fallback/remapping into the initialization path or the fix remains partial.

// 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)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<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
Expand Down Expand Up @@ -241,6 +259,42 @@ private string GetFunctionName() =>
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, System.StringComparison.Ordinal));

if (function is null)
{
return new FunctionResultContent(
this.Id,
$"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 (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)
Expand Down
Loading