mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: Pass through external input request and handle response conversion for workflow as agent scenario (#4361)
* Handle external input request and response conversion for workflow as agent scenario * Remove unnecessary test comment * Fix PR comments * Updated to fix edge cases, and add more tests. * Update pending requests to use typed properties instead of relying on StateBag. replying to PR feedback. * Fixed external response de-dup and updated possible brittle test. * Address PR comments on sending turn token for normal messages and handle contentId collision by source agent * Remove unnecessary serialization element and address pr comment on intercepted outgoing requests * Updated MEAI changes for UserInput request and response abstractions.
This commit is contained in:
@@ -53,6 +53,9 @@ internal sealed class AsyncRunHandle : ICheckpointingHandle, IAsyncDisposable
|
||||
public ValueTask<RunStatus> GetStatusAsync(CancellationToken cancellationToken = default)
|
||||
=> this._eventStream.GetStatusAsync(cancellationToken);
|
||||
|
||||
internal bool TryGetResponsePortExecutorId(string portId, out string? executorId)
|
||||
=> this._stepRunner.TryGetResponsePortExecutorId(portId, out executorId);
|
||||
|
||||
public async IAsyncEnumerable<WorkflowEvent> TakeEventStreamAsync(bool blockOnPendingRequest, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
//Debug.Assert(breakOnHalt);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -95,6 +96,18 @@ internal sealed class EdgeMap
|
||||
return portRunner.ChaseEdgeAsync(new MessageEnvelope(response, ExecutorIdentity.None), this._stepTracer, cancellationToken);
|
||||
}
|
||||
|
||||
internal bool TryGetResponsePortExecutorId(string portId, [NotNullWhen(true)] out string? executorId)
|
||||
{
|
||||
if (this._portEdgeRunners.TryGetValue(portId, out ResponseEdgeRunner? portRunner))
|
||||
{
|
||||
executorId = portRunner.ExecutorId;
|
||||
return true;
|
||||
}
|
||||
|
||||
executorId = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
internal async ValueTask<Dictionary<EdgeId, PortableValue>> ExportStateAsync()
|
||||
{
|
||||
Dictionary<EdgeId, PortableValue> exportedStates = [];
|
||||
|
||||
@@ -19,6 +19,7 @@ internal interface ISuperStepRunner
|
||||
bool HasUnprocessedMessages { get; }
|
||||
|
||||
ValueTask EnqueueResponseAsync(ExternalResponse response, CancellationToken cancellationToken = default);
|
||||
bool TryGetResponsePortExecutorId(string portId, out string? executorId);
|
||||
|
||||
ValueTask<bool> IsValidInputTypeAsync<T>(CancellationToken cancellationToken = default);
|
||||
ValueTask<bool> EnqueueMessageAsync<T>(T message, CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -160,6 +160,8 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle
|
||||
|
||||
bool ISuperStepRunner.HasUnservicedRequests => this.RunContext.HasUnservicedRequests;
|
||||
bool ISuperStepRunner.HasUnprocessedMessages => this.RunContext.NextStepHasActions;
|
||||
bool ISuperStepRunner.TryGetResponsePortExecutorId(string portId, out string? executorId)
|
||||
=> this.RunContext.TryGetResponsePortExecutorId(portId, out executorId);
|
||||
|
||||
public bool IsCheckpointingEnabled => this.RunContext.IsCheckpointingEnabled;
|
||||
|
||||
|
||||
@@ -296,6 +296,9 @@ internal sealed class InProcessRunnerContext : IRunnerContext
|
||||
return this._externalRequests.TryRemove(requestId, out _);
|
||||
}
|
||||
|
||||
internal bool TryGetResponsePortExecutorId(string portId, [NotNullWhen(true)] out string? executorId)
|
||||
=> this._edgeMap.TryGetResponsePortExecutorId(portId, out executorId);
|
||||
|
||||
private IEventSink OutgoingEvents { get; }
|
||||
|
||||
internal StateManager StateManager { get; } = new();
|
||||
|
||||
@@ -68,10 +68,17 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
|
||||
throw new InvalidOperationException($"No pending ToolApprovalRequest found with id '{response.RequestId}'.");
|
||||
}
|
||||
|
||||
List<ChatMessage> implicitTurnMessages = [new ChatMessage(ChatRole.User, [response])];
|
||||
// Merge the external response with any already-buffered regular messages so mixed-content
|
||||
// resumes can be processed in one invocation.
|
||||
return this.ProcessTurnMessagesAsync(async (pendingMessages, ctx, ct) =>
|
||||
{
|
||||
pendingMessages.Add(new ChatMessage(ChatRole.User, [response]));
|
||||
|
||||
// ContinueTurnAsync owns failing to emit a TurnToken if this response does not clear up all remaining outstanding requests.
|
||||
return this.ContinueTurnAsync(implicitTurnMessages, context, this._currentTurnEmitEvents ?? false, cancellationToken);
|
||||
await this.ContinueTurnAsync(pendingMessages, ctx, this._currentTurnEmitEvents ?? false, ct).ConfigureAwait(false);
|
||||
|
||||
// Clear the buffered turn messages because they were consumed by ContinueTurnAsync.
|
||||
return null;
|
||||
}, context, cancellationToken);
|
||||
}
|
||||
|
||||
private ValueTask HandleFunctionResultAsync(
|
||||
@@ -84,8 +91,17 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
|
||||
throw new InvalidOperationException($"No pending FunctionCall found with id '{result.CallId}'.");
|
||||
}
|
||||
|
||||
List<ChatMessage> implicitTurnMessages = [new ChatMessage(ChatRole.Tool, [result])];
|
||||
return this.ContinueTurnAsync(implicitTurnMessages, context, this._currentTurnEmitEvents ?? false, cancellationToken);
|
||||
// Merge the external response with any already-buffered regular messages so mixed-content
|
||||
// resumes can be processed in one invocation.
|
||||
return this.ProcessTurnMessagesAsync(async (pendingMessages, ctx, ct) =>
|
||||
{
|
||||
pendingMessages.Add(new ChatMessage(ChatRole.Tool, [result]));
|
||||
|
||||
await this.ContinueTurnAsync(pendingMessages, ctx, this._currentTurnEmitEvents ?? false, ct).ConfigureAwait(false);
|
||||
|
||||
// Clear the buffered turn messages because they were consumed by ContinueTurnAsync.
|
||||
return null;
|
||||
}, context, cancellationToken);
|
||||
}
|
||||
|
||||
public bool ShouldEmitStreamingEvents(bool? emitEvents)
|
||||
@@ -198,7 +214,7 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
|
||||
ExtractUnservicedRequests(response.Messages.SelectMany(message => message.Contents));
|
||||
}
|
||||
|
||||
if (this._options.EmitAgentResponseEvents == true)
|
||||
if (this._options.EmitAgentResponseEvents)
|
||||
{
|
||||
await context.YieldOutputAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -16,10 +16,12 @@ internal sealed class AIContentExternalHandler<TRequestContent, TResponseContent
|
||||
where TResponseContent : AIContent
|
||||
{
|
||||
private readonly PortBinding? _portBinding;
|
||||
private readonly string _portId;
|
||||
private ConcurrentDictionary<string, TRequestContent> _pendingRequests = new();
|
||||
|
||||
public AIContentExternalHandler(ref ProtocolBuilder protocolBuilder, string portId, bool intercepted, Func<TResponseContent, IWorkflowContext, CancellationToken, ValueTask> handler)
|
||||
{
|
||||
this._portId = portId;
|
||||
PortBinding? portBinding = null;
|
||||
protocolBuilder = protocolBuilder.ConfigureRoutes(routeBuilder => ConfigureRoutes(routeBuilder, out portBinding));
|
||||
this._portBinding = portBinding;
|
||||
@@ -58,12 +60,14 @@ internal sealed class AIContentExternalHandler<TRequestContent, TResponseContent
|
||||
{
|
||||
if (!this._pendingRequests.TryAdd(id, requestContent))
|
||||
{
|
||||
throw new InvalidOperationException($"A pending request with ID '{id}' already exists.");
|
||||
// Request is already pending; treat as an idempotent re-emission.
|
||||
// Do not repost to the sink because request IDs must remain unique while pending.
|
||||
return default;
|
||||
}
|
||||
|
||||
return this.IsIntercepted
|
||||
? context.SendMessageAsync(requestContent, cancellationToken: cancellationToken)
|
||||
: this._portBinding.PostRequestAsync(requestContent, id, cancellationToken);
|
||||
: this._portBinding.PostRequestAsync(requestContent, this.CreateExternalRequestId(id), cancellationToken);
|
||||
}
|
||||
|
||||
public bool MarkRequestAsHandled(string id)
|
||||
@@ -74,6 +78,8 @@ internal sealed class AIContentExternalHandler<TRequestContent, TResponseContent
|
||||
[MemberNotNullWhen(false, nameof(_portBinding))]
|
||||
private bool IsIntercepted => this._portBinding == null;
|
||||
|
||||
private string CreateExternalRequestId(string requestId) => $"{this._portId.Length}:{this._portId}:{requestId}";
|
||||
|
||||
private static string MakeKey(string id) => $"{id}_PendingRequests";
|
||||
|
||||
public async ValueTask OnCheckpointingAsync(string id, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -60,6 +60,9 @@ public sealed class StreamingRun : CheckpointableRunBase, IAsyncDisposable
|
||||
internal ValueTask<bool> TrySendMessageUntypedAsync(object message, Type? declaredType = null)
|
||||
=> this._runHandle.EnqueueMessageUntypedAsync(message, declaredType);
|
||||
|
||||
internal bool TryGetResponsePortExecutorId(string portId, out string? executorId)
|
||||
=> this._runHandle.TryGetResponsePortExecutorId(portId, out executorId);
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronously streams workflow events as they occur during workflow execution.
|
||||
/// </summary>
|
||||
|
||||
@@ -25,6 +25,25 @@ internal sealed class WorkflowSession : AgentSession
|
||||
|
||||
private InMemoryCheckpointManager? _inMemoryCheckpointManager;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks pending external requests by their workflow-facing request ID.
|
||||
/// This mapping enables converting incoming response content back to <see cref="ExternalResponse"/>
|
||||
/// when resuming a workflow from a checkpoint.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Entries are added when a <see cref="RequestInfoEvent"/> is received during workflow execution,
|
||||
/// and removed when a matching response is delivered via <see cref="SendMessagesWithResponseConversionAsync"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private readonly Dictionary<string, ExternalRequest> _pendingRequests = [];
|
||||
|
||||
internal static bool VerifyCheckpointingConfiguration(IWorkflowExecutionEnvironment executionEnvironment, [NotNullWhen(true)] out InProcessExecutionEnvironment? inProcEnv)
|
||||
{
|
||||
inProcEnv = null;
|
||||
@@ -90,6 +109,7 @@ internal sealed class WorkflowSession : AgentSession
|
||||
|
||||
this.LastCheckpoint = sessionState.LastCheckpoint;
|
||||
this.StateBag = sessionState.StateBag;
|
||||
this._pendingRequests = sessionState.PendingRequests ?? [];
|
||||
}
|
||||
|
||||
public CheckpointInfo? LastCheckpoint { get; set; }
|
||||
@@ -101,7 +121,8 @@ internal sealed class WorkflowSession : AgentSession
|
||||
this.SessionId,
|
||||
this.LastCheckpoint,
|
||||
this._inMemoryCheckpointManager,
|
||||
this.StateBag);
|
||||
this.StateBag,
|
||||
this._pendingRequests);
|
||||
|
||||
return marshaller.Marshal(info);
|
||||
}
|
||||
@@ -141,7 +162,7 @@ internal sealed class WorkflowSession : AgentSession
|
||||
return update;
|
||||
}
|
||||
|
||||
private async ValueTask<StreamingRun> CreateOrResumeRunAsync(List<ChatMessage> messages, CancellationToken cancellationToken = default)
|
||||
private async ValueTask<ResumeRunResult> CreateOrResumeRunAsync(List<ChatMessage> 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.
|
||||
@@ -154,18 +175,155 @@ internal sealed class WorkflowSession : AgentSession
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await run.TrySendMessageAsync(messages).ConfigureAwait(false);
|
||||
return run;
|
||||
// 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);
|
||||
}
|
||||
|
||||
return await this._executionEnvironment
|
||||
StreamingRun newRun = await this._executionEnvironment
|
||||
.RunStreamingAsync(this._workflow,
|
||||
messages,
|
||||
this.SessionId,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return new ResumeRunResult(newRun);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends messages to the run, converting FunctionResultContent and UserInputResponseContent
|
||||
/// to ExternalResponse when there's a matching pending request.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// Structured information about how resume content was dispatched.
|
||||
/// </returns>
|
||||
private async ValueTask<ResumeDispatchInfo> SendMessagesWithResponseConversionAsync(StreamingRun run, List<ChatMessage> messages)
|
||||
{
|
||||
List<ChatMessage> 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<string>? matchedContentIds = null;
|
||||
|
||||
foreach (ChatMessage message in messages)
|
||||
{
|
||||
List<AIContent> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the workflow-facing request content surfaced in response updates.
|
||||
/// </summary>
|
||||
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(),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Rewrites workflow-facing response content back to the original agent-owned content ID.
|
||||
/// </summary>
|
||||
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,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gets the workflow-facing request ID from response content types.
|
||||
/// </summary>
|
||||
private static string? GetResponseContentId(AIContent content) => content switch
|
||||
{
|
||||
FunctionResultContent functionResultContent => functionResultContent.CallId,
|
||||
ToolApprovalResponseContent toolApprovalResponseContent => toolApprovalResponseContent.RequestId,
|
||||
_ => null
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Tries to get a pending request by workflow-facing request ID.
|
||||
/// </summary>
|
||||
private ExternalRequest? TryGetPendingRequest(string requestId) =>
|
||||
this._pendingRequests.TryGetValue(requestId, out ExternalRequest? request) ? request : null;
|
||||
|
||||
/// <summary>
|
||||
/// Adds a pending request indexed by workflow-facing request ID.
|
||||
/// </summary>
|
||||
private void AddPendingRequest(string requestId, ExternalRequest request) => this._pendingRequests[requestId] = request;
|
||||
|
||||
/// <summary>
|
||||
/// Removes a pending request by workflow-facing request ID.
|
||||
/// </summary>
|
||||
private void RemovePendingRequest(string requestId) =>
|
||||
this._pendingRequests.Remove(requestId);
|
||||
|
||||
internal async
|
||||
IAsyncEnumerable<AgentResponseUpdate> InvokeStageAsync(
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
@@ -175,12 +333,25 @@ internal sealed class WorkflowSession : AgentSession
|
||||
this.LastResponseId = Guid.NewGuid().ToString("N");
|
||||
List<ChatMessage> messages = this.ChatHistoryProvider.GetFromBookmark(this).ToList();
|
||||
|
||||
#pragma warning disable CA2007 // Analyzer misfiring and not seeing .ConfigureAwait(false) below.
|
||||
await using StreamingRun run =
|
||||
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
|
||||
|
||||
await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false);
|
||||
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))
|
||||
@@ -192,8 +363,13 @@ internal sealed class WorkflowSession : AgentSession
|
||||
break;
|
||||
|
||||
case RequestInfoEvent requestInfo:
|
||||
FunctionCallContent fcContent = requestInfo.Request.ToFunctionCall();
|
||||
AgentResponseUpdate update = this.CreateUpdate(this.LastResponseId, evt, fcContent);
|
||||
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;
|
||||
|
||||
@@ -267,15 +443,116 @@ internal sealed class WorkflowSession : AgentSession
|
||||
/// <inheritdoc/>
|
||||
public WorkflowChatHistoryProvider ChatHistoryProvider { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Captures the outcome of creating or resuming a workflow run,
|
||||
/// indicating what types of messages were sent during resume.
|
||||
/// </summary>
|
||||
private readonly struct ResumeRunResult
|
||||
{
|
||||
/// <summary>The streaming run that was created or resumed.</summary>
|
||||
public StreamingRun Run { get; }
|
||||
|
||||
/// <summary>How resume-time content was dispatched into the workflow runtime.</summary>
|
||||
public ResumeDispatchInfo DispatchInfo { get; }
|
||||
|
||||
public ResumeRunResult(StreamingRun run, ResumeDispatchInfo dispatchInfo = default)
|
||||
{
|
||||
this.Run = Throw.IfNull(run);
|
||||
this.DispatchInfo = dispatchInfo;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures how resumed input was split across regular-message and external-response delivery paths.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clones a <see cref="FunctionCallContent"/> with a workflow-facing call ID.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clones a <see cref="FunctionResultContent"/> with an agent-owned call ID.
|
||||
/// </summary>
|
||||
private static FunctionResultContent CloneFunctionResultContent(FunctionResultContent content, string callId)
|
||||
{
|
||||
FunctionResultContent clone = new(callId, content.Result)
|
||||
{
|
||||
Exception = content.Exception,
|
||||
};
|
||||
|
||||
return CopyContentMetadata(content, clone);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clones a <see cref="ToolApprovalRequestContent"/> with a workflow-facing request ID.
|
||||
/// </summary>
|
||||
private static ToolApprovalRequestContent CloneToolApprovalRequestContent(ToolApprovalRequestContent content, string id)
|
||||
{
|
||||
ToolApprovalRequestContent clone = new(id, content.ToolCall);
|
||||
return CopyContentMetadata(content, clone);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clones a <see cref="ToolApprovalResponseContent"/> with an agent-owned request ID.
|
||||
/// </summary>
|
||||
private static ToolApprovalResponseContent CloneToolApprovalResponseContent(ToolApprovalResponseContent content, string id)
|
||||
{
|
||||
ToolApprovalResponseContent clone = new(id, content.Approved, content.ToolCall)
|
||||
{
|
||||
Reason = content.Reason,
|
||||
};
|
||||
|
||||
return CopyContentMetadata(content, clone);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies shared <see cref="AIContent"/> metadata to a cloned content instance.
|
||||
/// </summary>
|
||||
private static TContent CopyContentMetadata<TContent>(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)
|
||||
AgentSessionStateBag? stateBag = null,
|
||||
Dictionary<string, ExternalRequest>? 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<string, ExternalRequest>? PendingRequests { get; } = pendingRequests;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user