From d3b9639344a280941700edb40d9d2a827e2fc119 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Mon, 26 Jan 2026 17:52:48 +0000 Subject: [PATCH 01/11] Add an AsyncLocal AgentRunContext --- .../AIAgent.cs | 42 ++++++++++++++++--- .../AgentRunContext.cs | 41 ++++++++++++++++++ 2 files changed, 77 insertions(+), 6 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunContext.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgent.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgent.cs index 3314177bf1..9b9ef8565b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgent.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Runtime.CompilerServices; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -22,6 +23,8 @@ namespace Microsoft.Agents.AI; [DebuggerDisplay("{DebuggerDisplay,nq}")] public abstract class AIAgent { + private static readonly AsyncLocal s_currentContext = new(); + [DebuggerBrowsable(DebuggerBrowsableState.Never)] private string DebuggerDisplay => this.Name is { } name ? $"Id = {this.Id}, Name = {name}" : $"Id = {this.Id}"; @@ -76,6 +79,18 @@ public abstract class AIAgent /// public virtual string? Description { get; } + /// + /// Gets or sets the for the current agent run. + /// + /// + /// This value flows across async calls. + /// + public static AgentRunContext? CurrentRunContext + { + get => s_currentContext.Value; + protected set => s_currentContext.Value = value; + } + /// Asks the for an object of the specified type . /// The type of object being requested. /// An optional key that can be used to help identify the target service. @@ -233,12 +248,16 @@ public Task RunAsync( /// The agent's response will also be added to if one is provided. /// /// - public Task RunAsync( + public async Task RunAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, - CancellationToken cancellationToken = default) => - this.RunCoreAsync(messages, thread, options, cancellationToken); + CancellationToken cancellationToken = default) + { + thread ??= await this.GetNewThreadAsync(cancellationToken).ConfigureAwait(false); + CurrentRunContext = new(this, thread) { RunOptions = options }; + return await this.RunCoreAsync(messages, thread, options, cancellationToken).ConfigureAwait(false); + } /// /// Core implementation of the agent invocation logic with a collection of chat messages. @@ -355,12 +374,23 @@ public IAsyncEnumerable RunStreamingAsync( /// to display partial results, implement progressive loading, or provide immediate feedback to users. /// /// - public IAsyncEnumerable RunStreamingAsync( + public async IAsyncEnumerable RunStreamingAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, - CancellationToken cancellationToken = default) => - this.RunCoreStreamingAsync(messages, thread, options, cancellationToken); + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + thread ??= await this.GetNewThreadAsync(cancellationToken).ConfigureAwait(false); + AgentRunContext context = new(this, thread) { RunOptions = options }; + CurrentRunContext = context; + await foreach (var update in this.RunCoreStreamingAsync(messages, thread, options, cancellationToken).ConfigureAwait(false)) + { + yield return update; + + // Restore context again when resuming after the caller code executes. + CurrentRunContext = context; + } + } /// /// Core implementation of the agent streaming invocation logic with a collection of chat messages. diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunContext.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunContext.cs new file mode 100644 index 0000000000..fefb1e895f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunContext.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// Provides context for an in-flight agent run. +public class AgentRunContext +{ + /// + /// Initializes a new instance of the class. + /// + /// The that is executing the current run. + /// The that is associated with the current run. + public AgentRunContext(AIAgent agent, AgentThread thread) + { + this.Agent = Throw.IfNull(agent); + this.Thread = Throw.IfNull(thread); + } + + /// Gets or sets the that is executing the current run. + public AIAgent Agent + { + get; + private set => field = Throw.IfNull(value); + } + + /// Gets or sets the that is associated with the current run. + public AgentThread Thread + { + get; + private set => field = Throw.IfNull(value); + } + + /// Gets or sets the that was passed to the current run. + public AgentRunOptions? RunOptions + { + get; + set; + } +} From bc55ee2a802935b17c25b825119eddac724a7b3a Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Mon, 26 Jan 2026 17:56:21 +0000 Subject: [PATCH 02/11] Update AgentRunContext session naming --- .../AgentRunContext.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunContext.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunContext.cs index fefb1e895f..0c57709713 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunContext.cs @@ -11,11 +11,11 @@ public class AgentRunContext /// Initializes a new instance of the class. /// /// The that is executing the current run. - /// The that is associated with the current run. - public AgentRunContext(AIAgent agent, AgentThread thread) + /// The that is associated with the current run. + public AgentRunContext(AIAgent agent, AgentSession session) { this.Agent = Throw.IfNull(agent); - this.Thread = Throw.IfNull(thread); + this.Session = Throw.IfNull(session); } /// Gets or sets the that is executing the current run. @@ -25,8 +25,8 @@ public AIAgent Agent private set => field = Throw.IfNull(value); } - /// Gets or sets the that is associated with the current run. - public AgentThread Thread + /// Gets or sets the that is associated with the current run. + public AgentSession Session { get; private set => field = Throw.IfNull(value); From 8f23f22d521089346255bad648d66f6c3754eabf Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Wed, 28 Jan 2026 11:13:22 +0000 Subject: [PATCH 03/11] Make AgentRunContext readonly and add ADR --- docs/decisions/0015-agent-run-context.md | 147 ++++++++++++++++++ .../AIAgent.cs | 5 +- .../AgentRunContext.cs | 35 +++-- 3 files changed, 168 insertions(+), 19 deletions(-) create mode 100644 docs/decisions/0015-agent-run-context.md diff --git a/docs/decisions/0015-agent-run-context.md b/docs/decisions/0015-agent-run-context.md new file mode 100644 index 0000000000..55d61f5a60 --- /dev/null +++ b/docs/decisions/0015-agent-run-context.md @@ -0,0 +1,147 @@ +--- +status: proposed +contact: westey-m +date: 2026-01-27 +deciders: sergeymenshykh, markwallace, rbarreto, dmytrostruk, westey-m, eavanvalkenburg, stephentoub, lokitoth, alliscode, taochenosu, moonbox3 +consulted: +informed: +--- + +# AgentRunContext for Agent Run + +## Context and Problem Statement + +During an agent run, various components involved in the execution (middleware, filters, tools, nested agents, etc.) may need access to contextual information about the current run, such as: + +1. The agent that is executing the run +2. The session associated with the run +3. The request messages passed to the agent +4. The run options controlling the agent's behavior + +Additionally, some components may need to modify this context during execution, for example: + +- Replacing the session with a different one +- Modifying the request messages before they reach the agent core +- Updating or replacing the run options entirely + +Currently, there is no standardized way to access or modify this context from arbitrary code that executes during an agent run, especially from deeply nested call stacks where the context is not explicitly passed. + +## Sample Scenario + +When using an Agent as an AIFunction developers may want to pass context from the parent agent run to the child agent run. For example, the developer may want to copy chat history to the child agent, or share the same session across both agents. + +To enable these scenarios, we need a way to access the parent agent run context, including e.g. the parent agent itself, the parent agent session, and the parent run options from function tool calls. + +```csharp + public static AIFunction AsAIFunctionWithSessionPropagation(this ChatClientAgent agent, AIFunctionFactoryOptions? options = null) + { + Throw.IfNull(agent); + + [Description("Invoke an agent to retrieve some information.")] + async Task InvokeAgentAsync( + [Description("Input query to invoke the agent.")] string query, + CancellationToken cancellationToken) + { + // Get the session from the parent agent and pass it to the child agent. + var session = AIAgent.CurrentRunContext?.Session; + + // Alternatively, the developer may want to create a new session but copy over the chat history from the parent agent. + // var parentChatHistory = AIAgent.CurrentRunContext?.Session?.GetService>(); + // if (parentChatHistory != null) + // { + // var chp = new InMemoryChatHistoryProvider() + // foreach (var message in parentChatHistory) + // { + // chp.Add(message); + // } + // session = agent.GetNewSession(chp); + // } + + var response = await agent.RunAsync(query, session: session, options: agentRunOptions, cancellationToken: cancellationToken).ConfigureAwait(false); + return response.Text; + } + + options ??= new(); + options.Name ??= SanitizeAgentName(agent.Name); + options.Description ??= agent.Description; + + return AIFunctionFactory.Create(InvokeAgentAsync, options); + } +``` + +## Decision Drivers + +- Components executing during an agent run need access to run context without explicit parameter passing through every layer +- Context should flow naturally across async calls without manual propagation +- The design should allow modification of context properties by agent decorators (e.g., replacing options or session) +- Solution should be consistent with patterns used in similar frameworks (e.g., `FunctionInvokingChatClient.CurrentContext` `HttpContext.Current`, `Activity.Current`) + +## Considered Options + +- **Option 1**: Pass context explicitly through all method signatures +- **Option 2**: Use `AsyncLocal` to provide ambient context accessible anywhere during the run +- **Option 3**: Use a combination of explicit parameters for `RunCoreAsync` and `AsyncLocal` for ambient access + +## Decision Outcome + +Chosen option: **Option 3** - Combination of explicit parameters and AsyncLocal ambient access. + +This approach provides the best of both worlds: + +1. **Explicit parameters are passed to `RunCoreAsync`**: The core agent implementation receives the parameters explicitly, making it clear what data is available and enabling easy unit testing. Any modification of these in a decorator will require calling `RunAsync` on the inner agent with the updated parameters, which would result in the inner agent creating a new `AgentRunContext` instance. + + ```csharp + public async Task RunAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + session ??= await this.GetNewSessionAsync(cancellationToken).ConfigureAwait(false); + CurrentRunContext = new(this, session, messages as IReadOnlyCollection ?? messages.ToList(), options); + return await this.RunCoreAsync(messages, session, options, cancellationToken).ConfigureAwait(false); + } + ``` + +2. **`AsyncLocal` for ambient access**: The context is stored in an `AsyncLocal` field, making it accessible from any code executing during the agent run via a static property. + + The main scenario for this is to allow deeply nested components (e.g., tools, chat client middleware) to access the context without needing to pass it through every method signature. These are external components that cannot easily be modified to accept additional parameters. For internal components, we prefer passing any parameters explicitly. + + ```csharp + public static AgentRunContext? CurrentRunContext + { + get => s_currentContext.Value; + protected set => s_currentContext.Value = value; + } + ``` + +### AgentRunContext Design + +The `AgentRunContext` class encapsulates all run-related state: + +```csharp +public class AgentRunContext +{ + public AgentRunContext( + AIAgent agent, + AgentSession session, + IReadOnlyCollection requestMessages, + AgentRunOptions? agentRunOptions) + + public AIAgent Agent { get; } + public AgentSession Session { get; } + public IReadOnlyCollection RequestMessages { get; } + public AgentRunOptions? RunOptions { get; } +} +``` + +Key design decisions: + +- **All properties are read-only**: While some of the sub-properties on the provided properties (like `AgentRunOptions.AllowBackgroundResponses`) may be mutable, the `AgentRunContext` itself is immutable and we want to discourage anyone modifying the values in the context. Modifying the context is unlikely to result in the desired behavior, as the values will typically already have been used by the time any custom code accesses them. + +### Benefits + +1. **Ambient Access**: Any code executing during the run can access context via `AIAgent.CurrentRunContext` without needing explicit parameters +2. **Async Flow**: `AsyncLocal` automatically flows across async/await boundaries +3. **Modifiability**: Components can modify or replace session, messages, or options as needed +4. **Testability**: The explicit parameter to `RunCoreAsync` makes unit testing straightforward diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgent.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgent.cs index 23abcae4a9..f3b71dedfc 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgent.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Linq; using System.Runtime.CompilerServices; using System.Text.Json; using System.Threading; @@ -255,7 +256,7 @@ public async Task RunAsync( CancellationToken cancellationToken = default) { session ??= await this.GetNewSessionAsync(cancellationToken).ConfigureAwait(false); - CurrentRunContext = new(this, session) { RunOptions = options }; + CurrentRunContext = new(this, session, messages as IReadOnlyCollection ?? messages.ToList(), options); return await this.RunCoreAsync(messages, session, options, cancellationToken).ConfigureAwait(false); } @@ -381,7 +382,7 @@ public async IAsyncEnumerable RunStreamingAsync( [EnumeratorCancellation] CancellationToken cancellationToken = default) { session ??= await this.GetNewSessionAsync(cancellationToken).ConfigureAwait(false); - AgentRunContext context = new(this, session) { RunOptions = options }; + AgentRunContext context = new(this, session, messages as IReadOnlyCollection ?? messages.ToList(), options); CurrentRunContext = context; await foreach (var update in this.RunCoreStreamingAsync(messages, session, options, cancellationToken).ConfigureAwait(false)) { diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunContext.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunContext.cs index 0c57709713..84300b1ba8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunContext.cs @@ -1,41 +1,42 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Collections.Generic; +using Microsoft.Extensions.AI; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI; /// Provides context for an in-flight agent run. -public class AgentRunContext +public sealed class AgentRunContext { /// /// Initializes a new instance of the class. /// /// The that is executing the current run. /// The that is associated with the current run. - public AgentRunContext(AIAgent agent, AgentSession session) + /// The request messages passed into the current run. + /// The that was passed to the current run. + public AgentRunContext( + AIAgent agent, + AgentSession session, + IReadOnlyCollection requestMessages, + AgentRunOptions? agentRunOptions) { this.Agent = Throw.IfNull(agent); this.Session = Throw.IfNull(session); + this.RequestMessages = Throw.IfNull(requestMessages); + this.RunOptions = agentRunOptions; } /// Gets or sets the that is executing the current run. - public AIAgent Agent - { - get; - private set => field = Throw.IfNull(value); - } + public AIAgent Agent { get; } /// Gets or sets the that is associated with the current run. - public AgentSession Session - { - get; - private set => field = Throw.IfNull(value); - } + public AgentSession Session { get; } + + /// Gets or sets the request messages passed into the current run. + public IReadOnlyCollection RequestMessages { get; } /// Gets or sets the that was passed to the current run. - public AgentRunOptions? RunOptions - { - get; - set; - } + public AgentRunOptions? RunOptions { get; } } From e56f91164cb49f22646a59f21502a69957309ec1 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Wed, 28 Jan 2026 11:26:40 +0000 Subject: [PATCH 04/11] Make session nullable and add unit tests --- .../AIAgent.cs | 2 - .../AgentRunContext.cs | 8 +- .../AgentRunContextTests.cs | 230 ++++++++++++++++++ 3 files changed, 234 insertions(+), 6 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunContextTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgent.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgent.cs index f3b71dedfc..a666d47412 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgent.cs @@ -255,7 +255,6 @@ public async Task RunAsync( AgentRunOptions? options = null, CancellationToken cancellationToken = default) { - session ??= await this.GetNewSessionAsync(cancellationToken).ConfigureAwait(false); CurrentRunContext = new(this, session, messages as IReadOnlyCollection ?? messages.ToList(), options); return await this.RunCoreAsync(messages, session, options, cancellationToken).ConfigureAwait(false); } @@ -381,7 +380,6 @@ public async IAsyncEnumerable RunStreamingAsync( AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - session ??= await this.GetNewSessionAsync(cancellationToken).ConfigureAwait(false); AgentRunContext context = new(this, session, messages as IReadOnlyCollection ?? messages.ToList(), options); CurrentRunContext = context; await foreach (var update in this.RunCoreStreamingAsync(messages, session, options, cancellationToken).ConfigureAwait(false)) diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunContext.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunContext.cs index 84300b1ba8..67910a2a7b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunContext.cs @@ -13,17 +13,17 @@ public sealed class AgentRunContext /// Initializes a new instance of the class. /// /// The that is executing the current run. - /// The that is associated with the current run. + /// The that is associated with the current run if any. /// The request messages passed into the current run. /// The that was passed to the current run. public AgentRunContext( AIAgent agent, - AgentSession session, + AgentSession? session, IReadOnlyCollection requestMessages, AgentRunOptions? agentRunOptions) { this.Agent = Throw.IfNull(agent); - this.Session = Throw.IfNull(session); + this.Session = session; this.RequestMessages = Throw.IfNull(requestMessages); this.RunOptions = agentRunOptions; } @@ -32,7 +32,7 @@ public AgentRunContext( public AIAgent Agent { get; } /// Gets or sets the that is associated with the current run. - public AgentSession Session { get; } + public AgentSession? Session { get; } /// Gets or sets the request messages passed into the current run. public IReadOnlyCollection RequestMessages { get; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunContextTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunContextTests.cs new file mode 100644 index 0000000000..8bcadcc4a6 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunContextTests.cs @@ -0,0 +1,230 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Abstractions.UnitTests; + +/// +/// Unit tests for the class. +/// +public sealed class AgentRunContextTests +{ + #region Constructor Validation Tests + + /// + /// Verifies that passing null for agent throws ArgumentNullException. + /// + [Fact] + public void Constructor_NullAgent_ThrowsArgumentNullException() + { + // Arrange + AgentSession session = new TestAgentSession(); + IReadOnlyCollection messages = new List(); + AgentRunOptions options = new(); + + // Act & Assert + Assert.Throws(() => new AgentRunContext(null!, session, messages, options)); + } + + /// + /// Verifies that passing null for session does not throw + /// + [Fact] + public void Constructor_NullSession_DoesNotThrow() + { + // Arrange + AIAgent agent = new TestAgent(); + IReadOnlyCollection messages = new List(); + AgentRunOptions options = new(); + + // Act + AgentRunContext context = new(agent, null, messages, options); + + // Assert + Assert.NotNull(context); + Assert.Null(context.Session); + } + + /// + /// Verifies that passing null for requestMessages throws ArgumentNullException. + /// + [Fact] + public void Constructor_NullRequestMessages_ThrowsArgumentNullException() + { + // Arrange + AIAgent agent = new TestAgent(); + AgentSession session = new TestAgentSession(); + AgentRunOptions options = new(); + + // Act & Assert + Assert.Throws(() => new AgentRunContext(agent, session, null!, options)); + } + + /// + /// Verifies that passing null for agentRunOptions does not throw. + /// + [Fact] + public void Constructor_NullAgentRunOptions_DoesNotThrow() + { + // Arrange + AIAgent agent = new TestAgent(); + AgentSession session = new TestAgentSession(); + IReadOnlyCollection messages = new List(); + + // Act + AgentRunContext context = new(agent, session, messages, null); + + // Assert + Assert.NotNull(context); + Assert.Null(context.RunOptions); + } + + #endregion + + #region Property Roundtrip Tests + + /// + /// Verifies that the Agent property returns the value passed to the constructor. + /// + [Fact] + public void Agent_ReturnsValueFromConstructor() + { + // Arrange + AIAgent agent = new TestAgent(); + AgentSession session = new TestAgentSession(); + IReadOnlyCollection messages = new List(); + AgentRunOptions options = new(); + + // Act + AgentRunContext context = new(agent, session, messages, options); + + // Assert + Assert.Same(agent, context.Agent); + } + + /// + /// Verifies that the Session property returns the value passed to the constructor. + /// + [Fact] + public void Session_ReturnsValueFromConstructor() + { + // Arrange + AIAgent agent = new TestAgent(); + AgentSession session = new TestAgentSession(); + IReadOnlyCollection messages = new List(); + AgentRunOptions options = new(); + + // Act + AgentRunContext context = new(agent, session, messages, options); + + // Assert + Assert.Same(session, context.Session); + } + + /// + /// Verifies that the RequestMessages property returns the value passed to the constructor. + /// + [Fact] + public void RequestMessages_ReturnsValueFromConstructor() + { + // Arrange + AIAgent agent = new TestAgent(); + AgentSession session = new TestAgentSession(); + IReadOnlyCollection messages = new List + { + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, "Hi there!") + }; + AgentRunOptions options = new(); + + // Act + AgentRunContext context = new(agent, session, messages, options); + + // Assert + Assert.Same(messages, context.RequestMessages); + Assert.Equal(2, context.RequestMessages.Count); + } + + /// + /// Verifies that the RunOptions property returns the value passed to the constructor. + /// + [Fact] + public void RunOptions_ReturnsValueFromConstructor() + { + // Arrange + AIAgent agent = new TestAgent(); + AgentSession session = new TestAgentSession(); + IReadOnlyCollection messages = new List(); + AgentRunOptions options = new() + { + AllowBackgroundResponses = true, + AdditionalProperties = new AdditionalPropertiesDictionary + { + ["key1"] = "value1" + } + }; + + // Act + AgentRunContext context = new(agent, session, messages, options); + + // Assert + Assert.Same(options, context.RunOptions); + Assert.True(context.RunOptions!.AllowBackgroundResponses); + } + + /// + /// Verifies that an empty messages collection is handled correctly. + /// + [Fact] + public void RequestMessages_EmptyCollection_ReturnsEmptyCollection() + { + // Arrange + AIAgent agent = new TestAgent(); + AgentSession session = new TestAgentSession(); + IReadOnlyCollection messages = new List(); + AgentRunOptions options = new(); + + // Act + AgentRunContext context = new(agent, session, messages, options); + + // Assert + Assert.NotNull(context.RequestMessages); + Assert.Empty(context.RequestMessages); + } + + #endregion + + #region Test Helpers + + private sealed class TestAgentSession : AgentSession; + + private sealed class TestAgent : AIAgent + { + public override ValueTask GetNewSessionAsync(CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + + public override ValueTask DeserializeSessionAsync(JsonElement serializedSession, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + } + + #endregion +} From 7677d189fbe0bbd9190066da040a3fc21ea2b199 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Wed, 28 Jan 2026 11:41:37 +0000 Subject: [PATCH 05/11] Add unit tests for setting the context in AIAgent --- .../AIAgentTests.cs | 131 +++++++++++++++++- 1 file changed, 129 insertions(+), 2 deletions(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AIAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AIAgentTests.cs index 4b872f0da1..cc3a6aa97c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AIAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AIAgentTests.cs @@ -220,6 +220,133 @@ public async Task InvokeStreamingWithSingleMessageCallsMockedInvokeWithMessageIn ItExpr.Is(ct => ct == cancellationToken)); } + /// + /// Theory data for RunAsync overloads. + /// + public static TheoryData RunAsyncOverloads => new() + { + "NoMessage", + "StringMessage", + "ChatMessage", + "MessagesCollection" + }; + + /// + /// Verifies that CurrentRunContext is properly set and accessible from RunCoreAsync for all RunAsync overloads. + /// + [Theory] + [MemberData(nameof(RunAsyncOverloads))] + public async Task RunAsync_SetsCurrentRunContext_AccessibleFromRunCoreAsync(string overload) + { + // Arrange + AgentRunContext? capturedContext = null; + var session = new TestAgentSession(); + var options = new AgentRunOptions(); + + var agentMock = new Mock { CallBase = true }; + agentMock + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Returns((IEnumerable _, AgentSession? _, AgentRunOptions? _, CancellationToken _) => + { + capturedContext = AIAgent.CurrentRunContext; + return Task.FromResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "Response"))); + }); + + // Act + switch (overload) + { + case "NoMessage": + await agentMock.Object.RunAsync(session, options); + break; + case "StringMessage": + await agentMock.Object.RunAsync("Hello", session, options); + break; + case "ChatMessage": + await agentMock.Object.RunAsync(new ChatMessage(ChatRole.User, "Hello"), session, options); + break; + case "MessagesCollection": + await agentMock.Object.RunAsync([new ChatMessage(ChatRole.User, "Hello")], session, options); + break; + } + + // Assert + Assert.NotNull(capturedContext); + Assert.Same(agentMock.Object, capturedContext!.Agent); + Assert.Same(session, capturedContext.Session); + Assert.Same(options, capturedContext.RunOptions); + + if (overload == "NoMessage") + { + Assert.Empty(capturedContext.RequestMessages); + } + else + { + Assert.Single(capturedContext.RequestMessages); + } + } + + /// + /// Verifies that CurrentRunContext is properly set and accessible from RunCoreStreamingAsync for all RunStreamingAsync overloads. + /// + [Theory] + [MemberData(nameof(RunAsyncOverloads))] + public async Task RunStreamingAsync_SetsCurrentRunContext_AccessibleFromRunCoreStreamingAsync(string overload) + { + // Arrange + AgentRunContext? capturedContext = null; + var session = new TestAgentSession(); + var options = new AgentRunOptions(); + + var agentMock = new Mock { CallBase = true }; + agentMock + .Protected() + .Setup>("RunCoreStreamingAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Returns((IEnumerable _, AgentSession? _, AgentRunOptions? _, CancellationToken _) => + { + capturedContext = AIAgent.CurrentRunContext; + return ToAsyncEnumerableAsync([new AgentResponseUpdate(ChatRole.Assistant, "Response")]); + }); + + // Act + IAsyncEnumerable stream = overload switch + { + "NoMessage" => agentMock.Object.RunStreamingAsync(session, options), + "StringMessage" => agentMock.Object.RunStreamingAsync("Hello", session, options), + "ChatMessage" => agentMock.Object.RunStreamingAsync(new ChatMessage(ChatRole.User, "Hello"), session, options), + "MessagesCollection" => agentMock.Object.RunStreamingAsync(new[] { new ChatMessage(ChatRole.User, "Hello") }, session, options), + _ => throw new InvalidOperationException($"Unknown overload: {overload}") + }; + + await foreach (AgentResponseUpdate _ in stream) + { + // Consume the stream + } + + // Assert + Assert.NotNull(capturedContext); + Assert.Same(agentMock.Object, capturedContext!.Agent); + Assert.Same(session, capturedContext.Session); + Assert.Same(options, capturedContext.RunOptions); + + if (overload == "NoMessage") + { + Assert.Empty(capturedContext.RequestMessages); + } + else + { + Assert.Single(capturedContext.RequestMessages); + } + } + [Fact] public void ValidateAgentIDIsIdempotent() { @@ -365,9 +492,9 @@ public void GetService_Generic_ReturnsNullForUnrelatedType() #endregion /// - /// Typed mock session. + /// Typed mock session for testing purposes. /// - public abstract class TestAgentSession : AgentSession; + private sealed class TestAgentSession : AgentSession; private sealed class MockAgent : AIAgent { From 068f6246071ebcb17d2bb1b6ff773b803bef019b Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Wed, 28 Jan 2026 11:50:12 +0000 Subject: [PATCH 06/11] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/decisions/0015-agent-run-context.md | 8 ++++---- .../Microsoft.Agents.AI.Abstractions/AgentRunContext.cs | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/decisions/0015-agent-run-context.md b/docs/decisions/0015-agent-run-context.md index 55d61f5a60..8b5eb716c9 100644 --- a/docs/decisions/0015-agent-run-context.md +++ b/docs/decisions/0015-agent-run-context.md @@ -49,7 +49,7 @@ To enable these scenarios, we need a way to access the parent agent run context, // var parentChatHistory = AIAgent.CurrentRunContext?.Session?.GetService>(); // if (parentChatHistory != null) // { - // var chp = new InMemoryChatHistoryProvider() + // var chp = new InMemoryChatHistoryProvider(); // foreach (var message in parentChatHistory) // { // chp.Add(message); @@ -97,7 +97,7 @@ This approach provides the best of both worlds: AgentRunOptions? options = null, CancellationToken cancellationToken = default) { - session ??= await this.GetNewSessionAsync(cancellationToken).ConfigureAwait(false); + CurrentRunContext = new(this, session, messages as IReadOnlyCollection ?? messages.ToList(), options); return await this.RunCoreAsync(messages, session, options, cancellationToken).ConfigureAwait(false); } @@ -124,12 +124,12 @@ public class AgentRunContext { public AgentRunContext( AIAgent agent, - AgentSession session, + AgentSession? session, IReadOnlyCollection requestMessages, AgentRunOptions? agentRunOptions) public AIAgent Agent { get; } - public AgentSession Session { get; } + public AgentSession? Session { get; } public IReadOnlyCollection RequestMessages { get; } public AgentRunOptions? RunOptions { get; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunContext.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunContext.cs index 67910a2a7b..d860fa311b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunContext.cs @@ -28,15 +28,15 @@ public AgentRunContext( this.RunOptions = agentRunOptions; } - /// Gets or sets the that is executing the current run. + /// Gets the that is executing the current run. public AIAgent Agent { get; } - /// Gets or sets the that is associated with the current run. + /// Gets the that is associated with the current run. public AgentSession? Session { get; } - /// Gets or sets the request messages passed into the current run. + /// Gets the request messages passed into the current run. public IReadOnlyCollection RequestMessages { get; } - /// Gets or sets the that was passed to the current run. + /// Gets the that was passed to the current run. public AgentRunOptions? RunOptions { get; } } From 326b96d49a5a3479b566ee02d37232050dd85e17 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Wed, 28 Jan 2026 11:51:02 +0000 Subject: [PATCH 07/11] Fix sample in ADR --- docs/decisions/0015-agent-run-context.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/decisions/0015-agent-run-context.md b/docs/decisions/0015-agent-run-context.md index 55d61f5a60..b26e4c70ee 100644 --- a/docs/decisions/0015-agent-run-context.md +++ b/docs/decisions/0015-agent-run-context.md @@ -57,7 +57,7 @@ To enable these scenarios, we need a way to access the parent agent run context, // session = agent.GetNewSession(chp); // } - var response = await agent.RunAsync(query, session: session, options: agentRunOptions, cancellationToken: cancellationToken).ConfigureAwait(false); + var response = await agent.RunAsync(query, session: session, cancellationToken: cancellationToken).ConfigureAwait(false); return response.Text; } From caf754cf753e84a38c5d060a37435e65641c59e0 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Wed, 28 Jan 2026 12:45:23 +0000 Subject: [PATCH 08/11] Fix broken unit test --- dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgent.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgent.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgent.cs index a666d47412..98bd1c6d04 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgent.cs @@ -249,14 +249,14 @@ public Task RunAsync( /// The agent's response will also be added to if one is provided. /// /// - public async Task RunAsync( + public Task RunAsync( IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) { CurrentRunContext = new(this, session, messages as IReadOnlyCollection ?? messages.ToList(), options); - return await this.RunCoreAsync(messages, session, options, cancellationToken).ConfigureAwait(false); + return this.RunCoreAsync(messages, session, options, cancellationToken); } /// From a29eb03a429f8e19add1cac7678c6a02361f82d6 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Fri, 30 Jan 2026 15:54:26 +0000 Subject: [PATCH 09/11] Add unit test for checking if middleware can access AgentRunContext --- .../AIAgentBuilderTests.cs | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AIAgentBuilderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AIAgentBuilderTests.cs index 5cb3858fbc..8dc620e622 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AIAgentBuilderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AIAgentBuilderTests.cs @@ -418,6 +418,51 @@ public void Use_WithBothDelegates_CreatesAnonymousDelegatingAgent() Assert.IsType(result); } + /// + /// Verify that Use with both delegates allows both to access AgentRunContext. + /// + [Fact] + public async Task Use_WithBothDelegates_AllowsDelegateToAccessAgentRunContextAsync() + { + // Arrange + var mockAgent = new Mock(); + var mockSession = new Mock(); + var builder = new AIAgentBuilder(mockAgent.Object); + + AIAgent? builtAgent = null; + + bool nonStreamingMiddlewareExecuted = false; + bool streamingMiddlwareExecuted = true; + + builtAgent = builder.Use( + (_, _, _, _, _) => + { + Assert.NotNull(AIAgent.CurrentRunContext); + Assert.Same(builtAgent, AIAgent.CurrentRunContext.Agent); + Assert.Same(mockSession.Object, AIAgent.CurrentRunContext.Session); + nonStreamingMiddlewareExecuted = true; + return Task.FromResult(new AgentResponse()); + }, + (_, _, _, _, _) => + { + Assert.NotNull(AIAgent.CurrentRunContext); + Assert.Same(builtAgent, AIAgent.CurrentRunContext.Agent); + Assert.Same(mockSession.Object, AIAgent.CurrentRunContext.Session); + streamingMiddlwareExecuted = true; + return AsyncEnumerable.Empty(); + }).Build(); + + // Act + await builtAgent.RunAsync("Input message", mockSession.Object); + await foreach (var update in builtAgent.RunStreamingAsync("Input message", mockSession.Object)) + { + } + + // Assert + Assert.True(nonStreamingMiddlewareExecuted); + Assert.True(streamingMiddlwareExecuted); + } + #endregion /// From 91f77b83b45721db06c76cc15ab582628be88848 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Tue, 3 Feb 2026 15:45:16 +0000 Subject: [PATCH 10/11] Fix build error after merge. --- .../AgentRunContextTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunContextTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunContextTests.cs index 8bcadcc4a6..54fe394f51 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunContextTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunContextTests.cs @@ -205,7 +205,7 @@ private sealed class TestAgentSession : AgentSession; private sealed class TestAgent : AIAgent { - public override ValueTask GetNewSessionAsync(CancellationToken cancellationToken = default) + public override ValueTask CreateSessionAsync(CancellationToken cancellationToken = default) => throw new NotImplementedException(); public override ValueTask DeserializeSessionAsync(JsonElement serializedSession, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) From cebf8173ff2dfb6c7dabc88d88ecec2e0d298a6c Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Wed, 4 Feb 2026 19:49:08 +0000 Subject: [PATCH 11/11] Fix AgentRunContextTests after merge from main --- .../AgentRunContextTests.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunContextTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunContextTests.cs index 54fe394f51..91b9726ae4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunContextTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunContextTests.cs @@ -208,7 +208,10 @@ private sealed class TestAgent : AIAgent public override ValueTask CreateSessionAsync(CancellationToken cancellationToken = default) => throw new NotImplementedException(); - public override ValueTask DeserializeSessionAsync(JsonElement serializedSession, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + public override ValueTask DeserializeSessionAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + + public override JsonElement SerializeSession(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null) => throw new NotImplementedException(); protected override Task RunCoreAsync(