diff --git a/samples/AgenTerra.Sample/ReasoningToolSample.cs b/samples/AgenTerra.Sample/ReasoningToolSample.cs index 5f08093..b8b0baa 100644 --- a/samples/AgenTerra.Sample/ReasoningToolSample.cs +++ b/samples/AgenTerra.Sample/ReasoningToolSample.cs @@ -16,111 +16,111 @@ public static async Task RunAsync() Console.WriteLine("=== Fox, Chicken, and Grain River Crossing Puzzle ==="); Console.WriteLine(); - var reasoningTool = new ReasoningTool(); + using var reasoningTool = new ReasoningTool(); var sessionId = Guid.NewGuid().ToString(); // Step 1: Think - Initial State Analysis var response1 = await reasoningTool.ThinkAsync(new ThinkInput( - SessionId: sessionId, - Title: "Initial State Analysis", - Thought: "Man, fox, chicken, grain on left bank. Goal: all on right bank. Boat holds man + one item.", - Action: "Identify constraints", - Confidence: 0.9 + sessionId, + "Initial State Analysis", + "Man, fox, chicken, grain on left bank. Goal: all on right bank. Boat holds man + one item.", + "Identify constraints", + 0.9 )); Console.WriteLine(response1); // Step 2: Analyze - Constraint Analysis var response2 = await reasoningTool.AnalyzeAsync(new AnalyzeInput( - SessionId: sessionId, - Title: "Constraint Analysis", - Result: "Fox eats chicken if alone. Chicken eats grain if alone.", - Analysis: "Must never leave fox+chicken or chicken+grain together without the man present.", - NextAction: NextAction.Continue, - Confidence: 0.95 + sessionId, + "Constraint Analysis", + "Fox eats chicken if alone. Chicken eats grain if alone.", + "Must never leave fox+chicken or chicken+grain together without the man present.", + NextAction.Continue, + 0.95 )); Console.WriteLine(response2); // Step 3: Think - First Move Strategy var response3 = await reasoningTool.ThinkAsync(new ThinkInput( - SessionId: sessionId, - Title: "First Move Strategy", - Thought: "Chicken is the conflict point. If we take fox first, chicken eats grain. If we take grain first, fox eats chicken.", - Action: "Take chicken across first", - Confidence: 0.85 + sessionId, + "First Move Strategy", + "Chicken is the conflict point. If we take fox first, chicken eats grain. If we take grain first, fox eats chicken.", + "Take chicken across first", + 0.85 )); Console.WriteLine(response3); // Step 4: Analyze - After First Move var response4 = await reasoningTool.AnalyzeAsync(new AnalyzeInput( - SessionId: sessionId, - Title: "After First Move", - Result: "Left bank: fox, grain. Right bank: chicken. Boat with man on left.", - Analysis: "Fox and grain safe together. Can now move either fox or grain.", - NextAction: NextAction.Continue, - Confidence: 0.9 + sessionId, + "After First Move", + "Left bank: fox, grain. Right bank: chicken. Boat with man on left.", + "Fox and grain safe together. Can now move either fox or grain.", + NextAction.Continue, + 0.9 )); Console.WriteLine(response4); // Step 5: Think - Second Move var response5 = await reasoningTool.ThinkAsync(new ThinkInput( - SessionId: sessionId, - Title: "Second Move", - Thought: "Take fox across. But if we leave fox with chicken, fox eats chicken.", - Action: "Take fox across, bring chicken back", - Confidence: 0.8 + sessionId, + "Second Move", + "Take fox across. But if we leave fox with chicken, fox eats chicken.", + "Take fox across, bring chicken back", + 0.8 )); Console.WriteLine(response5); // Step 6: Analyze - After Second Move var response6 = await reasoningTool.AnalyzeAsync(new AnalyzeInput( - SessionId: sessionId, - Title: "After Second Move", - Result: "Left bank: chicken, grain. Right bank: fox. Boat with man on left.", - Analysis: "Fox is safe alone. Now need to get grain across without leaving it with chicken.", - NextAction: NextAction.Continue, - Confidence: 0.9 + sessionId, + "After Second Move", + "Left bank: chicken, grain. Right bank: fox. Boat with man on left.", + "Fox is safe alone. Now need to get grain across without leaving it with chicken.", + NextAction.Continue, + 0.9 )); Console.WriteLine(response6); // Step 7: Think - Third Move var response7 = await reasoningTool.ThinkAsync(new ThinkInput( - SessionId: sessionId, - Title: "Third Move", - Thought: "Take grain across, leave fox and grain together (safe).", - Action: "Take grain across", - Confidence: 0.9 + sessionId, + "Third Move", + "Take grain across, leave fox and grain together (safe).", + "Take grain across", + 0.9 )); Console.WriteLine(response7); // Step 8: Analyze - After Third Move var response8 = await reasoningTool.AnalyzeAsync(new AnalyzeInput( - SessionId: sessionId, - Title: "After Third Move", - Result: "Left bank: chicken. Right bank: fox, grain. Boat with man on right.", - Analysis: "Fox and grain are safe together. Only chicken remains on left bank.", - NextAction: NextAction.Continue, - Confidence: 0.95 + sessionId, + "After Third Move", + "Left bank: chicken. Right bank: fox, grain. Boat with man on right.", + "Fox and grain are safe together. Only chicken remains on left bank.", + NextAction.Continue, + 0.95 )); Console.WriteLine(response8); // Step 9: Think - Final Move var response9 = await reasoningTool.ThinkAsync(new ThinkInput( - SessionId: sessionId, - Title: "Final Move", - Thought: "Go back empty, get chicken.", - Action: "Take chicken across", - Confidence: 1.0 + sessionId, + "Final Move", + "Go back empty, get chicken.", + "Take chicken across", + 1.0 )); Console.WriteLine(response9); // Step 10: Analyze - Final State var response10 = await reasoningTool.AnalyzeAsync(new AnalyzeInput( - SessionId: sessionId, - Title: "Final State", - Result: "Left bank: empty. Right bank: fox, chicken, grain, man.", - Analysis: "All items successfully transported. Puzzle solved!", - NextAction: NextAction.FinalAnswer, - Confidence: 1.0 + sessionId, + "Final State", + "Left bank: empty. Right bank: fox, chicken, grain, man.", + "All items successfully transported. Puzzle solved!", + NextAction.FinalAnswer, + 1.0 )); Console.WriteLine(response10); @@ -131,7 +131,7 @@ public static async Task RunAsync() { var step = history[i]; Console.WriteLine($"Step {i + 1}: [{step.Type.ToUpper()}] {step.Title}"); - Console.WriteLine($"Confidence: {step.Confidence:F2}"); + Console.WriteLine($" {step.Confidence:F2}"); Console.WriteLine($"Timestamp: {step.Timestamp:yyyy-MM-dd HH:mm:ss}"); Console.WriteLine(step.Content); Console.WriteLine(); diff --git a/src/AgenTerra.Core/AgenTerra.Core.csproj b/src/AgenTerra.Core/AgenTerra.Core.csproj index 51e5590..8691903 100644 --- a/src/AgenTerra.Core/AgenTerra.Core.csproj +++ b/src/AgenTerra.Core/AgenTerra.Core.csproj @@ -7,6 +7,7 @@ + diff --git a/src/AgenTerra.Core/Reasoning/IReasoningTool.cs b/src/AgenTerra.Core/Reasoning/IReasoningTool.cs index 6ff81db..15a7e20 100644 --- a/src/AgenTerra.Core/Reasoning/IReasoningTool.cs +++ b/src/AgenTerra.Core/Reasoning/IReasoningTool.cs @@ -9,21 +9,28 @@ public interface IReasoningTool /// Records a thinking step with a thought and optional action. /// /// The thinking input containing the session ID, title, thought, and optional action. + /// Cancellation token. /// A formatted string response suitable for LLM consumption. - Task ThinkAsync(ThinkInput input); + Task ThinkAsync(ThinkInput input, CancellationToken cancellationToken = default); /// /// Records an analysis step with a result and analysis of the next action. /// /// The analysis input containing the session ID, title, result, analysis, and next action. + /// Cancellation token. /// A formatted string response suitable for LLM consumption. - Task AnalyzeAsync(AnalyzeInput input); + Task AnalyzeAsync(AnalyzeInput input, CancellationToken cancellationToken = default); /// /// Retrieves the complete reasoning history for a specific session. /// /// The unique identifier of the session. /// An immutable list of reasoning steps for the session. + /// + /// Warning: This method blocks synchronously to acquire a lock. + /// In ASP.NET or UI contexts with a synchronization context, this may cause deadlocks. + /// This method is synchronous to maintain backward compatibility. + /// IReadOnlyList GetReasoningHistory(string sessionId); } diff --git a/src/AgenTerra.Core/Reasoning/Models/AnalyzeInput.cs b/src/AgenTerra.Core/Reasoning/Models/AnalyzeInput.cs index e99f8e7..2614449 100644 --- a/src/AgenTerra.Core/Reasoning/Models/AnalyzeInput.cs +++ b/src/AgenTerra.Core/Reasoning/Models/AnalyzeInput.cs @@ -1,21 +1,53 @@ -using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; namespace AgenTerra.Core.Reasoning; /// /// Represents input for an analysis step in the reasoning process. /// -/// The unique identifier of the reasoning session. -/// A brief title describing this analysis step. -/// The result or observation being analyzed. -/// The analysis of the result. -/// The recommended next action in the reasoning process. -/// Confidence level in this analysis (0.0 to 1.0, default 0.8). -public record AnalyzeInput( - [Required] string SessionId, - [Required] string Title, - [Required] string Result, - [Required] string Analysis, - NextAction NextAction = NextAction.Continue, - double Confidence = 0.8 -); +public record AnalyzeInput +{ + /// + /// Gets the unique identifier of the reasoning session. + /// + public required string SessionId { get; init; } + + /// + /// Gets a brief title describing this analysis step. + /// + public required string Title { get; init; } + + /// + /// Gets the result or observation being analyzed. + /// + public required string Result { get; init; } + + /// + /// Gets the analysis of the result. + /// + public required string Analysis { get; init; } + + /// + /// Gets the recommended next action in the reasoning process. + /// + public NextAction NextAction { get; init; } = NextAction.Continue; + + /// + /// Gets the confidence level in this analysis (0.0 to 1.0). + /// + public double Confidence { get; init; } = 0.8; + + /// + /// Initializes a new instance of the AnalyzeInput record with positional parameters (for backward compatibility). + /// + [SetsRequiredMembers] + public AnalyzeInput(string sessionId, string title, string result, string analysis, NextAction nextAction = NextAction.Continue, double confidence = 0.8) + { + SessionId = sessionId; + Title = title; + Result = result; + Analysis = analysis; + NextAction = nextAction; + Confidence = confidence; + } +} diff --git a/src/AgenTerra.Core/Reasoning/Models/ReasoningException.cs b/src/AgenTerra.Core/Reasoning/Models/ReasoningException.cs new file mode 100644 index 0000000..799e27a --- /dev/null +++ b/src/AgenTerra.Core/Reasoning/Models/ReasoningException.cs @@ -0,0 +1,21 @@ +namespace AgenTerra.Core.Reasoning; + +/// +/// Exception thrown when reasoning operations fail. +/// +public class ReasoningException : Exception +{ + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The message that describes the error. + public ReasoningException(string message) : base(message) { } + + /// + /// Initializes a new instance of the class with a specified error message and a reference to the inner exception. + /// + /// The message that describes the error. + /// The exception that is the cause of the current exception. + public ReasoningException(string message, Exception innerException) + : base(message, innerException) { } +} diff --git a/src/AgenTerra.Core/Reasoning/Models/ReasoningStep.cs b/src/AgenTerra.Core/Reasoning/Models/ReasoningStep.cs index 1f8d6c7..f6b8396 100644 --- a/src/AgenTerra.Core/Reasoning/Models/ReasoningStep.cs +++ b/src/AgenTerra.Core/Reasoning/Models/ReasoningStep.cs @@ -3,15 +3,30 @@ namespace AgenTerra.Core.Reasoning; /// /// Represents a single step in the reasoning process. /// -/// The type of step ("think" or "analyze"). -/// A brief title describing this step. -/// The content of the reasoning step. -/// Confidence level in this step (0.0 to 1.0). -/// When this step was recorded. -public record ReasoningStep( - string Type, - string Title, - string Content, - double Confidence, - DateTime Timestamp -); +public record ReasoningStep +{ + /// + /// Gets the type of step ("think" or "analyze"). + /// + public required string Type { get; init; } + + /// + /// Gets a brief title describing this step. + /// + public required string Title { get; init; } + + /// + /// Gets the content of the reasoning step. + /// + public required string Content { get; init; } + + /// + /// Gets the confidence level in this step (0.0 to 1.0). + /// + public required double Confidence { get; init; } + + /// + /// Gets the timestamp when this step was recorded. + /// + public required DateTime Timestamp { get; init; } +} diff --git a/src/AgenTerra.Core/Reasoning/Models/ThinkInput.cs b/src/AgenTerra.Core/Reasoning/Models/ThinkInput.cs index 018f5a5..591fc8e 100644 --- a/src/AgenTerra.Core/Reasoning/Models/ThinkInput.cs +++ b/src/AgenTerra.Core/Reasoning/Models/ThinkInput.cs @@ -1,19 +1,47 @@ -using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; namespace AgenTerra.Core.Reasoning; /// /// Represents input for a thinking step in the reasoning process. /// -/// The unique identifier of the reasoning session. -/// A brief title describing this thinking step. -/// The main thought or reasoning content. -/// An optional action to take based on this thought. -/// Confidence level in this thought (0.0 to 1.0, default 0.8). -public record ThinkInput( - [Required] string SessionId, - [Required] string Title, - [Required] string Thought, - string? Action = null, - double Confidence = 0.8 -); +public record ThinkInput +{ + /// + /// Gets the unique identifier of the reasoning session. + /// + public required string SessionId { get; init; } + + /// + /// Gets a brief title describing this thinking step. + /// + public required string Title { get; init; } + + /// + /// Gets the main thought or reasoning content. + /// + public required string Thought { get; init; } + + /// + /// Gets an optional action to take based on this thought. + /// + public string? Action { get; init; } + + /// + /// Gets the confidence level in this thought (0.0 to 1.0). + /// + public double Confidence { get; init; } = 0.8; + + /// + /// Initializes a new instance of the ThinkInput record with positional parameters (for backward compatibility). + /// + [SetsRequiredMembers] + public ThinkInput(string sessionId, string title, string thought, string? action = null, double confidence = 0.8) + { + SessionId = sessionId; + Title = title; + Thought = thought; + Action = action; + Confidence = confidence; + } +} diff --git a/src/AgenTerra.Core/Reasoning/ReasoningTool.cs b/src/AgenTerra.Core/Reasoning/ReasoningTool.cs index 0338bfb..d0b2a3a 100644 --- a/src/AgenTerra.Core/Reasoning/ReasoningTool.cs +++ b/src/AgenTerra.Core/Reasoning/ReasoningTool.cs @@ -6,13 +6,14 @@ namespace AgenTerra.Core.Reasoning; /// Provides step-by-step reasoning capabilities with in-memory session storage. /// This implementation is thread-safe and supports multiple concurrent sessions. /// -public class ReasoningTool : IReasoningTool +public class ReasoningTool : IReasoningTool, IDisposable { private readonly Dictionary> _sessions = new(); - private readonly object _lock = new(); + private readonly SemaphoreSlim _lock = new(1, 1); + private bool _disposed; /// - public Task ThinkAsync(ThinkInput input) + public async Task ThinkAsync(ThinkInput input, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(input); @@ -38,15 +39,17 @@ public Task ThinkAsync(ThinkInput input) content.AppendLine($"Action: {input.Action}"); } - var step = new ReasoningStep( - Type: "think", - Title: input.Title, - Content: content.ToString().TrimEnd(), - Confidence: input.Confidence, - Timestamp: DateTime.UtcNow - ); - - lock (_lock) + var step = new ReasoningStep + { + Type = "think", + Title = input.Title, + Content = content.ToString().TrimEnd(), + Confidence = input.Confidence, + Timestamp = DateTime.UtcNow + }; + + await _lock.WaitAsync(cancellationToken); + try { if (!_sessions.TryGetValue(input.SessionId, out var steps)) { @@ -55,6 +58,10 @@ public Task ThinkAsync(ThinkInput input) } steps.Add(step); } + finally + { + _lock.Release(); + } var response = new StringBuilder(); response.AppendLine($"[THINK] {input.Title}"); @@ -62,11 +69,11 @@ public Task ThinkAsync(ThinkInput input) response.AppendLine(content.ToString()); response.AppendLine($"Recorded at: {step.Timestamp:O}"); - return Task.FromResult(response.ToString()); + return response.ToString(); } /// - public Task AnalyzeAsync(AnalyzeInput input) + public async Task AnalyzeAsync(AnalyzeInput input, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(input); @@ -95,15 +102,17 @@ public Task AnalyzeAsync(AnalyzeInput input) content.AppendLine($"Analysis: {input.Analysis}"); content.AppendLine($"Next Action: {input.NextAction}"); - var step = new ReasoningStep( - Type: "analyze", - Title: input.Title, - Content: content.ToString().TrimEnd(), - Confidence: input.Confidence, - Timestamp: DateTime.UtcNow - ); - - lock (_lock) + var step = new ReasoningStep + { + Type = "analyze", + Title = input.Title, + Content = content.ToString().TrimEnd(), + Confidence = input.Confidence, + Timestamp = DateTime.UtcNow + }; + + await _lock.WaitAsync(cancellationToken); + try { if (!_sessions.TryGetValue(input.SessionId, out var steps)) { @@ -112,6 +121,10 @@ public Task AnalyzeAsync(AnalyzeInput input) } steps.Add(step); } + finally + { + _lock.Release(); + } var response = new StringBuilder(); response.AppendLine($"[ANALYZE] {input.Title}"); @@ -119,7 +132,7 @@ public Task AnalyzeAsync(AnalyzeInput input) response.AppendLine(content.ToString()); response.AppendLine($"Recorded at: {step.Timestamp:O}"); - return Task.FromResult(response.ToString()); + return response.ToString(); } /// @@ -130,7 +143,10 @@ public IReadOnlyList GetReasoningHistory(string sessionId) throw new ArgumentException("SessionId cannot be null or whitespace.", nameof(sessionId)); } - lock (_lock) + // Using Wait() explicitly as this is a synchronous method. + // The method is synchronous to maintain backward compatibility and avoid forcing async all the way up the call chain. + _lock.Wait(CancellationToken.None); + try { if (_sessions.TryGetValue(sessionId, out var steps)) { @@ -138,5 +154,22 @@ public IReadOnlyList GetReasoningHistory(string sessionId) } return Array.Empty(); } + finally + { + _lock.Release(); + } + } + + /// + /// Disposes the resources used by the ReasoningTool. + /// + public void Dispose() + { + if (!_disposed) + { + _lock.Dispose(); + _disposed = true; + } + GC.SuppressFinalize(this); } } diff --git a/src/AgenTerra.Core/ServiceCollectionExtensions.cs b/src/AgenTerra.Core/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..0543ab4 --- /dev/null +++ b/src/AgenTerra.Core/ServiceCollectionExtensions.cs @@ -0,0 +1,37 @@ +using AgenTerra.Core.Knowledge; +using AgenTerra.Core.Reasoning; +using AgenTerra.Core.State; +using Microsoft.Extensions.DependencyInjection; + +namespace AgenTerra.Core; + +/// +/// Extension methods for configuring AgenTerra services in an . +/// +public static class ServiceCollectionExtensions +{ + /// + /// Adds AgenTerra core services to the specified . + /// + /// The to add services to. + /// The so that additional calls can be chained. + /// + /// Note: ReasoningTool and InMemoryWorkflowStateStore implement IDisposable and are registered as singletons. + /// They will be automatically disposed when the DI container is disposed (typically on application shutdown). + /// Ensure the application's service provider is properly disposed to release these resources. + /// + public static IServiceCollection AddAgenTerra(this IServiceCollection services) + { + // Reasoning - Singleton for shared session management + services.AddSingleton(); + + // State - Singleton for shared state storage + services.AddSingleton(); + + // Knowledge - Singleton for shared factory with registered readers + // The factory creates and manages its own document reader instances + services.AddSingleton(); + + return services; + } +} diff --git a/src/AgenTerra.Core/State/IWorkflowStateStore.cs b/src/AgenTerra.Core/State/IWorkflowStateStore.cs index 22614aa..5d3c0d0 100644 --- a/src/AgenTerra.Core/State/IWorkflowStateStore.cs +++ b/src/AgenTerra.Core/State/IWorkflowStateStore.cs @@ -12,18 +12,20 @@ public interface IWorkflowStateStore /// Retrieves a workflow session by its unique identifier. /// /// The unique identifier of the session to retrieve. + /// Cancellation token. /// /// A task that represents the asynchronous operation. /// The task result contains the workflow session if found; otherwise, null. /// - Task GetSessionAsync(string sessionId); + Task GetSessionAsync(string sessionId, CancellationToken cancellationToken = default); /// /// Saves or updates a workflow session in the store. /// /// The workflow session to save or update. + /// Cancellation token. /// A task that represents the asynchronous save operation. - Task SaveSessionAsync(WorkflowSession session); + Task SaveSessionAsync(WorkflowSession session, CancellationToken cancellationToken = default); /// /// Retrieves a specific state value from a session. @@ -31,11 +33,12 @@ public interface IWorkflowStateStore /// The type of the state value to retrieve. /// The unique identifier of the session. /// The key identifying the state value. + /// Cancellation token. /// /// A task that represents the asynchronous operation. /// The task result contains the state value if found; otherwise, the default value for type T. /// - Task GetStateAsync(string sessionId, string key); + Task GetStateAsync(string sessionId, string key, CancellationToken cancellationToken = default); /// /// Sets a specific state value in a session, creating the session if it doesn't exist. @@ -44,25 +47,28 @@ public interface IWorkflowStateStore /// The unique identifier of the session. /// The key identifying the state value. /// The value to store. + /// Cancellation token. /// A task that represents the asynchronous set operation. - Task SetStateAsync(string sessionId, string key, T value); + Task SetStateAsync(string sessionId, string key, T value, CancellationToken cancellationToken = default); /// /// Deletes a workflow session from the store. /// /// The unique identifier of the session to delete. + /// Cancellation token. /// /// A task that represents the asynchronous operation. /// The task result is true if the session was deleted; false if it didn't exist. /// - Task DeleteSessionAsync(string sessionId); + Task DeleteSessionAsync(string sessionId, CancellationToken cancellationToken = default); /// /// Retrieves all active session identifiers. /// + /// Cancellation token. /// /// A task that represents the asynchronous operation. /// The task result contains a read-only list of all session identifiers. /// - Task> GetAllSessionIdsAsync(); + Task> GetAllSessionIdsAsync(CancellationToken cancellationToken = default); } diff --git a/src/AgenTerra.Core/State/InMemoryWorkflowStateStore.cs b/src/AgenTerra.Core/State/InMemoryWorkflowStateStore.cs index 581a5ba..14205b5 100644 --- a/src/AgenTerra.Core/State/InMemoryWorkflowStateStore.cs +++ b/src/AgenTerra.Core/State/InMemoryWorkflowStateStore.cs @@ -7,17 +7,18 @@ namespace AgenTerra.Core.State; /// Stores workflow sessions in memory using a thread-safe dictionary. /// Suitable for single-instance deployments and testing scenarios. /// -public class InMemoryWorkflowStateStore : IWorkflowStateStore +public class InMemoryWorkflowStateStore : IWorkflowStateStore, IDisposable { private readonly Dictionary _sessions = new(); private readonly SemaphoreSlim _lock = new(1, 1); + private bool _disposed; /// - public async Task GetSessionAsync(string sessionId) + public async Task GetSessionAsync(string sessionId, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(sessionId); - await _lock.WaitAsync(); + await _lock.WaitAsync(cancellationToken); try { return _sessions.TryGetValue(sessionId, out var session) @@ -31,11 +32,11 @@ public class InMemoryWorkflowStateStore : IWorkflowStateStore } /// - public async Task SaveSessionAsync(WorkflowSession session) + public async Task SaveSessionAsync(WorkflowSession session, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(session); - await _lock.WaitAsync(); + await _lock.WaitAsync(cancellationToken); try { var updatedSession = session with { UpdatedAt = DateTime.UtcNow }; @@ -48,12 +49,12 @@ public async Task SaveSessionAsync(WorkflowSession session) } /// - public async Task GetStateAsync(string sessionId, string key) + public async Task GetStateAsync(string sessionId, string key, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(sessionId); ArgumentNullException.ThrowIfNull(key); - var session = await GetSessionAsync(sessionId); + var session = await GetSessionAsync(sessionId, cancellationToken); if (session?.SessionState.TryGetValue(key, out var value) == true) { return (T?)value; @@ -67,12 +68,12 @@ public async Task SaveSessionAsync(WorkflowSession session) /// The null-forgiving operator is used because the Dictionary requires non-nullable object values, /// but the actual value can be null when T is nullable. /// - public async Task SetStateAsync(string sessionId, string key, T value) + public async Task SetStateAsync(string sessionId, string key, T value, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(sessionId); ArgumentNullException.ThrowIfNull(key); - await _lock.WaitAsync(); + await _lock.WaitAsync(cancellationToken); try { if (!_sessions.TryGetValue(sessionId, out var session)) @@ -104,11 +105,11 @@ public async Task SetStateAsync(string sessionId, string key, T value) } /// - public async Task DeleteSessionAsync(string sessionId) + public async Task DeleteSessionAsync(string sessionId, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(sessionId); - await _lock.WaitAsync(); + await _lock.WaitAsync(cancellationToken); try { return _sessions.Remove(sessionId); @@ -120,9 +121,9 @@ public async Task DeleteSessionAsync(string sessionId) } /// - public async Task> GetAllSessionIdsAsync() + public async Task> GetAllSessionIdsAsync(CancellationToken cancellationToken = default) { - await _lock.WaitAsync(); + await _lock.WaitAsync(cancellationToken); try { return _sessions.Keys.ToList(); @@ -132,4 +133,18 @@ public async Task> GetAllSessionIdsAsync() _lock.Release(); } } + + /// + /// Disposes the resources used by the InMemoryWorkflowStateStore. + /// + public void Dispose() + { + if (!_disposed) + { + _lock.Dispose(); + _disposed = true; + } + GC.SuppressFinalize(this); + GC.SuppressFinalize(this); + } } diff --git a/src/AgenTerra.Core/State/Models/WorkflowStateException.cs b/src/AgenTerra.Core/State/Models/WorkflowStateException.cs new file mode 100644 index 0000000..e65377c --- /dev/null +++ b/src/AgenTerra.Core/State/Models/WorkflowStateException.cs @@ -0,0 +1,21 @@ +namespace AgenTerra.Core.State.Models; + +/// +/// Exception thrown when workflow state operations fail. +/// +public class WorkflowStateException : Exception +{ + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The message that describes the error. + public WorkflowStateException(string message) : base(message) { } + + /// + /// Initializes a new instance of the class with a specified error message and a reference to the inner exception. + /// + /// The message that describes the error. + /// The exception that is the cause of the current exception. + public WorkflowStateException(string message, Exception innerException) + : base(message, innerException) { } +} diff --git a/tests/AgenTerra.Core.Tests/Reasoning/ReasoningHistoryTests.cs b/tests/AgenTerra.Core.Tests/Reasoning/ReasoningHistoryTests.cs index ad74854..98b74f7 100644 --- a/tests/AgenTerra.Core.Tests/Reasoning/ReasoningHistoryTests.cs +++ b/tests/AgenTerra.Core.Tests/Reasoning/ReasoningHistoryTests.cs @@ -8,7 +8,7 @@ public class ReasoningHistoryTests public async Task GetReasoningHistory_ReturnsReadOnlyList() { // Arrange - var tool = new ReasoningTool(); + using var tool = new ReasoningTool(); var sessionId = "readonly-test"; await tool.ThinkAsync(new ThinkInput(sessionId, "Test", "Thought")); @@ -24,7 +24,7 @@ public async Task GetReasoningHistory_ReturnsReadOnlyList() public async Task ReasoningStep_ContainsCorrectType() { // Arrange - var tool = new ReasoningTool(); + using var tool = new ReasoningTool(); var sessionId = "type-test"; // Act @@ -42,7 +42,7 @@ public async Task ReasoningStep_ContainsCorrectType() public async Task ReasoningStep_ContainsCorrectTitle() { // Arrange - var tool = new ReasoningTool(); + using var tool = new ReasoningTool(); var sessionId = "title-test"; // Act @@ -58,15 +58,12 @@ public async Task ReasoningStep_ContainsCorrectTitle() public async Task ThinkStep_ContentIncludesThoughtAndAction() { // Arrange - var tool = new ReasoningTool(); + using var tool = new ReasoningTool(); var sessionId = "content-test"; // Act - await tool.ThinkAsync(new ThinkInput( - SessionId: sessionId, - Title: "Test", - Thought: "My thought", - Action: "My action" + await tool.ThinkAsync(new ThinkInput(sessionId, "Test", "My thought", + "My action" )); var history = tool.GetReasoningHistory(sessionId); @@ -80,14 +77,11 @@ await tool.ThinkAsync(new ThinkInput( public async Task ThinkStep_ContentWithoutAction_OnlyIncludesThought() { // Arrange - var tool = new ReasoningTool(); + using var tool = new ReasoningTool(); var sessionId = "no-action-test"; // Act - await tool.ThinkAsync(new ThinkInput( - SessionId: sessionId, - Title: "Test", - Thought: "My thought" + await tool.ThinkAsync(new ThinkInput(sessionId, "Test", "My thought" )); var history = tool.GetReasoningHistory(sessionId); @@ -101,16 +95,12 @@ await tool.ThinkAsync(new ThinkInput( public async Task AnalyzeStep_ContentIncludesResultAnalysisAndNextAction() { // Arrange - var tool = new ReasoningTool(); + using var tool = new ReasoningTool(); var sessionId = "analyze-content-test"; // Act - await tool.AnalyzeAsync(new AnalyzeInput( - SessionId: sessionId, - Title: "Test", - Result: "My result", - Analysis: "My analysis", - NextAction: NextAction.Validate + await tool.AnalyzeAsync(new AnalyzeInput(sessionId, "Test", "My result", "My analysis", + NextAction.Validate )); var history = tool.GetReasoningHistory(sessionId); @@ -125,12 +115,12 @@ await tool.AnalyzeAsync(new AnalyzeInput( public async Task ReasoningStep_ConfidenceIsStoredCorrectly() { // Arrange - var tool = new ReasoningTool(); + using var tool = new ReasoningTool(); var sessionId = "confidence-test"; // Act - await tool.ThinkAsync(new ThinkInput(sessionId, "Test", "Thought", Confidence: 0.65)); - await tool.AnalyzeAsync(new AnalyzeInput(sessionId, "Test", "R", "A", Confidence: 0.92)); + await tool.ThinkAsync(new ThinkInput(sessionId, "Test", "Thought", null, 0.65)); + await tool.AnalyzeAsync(new AnalyzeInput(sessionId, "Test", "R", "A", NextAction.Continue, 0.92)); var history = tool.GetReasoningHistory(sessionId); @@ -143,7 +133,7 @@ public async Task ReasoningStep_ConfidenceIsStoredCorrectly() public async Task ReasoningStep_TimestampsAreChronological() { // Arrange - var tool = new ReasoningTool(); + using var tool = new ReasoningTool(); var sessionId = "timestamp-test"; // Act @@ -165,16 +155,11 @@ public async Task ReasoningStep_TimestampsAreChronological() public async Task ReasoningStep_AllFieldsArePopulated() { // Arrange - var tool = new ReasoningTool(); + using var tool = new ReasoningTool(); var sessionId = "complete-test"; // Act - await tool.ThinkAsync(new ThinkInput( - SessionId: sessionId, - Title: "Complete Step", - Thought: "Full thought", - Action: "Full action", - Confidence: 0.88 + await tool.ThinkAsync(new ThinkInput(sessionId, "Complete Step", "Full thought", "Full action", 0.88 )); var history = tool.GetReasoningHistory(sessionId); @@ -195,7 +180,7 @@ await tool.ThinkAsync(new ThinkInput( public async Task GetReasoningHistory_AfterMultipleCalls_ReturnsConsistentData() { // Arrange - var tool = new ReasoningTool(); + using var tool = new ReasoningTool(); var sessionId = "consistency-test"; await tool.ThinkAsync(new ThinkInput(sessionId, "Test", "Thought")); diff --git a/tests/AgenTerra.Core.Tests/Reasoning/ReasoningToolTests.cs b/tests/AgenTerra.Core.Tests/Reasoning/ReasoningToolTests.cs index cd737eb..a8b72bf 100644 --- a/tests/AgenTerra.Core.Tests/Reasoning/ReasoningToolTests.cs +++ b/tests/AgenTerra.Core.Tests/Reasoning/ReasoningToolTests.cs @@ -8,13 +8,8 @@ public class ReasoningToolTests public async Task ThinkAsync_WithValidInput_ReturnsFormattedResponse() { // Arrange - var tool = new ReasoningTool(); - var input = new ThinkInput( - SessionId: "test-session", - Title: "Test Thought", - Thought: "This is a test thought", - Action: "Test action", - Confidence: 0.9 + using var tool = new ReasoningTool(); + var input = new ThinkInput("test-session", "Test Thought", "This is a test thought", "Test action", 0.9 ); // Act @@ -32,7 +27,7 @@ public async Task ThinkAsync_WithValidInput_ReturnsFormattedResponse() public async Task ThinkAsync_WithNullInput_ThrowsArgumentNullException() { // Arrange - var tool = new ReasoningTool(); + using var tool = new ReasoningTool(); // Act & Assert await Assert.ThrowsAsync(() => tool.ThinkAsync(null!)); @@ -42,11 +37,8 @@ public async Task ThinkAsync_WithNullInput_ThrowsArgumentNullException() public async Task ThinkAsync_WithNullSessionId_ThrowsArgumentException() { // Arrange - var tool = new ReasoningTool(); - var input = new ThinkInput( - SessionId: null!, - Title: "Test", - Thought: "Test" + using var tool = new ReasoningTool(); + var input = new ThinkInput(null!, "Test", "Test" ); // Act & Assert @@ -57,11 +49,8 @@ public async Task ThinkAsync_WithNullSessionId_ThrowsArgumentException() public async Task ThinkAsync_WithEmptyTitle_ThrowsArgumentException() { // Arrange - var tool = new ReasoningTool(); - var input = new ThinkInput( - SessionId: "test-session", - Title: "", - Thought: "Test" + using var tool = new ReasoningTool(); + var input = new ThinkInput("test-session", "", "Test" ); // Act & Assert @@ -72,11 +61,8 @@ public async Task ThinkAsync_WithEmptyTitle_ThrowsArgumentException() public async Task ThinkAsync_WithEmptyThought_ThrowsArgumentException() { // Arrange - var tool = new ReasoningTool(); - var input = new ThinkInput( - SessionId: "test-session", - Title: "Test", - Thought: "" + using var tool = new ReasoningTool(); + var input = new ThinkInput("test-session", "Test", "" ); // Act & Assert @@ -87,11 +73,8 @@ public async Task ThinkAsync_WithEmptyThought_ThrowsArgumentException() public async Task ThinkAsync_WithoutAction_ReturnsResponseWithoutAction() { // Arrange - var tool = new ReasoningTool(); - var input = new ThinkInput( - SessionId: "test-session", - Title: "Test Thought", - Thought: "This is a test thought" + using var tool = new ReasoningTool(); + var input = new ThinkInput("test-session", "Test Thought", "This is a test thought" ); // Act @@ -107,14 +90,8 @@ public async Task ThinkAsync_WithoutAction_ReturnsResponseWithoutAction() public async Task AnalyzeAsync_WithValidInput_ReturnsFormattedResponse() { // Arrange - var tool = new ReasoningTool(); - var input = new AnalyzeInput( - SessionId: "test-session", - Title: "Test Analysis", - Result: "Test result", - Analysis: "Test analysis", - NextAction: NextAction.Validate, - Confidence: 0.85 + using var tool = new ReasoningTool(); + var input = new AnalyzeInput("test-session", "Test Analysis", "Test result", "Test analysis", NextAction.Validate, 0.85 ); // Act @@ -133,7 +110,7 @@ public async Task AnalyzeAsync_WithValidInput_ReturnsFormattedResponse() public async Task AnalyzeAsync_WithNullInput_ThrowsArgumentNullException() { // Arrange - var tool = new ReasoningTool(); + using var tool = new ReasoningTool(); // Act & Assert await Assert.ThrowsAsync(() => tool.AnalyzeAsync(null!)); @@ -143,12 +120,8 @@ public async Task AnalyzeAsync_WithNullInput_ThrowsArgumentNullException() public async Task AnalyzeAsync_WithNullSessionId_ThrowsArgumentException() { // Arrange - var tool = new ReasoningTool(); - var input = new AnalyzeInput( - SessionId: null!, - Title: "Test", - Result: "Test", - Analysis: "Test" + using var tool = new ReasoningTool(); + var input = new AnalyzeInput(null!, "Test", "Test", "Test" ); // Act & Assert @@ -159,12 +132,8 @@ public async Task AnalyzeAsync_WithNullSessionId_ThrowsArgumentException() public async Task AnalyzeAsync_WithEmptyResult_ThrowsArgumentException() { // Arrange - var tool = new ReasoningTool(); - var input = new AnalyzeInput( - SessionId: "test-session", - Title: "Test", - Result: "", - Analysis: "Test" + using var tool = new ReasoningTool(); + var input = new AnalyzeInput("test-session", "Test", "", "Test" ); // Act & Assert @@ -175,12 +144,8 @@ public async Task AnalyzeAsync_WithEmptyResult_ThrowsArgumentException() public async Task AnalyzeAsync_WithDefaultNextAction_UsesContinue() { // Arrange - var tool = new ReasoningTool(); - var input = new AnalyzeInput( - SessionId: "test-session", - Title: "Test Analysis", - Result: "Test result", - Analysis: "Test analysis" + using var tool = new ReasoningTool(); + var input = new AnalyzeInput("test-session", "Test Analysis", "Test result", "Test analysis" ); // Act @@ -194,7 +159,7 @@ public async Task AnalyzeAsync_WithDefaultNextAction_UsesContinue() public void GetReasoningHistory_WithNullSessionId_ThrowsArgumentException() { // Arrange - var tool = new ReasoningTool(); + using var tool = new ReasoningTool(); // Act & Assert Assert.Throws(() => tool.GetReasoningHistory(null!)); @@ -204,7 +169,7 @@ public void GetReasoningHistory_WithNullSessionId_ThrowsArgumentException() public void GetReasoningHistory_WithNonExistentSession_ReturnsEmptyList() { // Arrange - var tool = new ReasoningTool(); + using var tool = new ReasoningTool(); // Act var history = tool.GetReasoningHistory("non-existent-session"); @@ -218,7 +183,7 @@ public void GetReasoningHistory_WithNonExistentSession_ReturnsEmptyList() public async Task GetReasoningHistory_AfterAddingSteps_ReturnsAllStepsInOrder() { // Arrange - var tool = new ReasoningTool(); + using var tool = new ReasoningTool(); var sessionId = "test-session"; await tool.ThinkAsync(new ThinkInput(sessionId, "First", "First thought")); @@ -242,7 +207,7 @@ public async Task GetReasoningHistory_AfterAddingSteps_ReturnsAllStepsInOrder() public async Task GetReasoningHistory_ReturnsSnapshotOfCurrentState() { // Arrange - var tool = new ReasoningTool(); + using var tool = new ReasoningTool(); var sessionId = "test-session"; await tool.ThinkAsync(new ThinkInput(sessionId, "First", "First thought")); @@ -266,7 +231,7 @@ public async Task GetReasoningHistory_ReturnsSnapshotOfCurrentState() public async Task ReasoningSteps_ContainCorrectTimestamps() { // Arrange - var tool = new ReasoningTool(); + using var tool = new ReasoningTool(); var sessionId = "test-session"; var beforeTime = DateTime.UtcNow; @@ -286,12 +251,12 @@ public async Task ReasoningSteps_ContainCorrectTimestamps() public async Task ReasoningSteps_StoreCorrectConfidence() { // Arrange - var tool = new ReasoningTool(); + using var tool = new ReasoningTool(); var sessionId = "test-session"; // Act - await tool.ThinkAsync(new ThinkInput(sessionId, "Test", "Test", Confidence: 0.75)); - await tool.AnalyzeAsync(new AnalyzeInput(sessionId, "Test", "R", "A", Confidence: 0.95)); + await tool.ThinkAsync(new ThinkInput(sessionId, "Test", "Test", null, 0.75)); + await tool.AnalyzeAsync(new AnalyzeInput(sessionId, "Test", "R", "A", NextAction.Continue, 0.95)); var history = tool.GetReasoningHistory(sessionId); @@ -299,4 +264,34 @@ public async Task ReasoningSteps_StoreCorrectConfidence() Assert.Equal(0.75, history[0].Confidence); Assert.Equal(0.95, history[1].Confidence); } + + [Fact] + public async Task ThinkAsync_WithCancellationToken_SupportsCancellation() + { + // Arrange + using var tool = new ReasoningTool(); + var input = new ThinkInput("test-session", "Test Thought", "This is a test thought" + ); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + // Act & Assert + await Assert.ThrowsAnyAsync( + async () => await tool.ThinkAsync(input, cts.Token)); + } + + [Fact] + public async Task AnalyzeAsync_WithCancellationToken_SupportsCancellation() + { + // Arrange + using var tool = new ReasoningTool(); + var input = new AnalyzeInput("test-session", "Test Analysis", "Test result", "Test analysis" + ); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + // Act & Assert + await Assert.ThrowsAnyAsync( + async () => await tool.AnalyzeAsync(input, cts.Token)); + } } diff --git a/tests/AgenTerra.Core.Tests/Reasoning/SessionManagementTests.cs b/tests/AgenTerra.Core.Tests/Reasoning/SessionManagementTests.cs index 148bcd8..6f4b9d9 100644 --- a/tests/AgenTerra.Core.Tests/Reasoning/SessionManagementTests.cs +++ b/tests/AgenTerra.Core.Tests/Reasoning/SessionManagementTests.cs @@ -8,7 +8,7 @@ public class SessionManagementTests public async Task DifferentSessions_AreIsolatedFromEachOther() { // Arrange - var tool = new ReasoningTool(); + using var tool = new ReasoningTool(); var sessionId1 = "session-1"; var sessionId2 = "session-2"; @@ -31,7 +31,7 @@ public async Task DifferentSessions_AreIsolatedFromEachOther() public async Task MultipleSessions_CanBeCreatedConcurrently() { // Arrange - var tool = new ReasoningTool(); + using var tool = new ReasoningTool(); var sessionCount = 10; var tasks = new List(); @@ -39,10 +39,7 @@ public async Task MultipleSessions_CanBeCreatedConcurrently() for (int i = 0; i < sessionCount; i++) { var sessionId = $"session-{i}"; - var task = tool.ThinkAsync(new ThinkInput( - SessionId: sessionId, - Title: $"Session {i}", - Thought: $"Thought for session {i}" + var task = tool.ThinkAsync(new ThinkInput(sessionId, $"Session {i}", $"Thought for session {i}" )); tasks.Add(task); } @@ -62,7 +59,7 @@ public async Task MultipleSessions_CanBeCreatedConcurrently() public async Task SameSession_AccumulatesStepsOverTime() { // Arrange - var tool = new ReasoningTool(); + using var tool = new ReasoningTool(); var sessionId = "accumulation-test"; // Act & Assert @@ -83,7 +80,7 @@ public async Task SameSession_AccumulatesStepsOverTime() public async Task NewSession_IsCreatedAutomaticallyOnFirstUse() { // Arrange - var tool = new ReasoningTool(); + using var tool = new ReasoningTool(); var sessionId = "auto-created-session"; // Act @@ -100,7 +97,7 @@ public async Task NewSession_IsCreatedAutomaticallyOnFirstUse() public async Task SessionHistory_PreservesInsertionOrder() { // Arrange - var tool = new ReasoningTool(); + using var tool = new ReasoningTool(); var sessionId = "order-test"; var expectedTitles = new List(); @@ -134,7 +131,7 @@ public async Task SessionHistory_PreservesInsertionOrder() public async Task MixedStepTypes_AreCorrectlyStoredInSession() { // Arrange - var tool = new ReasoningTool(); + using var tool = new ReasoningTool(); var sessionId = "mixed-types-test"; // Act diff --git a/tests/AgenTerra.Core.Tests/Reasoning/ThreadSafetyTests.cs b/tests/AgenTerra.Core.Tests/Reasoning/ThreadSafetyTests.cs index 8b69bce..252f405 100644 --- a/tests/AgenTerra.Core.Tests/Reasoning/ThreadSafetyTests.cs +++ b/tests/AgenTerra.Core.Tests/Reasoning/ThreadSafetyTests.cs @@ -8,7 +8,7 @@ public class ThreadSafetyTests public async Task ConcurrentThinkCalls_ToSameSession_AreThreadSafe() { // Arrange - var tool = new ReasoningTool(); + using var tool = new ReasoningTool(); var sessionId = "concurrent-think-test"; var taskCount = 100; var tasks = new List(); @@ -17,10 +17,7 @@ public async Task ConcurrentThinkCalls_ToSameSession_AreThreadSafe() for (int i = 0; i < taskCount; i++) { var index = i; - var task = tool.ThinkAsync(new ThinkInput( - SessionId: sessionId, - Title: $"Concurrent Think {index}", - Thought: $"Thought {index}" + var task = tool.ThinkAsync(new ThinkInput(sessionId, $"Concurrent Think {index}", $"Thought {index}" )); tasks.Add(task); } @@ -38,7 +35,7 @@ public async Task ConcurrentThinkCalls_ToSameSession_AreThreadSafe() public async Task ConcurrentAnalyzeCalls_ToSameSession_AreThreadSafe() { // Arrange - var tool = new ReasoningTool(); + using var tool = new ReasoningTool(); var sessionId = "concurrent-analyze-test"; var taskCount = 100; var tasks = new List(); @@ -47,11 +44,7 @@ public async Task ConcurrentAnalyzeCalls_ToSameSession_AreThreadSafe() for (int i = 0; i < taskCount; i++) { var index = i; - var task = tool.AnalyzeAsync(new AnalyzeInput( - SessionId: sessionId, - Title: $"Concurrent Analyze {index}", - Result: $"Result {index}", - Analysis: $"Analysis {index}" + var task = tool.AnalyzeAsync(new AnalyzeInput(sessionId, $"Concurrent Analyze {index}", $"Result {index}", $"Analysis {index}" )); tasks.Add(task); } @@ -69,7 +62,7 @@ public async Task ConcurrentAnalyzeCalls_ToSameSession_AreThreadSafe() public async Task ConcurrentMixedCalls_ToSameSession_AreThreadSafe() { // Arrange - var tool = new ReasoningTool(); + using var tool = new ReasoningTool(); var sessionId = "concurrent-mixed-test"; var taskCount = 100; var tasks = new List(); @@ -82,19 +75,12 @@ public async Task ConcurrentMixedCalls_ToSameSession_AreThreadSafe() if (i % 2 == 0) { - task = tool.ThinkAsync(new ThinkInput( - SessionId: sessionId, - Title: $"Think {index}", - Thought: $"Thought {index}" + task = tool.ThinkAsync(new ThinkInput(sessionId, $"Think {index}", $"Thought {index}" )); } else { - task = tool.AnalyzeAsync(new AnalyzeInput( - SessionId: sessionId, - Title: $"Analyze {index}", - Result: $"Result {index}", - Analysis: $"Analysis {index}" + task = tool.AnalyzeAsync(new AnalyzeInput(sessionId, $"Analyze {index}", $"Result {index}", $"Analysis {index}" )); } @@ -113,7 +99,7 @@ public async Task ConcurrentMixedCalls_ToSameSession_AreThreadSafe() public async Task ConcurrentCallsToMultipleSessions_AreThreadSafe() { // Arrange - var tool = new ReasoningTool(); + using var tool = new ReasoningTool(); var sessionCount = 50; var stepsPerSession = 20; var tasks = new List(); @@ -126,10 +112,7 @@ public async Task ConcurrentCallsToMultipleSessions_AreThreadSafe() for (int i = 0; i < stepsPerSession; i++) { var index = i; - var task = tool.ThinkAsync(new ThinkInput( - SessionId: sessionId, - Title: $"Step {index}", - Thought: $"Thought {index}" + var task = tool.ThinkAsync(new ThinkInput(sessionId, $"Step {index}", $"Thought {index}" )); tasks.Add(task); } @@ -149,7 +132,7 @@ public async Task ConcurrentCallsToMultipleSessions_AreThreadSafe() public async Task ConcurrentReads_WhileWriting_AreThreadSafe() { // Arrange - var tool = new ReasoningTool(); + using var tool = new ReasoningTool(); var sessionId = "read-write-test"; var writeCount = 100; var readCount = 100; @@ -159,10 +142,7 @@ public async Task ConcurrentReads_WhileWriting_AreThreadSafe() for (int i = 0; i < writeCount; i++) { var index = i; - var task = tool.ThinkAsync(new ThinkInput( - SessionId: sessionId, - Title: $"Step {index}", - Thought: $"Thought {index}" + var task = tool.ThinkAsync(new ThinkInput(sessionId, $"Step {index}", $"Thought {index}" )); tasks.Add(task); } @@ -186,15 +166,12 @@ public async Task ConcurrentReads_WhileWriting_AreThreadSafe() public async Task ParallelSessionCreation_IsThreadSafe() { // Arrange - var tool = new ReasoningTool(); + using var tool = new ReasoningTool(); var sessionCount = 100; // Act var tasks = Enumerable.Range(0, sessionCount) - .Select(i => tool.ThinkAsync(new ThinkInput( - SessionId: $"session-{i}", - Title: "Initial Step", - Thought: "Initial thought" + .Select(i => tool.ThinkAsync(new ThinkInput($"session-{i}", "Initial Step", "Initial thought" ))) .ToList(); @@ -212,7 +189,7 @@ public async Task ParallelSessionCreation_IsThreadSafe() public async Task StressTest_ManyOperationsSimultaneously() { // Arrange - var tool = new ReasoningTool(); + using var tool = new ReasoningTool(); var sessionCount = 10; var operationsPerSession = 50; var random = new Random(42); // Fixed seed for reproducibility @@ -233,19 +210,12 @@ public async Task StressTest_ManyOperationsSimultaneously() if (operation == 0) { - task = tool.ThinkAsync(new ThinkInput( - SessionId: sessionId, - Title: $"Think {index}", - Thought: $"Thought {index}" + task = tool.ThinkAsync(new ThinkInput(sessionId, $"Think {index}", $"Thought {index}" )); } else if (operation == 1) { - task = tool.AnalyzeAsync(new AnalyzeInput( - SessionId: sessionId, - Title: $"Analyze {index}", - Result: $"Result {index}", - Analysis: $"Analysis {index}" + task = tool.AnalyzeAsync(new AnalyzeInput(sessionId, $"Analyze {index}", $"Result {index}", $"Analysis {index}" )); } else diff --git a/tests/AgenTerra.Core.Tests/State/InMemoryWorkflowStateStoreTests.cs b/tests/AgenTerra.Core.Tests/State/InMemoryWorkflowStateStoreTests.cs index 6f72330..7370d5e 100644 --- a/tests/AgenTerra.Core.Tests/State/InMemoryWorkflowStateStoreTests.cs +++ b/tests/AgenTerra.Core.Tests/State/InMemoryWorkflowStateStoreTests.cs @@ -9,7 +9,7 @@ public class InMemoryWorkflowStateStoreTests public async Task GetSessionAsync_ReturnsNull_ForNonExistentSession() { // Arrange - var store = new InMemoryWorkflowStateStore(); + using var store = new InMemoryWorkflowStateStore(); var sessionId = "non-existent-session"; // Act @@ -23,7 +23,7 @@ public async Task GetSessionAsync_ReturnsNull_ForNonExistentSession() public async Task GetSessionAsync_ThrowsArgumentNullException_WhenSessionIdIsNull() { // Arrange - var store = new InMemoryWorkflowStateStore(); + using var store = new InMemoryWorkflowStateStore(); // Act & Assert await Assert.ThrowsAsync(() => store.GetSessionAsync(null!)); @@ -33,7 +33,7 @@ public async Task GetSessionAsync_ThrowsArgumentNullException_WhenSessionIdIsNul public async Task SaveSessionAsync_CreatesNewSession() { // Arrange - var store = new InMemoryWorkflowStateStore(); + using var store = new InMemoryWorkflowStateStore(); var session = new WorkflowSession { SessionId = "test-session", @@ -53,7 +53,7 @@ public async Task SaveSessionAsync_CreatesNewSession() public async Task SaveSessionAsync_ThrowsArgumentNullException_WhenSessionIsNull() { // Arrange - var store = new InMemoryWorkflowStateStore(); + using var store = new InMemoryWorkflowStateStore(); // Act & Assert await Assert.ThrowsAsync(() => store.SaveSessionAsync(null!)); @@ -63,7 +63,7 @@ public async Task SaveSessionAsync_ThrowsArgumentNullException_WhenSessionIsNull public async Task SaveSessionAsync_UpdatesExistingSessionTimestamp() { // Arrange - var store = new InMemoryWorkflowStateStore(); + using var store = new InMemoryWorkflowStateStore(); var session = new WorkflowSession { SessionId = "test-session", @@ -88,7 +88,7 @@ public async Task SaveSessionAsync_UpdatesExistingSessionTimestamp() public async Task GetStateAsync_ReturnsCorrectTypedValue() { // Arrange - var store = new InMemoryWorkflowStateStore(); + using var store = new InMemoryWorkflowStateStore(); var sessionId = "test-session"; var expectedValue = "test-value"; @@ -104,7 +104,7 @@ public async Task GetStateAsync_ReturnsCorrectTypedValue() public async Task GetStateAsync_ReturnsDefault_ForMissingKey() { // Arrange - var store = new InMemoryWorkflowStateStore(); + using var store = new InMemoryWorkflowStateStore(); var sessionId = "test-session"; // Act @@ -119,7 +119,7 @@ public async Task GetStateAsync_ReturnsDefault_ForMissingKey() public async Task GetStateAsync_ReturnsDefault_ForNonExistentSession() { // Arrange - var store = new InMemoryWorkflowStateStore(); + using var store = new InMemoryWorkflowStateStore(); // Act var result = await store.GetStateAsync("non-existent", "key1"); @@ -132,7 +132,7 @@ public async Task GetStateAsync_ReturnsDefault_ForNonExistentSession() public async Task GetStateAsync_ThrowsArgumentNullException_WhenSessionIdIsNull() { // Arrange - var store = new InMemoryWorkflowStateStore(); + using var store = new InMemoryWorkflowStateStore(); // Act & Assert await Assert.ThrowsAsync(() => store.GetStateAsync(null!, "key")); @@ -142,7 +142,7 @@ public async Task GetStateAsync_ThrowsArgumentNullException_WhenSessionIdIsNull( public async Task GetStateAsync_ThrowsArgumentNullException_WhenKeyIsNull() { // Arrange - var store = new InMemoryWorkflowStateStore(); + using var store = new InMemoryWorkflowStateStore(); // Act & Assert await Assert.ThrowsAsync(() => store.GetStateAsync("session", null!)); @@ -152,7 +152,7 @@ public async Task GetStateAsync_ThrowsArgumentNullException_WhenKeyIsNull() public async Task SetStateAsync_CreatesSession_IfNotExists() { // Arrange - var store = new InMemoryWorkflowStateStore(); + using var store = new InMemoryWorkflowStateStore(); var sessionId = "new-session"; // Act @@ -168,7 +168,7 @@ public async Task SetStateAsync_CreatesSession_IfNotExists() public async Task SetStateAsync_ThrowsArgumentNullException_WhenSessionIdIsNull() { // Arrange - var store = new InMemoryWorkflowStateStore(); + using var store = new InMemoryWorkflowStateStore(); // Act & Assert await Assert.ThrowsAsync(() => store.SetStateAsync(null!, "key", "value")); @@ -178,7 +178,7 @@ public async Task SetStateAsync_ThrowsArgumentNullException_WhenSessionIdIsNull( public async Task SetStateAsync_ThrowsArgumentNullException_WhenKeyIsNull() { // Arrange - var store = new InMemoryWorkflowStateStore(); + using var store = new InMemoryWorkflowStateStore(); // Act & Assert await Assert.ThrowsAsync(() => store.SetStateAsync("session", null!, "value")); @@ -188,7 +188,7 @@ public async Task SetStateAsync_ThrowsArgumentNullException_WhenKeyIsNull() public async Task SetStateAsync_UpdatesStateCorrectly() { // Arrange - var store = new InMemoryWorkflowStateStore(); + using var store = new InMemoryWorkflowStateStore(); var sessionId = "test-session"; // Act @@ -206,7 +206,7 @@ public async Task SetStateAsync_UpdatesStateCorrectly() public async Task SetStateAsync_PreservesImmutability() { // Arrange - var store = new InMemoryWorkflowStateStore(); + using var store = new InMemoryWorkflowStateStore(); var sessionId = "test-session"; // Act @@ -226,7 +226,7 @@ public async Task SetStateAsync_PreservesImmutability() public async Task DeleteSessionAsync_RemovesSession() { // Arrange - var store = new InMemoryWorkflowStateStore(); + using var store = new InMemoryWorkflowStateStore(); var sessionId = "test-session"; await store.SetStateAsync(sessionId, "key1", "value1"); @@ -243,7 +243,7 @@ public async Task DeleteSessionAsync_RemovesSession() public async Task DeleteSessionAsync_ReturnsFalse_ForNonExistentSession() { // Arrange - var store = new InMemoryWorkflowStateStore(); + using var store = new InMemoryWorkflowStateStore(); // Act var result = await store.DeleteSessionAsync("non-existent"); @@ -256,7 +256,7 @@ public async Task DeleteSessionAsync_ReturnsFalse_ForNonExistentSession() public async Task DeleteSessionAsync_ThrowsArgumentNullException_WhenSessionIdIsNull() { // Arrange - var store = new InMemoryWorkflowStateStore(); + using var store = new InMemoryWorkflowStateStore(); // Act & Assert await Assert.ThrowsAsync(() => store.DeleteSessionAsync(null!)); @@ -266,7 +266,7 @@ public async Task DeleteSessionAsync_ThrowsArgumentNullException_WhenSessionIdIs public async Task GetAllSessionIdsAsync_ReturnsAllSessions() { // Arrange - var store = new InMemoryWorkflowStateStore(); + using var store = new InMemoryWorkflowStateStore(); await store.SetStateAsync("session1", "key", "value"); await store.SetStateAsync("session2", "key", "value"); await store.SetStateAsync("session3", "key", "value"); @@ -285,7 +285,7 @@ public async Task GetAllSessionIdsAsync_ReturnsAllSessions() public async Task GetAllSessionIdsAsync_ReturnsEmpty_WhenNoSessions() { // Arrange - var store = new InMemoryWorkflowStateStore(); + using var store = new InMemoryWorkflowStateStore(); // Act var sessionIds = await store.GetAllSessionIdsAsync(); @@ -298,7 +298,7 @@ public async Task GetAllSessionIdsAsync_ReturnsEmpty_WhenNoSessions() public async Task ConcurrentOperations_AreThreadSafe() { // Arrange - var store = new InMemoryWorkflowStateStore(); + using var store = new InMemoryWorkflowStateStore(); var sessionId = "concurrent-session"; var tasks = new List(); @@ -324,7 +324,7 @@ public async Task ConcurrentOperations_AreThreadSafe() public async Task SetStateAsync_SupportsComplexTypes() { // Arrange - var store = new InMemoryWorkflowStateStore(); + using var store = new InMemoryWorkflowStateStore(); var sessionId = "test-session"; var complexObject = new List { "item1", "item2", "item3" }; @@ -342,7 +342,7 @@ public async Task SetStateAsync_SupportsComplexTypes() public async Task GetSessionAsync_ReturnsCopy_NotReference() { // Arrange - var store = new InMemoryWorkflowStateStore(); + using var store = new InMemoryWorkflowStateStore(); var sessionId = "test-session"; await store.SetStateAsync(sessionId, "key1", "value1"); @@ -354,4 +354,87 @@ public async Task GetSessionAsync_ReturnsCopy_NotReference() Assert.NotSame(session1, session2); Assert.Equal(session1!.SessionId, session2!.SessionId); } + + [Fact] + public async Task GetSessionAsync_WithCancellationToken_SupportsCancellation() + { + // Arrange + using var store = new InMemoryWorkflowStateStore(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + // Act & Assert + await Assert.ThrowsAnyAsync( + async () => await store.GetSessionAsync("test-session", cts.Token)); + } + + [Fact] + public async Task SaveSessionAsync_WithCancellationToken_SupportsCancellation() + { + // Arrange + using var store = new InMemoryWorkflowStateStore(); + var session = new WorkflowSession + { + SessionId = "test-session", + SessionState = new Dictionary() + }; + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + // Act & Assert + await Assert.ThrowsAnyAsync( + async () => await store.SaveSessionAsync(session, cts.Token)); + } + + [Fact] + public async Task GetStateAsync_WithCancellationToken_SupportsCancellation() + { + // Arrange + using var store = new InMemoryWorkflowStateStore(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + // Act & Assert + await Assert.ThrowsAnyAsync( + async () => await store.GetStateAsync("test-session", "key", cts.Token)); + } + + [Fact] + public async Task SetStateAsync_WithCancellationToken_SupportsCancellation() + { + // Arrange + using var store = new InMemoryWorkflowStateStore(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + // Act & Assert + await Assert.ThrowsAnyAsync( + async () => await store.SetStateAsync("test-session", "key", "value", cts.Token)); + } + + [Fact] + public async Task DeleteSessionAsync_WithCancellationToken_SupportsCancellation() + { + // Arrange + using var store = new InMemoryWorkflowStateStore(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + // Act & Assert + await Assert.ThrowsAnyAsync( + async () => await store.DeleteSessionAsync("test-session", cts.Token)); + } + + [Fact] + public async Task GetAllSessionIdsAsync_WithCancellationToken_SupportsCancellation() + { + // Arrange + using var store = new InMemoryWorkflowStateStore(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + // Act & Assert + await Assert.ThrowsAnyAsync( + async () => await store.GetAllSessionIdsAsync(cts.Token)); + } }