diff --git a/docs/decisions/0015-agent-run-context.md b/docs/decisions/0015-agent-run-context.md new file mode 100644 index 0000000000..615d6ed97b --- /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, 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) + { + + 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 f2af2680f1..924628f62a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgent.cs @@ -3,6 +3,8 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Linq; +using System.Runtime.CompilerServices; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -22,6 +24,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 +80,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. @@ -252,8 +268,11 @@ public Task RunAsync( IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, - CancellationToken cancellationToken = default) => - this.RunCoreAsync(messages, session, options, cancellationToken); + CancellationToken cancellationToken = default) + { + CurrentRunContext = new(this, session, messages as IReadOnlyCollection ?? messages.ToList(), options); + return this.RunCoreAsync(messages, session, options, cancellationToken); + } /// /// Core implementation of the agent invocation logic with a collection of chat messages. @@ -370,12 +389,22 @@ 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, AgentSession? session = null, AgentRunOptions? options = null, - CancellationToken cancellationToken = default) => - this.RunCoreStreamingAsync(messages, session, options, cancellationToken); + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + 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)) + { + 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..d860fa311b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunContext.cs @@ -0,0 +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 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 if any. + /// 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 = session; + this.RequestMessages = Throw.IfNull(requestMessages); + this.RunOptions = agentRunOptions; + } + + /// Gets the that is executing the current run. + public AIAgent Agent { get; } + + /// Gets the that is associated with the current run. + public AgentSession? Session { get; } + + /// Gets the request messages passed into the current run. + public IReadOnlyCollection RequestMessages { get; } + + /// Gets the that was passed to the current run. + public AgentRunOptions? RunOptions { get; } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AIAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AIAgentTests.cs index 900de7dc47..1050e34194 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() { @@ -433,9 +560,9 @@ public void Description_ReturnsNullByDefault() #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 { 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..91b9726ae4 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunContextTests.cs @@ -0,0 +1,233 @@ +// 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 CreateSessionAsync(CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + + 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( + 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 +} 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 ///