Files
agent-framework/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FileSystemAgentSessionStore.cs
T
Ben Thomas 4b5a8478de .NET: Hosting updates to declarative workflows (#5589)
* Make DeclarativeWorkflowExecutor ChatProtocol-compatible for AsAIAgent hosting

Extends the existing DeclarativeWorkflowExecutor<TInput> root executor with
additional ChatProtocol-compatible input routes (string, ChatMessage,
IEnumerable<ChatMessage>, ChatMessage[], TurnToken) so that workflows built
via DeclarativeWorkflowBuilder.Build<TInput>(...) work both for direct
invocation and when hosted via Workflow.AsAIAgent(...).

- Each input message advances the declarative graph immediately; the
  TurnToken that the host sends after the message batch is treated as a
  no-op since the message has already been processed.
- Conversation id resolution now prefers persisted workflow system state,
  then DeclarativeWorkflowOptions.ConversationId, then a newly created
  conversation. This makes multi-turn invocations reuse the prior
  conversation rather than creating a fresh one each turn.
- The separate DeclarativeChatProtocolStartExecutor and
  DeclarativeWorkflowBuilder.BuildChatProtocol overloads introduced
  earlier are removed; callers continue to use Build<TInput>(...).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: use DeclarativeWorkflowContext when reading workflow conversation id

GetWorkflowConversation() requires a DeclarativeWorkflowContext (it calls ReadState which dynamic-casts via the DeclarativeContext helper). The chat-protocol auxiliary handlers receive a BoundWorkflowContext, so calling the extension on the raw IWorkflowContext throws `Invalid workflow context: BoundWorkflowContext`. Use the wrapped declarativeContext that we already constructed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: surface ExecutorFailedEvent as ErrorContent in AsAIAgent response

WorkflowSession.InvokeStageAsync only converted WorkflowErrorEvent into an ErrorContent payload. ExecutorFailedEvent fell through to the default branch which emits an empty AgentResponseUpdate carrying the event in RawRepresentation. OutputConverter then mapped that to a workflow_action item with status=failed and dropped the exception entirely, so callers got status=completed and error=null even when an executor threw.

- WorkflowSession.cs: add ExecutorFailedEvent case mirroring WorkflowErrorEvent. Honors _includeExceptionDetails.

- OutputConverter.cs: when an update carries both a WorkflowEvent in RawRepresentation and non-empty Contents, fall through to content processing so the unwrapped error (or any future content payload from a workflow event) is actually emitted.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* improve: walk inner exceptions when surfacing ExecutorFailedEvent

DeclarativeActionExecutor wraps inner exceptions in DeclarativeActionException with a generic `Unhandled workflow failure` message, hiding the real cause. Walk InnerException so the response shows the full chain (e.g. the underlying HTTP 400 / auth error).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Surface declarative SendActivity output as chat content

SendActivityExecutor now emits AgentResponseEvent in addition to
MessageActivityEvent so chat protocols (e.g. AsAIAgent) receive the
formatted activity text. The existing MessageActivityEvent is preserved
for DevUI/observability.

Also extend WorkflowSession.WorkflowOutputEvent handling to accept
AgentResponse payloads, mapping them to their constituent ChatMessages.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Persist hosted-agent sessions to disk; fix System.LastMessageText

Adds FileSystemAgentSessionStore that writes the serialized AgentSession JSON

(which already embeds the workflow's in-memory checkpoint manager) to a per-

conversation file under /.checkpoints when running in a Foundry hosted env

or {cwd}/.checkpoints locally. Mirrors the python foundry_hosting._responses

FileCheckpointStorage pattern so multi-turn workflow state survives process

restarts without requiring callers to wire up storage themselves.

AddFoundryResponses now defaults to FileSystemAgentSessionStore.CreateDefault()

instead of InMemoryAgentSessionStore; callers can still override via DI.

Also fixes {System.LastMessageText} resolving empty: DeclarativeWorkflowExecutor

.AdvanceAsync was passing the message rehydrated from CreateMessageAsync to

SetLastMessageAsync, but ResponseItem -> ChatMessage round-trip drops the .Text

extension content. Use the original input ChatMessage (which still has the

user-supplied text) and copy the server-assigned MessageId across when present.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Close multi-modal input parity gaps with python foundry_hosting

InputConverter now mirrors the python _responses.py content handling:

- ComputerScreenshotContent maps to UriContent/HostedFileContent (was dropped).

- Plain TextContent and SummaryTextContent map to MEAI TextContent.

- MessageContentReasoningTextContent maps to MEAI TextReasoningContent.

- input_file with text/* file_data data URIs is decoded inline into

  TextContent with a [File: name] prefix, matching python _convert_file_data

  so {System.LastMessageText} surfaces the file body. Non-text data URIs and

  hosted/url file references preserve filename as AdditionalProperties.

Image/file extraction logic is extracted into shared AppendImageContent and

AppendFileContent helpers used by both the fresh-input and history-replay

switches. Existing 37 InputConverter tests still pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Foundry hosting: round-trip tool-approval (HITL) content as mcp_approval_request/response

Closes the gap where Microsoft.Agents.AI.Foundry.Hosting silently dropped
MEAI ToolApprovalRequestContent/ToolApprovalResponseContent in both
directions. We now serialize them onto the wire as the standard Responses
API mcp_approval_request/mcp_approval_response items with
server_label='agent_framework', and parse the symmetric inbound shapes
back into MEAI content.

Wire format:
- The Responses API only standardizes mcp_approval_* as the approval
  primitive. We declare AF as a virtual MCP server via the server_label
  field, which is honest for AF's server-side tool-call holding pattern.
- The SDK enforces a strict {prefix}_{50hex} wire-id format, so we hash
  the AF RequestId and persist a wireId<->afRequestId mapping in
  AgentSession.StateBag so a later mcp_approval_response can be matched
  back to the originating workflow request.

Coexists with the existing ConsentAwareMcpClientAIFunction flow
(AgentFrameworkResponseHandler.cs) which emits mcp_approval_request from
a side-channel, not via OutputConverter's content switch.

Known follow-up: python (foundry_hosting/_responses.py) has the same
output-side gap (ToolApprovalRequestContent emission). Out of scope here.

Tests: +9 unit tests covering both fresh-input and history-replay shapes,
StateBag mapping resolution, and the non-FunctionCallContent skip path.
Existing 108 converter tests still pass; full suite 370/370.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address PR review feedback for hosted-declarative-dotnet

FileSystemAgentSessionStore reliability/scoping:

- Bound Sanitize() stackalloc at 256 chars, fall back to ArrayPool for longer ids so a long conversationId can no longer crash the hosting process with StackOverflowException.

- Use a Guid-suffixed temp file (\{path}.{guid}.tmp\) so concurrent SaveSessionAsync calls on the same conversation can no longer race on the same temp file. Best-effort temp cleanup on failure.

- Bucket session files by agent.Name when set so two keyed agents that happen to share a conversationId no longer overwrite each other's persisted state. Single-agent / unnamed-agent cases keep the original flat layout (Python parity).

DeclarativeWorkflowExecutor chat-protocol routing:

- ConfigureChatProtocolRoutes uses IsAssignableFrom rather than exact type equality so a broader TInput (object, base interfaces) does not have its inherited inputTransform shadowed by handlers we register here.

- HandleChatMessagesAsync / HandleChatMessageArrayAsync now advance through every message in the batch instead of keeping only the trailing one, so multi-message turns and replayed history are no longer silently truncated. AdvanceAsync gains a finalizeTurn flag so only the last message in the batch sends the result.

Tests:

- New FileSystemAgentSessionStoreTests covering constructor, fresh-session fallback for missing/empty files, root-directory creation, save/get round-trip, agent-Name scoping isolation, long conversationId, invalid-character sanitization, and concurrent-save behavior.

- New InputConverterTests covering AppendFileContent: text/* data URI decode (with and without filename prefix), non-text data URI passthrough, malformed data URI fallback, and filename propagation onto UriContent / HostedFileContent.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add tests for remaining PR review feedback (C2, D1, E1)

C2: InputConverter — add 9 tests covering SDK content types that previously
had no coverage:
  - SdkTextContent → TextContent (input + output paths)
  - SummaryTextContent → TextContent (input + output paths)
  - MessageContentReasoningTextContent → TextReasoningContent (input + output)
  - ComputerScreenshotContent (HTTP URL → UriContent, data: URI → DataContent,
    output path → UriContent)

D1: OutputConverter — add 2 tests for the WorkflowEvent + Contents fall-through:
  - WorkflowEvent in RawRepresentation with text Contents must flow through
    the content-processing path (text-delta event emitted).
  - WorkflowEvent + ErrorContent must produce a failed event rather than be
    swallowed by the workflow branch.

E1: SendActivityExecutor — extend CaptureActivityAsync to assert that the
executor emits an AgentResponseEvent carrying the activity text with the
correct ExecutorId and ChatRole.Assistant role.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Defense-in-depth: neutralize dot-segments in Sanitize and cap TryDecodeTextDataUri input size

Addresses claude-opus-4.6 security review on PR #5589:

- FileSystemAgentSessionStore.Sanitize now replaces all-dot segments
  (., .., ...) with underscores so a developer-controlled agent.Name
  cannot escape the root directory on Linux (where Path.GetInvalidFileNameChars
  only contains NUL and '/').

- InputConverter.TryDecodeTextDataUri rejects encoded payloads larger than
  16 MiB before calling Convert.FromBase64String, preventing a single
  oversized data URI from triggering a multi-megabyte allocation.

- Adds unit tests covering both fixes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix Linux-only failure in SaveSessionAsync_SanitizesInvalidPathCharactersAsync

'?' is in Path.GetInvalidFileNameChars only on Windows, not on Linux/macOS,
so the test failed on Ubuntu in CI. Use Path.GetInvalidFileNameChars()[0]
(skipping NUL) to pick a guaranteed-invalid character for the running OS,
and assert the result no longer contains it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address claude-opus-4.6 security/reliability review feedback

WorkflowSession.cs:
- ExecutorFailedEvent handler no longer leaks the internal executor ID
  in error messages. Mirror the WorkflowErrorEvent pattern: surface the
  exception's Message when _includeExceptionDetails is true, fall back
  to the generic 'An error occurred while executing the workflow.' otherwise.
  This also resolves the failing WorkflowHostSmokeTests assertions.

FileSystemAgentSessionStore.cs:
- GetSessionPath no longer has a write side effect. Directory.CreateDirectory
  for the per-agent bucket is now performed only on the SaveSessionAsync
  path, so a read miss on GetSessionAsync no longer leaves an empty
  directory on disk.
- Adds GetSessionAsync_NoExistingFile_DoesNotCreateAgentDirectoryAsync
  to lock in the no-side-effect-on-read contract.

OutputConverterTests.cs:
- Strengthen ConvertUpdatesToEventsAsync_ToolApprovalRequest_NonFunctionToolCall_SkippedAsync
  to assert exactly one event (the terminal ResponseCompletedEvent) so a
  spurious output-item-added/-done leak would now fail the test.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address PR review: clean up comments and rename TryParseArguments

- Remove Python-codebase references from C# XML docs and inline comments.
- Drop fix-history comments referring to previously-resolved issues.
- Drop `Defense-in-depth:` prefixes; keep the concrete `what & why`.
- Drop `previously we kept only the trailing message` comment in
  DeclarativeWorkflowExecutor; just describe current loop behavior.
- Rename InputConverter.TryParseArguments to ParseFunctionArgumentsObject
  to make the intent obvious at the call site.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address PR review: collision-free Sanitize, MAF-style refactors

- FileSystemAgentSessionStore.Sanitize now percent-encodes invalid chars
  (and `%` itself) instead of replacing them with `_`, eliminating
  collisions like `foo/bar` vs `foo_bar` mapping to the same bucket.
  All-dot segments encode every dot so Windows trailing-dot trimming
  cannot reintroduce a navigable name.
- AddFoundryResponses XML doc updated to accurately describe the default
  store root (/.checkpoints when hosted, {cwd}/.checkpoints locally).
- DeclarativeWorkflowExecutor.ConfigureChatProtocolRoutes now uses exact
  type equality instead of IsAssignableFrom so a broad TInput (e.g.
  object) does not skip registering IEnumerable<ChatMessage>, which
  ChatProtocolExtensions.IsChatProtocol requires verbatim.
- SendActivityExecutor uses context.YieldOutputAsync(response) instead
  of manually constructing AgentResponseEvent, so the activity will
  participate in any future OutputFilter coverage.
- WorkflowSession handles AgentResponseEvent in its own switch case,
  avoiding the second typecheck against output.Data.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(workflows): bridge declarative HITL through Foundry hosting via IExternalRequestEnvelope

Introduce a new public interface IExternalRequestEnvelope in
Microsoft.Agents.AI.Workflows that lets the runtime peek through a
declarative-layer envelope without taking a circular reference back into
the declarative package. ExternalInputRequest (declarative) implements
it; ExternalInputResponse is constructed via the request's CreateResponse
factory. WorkflowSession unwraps inner AIContent on the request side and
rewraps the client's ChatMessage reply into an ExternalInputResponse on
the response side. PortableValue cannot deserialize directly into an
interface, so TryGetRequestEnvelope resolves the concrete type via
RequestPortInfo.RequestType (TypeId -> Type.GetType) before casting.

Public WorkflowHarness contract preserved: InvokeFunctionToolExecutor
and WorkflowActionVisitor are unchanged from upstream, so public
InvokeToolWorkflowTest scenarios continue to drive
ExternalInputRequest / ExternalInputResponse directly through the
harness.

AgentFrameworkResponseHandler: skip prior conversation history replay
when an existing session is being resumed (workflow checkpoint already
holds the prior messages).

WorkflowSession: when includeExceptionDetails is opted in, also unwrap
DeclarativeActionException so HITL failures are debuggable.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: alliscode <bentho@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-04 22:09:54 +00:00

262 lines
10 KiB
C#

// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Buffers;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Provides a file-system backed implementation of <see cref="AgentSessionStore"/> that persists
/// the agent-framework's serialized <see cref="AgentSession"/> state for each (agent, conversation)
/// pair to disk. This complements Foundry storage (which owns conversation messages, agent
/// definitions, and threads) — it is not a replacement for it.
/// </summary>
/// <remarks>
/// <para>
/// The session JSON stored here is the AF runtime's own state (workflow checkpoint manager,
/// pending external requests, internal port state) that is required to resume an
/// <see cref="AgentSession"/> across HTTP requests or process restarts but is not part of
/// Foundry's data model.
/// </para>
/// <para>
/// When running in a Foundry hosted environment, sessions are stored under the well-known
/// <c>/.checkpoints</c> path; locally, they fall under <c>{cwd}/.checkpoints</c>. The session
/// JSON produced when the agent serializes the session already contains the workflow's
/// in-memory checkpoint manager state, so a single file per (agent, conversation) pair is
/// sufficient to resume long-running workflows across process restarts.
/// </para>
/// <para>
/// Files are written atomically via a temp-file + <see cref="File.Move(string, string, bool)"/>
/// rename so a partially-written file cannot be observed by a concurrent reader.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public sealed class FileSystemAgentSessionStore : AgentSessionStore
{
/// <summary>
/// The well-known absolute path used when running inside a Foundry hosted environment.
/// </summary>
public const string HostedCheckpointDirectory = "/.checkpoints";
/// <summary>
/// The directory name used under the current working directory when running locally.
/// </summary>
public const string LocalCheckpointDirectoryName = ".checkpoints";
/// <summary>
/// Initializes a new instance of the <see cref="FileSystemAgentSessionStore"/> class
/// that stores serialized sessions under <paramref name="rootDirectory"/>.
/// </summary>
/// <param name="rootDirectory">
/// The absolute or relative directory where session files will be written.
/// The directory is created on first write if it does not already exist.
/// </param>
public FileSystemAgentSessionStore(string rootDirectory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(rootDirectory);
this.RootDirectory = Path.GetFullPath(rootDirectory);
}
/// <summary>
/// Gets the root directory under which session files are written.
/// </summary>
public string RootDirectory { get; }
/// <summary>
/// Creates a <see cref="FileSystemAgentSessionStore"/> rooted at the default location:
/// <see cref="HostedCheckpointDirectory"/> when running in a Foundry hosted environment,
/// otherwise <see cref="LocalCheckpointDirectoryName"/> under the current working directory.
/// </summary>
/// <returns>A new <see cref="FileSystemAgentSessionStore"/> instance.</returns>
public static FileSystemAgentSessionStore CreateDefault()
{
string root = FoundryEnvironment.IsHosted
? HostedCheckpointDirectory
: Path.Combine(Environment.CurrentDirectory, LocalCheckpointDirectoryName);
return new FileSystemAgentSessionStore(root);
}
/// <inheritdoc/>
public override async ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(agent);
ArgumentException.ThrowIfNullOrWhiteSpace(conversationId);
ArgumentNullException.ThrowIfNull(session);
JsonElement serialized = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false);
Directory.CreateDirectory(this.RootDirectory);
string path = this.GetSessionPath(agent, conversationId);
string? parentDir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(parentDir))
{
Directory.CreateDirectory(parentDir);
}
// Each save writes to its own temp file before atomically renaming over the
// destination. Last writer wins for the final file, but no reader can observe
// a torn or partially-written JSON document.
string tempPath = $"{path}.{Guid.NewGuid():N}.tmp";
try
{
using (FileStream stream = new(tempPath, FileMode.Create, FileAccess.Write, FileShare.None))
using (Utf8JsonWriter writer = new(stream))
{
serialized.WriteTo(writer);
}
File.Move(tempPath, path, overwrite: true);
}
catch
{
try { File.Delete(tempPath); } catch { /* best-effort cleanup */ }
throw;
}
}
/// <inheritdoc/>
public override async ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(agent);
ArgumentException.ThrowIfNullOrWhiteSpace(conversationId);
string path = this.GetSessionPath(agent, conversationId);
if (!File.Exists(path))
{
return await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
}
byte[] bytes = await File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false);
if (bytes.Length == 0)
{
return await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
}
// Parse and clone so the document buffer can be released.
using JsonDocument document = JsonDocument.Parse(bytes);
JsonElement element = document.RootElement.Clone();
return await agent.DeserializeSessionAsync(element, cancellationToken: cancellationToken).ConfigureAwait(false);
}
private string GetSessionPath(AIAgent agent, string conversationId)
{
// When agent.Name is set we bucket sessions into a per-agent subdirectory so
// multiple keyed agents sharing a single in-process default store cannot
// collide on the same conversationId. agent.Id is intentionally NOT used
// because it is regenerated on every startup for in-memory-defined agents.
string fileName = $"{Sanitize(conversationId)}.json";
if (string.IsNullOrEmpty(agent.Name))
{
return Path.Combine(this.RootDirectory, fileName);
}
string agentDir = Path.Combine(this.RootDirectory, Sanitize(agent.Name!));
return Path.Combine(agentDir, fileName);
}
private static string Sanitize(string value)
{
// Percent-encode every character that is invalid in a filename, plus '%' itself
// so the encoding is unambiguous. This is reversible and avoids the collision
// hazard of a lossy character substitution (e.g. "foo/bar" and "foo_bar" sharing
// a sanitized name).
char[] invalid = Path.GetInvalidFileNameChars();
int encodedLength = ComputeEncodedLength(value, invalid);
// stackalloc is bounded so an externally-controlled length cannot crash the
// hosting process with StackOverflowException.
const int StackLimit = 512;
string sanitized;
if (encodedLength <= StackLimit)
{
Span<char> buffer = stackalloc char[encodedLength];
SanitizeCore(value, invalid, buffer);
sanitized = new string(buffer);
}
else
{
char[] rented = ArrayPool<char>.Shared.Rent(encodedLength);
try
{
Span<char> buffer = rented.AsSpan(0, encodedLength);
SanitizeCore(value, invalid, buffer);
sanitized = new string(buffer);
}
finally
{
ArrayPool<char>.Shared.Return(rented);
}
}
// '.' and '..' are valid filename characters but resolve to current/parent
// directory when used as a bare path component. Windows additionally strips
// trailing dots from filenames, so a segment like "..." would survive on disk
// as "" and a partial-encode like "%2E.." would survive as "%2E". Encode every
// dot in any all-dot segment so the result has no special meaning to the OS.
if (sanitized.Length > 0 && IsAllDots(sanitized))
{
return string.Concat(Enumerable.Repeat("%2E", sanitized.Length));
}
return sanitized;
}
private static int ComputeEncodedLength(string value, char[] invalid)
{
int extra = 0;
for (int i = 0; i < value.Length; i++)
{
char c = value[i];
if (c == '%' || Array.IndexOf(invalid, c) >= 0)
{
extra += 2; // 1 char ('%' or invalid) becomes 3 chars ("%XX")
}
}
return value.Length + extra;
}
private static bool IsAllDots(string value)
{
for (int i = 0; i < value.Length; i++)
{
if (value[i] != '.')
{
return false;
}
}
return true;
}
private static void SanitizeCore(string value, char[] invalid, Span<char> buffer)
{
int j = 0;
for (int i = 0; i < value.Length; i++)
{
char c = value[i];
if (c == '%' || Array.IndexOf(invalid, c) >= 0)
{
buffer[j++] = '%';
buffer[j++] = HexChar((c >> 4) & 0xF);
buffer[j++] = HexChar(c & 0xF);
}
else
{
buffer[j++] = c;
}
}
}
private static char HexChar(int n) => (char)(n < 10 ? '0' + n : 'A' + n - 10);
}