// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Checkpointing; using Microsoft.Agents.AI.Workflows.InProc; using Microsoft.Extensions.AI; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Workflows; internal sealed class WorkflowSession : AgentSession { private readonly Workflow _workflow; /// /// The execution environment for this session. Concrete type is required because /// uses the internal /// API. /// private readonly InProcessExecutionEnvironment _inProcEnvironment; private readonly bool _includeExceptionDetails; private readonly bool _includeWorkflowOutputsInResponse; private InMemoryCheckpointManager? _inMemoryCheckpointManager; /// /// Tracks pending external requests by their workflow-facing request ID. /// This mapping enables converting incoming response content back to /// when resuming a workflow from a checkpoint. /// /// /// /// Entries are added when a is received during workflow execution, /// and removed when a matching response is delivered via . /// /// /// The number of entries is bounded by the number of outstanding external requests in a single workflow run. /// When a session is abandoned, all pending requests are released with the session object. /// Request-level timeouts, if needed, should be implemented in the workflow definition itself /// (e.g., using a timer racing against an external event). /// /// private readonly Dictionary _pendingRequests = []; internal static bool VerifyCheckpointingConfiguration(IWorkflowExecutionEnvironment executionEnvironment, [NotNullWhen(true)] out InProcessExecutionEnvironment? inProcEnv) { inProcEnv = null; if (executionEnvironment.IsCheckpointingEnabled) { return false; } if ((inProcEnv = executionEnvironment as InProcessExecutionEnvironment) == null) { throw new InvalidOperationException("Cannot use a non-checkpointed execution environment. Implicit checkpointing is supported only for InProcess."); } return true; } public WorkflowSession(Workflow workflow, string sessionId, IWorkflowExecutionEnvironment executionEnvironment, bool includeExceptionDetails = false, bool includeWorkflowOutputsInResponse = false) { this._workflow = Throw.IfNull(workflow); this._includeExceptionDetails = includeExceptionDetails; this._includeWorkflowOutputsInResponse = includeWorkflowOutputsInResponse; IWorkflowExecutionEnvironment env = Throw.IfNull(executionEnvironment); if (VerifyCheckpointingConfiguration(env, out InProcessExecutionEnvironment? inProcEnv)) { // We have an InProcessExecutionEnvironment which is not configured for checkpointing. Ensure it has an externalizable checkpoint manager, // since we are responsible for maintaining the state. env = inProcEnv.WithCheckpointing(this.EnsureExternalizedInMemoryCheckpointing()); } this._inProcEnvironment = env as InProcessExecutionEnvironment ?? throw new InvalidOperationException( $"WorkflowSession requires an {nameof(InProcessExecutionEnvironment)}, " + $"but received {env.GetType().Name}."); this.SessionId = Throw.IfNullOrEmpty(sessionId); this.ChatHistoryProvider = new WorkflowChatHistoryProvider(); } private CheckpointManager EnsureExternalizedInMemoryCheckpointing() { return new(this._inMemoryCheckpointManager ??= new()); } public WorkflowSession(Workflow workflow, JsonElement serializedSession, IWorkflowExecutionEnvironment executionEnvironment, bool includeExceptionDetails = false, bool includeWorkflowOutputsInResponse = false, JsonSerializerOptions? jsonSerializerOptions = null) { this._workflow = Throw.IfNull(workflow); this._includeExceptionDetails = includeExceptionDetails; this._includeWorkflowOutputsInResponse = includeWorkflowOutputsInResponse; IWorkflowExecutionEnvironment env = Throw.IfNull(executionEnvironment); JsonMarshaller marshaller = new(jsonSerializerOptions); SessionState sessionState = marshaller.Marshal(serializedSession); this._inMemoryCheckpointManager = sessionState.CheckpointManager; if (this._inMemoryCheckpointManager != null && VerifyCheckpointingConfiguration(env, out InProcessExecutionEnvironment? inProcEnv)) { env = inProcEnv.WithCheckpointing(this.EnsureExternalizedInMemoryCheckpointing()); } else if (this._inMemoryCheckpointManager != null) { throw new ArgumentException("The session was saved with an externalized checkpoint manager, but the incoming execution environment does not support it.", nameof(executionEnvironment)); } this._inProcEnvironment = env as InProcessExecutionEnvironment ?? throw new InvalidOperationException( $"WorkflowSession requires an {nameof(InProcessExecutionEnvironment)}, " + $"but received {env.GetType().Name}."); this.SessionId = sessionState.SessionId; this.ChatHistoryProvider = new WorkflowChatHistoryProvider(); this.LastCheckpoint = sessionState.LastCheckpoint; this.StateBag = sessionState.StateBag; this._pendingRequests = sessionState.PendingRequests ?? []; } public CheckpointInfo? LastCheckpoint { get; set; } internal JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null) { JsonMarshaller marshaller = new(jsonSerializerOptions); SessionState info = new( this.SessionId, this.LastCheckpoint, this._inMemoryCheckpointManager, this.StateBag, this._pendingRequests); return marshaller.Marshal(info); } public AgentResponseUpdate CreateUpdate(string responseId, object raw, params AIContent[] parts) { Throw.IfNullOrEmpty(parts); return new(ChatRole.Assistant, parts) { CreatedAt = DateTimeOffset.UtcNow, MessageId = Guid.NewGuid().ToString("N"), Role = ChatRole.Assistant, ResponseId = responseId, RawRepresentation = raw }; } public AgentResponseUpdate CreateUpdate(string responseId, object raw, ChatMessage message) { Throw.IfNull(message); return new(message.Role, message.Contents) { CreatedAt = message.CreatedAt ?? DateTimeOffset.UtcNow, MessageId = message.MessageId ?? Guid.NewGuid().ToString("N"), ResponseId = responseId, RawRepresentation = raw }; } private async ValueTask CreateOrResumeRunAsync(List messages, CancellationToken cancellationToken = default) { // The workflow is validated to be a ChatProtocol workflow by the WorkflowHostAgent before creating the session, // and does not need to be checked again here. if (this.LastCheckpoint is not null) { // Use the internal resume path that suppresses pending request republishing. // WorkflowSession handles pending requests itself by converting matching responses // via SendMessagesWithResponseConversionAsync, so event-stream republishing would // cause unwanted duplicate events visible to the consumer. StreamingRun run = await this._inProcEnvironment .ResumeStreamingInternalAsync(this._workflow, this.LastCheckpoint, republishPendingEvents: false, cancellationToken) .ConfigureAwait(false); // Process messages: convert response content to ExternalResponse, send regular messages as-is ResumeDispatchInfo dispatchInfo = await this.SendMessagesWithResponseConversionAsync(run, messages).ConfigureAwait(false); return new ResumeRunResult(run, dispatchInfo); } StreamingRun newRun = await this._inProcEnvironment .RunStreamingAsync(this._workflow, messages, this.SessionId, cancellationToken) .ConfigureAwait(false); return new ResumeRunResult(newRun); } /// /// Sends messages to the run, converting FunctionResultContent and UserInputResponseContent /// to ExternalResponse when there's a matching pending request. /// /// /// Structured information about how resume content was dispatched. /// private async ValueTask SendMessagesWithResponseConversionAsync(StreamingRun run, List messages) { List regularMessages = []; // Responses are deferred until after regular messages are queued so response handlers // can merge buffered regular content in the same continuation turn. List<(ExternalResponse Response, string RequestId)> externalResponses = []; bool hasMatchedResponseForStartExecutor = false; // Tracks content IDs already matched to pending requests within this invocation, // preventing duplicate responses for the same ID from being sent to the workflow engine. HashSet? matchedContentIds = null; foreach (ChatMessage message in messages) { List regularContents = []; foreach (AIContent content in message.Contents) { string? contentId = GetResponseContentId(content); // Skip duplicate response content for an already-matched content ID if (contentId != null && matchedContentIds?.Contains(contentId) == true) { continue; } if (contentId != null && this.TryGetPendingRequest(contentId) is ExternalRequest pendingRequest) { // For intercepted/complex topologies the port may not be registered in the EdgeMap. // Treat unknown port as non-start-executor (conservative): TurnToken will still be sent. if (run.TryGetResponsePortExecutorId(pendingRequest.PortInfo.PortId, out string? responseExecutorId)) { hasMatchedResponseForStartExecutor |= string.Equals(responseExecutorId, this._workflow.StartExecutorId, StringComparison.Ordinal); } AIContent normalizedResponseContent = NormalizeResponseContentForDelivery(content, pendingRequest); externalResponses.Add((pendingRequest.CreateResponse(normalizedResponseContent), pendingRequest.RequestId)); (matchedContentIds ??= new(StringComparer.Ordinal)).Add(contentId); } else { regularContents.Add(content); } } if (regularContents.Count > 0) { ChatMessage cloned = message.Clone(); cloned.Contents = regularContents; regularMessages.Add(cloned); } } // Send regular messages first so response handlers can merge them with responses. bool hasRegularMessages = regularMessages.Count > 0; if (hasRegularMessages) { await run.TrySendMessageAsync(regularMessages).ConfigureAwait(false); } // Send external responses after regular messages. bool hasMatchedExternalResponses = false; foreach ((ExternalResponse response, string requestId) in externalResponses) { await run.SendResponseAsync(response).ConfigureAwait(false); hasMatchedExternalResponses = true; this.RemovePendingRequest(requestId); } return new ResumeDispatchInfo( hasRegularMessages, hasMatchedExternalResponses, hasMatchedResponseForStartExecutor); } /// /// Creates the workflow-facing request content surfaced in response updates. /// private static AIContent CreateRequestContentForDelivery(ExternalRequest request) => request switch { ExternalRequest externalRequest when externalRequest.TryGetDataAs(out FunctionCallContent? functionCallContent) => CloneFunctionCallContent(functionCallContent, externalRequest.RequestId), ExternalRequest externalRequest when externalRequest.TryGetDataAs(out ToolApprovalRequestContent? toolApprovalRequestContent) => CloneToolApprovalRequestContent(toolApprovalRequestContent, externalRequest.RequestId), ExternalRequest externalRequest => externalRequest.ToFunctionCall(), }; /// /// Rewrites workflow-facing response content back to the original agent-owned content ID. /// private static AIContent NormalizeResponseContentForDelivery(AIContent content, ExternalRequest request) => content switch { FunctionResultContent functionResultContent when request.TryGetDataAs(out FunctionCallContent? functionCallContent) => CloneFunctionResultContent(functionResultContent, functionCallContent.CallId), ToolApprovalResponseContent toolApprovalResponseContent when request.TryGetDataAs(out ToolApprovalRequestContent? toolApprovalRequestContent) => CloneToolApprovalResponseContent(toolApprovalResponseContent, toolApprovalRequestContent.RequestId), _ => content, }; /// /// Gets the workflow-facing request ID from response content types. /// private static string? GetResponseContentId(AIContent content) => content switch { FunctionResultContent functionResultContent => functionResultContent.CallId, ToolApprovalResponseContent toolApprovalResponseContent => toolApprovalResponseContent.RequestId, _ => null }; /// /// Tries to get a pending request by workflow-facing request ID. /// private ExternalRequest? TryGetPendingRequest(string requestId) => this._pendingRequests.TryGetValue(requestId, out ExternalRequest? request) ? request : null; /// /// Adds a pending request indexed by workflow-facing request ID. /// private void AddPendingRequest(string requestId, ExternalRequest request) => this._pendingRequests[requestId] = request; /// /// Removes a pending request by workflow-facing request ID. /// private void RemovePendingRequest(string requestId) => this._pendingRequests.Remove(requestId); internal async IAsyncEnumerable InvokeStageAsync( [EnumeratorCancellation] CancellationToken cancellationToken = default) { this.LastResponseId = Guid.NewGuid().ToString("N"); List messages = this.ChatHistoryProvider.GetFromBookmark(this).ToList(); ResumeRunResult resumeResult = await this.CreateOrResumeRunAsync(messages, cancellationToken).ConfigureAwait(false); #pragma warning disable CA2007 // Analyzer misfiring. await using StreamingRun run = resumeResult.Run; #pragma warning restore CA2007 ResumeDispatchInfo dispatchInfo = resumeResult.DispatchInfo; // Send a TurnToken to the start executor unless the only activity is an external // response directed at the start executor itself (which self-emits a TurnToken via // ContinueTurnAsync). Non-start executors (e.g., RequestInfoExecutor) do not emit // TurnTokens after processing responses, so the session must always provide one. bool shouldSendTurnToken = !dispatchInfo.HasMatchedExternalResponses || !dispatchInfo.HasMatchedResponseForStartExecutor; if (shouldSendTurnToken) { await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false); } await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false, cancellationToken) .ConfigureAwait(false) .WithCancellation(cancellationToken)) { switch (evt) { case AgentResponseUpdateEvent agentUpdate: yield return agentUpdate.Update; break; case RequestInfoEvent requestInfo: AIContent requestContent = CreateRequestContentForDelivery(requestInfo.Request); // Track the pending request so we can convert incoming responses back to ExternalResponse. // External callers respond using the workflow-facing request ID, which is always RequestId. this.AddPendingRequest(requestInfo.Request.RequestId, requestInfo.Request); AgentResponseUpdate update = this.CreateUpdate(this.LastResponseId, evt, requestContent); yield return update; break; case WorkflowErrorEvent workflowError: Exception? exception = workflowError.Exception; if (exception is TargetInvocationException tie && tie.InnerException != null) { exception = tie.InnerException; } if (exception != null) { string message = this._includeExceptionDetails ? exception.Message : "An error occurred while executing the workflow."; ErrorContent errorContent = new(message); yield return this.CreateUpdate(this.LastResponseId, evt, errorContent); } break; case SuperStepCompletedEvent stepCompleted: this.LastCheckpoint = stepCompleted.CompletionInfo?.Checkpoint; goto default; case WorkflowOutputEvent output: IEnumerable? updateMessages = output.Data switch { IEnumerable chatMessages => chatMessages, ChatMessage chatMessage => [chatMessage], _ => null }; if (!this._includeWorkflowOutputsInResponse || updateMessages == null) { goto default; } foreach (ChatMessage message in updateMessages) { yield return this.CreateUpdate(this.LastResponseId, evt, message); } break; default: // Emit all other workflow events for observability (DevUI, logging, etc.) yield return new AgentResponseUpdate(ChatRole.Assistant, []) { CreatedAt = DateTimeOffset.UtcNow, MessageId = Guid.NewGuid().ToString("N"), Role = ChatRole.Assistant, ResponseId = this.LastResponseId, RawRepresentation = evt }; break; } } } public string? LastResponseId { get; set; } public string SessionId { get; } /// public WorkflowChatHistoryProvider ChatHistoryProvider { get; } /// /// Captures the outcome of creating or resuming a workflow run, /// indicating what types of messages were sent during resume. /// private readonly struct ResumeRunResult { /// The streaming run that was created or resumed. public StreamingRun Run { get; } /// How resume-time content was dispatched into the workflow runtime. public ResumeDispatchInfo DispatchInfo { get; } public ResumeRunResult(StreamingRun run, ResumeDispatchInfo dispatchInfo = default) { this.Run = Throw.IfNull(run); this.DispatchInfo = dispatchInfo; } } /// /// Captures how resumed input was split across regular-message and external-response delivery paths. /// private readonly struct ResumeDispatchInfo { public ResumeDispatchInfo(bool hasRegularMessages, bool hasMatchedExternalResponses, bool hasMatchedResponseForStartExecutor) { this.HasRegularMessages = hasRegularMessages; this.HasMatchedExternalResponses = hasMatchedExternalResponses; this.HasMatchedResponseForStartExecutor = hasMatchedResponseForStartExecutor; } public bool HasRegularMessages { get; } public bool HasMatchedExternalResponses { get; } public bool HasMatchedResponseForStartExecutor { get; } } /// /// Clones a with a workflow-facing call ID. /// private static FunctionCallContent CloneFunctionCallContent(FunctionCallContent content, string callId) { FunctionCallContent clone = new(callId, content.Name, content.Arguments) { Exception = content.Exception, InformationalOnly = content.InformationalOnly, }; return CopyContentMetadata(content, clone); } /// /// Clones a with an agent-owned call ID. /// private static FunctionResultContent CloneFunctionResultContent(FunctionResultContent content, string callId) { FunctionResultContent clone = new(callId, content.Result) { Exception = content.Exception, }; return CopyContentMetadata(content, clone); } /// /// Clones a with a workflow-facing request ID. /// private static ToolApprovalRequestContent CloneToolApprovalRequestContent(ToolApprovalRequestContent content, string id) { ToolApprovalRequestContent clone = new(id, content.ToolCall); return CopyContentMetadata(content, clone); } /// /// Clones a with an agent-owned request ID. /// private static ToolApprovalResponseContent CloneToolApprovalResponseContent(ToolApprovalResponseContent content, string id) { ToolApprovalResponseContent clone = new(id, content.Approved, content.ToolCall) { Reason = content.Reason, }; return CopyContentMetadata(content, clone); } /// /// Copies shared metadata to a cloned content instance. /// private static TContent CopyContentMetadata(AIContent source, TContent target) where TContent : AIContent { target.AdditionalProperties = source.AdditionalProperties; target.Annotations = source.Annotations; target.RawRepresentation = source.RawRepresentation; return target; } internal sealed class SessionState( string sessionId, CheckpointInfo? lastCheckpoint, InMemoryCheckpointManager? checkpointManager = null, AgentSessionStateBag? stateBag = null, Dictionary? pendingRequests = null) { public string SessionId { get; } = sessionId; public CheckpointInfo? LastCheckpoint { get; } = lastCheckpoint; public InMemoryCheckpointManager? CheckpointManager { get; } = checkpointManager; public AgentSessionStateBag StateBag { get; } = stateBag ?? new(); public Dictionary? PendingRequests { get; } = pendingRequests; } }