mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
* test: reshuffle .NET Workflow tests in preparation for Outputs overhaul Phase 1 of the .NET Workflows outputs overhaul (see working/implementation-plan.md). Pure moves/renames in dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests; no production code changes, no new test cases. The split keeps each orchestration mode in its own source file so the upcoming tag-aware and orchestration-default test additions land on clean diffs. Renames: * WorkflowBuilderSmokeTests.cs -> WorkflowBuilderTests.cs (with class rename to match). The scope is no longer "smoke"-only once subsequent phases add tag-aware builder tests. * InputWaiterAndOutputFilterTests.cs -> InputWaiterTests.cs + OutputFilterTests.cs. The file already declared the two test classes separately; this split simply gives each its own file so the output-filter cases have a dedicated home for tag-aware additions. Split of AgentWorkflowBuilderTests.cs: * AgentWorkflowBuilderTests.cs is now the outer `public static partial class AgentWorkflowBuilderTests` holding the shared test helpers (DoubleEchoAgent + session + WithBarrier variant, WorkflowRunResult, RunWorkflow* methods) bumped from `private` to `internal` so the new top-level GroupChatWorkflowBuilderTests in the same assembly can reach them. * AgentWorkflowBuilder.SequentialTests.cs (nested SequentialTests): BuildSequential_InvalidArguments_Throws, BuildSequential_AgentsRunInOrderAsync. * AgentWorkflowBuilder.ConcurrentTests.cs (nested ConcurrentTests): BuildConcurrent_InvalidArguments_Throws, BuildConcurrent_AgentsRunInParallelAsync. Sequential and Concurrent are kept as nested classes because they're modes of the same `AgentWorkflowBuilder` static factory and do not produce dedicated builder types. New file: * GroupChatWorkflowBuilderTests.cs (top-level): the existing BuildGroupChat_* and GroupChatManager_* cases moved out of the old AgentWorkflowBuilderTests file. They exercise the `GroupChatWorkflowBuilder` type (returned by `AgentWorkflowBuilder.CreateGroupChatBuilderWith`), so a dedicated top-level test class - matching the convention reserved by the plan for HandoffWorkflowBuilderTests / MagenticWorkflowBuilderTests - is the right home. Cross-class helper references qualify with `AgentWorkflowBuilderTests.DoubleEchoAgent` and `AgentWorkflowBuilderTests.RunWorkflowAsync`. The outer partial class is `static` (and nested classes carry the instance test methods) because the outer holds only static helpers; this satisfies CA1052 without suppressions and is invisible to xUnit discovery, which finds tests on the nested classes as `AgentWorkflowBuilderTests.SequentialTests.*` etc. Validation: `dotnet build` clean on both target frameworks; all 547 tests in Microsoft.Agents.AI.Workflows.UnitTests pass on net10.0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: introduce OutputTag, Futures, and tag-aware WorkflowBuilder API Phase 2 of the .NET Workflows outputs overhaul. Additive code change only - no observable runtime behavior change. The runner still uses the legacy bypass for AgentResponse / AgentResponseUpdate payloads, and the new `Futures.EnableAgentResponseOutputTaggingAndFiltering` flag defaults to false. Phase 3 will wire the flag into the runner; this commit only introduces the types and the builder API. New public surface: * `OutputTag` (readonly struct): wraps a string Value with ordinal equality (IEquatable, GetHashCode, == / !=) so it can participate as a HashSet element. Internal ctor closes the set. One public singleton: `OutputTag.Intermediate`. Terminal / regular outputs carry no tag (empty Tags set). JSON-serialized as a bare string via [JsonConverter(typeof(OutputTagJsonConverter))], with the converter rehydrating to the well-known singleton on read. * `Futures` (static class): hosts opt-in pre-GA behavior switches. First flag is `EnableAgentResponseOutputTaggingAndFiltering`; XML doc captures the v2.0.0 obsoletion / v3.0.0 removal lifecycle. * `WorkflowOutputEvent.Tags`: `HashSet<OutputTag>` exposed directly (concrete collection, matches the JSON-serialization convention used for `WorkflowInfo.OutputExecutorIds`). Never null; empty for legacy / terminal events. New ctors take a single `OutputTag` or `IEnumerable<OutputTag>?`; the existing (data, executorId) ctor remains and produces an untagged event. `HasTag(OutputTag)` helper. `AgentResponseEvent` and `AgentResponseUpdateEvent` gain matching tag-accepting ctors forwarding to the base. * `WorkflowOutputEventExtensions.IsIntermediate(this WorkflowOutputEvent)`: extension method returning `evt.HasTag(OutputTag.Intermediate)`. The preferred way to ask "is this an intermediate output?" without reaching into the Tags set. * `WorkflowBuilder.WithOutputFrom(IEnumerable<ExecutorBinding>, OutputTag)` and `WorkflowBuilder.WithOutputFrom(ExecutorBinding, OutputTag)`: forward-looking tagged overloads. The IEnumerable form is the primary tagged surface; the single-executor form is a convenience for the common one-executor case. Currently usable for the `OutputTag.Intermediate` singleton; will become the primary surface once the `OutputTag` constructor is opened to user-defined tags in a future release. Callers in this release should prefer the intent-specific `WithIntermediateOutputFrom` extension for the intermediate case. Tags accumulate across repeated calls; same tag repeated dedupes via the HashSet. * `WorkflowBuilderExtensions.WithIntermediateOutputFrom(this WorkflowBuilder, IEnumerable<ExecutorBinding>)`: helper that forwards to `WithOutputFrom(executors, OutputTag.Intermediate)`. Takes an IEnumerable (matching the tagged WithOutputFrom shape) - callers pass collection literals: `builder.WithIntermediateOutputFrom([a, b])`. XML doc remarks call out the Futures-flag interaction and the AIAgent-payload forwarding contract. Internal shape changes: * `WorkflowBuilder._outputExecutors`: HashSet<string> -> Dictionary< string, HashSet<OutputTag>>. The value set is empty for executors designated only via the untagged WithOutputFrom; contains Intermediate (and possibly future tags) otherwise. * `Workflow.OutputExecutors`: HashSet<string> -> Dictionary<string, HashSet<OutputTag>>. * `OutputFilter.CanOutput`: `Contains(id)` -> `ContainsKey(id)`. * `WorkflowInfo.OutputExecutorIds`: HashSet<string> -> Dictionary< string, HashSet<OutputTag>>, with a custom JsonConverter that reads both the new map shape (`{id: ["intermediate", ...]}`) and the legacy array shape (`[id1, id2]`, where each id is treated as an untagged output). Always writes the map shape. IsMatch updated to compare per-id tag sets. Tests landing in this commit (per the test-with-feature principle): * `OutputTagTests.cs` (6 tests): KnownValues, EqualityIsOrdinalOnValue, DefaultStructValueIsDistinct (default(OutputTag) does not collide with the Intermediate singleton in a HashSet), GetHashCodeMatchesEquals, JsonConverter_RoundtripsValueAsString, ConstructorIsInternal (reflection-based assertion that the (string) ctor is `internal`). * `WorkflowBuilderTests.cs` adds 7 new tests pinning the builder API contract: RegistersWithEmptyTagSet, AddsIntermediateTag, MultipleExecutorsAllUntagged, ThenIntermediate_AccumulatesTags, RepeatedDedupes, OnlyRegistersWithoutPriorWithOutputFrom, TracksExecutorBinding. * `BackwardsCompatibility/JsonCheckpointSerializationTests.cs` (new folder + file, 5 tests): event-level ctor contract tests (single-tag, no-tag, multi-tag — the last with a custom tag); IsIntermediate() asserted; load-bearing JSON BC tests for `WorkflowInfo.OutputExecutorIds` - `WorkflowOutputExecutorsReadsLegacyArrayShape` (legacy ids map to empty tag sets) and `WorkflowOutputExecutorsWritesMapShape`. The plan's three JSON round-trip tests for `WorkflowOutputEvent.Tags` were dropped: `WorkflowEvent` is not currently a serialized checkpoint shape (see the comment in WorkflowsJsonUtilities.cs about events not being persisted), so there is no real back-compat surface to pin through JSON. They are substituted with in-process ctor/property round-trip tests that exercise the `Tags` / `HasTag` / `IsIntermediate` contract. Validation: full `Microsoft.Agents.AI.Workflows.UnitTests` suite runs green on net10.0 (565 passing, 0 failing). Core library builds clean on net472, netstandard2.0, net8.0, net9.0, and net10.0. Test project builds clean on net472 + net10.0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: route AgentResponse(Update) through the output filter under a Futures flag `InProcessRunnerContext.YieldOutputAsync` historically special-cased AgentResponse and AgentResponseUpdate payloads: it built the typed event subclass and emitted it directly, bypassing the output filter. Rewrites the method so that: - When `Futures.EnableAgentResponseOutputTaggingAndFiltering` is `false` (the current default), AgentResponse(Update) keep the legacy bypass — emitted as AgentResponseEvent / AgentResponseUpdateEvent with no tags. Existing callers see no behavior change. - When the flag is `true`, AIAgent payloads flow through the output filter just like every other payload type: undesignated sources are dropped, and the emitted event carries the source's tag set (empty for terminal `WithOutputFrom`, `{Intermediate}` for `WithIntermediateOutputFrom`, the set union when both designations apply). Non-AIAgent (POCO) outputs also now carry the source's tag set on the emitted WorkflowOutputEvent unconditionally — additive, since no existing assertion inspected Tags. Subclass events (`AgentResponseEvent` / `AgentResponseUpdateEvent`) continue to be emitted under both modes so `switch (evt) { case AgentResponseEvent: ... }` consumer code keeps matching. Adds `OutputFilter.TryGetTags` as the tag-aware lookup used by the runner. `OutputFilter.CanOutput` is kept (still used by the existing sync tests in `OutputFilterTests.cs`). Tests ----- - `Futures/Futures.AgentResponseOutputFilteringAndTaggingTests.cs` (new): the F1–F13 matrix from the plan, covering every combination of `(flag on/off) × (designation) × (payload shape)`. Uses a `FuturesScope` IDisposable + a `FuturesSerial` xUnit collection (DisableParallelization = true) to keep the process-global flag from leaking across parallel tests. - `OutputFilterTests.cs`: four new `Test_OutputFilter_…` cases for the `TryGetTags` surface (empty-tag-set for terminal designation, `{Intermediate}` for intermediate designation, union for accumulated designation, `false` for unregistered). 582/582 unit tests pass on net10.0 (565 baseline + 17 new). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: tag-aware defaults and designation API on orchestration builders Aligns the .NET orchestration builders with Python's output / intermediate-output distinction. Each builder either applies a Python-aligned default designation set or replays the user's explicit `WithOutputFrom` / `WithIntermediateOutputFrom` calls, never both. Static `AgentWorkflowBuilder.BuildSequential` / `BuildConcurrent` apply defaults unconditionally (no user-facing fluent surface to take control through): - Sequential: terminal `end` + every agent designated intermediate. - Concurrent: terminal `end` + every agent and per-agent accumulator designated intermediate. The three fluent instance builders memoize agent-typed designation calls in a `Dictionary<AIAgent, HashSet<OutputTag>>` (empty set = terminal-only, non-empty = intermediate tag(s)) so repeated calls dedupe naturally. They replay the entries at `Build()` time, suppressing defaults when any call has been made: - `HandoffWorkflowBuilder` / `HandoffWorkflowBuilderCore<TBuilder>` (also picked up by the obsolete `HandoffsWorkflowBuilder` via inheritance). Default: terminal `HandoffEnd` + every handoff agent intermediate. (Bug fix: legacy code relied on `WithOutputFrom(end)` to bind `HandoffEnd`. The new explicit-designation path bypasses that, so `Build()` now calls `BindExecutor(end)` unconditionally to keep validation happy.) - `GroupChatWorkflowBuilder` — default: terminal host + every participant intermediate. - `MagenticWorkflowBuilder` — default: terminal orchestrator + every team member intermediate. Designating a non-participant agent throws `InvalidOperationException`. The bare `WorkflowBuilder` default is unchanged — only the orchestration-style builders gain implicit defaults, matching the plan's non-goal. Tests ----- - `AgentWorkflowBuilder.SequentialTests` / `.ConcurrentTests`: one default-spec assertion each. - `GroupChatWorkflowBuilderTests`: defaults-match-spec, explicit-replaces-defaults, non-participant throws. - `HandoffWorkflowBuilderTests` (new file): same three. - `MagenticWorkflowBuilderTests` (new file): same three. 593/593 unit tests pass on net10.0 (582 baseline + 11 new). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: WorkflowHostAgent forwards AgentResponseEvent unconditionally under Futures-on Aligns the .NET Workflow-as-Agent surface with Python `as_agent`. Under `Futures.EnableAgentResponseOutputTaggingAndFiltering = true`, `WorkflowSession.InvokeStageAsync` now forwards `AgentResponseEvent` unconditionally — joining `AgentResponseUpdateEvent` in ignoring the host's `includeWorkflowOutputsInResponse` switch. That switch keeps governing the generic `WorkflowOutputEvent` path for non-AIAgent payloads, where it is further short-circuited by an `IsIntermediate()` check (tagged intermediate outputs always surface). Under Futures-off the legacy asymmetry is preserved: `AgentResponseUpdateEvent` always forwarded, `AgentResponseEvent` gated by `includeWorkflowOutputsInResponse`. Back-compat: with `Futures.EnableAgentResponseOutputTaggingAndFiltering` left at its default `false`, observable behavior is identical to before. `Futures` documentation gains a remark explaining the `Workflow.AsAIAgent()` interaction in both flag states. Runner fix ---------- `InProcessRunnerContext.YieldOutputAsync` now skips `Executor.CanOutput` for AgentResponse-shaped payloads under both Futures branches. `AIAgentHostExecutor` doesn't declare AgentResponse(Update) in its `Yields` set, so the historical legacy bypass had silently skipped the check; Phase 3's Futures-on path was running it and would reject AIAgent payloads. AIAgent-shaped payloads are now always a valid output shape, matching the legacy bypass semantics. Phase 4 follow-on ----------------- Switched the three orchestration-builder designation-replay loops to iterate `Dictionary.Keys` with a value lookup instead of constructing/destructuring `KeyValuePair<,>`. Cleaner shape and avoids the netstandard2.0 / net472 `KeyValuePair<,>.Deconstruct` unavailability that surfaced when this branch multi-TFM-built. Tests ----- `WorkflowHostSmokeTests.IntermediateForwarding` (new nested class, 6 tests): - intermediate AgentResponse forwarded past the include-outputs gate (Futures on) - terminal AgentResponse forwarded unconditionally (Futures on) - terminal AgentResponse gated by include flag (Futures off, legacy) - undesignated AIAgent executor emits no AgentResponseEvent under Futures-on - legacy bypass still emits AgentResponseEvent under Futures-off - intermediate tag is observable via `update.RawRepresentation` The class joins the `FuturesSerial` xUnit collection so the process-global flag is serialized against other Futures-toggling tests. 599/599 unit tests pass on net10.0 (593 baseline + 6 new). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: SequentialWorkflowBuilder and ConcurrentWorkflowBuilder, OrchestrationBuilderBase Promotes the Sequential and Concurrent orchestration shapes to first-class fluent builder classes, matching Handoff / GroupChat / Magentic. Users can call `WithOutputFrom(agents)` / `WithIntermediateOutputFrom(agents)` to control which agents are designated output / intermediate sources; when no designation call is made, the Python-aligned defaults apply (terminal aggregator output + every agent intermediate; Concurrent also tags per-agent accumulators). `AgentWorkflowBuilder.BuildSequential(...)` and `BuildConcurrent(...)` are kept and now delegate to the new builders; observable behavior unchanged. Five static factories now mirror each other: - `AgentWorkflowBuilder.CreateSequentialBuilderWith(params IEnumerable<AIAgent>)` - `AgentWorkflowBuilder.CreateConcurrentBuilderWith(params IEnumerable<AIAgent>)` - `AgentWorkflowBuilder.CreateHandoffBuilderWith(AIAgent)` (already existed) - `AgentWorkflowBuilder.CreateGroupChatBuilderWith(Func<...>)` (already existed) - `AgentWorkflowBuilder.CreateMagenticBuilderWith(AIAgent)` (new) OrchestrationBuilderBase ------------------------ New abstract `OrchestrationBuilderBase<TBuilder>` unifies the shared fluent surface across all five orchestration builders: `WithName`, `WithDescription`, `WithOutputFrom`, `WithIntermediateOutputFrom`, and the `ApplyOutputDesignations(builder, agentMap, kind, applyDefaults)` helper that either replays the user's designations or invokes the orchestration-specific defaults. Removes ~150 LOC of duplicated designation-management code from the four non-Handoff builders, plus the equivalent from `HandoffWorkflowBuilderCore`. Tests ----- - New `SequentialWorkflowBuilderTests.cs` / `ConcurrentWorkflowBuilderTests.cs` (replace the old `AgentWorkflowBuilder.{Sequential,Concurrent}Tests.cs` nested-class files). Method names normalized to `Test_<BuilderType>_<Scenario>[Async]`. - Shared helpers (`DoubleEchoAgent`, `DoubleEchoAgentWithBarrier`, `WorkflowRunResult`, `RunWorkflow*`) moved from the old `AgentWorkflowBuilderTests` partial class into a new `OrchestrationTestHelpers` static class in `OrchestrationTestHelpers.cs`. Downstream test files (Group Chat, Handoff, Sequential, Concurrent) updated to qualify with `OrchestrationTestHelpers.*`. - A new `AgentWorkflowBuilderTests.cs` covers the static surface directly: `BuildSequential` / `BuildConcurrent` invariants and aggregator wiring, plus null-rejection + round-trip checks for every `Create*BuilderWith` factory. - New AsAgent intermediate-suppression tests on a nested `AsAgentForwarding` class for each of Sequential and Concurrent: build with only the terminal agent designated via `WithOutputFrom`, run via `AsAIAgent(...)`, assert via `AgentResponseUpdate.AuthorName` that intermediate agents do not surface. Both join the `FuturesSerial` collection. - New `Test_<Builder>_WithDescriptionPropagatesToWorkflow` smoke tests on Sequential and Concurrent (newly available via the base class). 625/625 unit tests pass on net10.0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: dotnet format * fixup: encoding * fixup: charset * fixup: Updates for PR feedback * fixup: format * fixup: merge issue * Fix intermediate filtering on .AsAgent() * fix filter logic * fix: Revert logic change and add comments --------- Co-authored-by: Jacob Alber <jalber@lokitoth.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
701 lines
32 KiB
C#
701 lines
32 KiB
C#
// 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;
|
|
|
|
/// <summary>
|
|
/// The execution environment for this session. Concrete type is required because
|
|
/// <see cref="CreateOrResumeRunAsync"/> uses the internal
|
|
/// <see cref="InProcessExecutionEnvironment.ResumeStreamingInternalAsync"/> API.
|
|
/// </summary>
|
|
private readonly InProcessExecutionEnvironment _inProcEnvironment;
|
|
|
|
private readonly bool _includeExceptionDetails;
|
|
private readonly bool _includeWorkflowOutputsInResponse;
|
|
|
|
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;
|
|
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<SessionState>(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<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.
|
|
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);
|
|
}
|
|
|
|
/// <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);
|
|
}
|
|
|
|
object 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>
|
|
/// Resolves the concrete request payload type from <see cref="RequestPortInfo.RequestType"/>
|
|
/// and returns it as an <see cref="IExternalRequestEnvelope"/> if the type implements that
|
|
/// abstraction. Resolving via the concrete <see cref="TypeId"/> (rather than asking the
|
|
/// PortableValue to deserialize directly to <see cref="IExternalRequestEnvelope"/>) is
|
|
/// required because checkpointed payloads round-trip as JSON which cannot be deserialized
|
|
/// to an interface; the concrete type populates the deserialization cache so subsequent
|
|
/// interface assignment succeeds.
|
|
/// </summary>
|
|
[UnconditionalSuppressMessage("Trimming", "IL2057:Unrecognized value passed to the parameter of method", Justification = "Higher-layer envelope types are explicitly preserved by the package that defines them.")]
|
|
private static bool TryGetRequestEnvelope(ExternalRequest request, [NotNullWhen(true)] out IExternalRequestEnvelope? envelope)
|
|
{
|
|
envelope = null;
|
|
|
|
TypeId requestType = request.PortInfo.RequestType;
|
|
Type? concreteType = Type.GetType($"{requestType.TypeName}, {requestType.AssemblyName}", throwOnError: false);
|
|
if (concreteType is null || !typeof(IExternalRequestEnvelope).IsAssignableFrom(concreteType))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!request.TryGetDataAs(concreteType, out object? data) || data is not IExternalRequestEnvelope env)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
envelope = env;
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates the workflow-facing request content surfaced in response updates.
|
|
/// </summary>
|
|
private static AIContent CreateRequestContentForDelivery(ExternalRequest request)
|
|
{
|
|
// If the request payload is a higher-layer envelope (e.g., a declarative
|
|
// ExternalInputRequest), surface its inner FCC/TARC to the host on the wire.
|
|
if (TryGetRequestEnvelope(request, out IExternalRequestEnvelope? envelope))
|
|
{
|
|
AIContent? inner = envelope.GetInnerRequestContent();
|
|
if (inner is ToolApprovalRequestContent toolApprovalRequest)
|
|
{
|
|
return CloneToolApprovalRequestContent(toolApprovalRequest, request.RequestId);
|
|
}
|
|
if (inner is FunctionCallContent functionCall)
|
|
{
|
|
return CloneFunctionCallContent(functionCall, request.RequestId);
|
|
}
|
|
}
|
|
|
|
return 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 object NormalizeResponseContentForDelivery(AIContent content, ExternalRequest request)
|
|
{
|
|
// If the request payload is a higher-layer envelope, recover the original
|
|
// CallId/RequestId from the inner content and ask the envelope to wrap the
|
|
// response back into its paired response type for delivery to the request port.
|
|
if (TryGetRequestEnvelope(request, out IExternalRequestEnvelope? envelope))
|
|
{
|
|
AIContent? inner = envelope.GetInnerRequestContent();
|
|
AIContent payload = (content, inner) switch
|
|
{
|
|
(FunctionResultContent functionResult, FunctionCallContent functionCall)
|
|
=> CloneFunctionResultContent(functionResult, functionCall.CallId),
|
|
(FunctionResultContent functionResult, ToolApprovalRequestContent toolApprovalRequest)
|
|
=> CloneFunctionResultContent(functionResult, toolApprovalRequest.ToolCall.CallId),
|
|
(ToolApprovalResponseContent toolApprovalResponse, ToolApprovalRequestContent toolApprovalRequest)
|
|
=> CloneToolApprovalResponseContent(toolApprovalResponse, toolApprovalRequest.RequestId),
|
|
_ => content,
|
|
};
|
|
|
|
ChatMessage message = new(ChatRole.Tool, [payload]);
|
|
return envelope.CreateResponse([message]);
|
|
}
|
|
|
|
switch (content)
|
|
{
|
|
// If we got a FRC, and were expecting a FRC (because the request started out as a FCC, rather than getting converted to
|
|
// on at the WorkflowSession boundary), clone it and send it in.
|
|
case FunctionResultContent functionResultContent when request.TryGetDataAs(out FunctionCallContent? functionCallContent):
|
|
return CloneFunctionResultContent(functionResultContent, functionCallContent.CallId);
|
|
case FunctionResultContent functionResultContent when !request.PortInfo.ResponseType.IsMatchPolymorphic(typeof(FunctionResultContent)):
|
|
{
|
|
object? result = functionResultContent.Result;
|
|
if (result != null)
|
|
{
|
|
if (request.PortInfo.ResponseType.IsMatchPolymorphic(result.GetType()) || result is PortableValue)
|
|
{
|
|
return result;
|
|
}
|
|
|
|
throw new InvalidOperationException($"Unexpected result type in FunctionResultContent {result.GetType()}; expecting {request.PortInfo.ResponseType}");
|
|
}
|
|
|
|
throw new NotSupportedException($"Null result is not supported when using RequestPort with non-AIContent-typed requests. {functionResultContent}");
|
|
}
|
|
case ToolApprovalResponseContent toolApprovalResponseContent when request.TryGetDataAs(out ToolApprovalRequestContent? toolApprovalRequestContent):
|
|
return CloneToolApprovalResponseContent(toolApprovalResponseContent, toolApprovalRequestContent.RequestId);
|
|
default:
|
|
return 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)
|
|
{
|
|
this.LastResponseId = Guid.NewGuid().ToString("N");
|
|
List<ChatMessage> 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 ExecutorFailedEvent executorFailed:
|
|
// Mirror WorkflowErrorEvent: never expose internal workflow graph
|
|
// identifiers (executor ID) to the client. Surface the exception
|
|
// message only when the host opts in via _includeExceptionDetails.
|
|
Exception? executorException = executorFailed.Data;
|
|
while (executorException is { InnerException: not null }
|
|
&& (executorException is TargetInvocationException
|
|
|| executorException.GetType().Name == "DeclarativeActionException"))
|
|
{
|
|
executorException = executorException.InnerException;
|
|
}
|
|
|
|
string executorMessage = this._includeExceptionDetails && executorException != null
|
|
? executorException.Message
|
|
: "An error occurred while executing the workflow.";
|
|
|
|
yield return this.CreateUpdate(this.LastResponseId, evt, new ErrorContent(executorMessage));
|
|
break;
|
|
|
|
case SuperStepCompletedEvent stepCompleted:
|
|
this.LastCheckpoint = stepCompleted.CompletionInfo?.Checkpoint;
|
|
goto default;
|
|
|
|
case AgentResponseEvent agentResponse:
|
|
// Under Futures.EnableAgentResponseOutputTaggingAndFiltering=true, mirror
|
|
// AgentResponseUpdateEvent's behavior: always forward, regardless of the
|
|
// _includeWorkflowOutputsInResponse host flag / "intermediate" tag. Under
|
|
// the legacy default, keep today's behavior — gated by the include flag.
|
|
if (!Futures.EnableAgentResponseOutputTaggingAndFiltering && !this._includeWorkflowOutputsInResponse)
|
|
{
|
|
goto default;
|
|
}
|
|
|
|
// Either EnableAgentResponseOutputTaggingAndFiltering -- so yield the Response
|
|
// regardless of whether it is tagged "intermediate" or whether the
|
|
// _includeWorkflowOutputInResponse flag is set. Reason being: The user specifies
|
|
// exclusion of an event by enabling filtering and then _not_ marking an Executor
|
|
// as an output executor.
|
|
foreach (ChatMessage message in agentResponse.Response.Messages)
|
|
{
|
|
yield return this.CreateUpdate(this.LastResponseId, evt, message);
|
|
}
|
|
break;
|
|
|
|
case WorkflowOutputEvent output:
|
|
IEnumerable<ChatMessage>? updateMessages = output.Data switch
|
|
{
|
|
IEnumerable<ChatMessage> chatMessages => chatMessages,
|
|
ChatMessage chatMessage => [chatMessage],
|
|
_ => null
|
|
};
|
|
|
|
// Same assymetry as with AgentResponseEvent, but there is no EnableFiltering flag
|
|
// to consider. If this made it here (and since it is not an AgentResponse[Update]),
|
|
// it means it is already been selected as an Output() from the user. Intermediate
|
|
// is irrelevant here.
|
|
if (updateMessages == null || !this._includeWorkflowOutputsInResponse)
|
|
{
|
|
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; }
|
|
|
|
/// <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,
|
|
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;
|
|
}
|
|
}
|