feat(core): agent loop, session facade, factory, hosting and faux provider

This commit is contained in:
VolcanicAgenticCore
2026-09-25 12:28:24 +00:00
parent a49a2ea375
commit 26c634a2d5
20 changed files with 2503 additions and 0 deletions

View File

@@ -0,0 +1,109 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Events;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Abstractions.Sessions;
using Volcanic.AgenticCore.Abstractions.Ui;
using Volcanic.AgenticCore.Features.Extensions;
namespace Volcanic.AgenticCore.Features.Agent;
/// <summary>What extensions see through their context and API actions.</summary>
public sealed partial class AgentSession : IExtensionHost
{
IUserInterface IExtensionHost.Ui => _ui;
IReadOnlySessionManager IExtensionHost.SessionManager => _session;
IModelRegistry IExtensionHost.ModelRegistry => _models;
Model? IExtensionHost.Model => _model;
ThinkingLevel IExtensionHost.ThinkingLevel => _thinkingLevel;
CancellationToken IExtensionHost.Signal
{
get { lock (_stateLock) return _abortCts?.Token ?? CancellationToken.None; }
}
bool IExtensionHost.IsIdle() => !IsStreaming;
void IExtensionHost.Abort()
{
lock (_stateLock) _abortCts?.Cancel();
}
bool IExtensionHost.HasPendingMessages() => _steering.Count > 0 || _followUp.Count > 0 || _nextTurnMessages.Count > 0;
ContextUsage? IExtensionHost.GetContextUsage() => GetContextUsage();
string IExtensionHost.GetSystemPrompt() => SystemPrompt;
void IExtensionHost.RequestCompaction(CompactOptions? options)
{
if (IsStreaming) _pendingCompaction = options ?? new CompactOptions();
else _ = CompactCoreAsync(CompactionReason.Manual, options?.CustomInstructions, CancellationToken.None);
}
Task IExtensionHost.WaitForIdleAsync(CancellationToken cancellationToken) => WaitForIdleAsync(cancellationToken);
Task<SwitchResult> IExtensionHost.NewSessionAsync(NewSessionOptions? options, CancellationToken cancellationToken) => NewSessionAsync(options, cancellationToken);
Task<SwitchResult> IExtensionHost.SwitchSessionAsync(string sessionId, CancellationToken cancellationToken) => SwitchSessionAsync(sessionId, cancellationToken);
void IExtensionHost.SendMessage(CustomMessage message, MessageDelivery delivery, bool triggerTurn)
{
switch (delivery)
{
case MessageDelivery.NextTurn:
lock (_nextTurnMessages) _nextTurnMessages.Add(message);
return;
case MessageDelivery.FollowUp when IsStreaming:
FollowUp(message);
return;
case MessageDelivery.Steer when IsStreaming:
Steer(message);
return;
}
if (triggerTurn)
{
_steering.Enqueue(message);
_ = StartRunAsync(null, null, CancellationToken.None);
return;
}
// Idle and no turn wanted: append to the transcript immediately.
_ = AppendAndEmitAsync(message, CancellationToken.None);
}
void IExtensionHost.SendUserMessage(string text, IReadOnlyList<ImageContent>? images, MessageDelivery delivery)
{
var message = UserMessage.From(text, images);
if (IsStreaming)
{
if (delivery == MessageDelivery.FollowUp) FollowUp(message);
else Steer(message);
return;
}
_ = StartRunAsync(message, images, CancellationToken.None);
}
void IExtensionHost.AppendEntry(string customType, JsonElement? data) =>
_ = AppendCustomEntryAsync(customType, data);
private async Task AppendCustomEntryAsync(string customType, JsonElement? data)
{
try
{
var entry = await _session.AppendCustomAsync(customType, data).ConfigureAwait(false);
Emit(new AgentSessionEvent.EntryAppended(entry));
}
catch (Exception ex) { _logger.LogError(ex, "appendEntry failed"); }
}
Task<bool> IExtensionHost.SetModelAsync(Model model, CancellationToken cancellationToken) => SetModelAsync(model, cancellationToken);
void IExtensionHost.SetThinkingLevel(ThinkingLevel level) => _ = SetThinkingLevelAsync(level);
string? IExtensionHost.GetSessionName() => _session.Name;
void IExtensionHost.SetSessionName(string? name) => _ = SetSessionNameAsync(name);
}

View File

@@ -0,0 +1,311 @@
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Events;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Features.Compaction;
using Volcanic.AgenticCore.Features.Extensions;
namespace Volcanic.AgenticCore.Features.Agent;
/// <summary>The agent loop: turns, streaming with retry and overflow recovery, queues, compaction between turns.</summary>
public sealed partial class AgentSession
{
/// <summary>Starts a run if none is active. When a run is already active the messages must have been queued by the caller.</summary>
private async Task StartRunAsync(UserMessage? prompt, IReadOnlyList<ImageContent>? images, CancellationToken cancellationToken)
{
await _runGate.WaitAsync(cancellationToken).ConfigureAwait(false);
Task run;
try
{
lock (_stateLock)
{
if (_runTask is { IsCompleted: false })
{
if (prompt is not null) _steering.Enqueue(prompt);
run = _runTask;
}
else
{
_abortCts = new CancellationTokenSource();
run = _runTask = Task.Run(() => RunLoopAsync(prompt, images, _abortCts.Token), CancellationToken.None);
}
}
}
finally { _runGate.Release(); }
await run.ConfigureAwait(false);
}
private async Task RunLoopAsync(UserMessage? prompt, IReadOnlyList<ImageContent>? images, CancellationToken abort)
{
var newMessages = new List<Message>();
try
{
if (_model is null)
{
var noModel = new AssistantMessage { Provider = "", Api = "", Model = "", StopReason = StopReason.Error, ErrorMessage = "No model selected. Call SetModelAsync first." };
await AppendAndEmitAsync(noModel, CancellationToken.None).ConfigureAwait(false);
return;
}
// Pre-prompt compaction check (auto) and any manual request that arrived while idle.
if (_autoCompaction && _compactor.ShouldCompact(Compactor.ContextTokens(_session.BuildContext()), _model))
await CompactCoreAsync(CompactionReason.Threshold, null, abort).ConfigureAwait(false);
// before_agent_start: system prompt options and injected messages.
var promptOptions = _promptBuilder.DefaultOptions(_options.SystemPrompt ?? _coreOptions.DefaultSystemPrompt);
var before = await _runner.EmitAsync(new BeforeAgentStartEvent(prompt?.Text ?? "", images, promptOptions, _promptBuilder.Render), abort).ConfigureAwait(false);
_systemPromptForRun = before.CurrentSystemPrompt;
Emit(new AgentSessionEvent.AgentStart());
await _runner.EmitAsync(new AgentStartEvent(), abort).ConfigureAwait(false);
var pending = new List<Message>();
if (prompt is not null) pending.Add(prompt);
lock (_nextTurnMessages) { pending.AddRange(_nextTurnMessages); _nextTurnMessages.Clear(); }
pending.AddRange(before.InjectedMessages);
pending.AddRange(_steering.Dequeue());
var turnIndex = 0;
while (true)
{
Emit(new AgentSessionEvent.TurnStart(turnIndex));
await _runner.EmitAsync(new TurnStartEvent(turnIndex), abort).ConfigureAwait(false);
foreach (var message in pending)
{
await AppendAndEmitAsync(message, CancellationToken.None).ConfigureAwait(false);
newMessages.Add(message);
}
pending.Clear();
Emit(new AgentSessionEvent.QueueUpdate(_steering.Texts(), _followUp.Texts()));
// Between turns: manual compaction requests and the threshold check.
if (_pendingCompaction is { } manual)
{
_pendingCompaction = null;
await CompactCoreAsync(CompactionReason.Manual, manual.CustomInstructions, abort).ConfigureAwait(false);
}
else if (turnIndex > 0 && _autoCompaction && _compactor.ShouldCompact(Compactor.ContextTokens(_session.BuildContext()), _model))
{
await CompactCoreAsync(CompactionReason.Threshold, null, abort).ConfigureAwait(false);
}
var assistant = await StreamWithRetryAsync(abort).ConfigureAwait(false);
newMessages.Add(assistant);
if (assistant.IsFailure)
{
await _runner.EmitAsync(new TurnEndEvent(turnIndex, assistant, []), CancellationToken.None).ConfigureAwait(false);
Emit(new AgentSessionEvent.TurnEnd(turnIndex, assistant, []));
break;
}
var toolResults = new List<ToolResultMessage>();
if (assistant.ToolCalls.Count > 0)
{
toolResults = await ExecuteToolCallsAsync(assistant, abort).ConfigureAwait(false);
newMessages.AddRange(toolResults);
}
var turnEnd = await _runner.EmitAsync(new TurnEndEvent(turnIndex, assistant, toolResults), CancellationToken.None).ConfigureAwait(false);
Emit(new AgentSessionEvent.TurnEnd(turnIndex, assistant, toolResults));
var hasMoreToolCalls = toolResults.Count > 0 && !_lastBatchTerminated && !abort.IsCancellationRequested;
pending.AddRange(_steering.Dequeue());
if (hasMoreToolCalls || pending.Count > 0 || turnEnd.ContinueRequested)
{
turnIndex++;
continue;
}
pending.AddRange(_followUp.Dequeue());
if (pending.Count > 0 && !abort.IsCancellationRequested)
{
turnIndex++;
continue;
}
break;
}
Emit(new AgentSessionEvent.AgentEnd(newMessages, WillRetry: false));
await _runner.EmitAsync(new AgentEndEvent(newMessages), CancellationToken.None).ConfigureAwait(false);
if (!abort.IsCancellationRequested && _autoCompaction && _compactor.ShouldCompact(Compactor.ContextTokens(_session.BuildContext()), _model))
await CompactCoreAsync(CompactionReason.Threshold, null, CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Agent run failed");
var failure = new AssistantMessage
{
Provider = _model?.Provider ?? "", Api = _model?.Api ?? "", Model = _model?.Id ?? "",
StopReason = abort.IsCancellationRequested ? StopReason.Aborted : StopReason.Error,
ErrorMessage = ex.Message,
};
try { await AppendAndEmitAsync(failure, CancellationToken.None).ConfigureAwait(false); } catch (Exception) { }
Emit(new AgentSessionEvent.AgentEnd([.. newMessages, failure], WillRetry: false));
}
finally
{
_systemPromptForRun = null;
await _runner.EmitAsync(new AgentSettledEvent(), CancellationToken.None).ConfigureAwait(false);
Emit(new AgentSessionEvent.AgentSettled());
}
}
/// <summary>Stream one assistant message, retrying transient failures and compacting once on context overflow.</summary>
private async Task<AssistantMessage> StreamWithRetryAsync(CancellationToken abort)
{
var attempt = 0;
var compactedForOverflow = false;
while (true)
{
var assistant = await StreamOnceAsync(abort).ConfigureAwait(false);
if (attempt > 0)
Emit(new AgentSessionEvent.AutoRetryEnd(!assistant.IsFailure, attempt, assistant.IsFailure ? assistant.ErrorMessage : null));
if (!assistant.IsFailure || assistant.StopReason == StopReason.Aborted || abort.IsCancellationRequested) return assistant;
if (RetryPolicy.IsOverflow(assistant.ErrorMessage) && !compactedForOverflow && _autoCompaction)
{
compactedForOverflow = true;
var result = await CompactCoreAsync(CompactionReason.Overflow, null, abort, willRetry: true).ConfigureAwait(false);
if (result is not null) continue;
return assistant;
}
if (_retry.Enabled && attempt < _retry.MaxAttempts && RetryPolicy.IsRetryable(assistant.ErrorMessage))
{
attempt++;
var delay = RetryPolicy.DelayFor(_retry, attempt);
Emit(new AgentSessionEvent.AutoRetryStart(attempt, _retry.MaxAttempts, delay, assistant.ErrorMessage ?? ""));
try { await Task.Delay(delay, abort).ConfigureAwait(false); }
catch (OperationCanceledException) { return assistant with { StopReason = StopReason.Aborted }; }
continue;
}
return assistant;
}
}
/// <summary>One provider call: context hooks, streaming events, message_end hook, persistence.</summary>
private async Task<AssistantMessage> StreamOnceAsync(CancellationToken abort)
{
var model = _model!;
var messages = _session.BuildContext().ToList();
var contextEvent = await _runner.EmitAsync(new ContextEvent(messages), abort).ConfigureAwait(false);
var context = new LlmContext(_systemPromptForRun, contextEvent.Messages, _tools.ActiveDescriptors());
var options = new StreamOptions
{
Reasoning = _thinkingLevel == ThinkingLevel.Off ? null : _thinkingLevel,
SessionId = SessionId,
OnPayload = _runner.HasHandlers<BeforeProviderRequestEvent>()
? async payload => (await _runner.EmitAsync(new BeforeProviderRequestEvent(payload), abort).ConfigureAwait(false)).Payload
: null,
};
AssistantMessage? final = null;
var started = false;
var extensionUpdates = _runner.HasHandlers<MessageUpdateEvent>();
await foreach (var e in _models.StreamAsync(model, context, options, abort).ConfigureAwait(false))
{
switch (e)
{
case AssistantMessageEvent.Start:
started = true;
Emit(new AgentSessionEvent.MessageStart(e.Partial));
await _runner.EmitAsync(new MessageStartEvent(e.Partial), CancellationToken.None).ConfigureAwait(false);
break;
case AssistantMessageEvent.Done done:
final = done.Message;
break;
case AssistantMessageEvent.Error error:
final = error.Message;
break;
default:
if (!started)
{
started = true;
Emit(new AgentSessionEvent.MessageStart(e.Partial));
await _runner.EmitAsync(new MessageStartEvent(e.Partial), CancellationToken.None).ConfigureAwait(false);
}
Emit(new AgentSessionEvent.MessageUpdate(e.Partial, e));
if (extensionUpdates) await _runner.EmitAsync(new MessageUpdateEvent(e.Partial, e), CancellationToken.None).ConfigureAwait(false);
break;
}
}
final ??= new AssistantMessage { Provider = model.Provider, Api = model.Api, Model = model.Id, StopReason = StopReason.Error, ErrorMessage = "Provider produced no terminal event." };
if (!started)
{
Emit(new AgentSessionEvent.MessageStart(final with { StopReason = StopReason.Pending }));
await _runner.EmitAsync(new MessageStartEvent(final), CancellationToken.None).ConfigureAwait(false);
}
if (final.Usage.Cost == Cost.Zero && final.Usage.TotalTokens > 0)
final = final with { Usage = final.Usage with { Cost = model.CalculateCost(final.Usage) } };
return (AssistantMessage)await AppendAndEmitAsync(final, CancellationToken.None, emitStart: false).ConfigureAwait(false);
}
/// <summary>Runs the <see cref="MessageEndEvent"/> hook, persists the message and emits message_start/message_end.</summary>
private async Task<Message> AppendAndEmitAsync(Message message, CancellationToken cancellationToken, bool emitStart = true)
{
if (emitStart)
{
Emit(new AgentSessionEvent.MessageStart(message));
await _runner.EmitAsync(new MessageStartEvent(message), CancellationToken.None).ConfigureAwait(false);
}
var hook = await _runner.EmitAsync(new MessageEndEvent(message), CancellationToken.None).ConfigureAwait(false);
var finalMessage = hook.Message.Role == message.Role ? hook.Message : message;
if (!ReferenceEquals(hook.Message, message) && hook.Message.Role != message.Role)
_runner.Report(new ExtensionError("message_end", "message_end", new InvalidOperationException($"Replacement message must keep role '{message.Role}'.")));
await _session.AppendMessageAsync(finalMessage, cancellationToken).ConfigureAwait(false);
Emit(new AgentSessionEvent.MessageEnd(finalMessage));
return finalMessage;
}
/// <summary>Summarise older messages into a compaction entry. Returns null when cancelled, empty or failed.</summary>
private async Task<CompactionResult?> CompactCoreAsync(CompactionReason reason, string? customInstructions, CancellationToken cancellationToken, bool willRetry = false)
{
if (_model is null) return null;
_isCompacting = true;
Emit(new AgentSessionEvent.CompactionStart(reason));
try
{
var preparation = _compactor.Prepare(_session);
if (preparation.EntriesToSummarize.Count == 0)
{
Emit(new AgentSessionEvent.CompactionEnd(reason, null, Aborted: false, willRetry, "Nothing to compact."));
return null;
}
var before = await _runner.EmitAsync(new SessionBeforeCompactEvent(preparation, reason, customInstructions), cancellationToken).ConfigureAwait(false);
if (before.Cancelled)
{
Emit(new AgentSessionEvent.CompactionEnd(reason, null, Aborted: true, willRetry, null));
return null;
}
var fromExtension = before.Override is not null;
var result = before.Override ?? await _compactor.SummarizeAsync(preparation, _model, customInstructions, cancellationToken).ConfigureAwait(false);
var entry = await _session.AppendCompactionAsync(result.Summary, result.FirstKeptEntryId, result.TokensBefore, result.Details, result.Usage, cancellationToken).ConfigureAwait(false);
await _runner.EmitAsync(new SessionCompactEvent(entry, reason, fromExtension), CancellationToken.None).ConfigureAwait(false);
Emit(new AgentSessionEvent.EntryAppended(entry));
Emit(new AgentSessionEvent.CompactionEnd(reason, result, Aborted: false, willRetry, null));
return result;
}
catch (OperationCanceledException)
{
Emit(new AgentSessionEvent.CompactionEnd(reason, null, Aborted: true, willRetry, null));
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Compaction failed");
Emit(new AgentSessionEvent.CompactionEnd(reason, null, Aborted: false, willRetry, ex.Message));
return null;
}
finally { _isCompacting = false; }
}
}

View File

@@ -0,0 +1,168 @@
using System.Text.Json;
using Volcanic.AgenticCore.Abstractions.Events;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
using Volcanic.AgenticCore.Abstractions.Json;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Tools;
using Volcanic.AgenticCore.Features.Tools;
using Volcanic.AgenticCore.Features.Tools.BuiltIn;
namespace Volcanic.AgenticCore.Features.Agent;
public sealed partial class AgentSession
{
private sealed record PreparedCall(ToolCallContent Call, ITool? Tool, JsonElement Args, ToolResult? Immediate);
private sealed record ExecutedCall(ToolCallContent Call, ToolResult Result, bool IsError);
/// <summary>True when every tool in the last batch asked to terminate the run (<see cref="ToolResult.Terminate"/>).</summary>
private bool _lastBatchTerminated;
/// <summary>
/// Runs the tool calls of one assistant message: preparation (validation, <see cref="ToolCallEvent"/>) in source order,
/// execution in parallel unless something asks for sequential, <see cref="ToolResultEvent"/> and
/// <c>tool_execution_end</c> in completion order, tool result messages appended in source order.
/// </summary>
private async Task<List<ToolResultMessage>> ExecuteToolCallsAsync(AssistantMessage assistant, CancellationToken abort)
{
var prepared = new List<PreparedCall>();
foreach (var call in assistant.ToolCalls)
{
Emit(new AgentSessionEvent.ToolExecutionStart(call.Id, call.Name, call.Arguments));
await _runner.EmitAsync(new ToolExecutionStartEvent(call.Id, call.Name, call.Arguments), CancellationToken.None).ConfigureAwait(false);
if (assistant.StopReason == StopReason.Length)
{
prepared.Add(new PreparedCall(call, null, call.Arguments, ErrorResult("The response hit the output token limit before the tool call was complete. Re-issue the tool call.")));
continue;
}
var tool = _tools.Get(call.Name);
if (tool is null)
{
prepared.Add(new PreparedCall(call, null, call.Arguments, ErrorResult($"Tool '{call.Name}' not found. Available tools: {string.Join(", ", _tools.ActiveNames)}")));
continue;
}
var validation = ArgumentValidator.Validate(tool.Parameters, call.Arguments);
if (validation is not null)
{
prepared.Add(new PreparedCall(call, tool, call.Arguments, ErrorResult($"Invalid arguments for '{call.Name}': {validation}")));
continue;
}
var hook = await _runner.EmitAsync(new ToolCallEvent(call.Id, call.Name, VolcanicJson.ToObject(call.Arguments)), CancellationToken.None).ConfigureAwait(false);
if (hook.IsBlocked)
{
prepared.Add(new PreparedCall(call, tool, call.Arguments, ErrorResult(hook.BlockReason ?? "Blocked by extension")));
continue;
}
var args = VolcanicJson.ToElement(hook.Input);
if (abort.IsCancellationRequested)
{
prepared.Add(new PreparedCall(call, tool, args, ErrorResult("Operation aborted")));
continue;
}
prepared.Add(new PreparedCall(call, tool, args, null));
}
var sequential = _options.ToolExecution == ToolExecutionMode.Sequential
|| prepared.Any(p => p.Tool?.ExecutionMode == ToolExecutionMode.Sequential);
var executed = new Dictionary<string, ExecutedCall>();
if (sequential)
{
foreach (var p in prepared)
{
var result = await RunOneAsync(p, abort).ConfigureAwait(false);
executed[p.Call.Id] = result;
if (abort.IsCancellationRequested && p != prepared[^1])
{
foreach (var rest in prepared.SkipWhile(x => x != p).Skip(1))
executed[rest.Call.Id] = await RunOneAsync(rest with { Immediate = ErrorResult("Operation aborted") }, abort).ConfigureAwait(false);
break;
}
}
}
else
{
var results = await Task.WhenAll(prepared.Select(p => RunOneAsync(p, abort))).ConfigureAwait(false);
foreach (var r in results) executed[r.Call.Id] = r;
}
_lastBatchTerminated = prepared.Count > 0 && prepared.All(p => executed[p.Call.Id].Result.Terminate);
var messages = new List<ToolResultMessage>();
foreach (var p in prepared)
{
var e = executed[p.Call.Id];
var message = new ToolResultMessage
{
ToolCallId = p.Call.Id,
ToolName = p.Call.Name,
Content = e.Result.Content,
Details = e.Result.Details,
Usage = e.Result.Usage,
IsError = e.IsError,
};
await AppendAndEmitAsync(message, CancellationToken.None).ConfigureAwait(false);
messages.Add(message);
}
return messages;
}
private async Task<ExecutedCall> RunOneAsync(PreparedCall p, CancellationToken abort)
{
ToolResult result;
bool isError;
if (p.Immediate is not null)
{
result = p.Immediate;
isError = true;
}
else
{
var progress = new ToolProgress(partial =>
{
Emit(new AgentSessionEvent.ToolExecutionUpdate(p.Call.Id, p.Call.Name, p.Args, partial));
_ = _runner.EmitAsync(new ToolExecutionUpdateEvent(p.Call.Id, p.Call.Name, p.Args, partial), CancellationToken.None);
});
var invocation = new ToolInvocation(p.Call.Id, _runner.CreateContext(), progress);
var call = p.Call with { Arguments = p.Args };
try
{
result = await p.Tool!.ExecuteAsync(call, invocation, abort).ConfigureAwait(false);
isError = false;
}
catch (ToolExecutionException tex)
{
result = tex.Result;
isError = true;
}
catch (OperationCanceledException) when (abort.IsCancellationRequested)
{
result = ErrorResult("Operation aborted");
isError = true;
}
catch (Exception ex)
{
result = ErrorResult(ex.Message);
isError = true;
}
}
var hook = await _runner.EmitAsync(new ToolResultEvent(p.Call.Id, p.Call.Name, p.Args, result.Content.ToList(), result.Details, isError, result.Usage), CancellationToken.None).ConfigureAwait(false);
result = result with { Content = hook.Content, Details = hook.Details, Usage = hook.Usage };
isError = hook.IsError;
Emit(new AgentSessionEvent.ToolExecutionEnd(p.Call.Id, p.Call.Name, result, isError));
await _runner.EmitAsync(new ToolExecutionEndEvent(p.Call.Id, p.Call.Name, result, isError), CancellationToken.None).ConfigureAwait(false);
return new ExecutedCall(p.Call, result, isError);
}
private static ToolResult ErrorResult(string message) => ToolResult.Text(message);
private sealed class ToolProgress(Action<ToolResult> report) : IProgress<ToolResult>
{
public void Report(ToolResult value) => report(value);
}
}

View File

@@ -0,0 +1,378 @@
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Events;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Abstractions.Sessions;
using Volcanic.AgenticCore.Abstractions.Ui;
using Volcanic.AgenticCore.Features.Commands;
using Volcanic.AgenticCore.Features.Compaction;
using Volcanic.AgenticCore.Features.Extensions;
using Volcanic.AgenticCore.Features.Models;
using Volcanic.AgenticCore.Features.Sessions;
using Volcanic.AgenticCore.Features.SystemPrompt;
using Volcanic.AgenticCore.Features.Tools;
using Volcanic.AgenticCore.Hosting;
namespace Volcanic.AgenticCore.Features.Agent;
public sealed record AgentSessionState(
string SessionId,
string Cwd,
Model? Model,
ThinkingLevel ThinkingLevel,
bool IsStreaming,
bool IsCompacting,
int MessageCount,
IReadOnlyList<string> PendingSteering,
IReadOnlyList<string> PendingFollowUp,
string? SessionName,
bool AutoCompaction,
IReadOnlyList<ExtensionLoadError> ExtensionErrors);
/// <summary>
/// One conversation: the facade a host (UI) talks to. Owns the session tree, the extensions, the tool and model
/// registries, and runs the agent loop. Create it through <see cref="IAgentSessionFactory"/>.
/// </summary>
public sealed partial class AgentSession : IAsyncDisposable
{
private readonly ISessionStore _store;
private readonly ExtensionRunner _runner;
private readonly ToolRegistry _tools;
private readonly CommandRegistry _commands;
private readonly ModelRegistry _models;
private readonly Compactor _compactor;
private readonly SystemPromptBuilder _promptBuilder;
private readonly AgentSessionOptions _options;
private readonly AgenticCoreOptions _coreOptions;
private readonly RetrySettings _retry;
private readonly ILogger _logger;
private readonly IUserInterface _ui;
private readonly PendingMessageQueue _steering;
private readonly PendingMessageQueue _followUp;
private readonly List<CustomMessage> _nextTurnMessages = [];
private readonly List<Action<AgentSessionEvent>> _subscribers = [];
private readonly SemaphoreSlim _runGate = new(1, 1);
private readonly Lock _stateLock = new();
private SessionManager _session;
private Model? _model;
private ThinkingLevel _thinkingLevel;
private bool _autoCompaction;
private CancellationTokenSource? _abortCts;
private Task? _runTask;
private bool _isCompacting;
private CompactOptions? _pendingCompaction;
private string? _systemPromptForRun;
private bool _disposed;
internal AgentSession(
SessionManager session, ISessionStore store, ExtensionRunner runner, ToolRegistry tools, CommandRegistry commands,
ModelRegistry models, Compactor compactor, SystemPromptBuilder promptBuilder, AgentSessionOptions options,
AgenticCoreOptions coreOptions, Model? model, ThinkingLevel thinkingLevel, ILogger logger)
{
_session = session;
_store = store;
_runner = runner;
_tools = tools;
_commands = commands;
_models = models;
_compactor = compactor;
_promptBuilder = promptBuilder;
_options = options;
_coreOptions = coreOptions;
_retry = options.Retry ?? coreOptions.Retry;
_logger = logger;
_ui = options.Ui ?? NullUserInterface.Instance;
_steering = new PendingMessageQueue(options.SteeringMode);
_followUp = new PendingMessageQueue(options.FollowUpMode);
_model = model;
_thinkingLevel = thinkingLevel;
_autoCompaction = options.AutoCompaction ?? coreOptions.Compaction.Enabled;
_runner.OnError += error => Emit(new AgentSessionEvent.ExtensionError(error.ExtensionName, error.Event, error.Exception.Message));
}
// ---- State ----
public string SessionId => _session.SessionId;
public string Cwd => _options.Cwd;
public Model? Model => _model;
public ThinkingLevel ThinkingLevel => _thinkingLevel;
public bool IsStreaming { get { lock (_stateLock) return _runTask is { IsCompleted: false }; } }
public bool IsCompacting => _isCompacting;
public IReadOnlyList<Message> Messages => _session.GetMessages();
public IReadOnlySessionManager SessionManager => _session;
public IModelRegistry Models => _models;
public IReadOnlyList<ToolInfo> Tools => _tools.All;
public IReadOnlyList<string> ActiveTools => _tools.ActiveNames;
public IReadOnlyList<CommandInfo> Commands => _commands.All;
public IReadOnlyList<ExtensionLoadError> ExtensionErrors => _runner.LoadErrors;
public IReadOnlyList<string> LoadedExtensions => _runner.LoadedExtensions;
public string? SessionName => _session.Name;
public bool AutoCompaction { get => _autoCompaction; set => _autoCompaction = value; }
public AgentSessionState State => new(
SessionId, Cwd, _model, _thinkingLevel, IsStreaming, _isCompacting, _session.GetMessages().Count,
_steering.Texts(), _followUp.Texts(), _session.Name, _autoCompaction, _runner.LoadErrors);
public string SystemPrompt => _systemPromptForRun ?? _promptBuilder.Render(_promptBuilder.DefaultOptions(_options.SystemPrompt ?? _coreOptions.DefaultSystemPrompt));
// ---- Events ----
/// <summary>Subscribe to session events. Handlers run synchronously on the loop; keep them fast and hand off to your own queue.</summary>
public IDisposable Subscribe(Action<AgentSessionEvent> handler)
{
lock (_subscribers) _subscribers.Add(handler);
return new Subscription(() => { lock (_subscribers) _subscribers.Remove(handler); });
}
private void Emit(AgentSessionEvent e)
{
Action<AgentSessionEvent>[] snapshot;
lock (_subscribers) snapshot = _subscribers.ToArray();
foreach (var handler in snapshot)
{
try { handler(e); }
catch (Exception ex) { _logger.LogError(ex, "Session event subscriber failed for {Event}", e.GetType().Name); }
}
}
// ---- Prompting ----
/// <summary>
/// Send user input. Slash commands run immediately (even while streaming). While a run is active,
/// <paramref name="behavior"/> decides whether the text steers the current run or follows up after it;
/// without it the call throws. When idle, starts a run and returns after the run settles.
/// </summary>
public async Task PromptAsync(string text, IReadOnlyList<ImageContent>? images = null, StreamingBehavior? behavior = null, CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
if (CommandRegistry.Parse(text) is { } parsed && _commands.Get(parsed.Name) is { } command)
{
try
{
await command.Handler(parsed.Args, _runner.CreateCommandContext(), cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_runner.Report(new ExtensionError("command:" + parsed.Name, "command", ex));
}
return;
}
var input = await _runner.EmitAsync(new InputEvent(text, images, InputSource.User, behavior), cancellationToken).ConfigureAwait(false);
if (input.Handled) return;
if (string.IsNullOrWhiteSpace(input.Text) && input.Images is not { Count: > 0 }) return;
var message = UserMessage.From(input.Text, input.Images);
if (IsStreaming)
{
switch (behavior)
{
case StreamingBehavior.Steer: Steer(message); return;
case StreamingBehavior.FollowUp: FollowUp(message); return;
default: throw new InvalidOperationException("The agent is streaming; pass StreamingBehavior.Steer or StreamingBehavior.FollowUp to queue the prompt.");
}
}
await StartRunAsync(message, images, cancellationToken).ConfigureAwait(false);
}
/// <summary>Queue a steering message; delivered after the current turn's tools, before the next model call.</summary>
public Task SteerAsync(string text, IReadOnlyList<ImageContent>? images = null)
{
Steer(UserMessage.From(text, images));
return Task.CompletedTask;
}
/// <summary>Queue a follow-up; delivered when the agent has no more tool calls or steering messages.</summary>
public Task FollowUpAsync(string text, IReadOnlyList<ImageContent>? images = null)
{
FollowUp(UserMessage.From(text, images));
return Task.CompletedTask;
}
private void Steer(Message message)
{
_steering.Enqueue(message);
Emit(new AgentSessionEvent.QueueUpdate(_steering.Texts(), _followUp.Texts()));
if (!IsStreaming) _ = StartRunAsync(null, null, CancellationToken.None);
}
private void FollowUp(Message message)
{
_followUp.Enqueue(message);
Emit(new AgentSessionEvent.QueueUpdate(_steering.Texts(), _followUp.Texts()));
if (!IsStreaming) _ = StartRunAsync(null, null, CancellationToken.None);
}
/// <summary>Remove queued steering and follow-up messages; returns their text so a UI can restore it.</summary>
public (IReadOnlyList<string> Steering, IReadOnlyList<string> FollowUp) ClearQueue()
{
var s = _steering.Clear().Select(m => m.Text).ToList();
var f = _followUp.Clear().Select(m => m.Text).ToList();
Emit(new AgentSessionEvent.QueueUpdate([], []));
return (s, f);
}
/// <summary>Abort the current run (and compaction) and wait until the session is idle.</summary>
public async Task AbortAsync()
{
CancellationTokenSource? cts;
Task? run;
lock (_stateLock) { cts = _abortCts; run = _runTask; }
cts?.Cancel();
if (run is not null)
{
try { await run.ConfigureAwait(false); } catch (Exception) { /* surfaced through events */ }
}
}
public async Task WaitForIdleAsync(CancellationToken cancellationToken = default)
{
while (true)
{
Task? run;
lock (_stateLock) run = _runTask;
if (run is null || run.IsCompleted) return;
try { await run.WaitAsync(cancellationToken).ConfigureAwait(false); } catch (Exception) when (!cancellationToken.IsCancellationRequested) { }
}
}
// ---- Model and thinking ----
/// <summary>Switch model. Returns false when the provider has no usable credentials.</summary>
public async Task<bool> SetModelAsync(Model model, CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
if (!_models.IsAvailable(model)) return false;
var previous = _model;
_model = model;
await _session.AppendModelChangeAsync(model, cancellationToken).ConfigureAwait(false);
var clamped = ThinkingLevels.Clamp(model, _thinkingLevel);
if (clamped != _thinkingLevel) await SetThinkingLevelAsync(clamped, cancellationToken).ConfigureAwait(false);
await _runner.EmitAsync(new ModelSelectEvent(model, previous, ModelSelectSource.Set), cancellationToken).ConfigureAwait(false);
Emit(new AgentSessionEvent.ModelChanged(model));
return true;
}
/// <summary>Resolve <c>provider/model-id</c> and switch. Returns false when unknown or unavailable.</summary>
public Task<bool> SetModelAsync(string key, CancellationToken cancellationToken = default)
{
var model = _models.Find(key);
return model is null ? Task.FromResult(false) : SetModelAsync(model, cancellationToken);
}
public async Task SetThinkingLevelAsync(ThinkingLevel level, CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
var effective = _model is null ? level : ThinkingLevels.Clamp(_model, level);
var previous = _thinkingLevel;
if (effective == previous) return;
_thinkingLevel = effective;
await _session.AppendThinkingLevelChangeAsync(effective, cancellationToken).ConfigureAwait(false);
await _runner.EmitAsync(new ThinkingLevelSelectEvent(effective, previous), cancellationToken).ConfigureAwait(false);
Emit(new AgentSessionEvent.ThinkingLevelChanged(effective));
}
public IReadOnlyList<ThinkingLevel> AvailableThinkingLevels => _model is null ? [ThinkingLevel.Off] : ThinkingLevels.Supported(_model);
public void SetActiveTools(IEnumerable<string> names) => _tools.SetActive(names);
// ---- Session management ----
public async Task SetSessionNameAsync(string? name, CancellationToken cancellationToken = default)
{
await _session.AppendSessionInfoAsync(name, cancellationToken).ConfigureAwait(false);
await _runner.EmitAsync(new SessionInfoChangedEvent(name), cancellationToken).ConfigureAwait(false);
Emit(new AgentSessionEvent.SessionInfoChanged(name));
}
/// <summary>Start a fresh session in the same working directory. Extensions may cancel.</summary>
public Task<SwitchResult> NewSessionAsync(NewSessionOptions? options = null, CancellationToken cancellationToken = default) =>
ReplaceSessionAsync(SessionStartReason.New, SessionShutdownReason.New, null, options?.ParentSessionId, cancellationToken);
/// <summary>Resume another session from the store. Extensions may cancel.</summary>
public Task<SwitchResult> SwitchSessionAsync(string sessionId, CancellationToken cancellationToken = default) =>
ReplaceSessionAsync(SessionStartReason.Resume, SessionShutdownReason.Resume, sessionId, null, cancellationToken);
public Task<IReadOnlyList<SessionSummary>> ListSessionsAsync(CancellationToken cancellationToken = default) =>
_store.ListAsync(_session.Scope, cancellationToken);
private async Task<SwitchResult> ReplaceSessionAsync(SessionStartReason startReason, SessionShutdownReason shutdownReason, string? targetSessionId, string? parentSessionId, CancellationToken cancellationToken)
{
ThrowIfDisposed();
var before = await _runner.EmitAsync(new SessionBeforeSwitchEvent(startReason, targetSessionId), cancellationToken).ConfigureAwait(false);
if (before.Cancelled) return new SwitchResult(true);
SessionManager next;
if (targetSessionId is not null)
{
next = await Sessions.SessionManager.OpenAsync(_store, _session.Scope, targetSessionId, cancellationToken).ConfigureAwait(false)
?? throw new KeyNotFoundException($"Session '{targetSessionId}' not found.");
}
else
{
next = await Sessions.SessionManager.CreateAsync(_store, _session.Scope, Cwd, parentSessionId, cancellationToken: cancellationToken).ConfigureAwait(false);
}
await AbortAsync().ConfigureAwait(false);
var previousId = _session.SessionId;
await _runner.EmitAsync(new SessionShutdownEvent(shutdownReason, targetSessionId), cancellationToken).ConfigureAwait(false);
_steering.Clear();
_followUp.Clear();
_nextTurnMessages.Clear();
_session = next;
RestoreBranchSettings();
await _runner.EmitAsync(new SessionStartEvent(startReason, previousId), cancellationToken).ConfigureAwait(false);
Emit(new AgentSessionEvent.SessionInfoChanged(_session.Name));
return new SwitchResult(false);
}
/// <summary>Apply the model and thinking level recorded on the branch (used on resume and switch).</summary>
internal void RestoreBranchSettings()
{
var (provider, modelId, level) = _session.GetBranchSettings();
if (provider is not null && modelId is not null && _models.Find(provider, modelId) is { } model) _model = model;
if (level is { } l) _thinkingLevel = _model is null ? l : ThinkingLevels.Clamp(_model, l);
}
// ---- Compaction ----
/// <summary>Compact now when idle; when streaming, compaction runs between turns and this returns null.</summary>
public async Task<CompactionResult?> CompactAsync(string? customInstructions = null, CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
if (IsStreaming)
{
_pendingCompaction = new CompactOptions(customInstructions);
return null;
}
return await CompactCoreAsync(CompactionReason.Manual, customInstructions, cancellationToken).ConfigureAwait(false);
}
public ContextUsage? GetContextUsage()
{
if (_model is null) return null;
var tokens = Compactor.ContextTokens(_session.BuildContext());
return new ContextUsage(tokens, _model.ContextWindow, tokens is null ? null : Math.Round(100.0 * tokens.Value / _model.ContextWindow, 1));
}
// ---- Lifecycle ----
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
await AbortAsync().ConfigureAwait(false);
try { await _runner.EmitAsync(new SessionShutdownEvent(SessionShutdownReason.Quit)).ConfigureAwait(false); }
catch (Exception ex) { _logger.LogError(ex, "session_shutdown failed"); }
_runner.Invalidate();
}
private void ThrowIfDisposed()
{
if (_disposed) throw new ObjectDisposedException(nameof(AgentSession));
}
}

View File

@@ -0,0 +1,114 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Abstractions.Sessions;
using Volcanic.AgenticCore.Features.Commands;
using Volcanic.AgenticCore.Features.Compaction;
using Volcanic.AgenticCore.Features.Extensions;
using Volcanic.AgenticCore.Features.Models;
using Volcanic.AgenticCore.Features.Sessions;
using Volcanic.AgenticCore.Features.SystemPrompt;
using Volcanic.AgenticCore.Features.Tools;
using Volcanic.AgenticCore.Hosting;
namespace Volcanic.AgenticCore.Features.Agent;
public interface IAgentSessionFactory
{
/// <summary>Create (or resume, when <see cref="AgentSessionOptions.SessionId"/> is set) a session and activate its extensions.</summary>
Task<AgentSession> CreateAsync(AgentSessionOptions options, CancellationToken cancellationToken = default);
/// <summary>Sessions stored for a working directory (or explicit scope).</summary>
Task<IReadOnlyList<SessionSummary>> ListSessionsAsync(string cwd, string? scope = null, CancellationToken cancellationToken = default);
}
/// <summary>Composes one <see cref="AgentSession"/>: store, registries, extensions, prompt builder and compactor.</summary>
public sealed class AgentSessionFactory : IAgentSessionFactory
{
private readonly IServiceProvider _services;
private readonly AgenticCoreOptions _options;
private readonly ILoggerFactory _loggerFactory;
private readonly ISessionStore _defaultStore;
private readonly VolcanicPaths _paths;
public AgentSessionFactory(IServiceProvider services, IOptions<AgenticCoreOptions> options, ILoggerFactory loggerFactory)
{
_services = services;
_options = options.Value;
_loggerFactory = loggerFactory;
_paths = new VolcanicPaths(_options.AgentDir);
_defaultStore = _options.SessionStoreFactory?.Invoke(services) ?? new FileSessionStore(_paths.SessionsDir);
}
public Task<IReadOnlyList<SessionSummary>> ListSessionsAsync(string cwd, string? scope = null, CancellationToken cancellationToken = default) =>
_defaultStore.ListAsync(scope ?? FileSessionStore.ScopeFor(Path.GetFullPath(cwd)), cancellationToken);
public async Task<AgentSession> CreateAsync(AgentSessionOptions options, CancellationToken cancellationToken = default)
{
var cwd = Path.GetFullPath(options.Cwd);
var store = options.Store ?? _defaultStore;
var scope = options.Scope ?? FileSessionStore.ScopeFor(cwd);
var logger = _loggerFactory.CreateLogger<AgentSession>();
var resumed = options.SessionId is not null;
var session = options.SessionId is null
? await SessionManager.CreateAsync(store, scope, cwd, cancellationToken: cancellationToken).ConfigureAwait(false)
: await SessionManager.OpenAsync(store, scope, options.SessionId, cancellationToken).ConfigureAwait(false)
?? throw new KeyNotFoundException($"Session '{options.SessionId}' not found in scope '{scope}'.");
var tools = new ToolRegistry();
if (_options.IncludeBuiltInTools)
{
var active = options.ActiveTools ?? BuiltInTools.DefaultActive;
foreach (var tool in BuiltInTools.All()) tools.AddBuiltIn(tool, active.Contains(tool.Name));
}
var commands = new CommandRegistry();
var models = new ModelRegistry(new ApiKeyResolver(_paths.AuthFile));
var runner = new ExtensionRunner(tools, commands, models, _loggerFactory);
var registrations = new List<ExtensionRegistration>(_options.Extensions);
if (options.ExtraExtensions is not null) registrations.AddRange(options.ExtraExtensions);
if (_options.DiscoverExtensionAssemblies && options.DiscoverExtensionAssemblies)
{
var loader = new ExtensionLoader(_loggerFactory.CreateLogger<ExtensionLoader>());
var (discovered, errors) = loader.Discover(new ExtensionLoader.DiscoveryOptions(cwd, _paths.AgentDir));
registrations.AddRange(discovered);
foreach (var error in errors) logger.LogWarning("Extension discovery: {Source}: {Error}", error.Source, error.Error);
}
foreach (var registration in registrations)
{
Abstractions.Extensions.IExtension instance;
try { instance = registration.Factory(_services); }
catch (Exception ex)
{
logger.LogError(ex, "Could not construct extension {Name}", registration.Name);
continue;
}
await runner.ActivateAsync(instance, registration.Source, cancellationToken).ConfigureAwait(false);
}
if (options.ActiveTools is not null) tools.SetActive(options.ActiveTools);
var promptBuilder = new SystemPromptBuilder(cwd, () => tools.ActiveTools);
var compactor = new Compactor(_options.Compaction, models);
Model? model = null;
var modelKey = options.Model ?? _options.DefaultModel;
if (modelKey is not null)
{
model = models.Find(modelKey);
if (model is null) logger.LogWarning("Model '{Key}' is not registered; the session starts without a model", modelKey);
}
var thinking = options.ThinkingLevel ?? _options.DefaultThinkingLevel;
var agentSession = new AgentSession(session, store, runner, tools, commands, models, compactor, promptBuilder, options, _options, model, thinking, logger);
runner.Bind(agentSession);
if (resumed) agentSession.RestoreBranchSettings();
else if (model is not null) await session.AppendModelChangeAsync(model, cancellationToken).ConfigureAwait(false);
if (agentSession.Model is { } m && agentSession.ThinkingLevel != ThinkingLevels.Clamp(m, agentSession.ThinkingLevel))
await agentSession.SetThinkingLevelAsync(ThinkingLevels.Clamp(m, agentSession.ThinkingLevel), cancellationToken).ConfigureAwait(false);
await runner.EmitAsync(new SessionStartEvent(resumed ? SessionStartReason.Resume : SessionStartReason.Startup), cancellationToken).ConfigureAwait(false);
return agentSession;
}
}

View File

@@ -0,0 +1,49 @@
using Volcanic.AgenticCore.Abstractions.Messages;
namespace Volcanic.AgenticCore.Features.Agent;
public enum QueueMode { OneAtATime, All }
/// <summary>Steering or follow-up messages waiting for the next delivery point.</summary>
public sealed class PendingMessageQueue(QueueMode mode)
{
private readonly List<Message> _items = [];
private readonly Lock _lock = new();
public QueueMode Mode { get; set; } = mode;
public int Count { get { lock (_lock) return _items.Count; } }
public void Enqueue(Message message)
{
lock (_lock) _items.Add(message);
}
/// <summary>Removes and returns the next batch: one message in <see cref="QueueMode.OneAtATime"/>, everything in <see cref="QueueMode.All"/>.</summary>
public List<Message> Dequeue()
{
lock (_lock)
{
if (_items.Count == 0) return [];
var take = Mode == QueueMode.All ? _items.Count : 1;
var batch = _items.Take(take).ToList();
_items.RemoveRange(0, take);
return batch;
}
}
public List<string> Texts()
{
lock (_lock) return _items.Select(m => m.Text).ToList();
}
public List<Message> Clear()
{
lock (_lock)
{
var all = _items.ToList();
_items.Clear();
return all;
}
}
}

View File

@@ -0,0 +1,38 @@
namespace Volcanic.AgenticCore.Features.Agent;
public sealed record RetrySettings
{
public bool Enabled { get; init; } = true;
public int MaxAttempts { get; init; } = 3;
public TimeSpan BaseDelay { get; init; } = TimeSpan.FromSeconds(2);
public TimeSpan MaxDelay { get; init; } = TimeSpan.FromSeconds(30);
}
/// <summary>Classifies provider errors as transient (retry) or context overflow (compact and retry).</summary>
public static class RetryPolicy
{
private static readonly string[] RetryableMarkers =
[
"429", "500", "502", "503", "504", "529", "overloaded", "rate limit", "rate_limit", "too many requests",
"stream ended before", "connection reset", "econnreset", "timed out", "timeout", "temporarily unavailable",
"server_error", "internal error", "socket hang up", "network error",
];
private static readonly string[] OverflowMarkers =
[
"context_length_exceeded", "prompt is too long", "maximum context length", "context window", "too many tokens",
"input is too long", "exceeds the model's maximum", "request too large",
];
public static bool IsOverflow(string? error) => error is not null && OverflowMarkers.Any(m => error.Contains(m, StringComparison.OrdinalIgnoreCase));
public static bool IsRetryable(string? error) =>
error is not null && !IsOverflow(error) && RetryableMarkers.Any(m => error.Contains(m, StringComparison.OrdinalIgnoreCase));
/// <summary>Exponential backoff: base * 2^(attempt-1), capped.</summary>
public static TimeSpan DelayFor(RetrySettings settings, int attempt)
{
var ms = settings.BaseDelay.TotalMilliseconds * Math.Pow(2, Math.Max(0, attempt - 1));
return TimeSpan.FromMilliseconds(Math.Min(ms, settings.MaxDelay.TotalMilliseconds));
}
}

View File

@@ -0,0 +1,38 @@
namespace Volcanic.AgenticCore.Features.Compaction;
public static class CompactionPrompts
{
public const string SystemPrompt = """
You are a conversation summariser for a coding agent. Produce a compact, factual markdown summary of the conversation
so the agent can continue the work without the original messages. Never invent details. Use exactly these sections:
## Goal
## Constraints & Preferences
## Progress
### Done
### In Progress
### Blocked
## Key Decisions
## Next Steps
## Critical Context
After the sections, list files the agent read and modified as:
<read-files>
one path per line
</read-files>
<modified-files>
one path per line
</modified-files>
""";
public static string UserPrompt(string conversation, string? previousSummary, string? customInstructions)
{
var sb = new System.Text.StringBuilder();
if (previousSummary is not null)
sb.Append("<previous-summary>\n").Append(previousSummary).Append("\n</previous-summary>\n\nUpdate the previous summary with the conversation below.\n\n");
sb.Append("<conversation>\n").Append(conversation).Append("\n</conversation>\n\n");
sb.Append("Write the summary now.");
if (!string.IsNullOrWhiteSpace(customInstructions)) sb.Append("\n\nAdditional instructions: ").Append(customInstructions.Trim());
return sb.ToString();
}
}

View File

@@ -0,0 +1,141 @@
using System.Text;
using Volcanic.AgenticCore.Abstractions.Events;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
using Volcanic.AgenticCore.Abstractions.Json;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Abstractions.Sessions;
using Volcanic.AgenticCore.Features.Sessions;
namespace Volcanic.AgenticCore.Features.Compaction;
public sealed record CompactionSettings(bool Enabled = true, int ReserveTokens = 16_384, int KeepRecentTokens = 20_000);
/// <summary>Decides when to compact and produces the summary that replaces older messages in context.</summary>
public sealed class Compactor
{
private readonly CompactionSettings _settings;
private readonly IModelRegistry _models;
public Compactor(CompactionSettings settings, IModelRegistry models)
{
_settings = settings;
_models = models;
}
public CompactionSettings Settings => _settings;
/// <summary>True when the last known context size exceeds the model's window minus the reserve.</summary>
public bool ShouldCompact(int? contextTokens, Model model) =>
_settings.Enabled && contextTokens is { } t && t > model.ContextWindow - _settings.ReserveTokens;
/// <summary>Tokens used by the current context: last assistant usage when present, otherwise an estimate.</summary>
public static int? ContextTokens(IReadOnlyList<Message> messages)
{
var last = messages.OfType<AssistantMessage>().LastOrDefault(a => !a.IsFailure && a.Usage.TotalTokens > 0);
if (last is null) return messages.Count == 0 ? null : TokenEstimator.Estimate(messages);
var index = messages.ToList().IndexOf(last);
return last.Usage.TotalTokens + TokenEstimator.Estimate(messages.Skip(index + 1));
}
/// <summary>Splits the branch into the part to summarise and the recent part to keep verbatim.</summary>
public CompactionPreparation Prepare(SessionManager session)
{
var branch = session.GetBranch();
var latest = session.LatestCompaction();
var start = latest is null ? 0 : branch.ToList().IndexOf(latest) + 1;
var candidates = branch.Skip(start).ToList();
var messagesInScope = candidates.OfType<MessageEntry>().ToList();
// Walk back from the end until KeepRecentTokens, cutting only before a user/assistant/custom message.
var kept = 0;
var cutIndex = messagesInScope.Count;
for (var i = messagesInScope.Count - 1; i >= 0; i--)
{
var tokens = TokenEstimator.Estimate(messagesInScope[i].Message);
if (kept + tokens > _settings.KeepRecentTokens) break;
kept += tokens;
if (IsCutPoint(messagesInScope[i].Message)) cutIndex = i;
}
// Never summarise everything when the recent window is small: keep at least the last user message onward.
if (cutIndex == messagesInScope.Count)
{
var lastUser = messagesInScope.FindLastIndex(m => m.Message is UserMessage);
cutIndex = lastUser < 0 ? messagesInScope.Count : lastUser;
}
var toSummarize = messagesInScope.Take(cutIndex).Cast<SessionEntry>().ToList();
var toKeep = messagesInScope.Skip(cutIndex).Cast<SessionEntry>().ToList();
var previousSummary = latest?.Summary;
var tokensBefore = ContextTokens(session.BuildContext()) ?? 0;
return new CompactionPreparation(toSummarize, toKeep, toKeep.FirstOrDefault()?.Id, tokensBefore, previousSummary);
}
private static bool IsCutPoint(Message message) => message is UserMessage or AssistantMessage or CustomMessage;
/// <summary>Renders the messages to summarise as a plain transcript.</summary>
public static string Serialize(IEnumerable<SessionEntry> entries, int maxBlockChars = 2_000)
{
var sb = new StringBuilder();
foreach (var message in entries.OfType<MessageEntry>().Select(e => e.Message))
{
switch (message)
{
case UserMessage u:
sb.Append("[User]: ").Append(Cap(u.Text, maxBlockChars)).Append("\n\n");
break;
case CustomMessage c:
sb.Append("[User]: ").Append(Cap(c.Text, maxBlockChars)).Append("\n\n");
break;
case AssistantMessage a:
foreach (var block in a.Content)
{
switch (block)
{
case ThinkingContent t when !string.IsNullOrWhiteSpace(t.Thinking):
sb.Append("[Assistant thinking]: ").Append(Cap(t.Thinking, maxBlockChars)).Append("\n\n");
break;
case TextContent t:
sb.Append("[Assistant]: ").Append(Cap(t.Text, maxBlockChars)).Append("\n\n");
break;
case ToolCallContent call:
sb.Append("[Assistant tool calls]: ").Append(call.Name).Append('(').Append(Cap(call.Arguments.GetRawText(), 500)).Append(")\n\n");
break;
}
}
break;
case ToolResultMessage r:
sb.Append("[Tool result").Append(r.IsError ? " (error)" : "").Append("]: ").Append(Cap(r.Text, maxBlockChars)).Append("\n\n");
break;
}
}
return sb.ToString().TrimEnd();
}
private static string Cap(string text, int max) => text.Length <= max ? text : text[..max] + $"… [{text.Length - max} more chars]";
/// <summary>Ask the model for a summary. Throws on provider failure.</summary>
public async Task<CompactionResult> SummarizeAsync(CompactionPreparation preparation, Model model, string? customInstructions, CancellationToken cancellationToken)
{
var conversation = Serialize(preparation.EntriesToSummarize);
var context = new LlmContext(CompactionPrompts.SystemPrompt,
[UserMessage.FromText(CompactionPrompts.UserPrompt(conversation, preparation.PreviousSummary, customInstructions))], []);
var options = new StreamOptions { MaxTokens = Math.Max(1024, (int)(_settings.ReserveTokens * 0.8)), CacheRetention = CacheRetention.None, Reasoning = ThinkingLevel.Off };
var response = await _models.CompleteAsync(model, context, options, cancellationToken).ConfigureAwait(false);
if (response.IsFailure) throw new InvalidOperationException(response.ErrorMessage ?? "Summarisation failed.");
var summary = response.Text.Trim();
if (summary.Length == 0) throw new InvalidOperationException("Summarisation returned no text.");
var keptTokens = TokenEstimator.Estimate(preparation.EntriesToKeep.OfType<MessageEntry>().Select(e => e.Message));
var details = VolcanicJson.ToElement(new { readFiles = ExtractList(summary, "read-files"), modifiedFiles = ExtractList(summary, "modified-files") });
return new CompactionResult(summary, preparation.FirstKeptEntryId, preparation.TokensBefore, TokenEstimator.Estimate(summary) + keptTokens, response.Usage, details);
}
private static string[] ExtractList(string summary, string tag)
{
var open = summary.IndexOf($"<{tag}>", StringComparison.Ordinal);
var close = summary.IndexOf($"</{tag}>", StringComparison.Ordinal);
if (open < 0 || close < open) return [];
return summary[(open + tag.Length + 2)..close].Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
}

View File

@@ -0,0 +1,29 @@
using Volcanic.AgenticCore.Abstractions.Messages;
namespace Volcanic.AgenticCore.Features.Compaction;
/// <summary>Rough token counts (characters / 4) used when no provider usage is available.</summary>
public static class TokenEstimator
{
public static int Estimate(string text) => (text.Length + 3) / 4;
public static int Estimate(Message message) => message switch
{
AssistantMessage a => a.Content.Sum(EstimateBlock),
UserMessage u => u.Content.Sum(EstimateBlock),
ToolResultMessage t => t.Content.Sum(EstimateBlock),
CustomMessage c => c.Content.Sum(EstimateBlock),
_ => Estimate(message.Text),
};
public static int Estimate(IEnumerable<Message> messages) => messages.Sum(Estimate);
private static int EstimateBlock(ContentBlock block) => block switch
{
TextContent t => Estimate(t.Text),
ThinkingContent th => Estimate(th.Thinking),
ToolCallContent tc => Estimate(tc.Name) + Estimate(tc.Arguments.GetRawText()),
ImageContent => 1_000,
_ => 0,
};
}

View File

@@ -0,0 +1,20 @@
namespace Volcanic.AgenticCore.Features.SystemPrompt;
public static class DefaultPrompts
{
public const string BasePrompt = """
You are an expert software engineering assistant operating inside VolcanicAgenticCore, an agent harness.
You work in the user's working directory using the tools listed below. Be direct and concise.
Explain non-trivial changes briefly: problem, then what you did. Do not invent files or APIs; read before you edit.
When a task needs several steps, do them without asking for permission unless an action is destructive or irreversible.
""";
public static readonly IReadOnlyList<string> BaseGuidelines =
[
"Read files before editing them and keep edits minimal and targeted.",
"Run the project's build or tests after code changes when a tool for that exists.",
"Report failures honestly, including error output, instead of claiming success.",
];
public static readonly IReadOnlyList<string> ProjectContextFiles = ["AGENTS.md", "CLAUDE.md", ".volcanic/AGENTS.md"];
}

View File

@@ -0,0 +1,88 @@
using System.Text;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
using Volcanic.AgenticCore.Abstractions.Tools;
namespace Volcanic.AgenticCore.Features.SystemPrompt;
/// <summary>
/// Composes the system prompt: base prompt, tools section (from <see cref="ITool.PromptSnippet"/>), guidelines,
/// extra sections, project context files (nearest AGENTS.md / CLAUDE.md walking up from cwd), then date and cwd.
/// </summary>
public sealed class SystemPromptBuilder
{
public const int MaxProjectContextBytes = 32 * 1024;
private readonly string _cwd;
private readonly Func<IReadOnlyList<ITool>> _activeTools;
private readonly Func<DateTimeOffset> _now;
public SystemPromptBuilder(string cwd, Func<IReadOnlyList<ITool>> activeTools, Func<DateTimeOffset>? now = null)
{
_cwd = cwd;
_activeTools = activeTools;
_now = now ?? (() => DateTimeOffset.Now);
}
public SystemPromptOptions DefaultOptions(string? basePrompt)
{
var options = new SystemPromptOptions { BasePrompt = basePrompt ?? DefaultPrompts.BasePrompt };
options.SelectedTools.AddRange(_activeTools().Select(t => t.Name));
options.Guidelines.AddRange(DefaultPrompts.BaseGuidelines);
return options;
}
public string Render(SystemPromptOptions options)
{
var tools = _activeTools().Where(t => options.SelectedTools.Count == 0 || options.SelectedTools.Contains(t.Name)).ToList();
var sb = new StringBuilder();
sb.AppendLine((options.BasePrompt ?? DefaultPrompts.BasePrompt).Trim());
var snippets = tools.Where(t => !string.IsNullOrWhiteSpace(t.PromptSnippet)).Select(t => $"- {t.PromptSnippet}").ToList();
if (snippets.Count > 0) AppendSection(sb, "tools", "Available tools:\n" + string.Join('\n', snippets));
var guidelines = options.Guidelines.Concat(tools.SelectMany(t => t.PromptGuidelines)).Distinct().Select(g => $"- {g}").ToList();
if (guidelines.Count > 0) AppendSection(sb, "guidelines", string.Join('\n', guidelines));
foreach (var (name, content) in options.Sections)
{
if (!string.IsNullOrWhiteSpace(content)) AppendSection(sb, name, content.Trim());
}
if (options.IncludeProjectContext)
{
var context = LoadProjectContext();
if (context.Length > 0) AppendSection(sb, "project_context", context);
}
AppendSection(sb, "environment", $"Current date: {_now():yyyy-MM-dd}\nWorking directory: {_cwd}");
return sb.ToString().TrimEnd();
}
/// <summary>Concatenates the nearest project context files found walking up from cwd (nearest first), size capped.</summary>
public string LoadProjectContext()
{
var sb = new StringBuilder();
var dir = new DirectoryInfo(_cwd);
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
while (dir is not null && sb.Length < MaxProjectContextBytes)
{
foreach (var name in DefaultPrompts.ProjectContextFiles)
{
var path = Path.Combine(dir.FullName, name);
if (!File.Exists(path) || !seen.Add(path)) continue;
string content;
try { content = File.ReadAllText(path); }
catch (IOException) { continue; }
var remaining = MaxProjectContextBytes - sb.Length;
if (remaining <= 0) break;
if (content.Length > remaining) content = content[..remaining] + "\n[truncated]";
sb.Append("# ").Append(Path.GetRelativePath(_cwd, path)).Append('\n').Append(content.Trim()).Append("\n\n");
}
dir = dir.Parent;
}
return sb.ToString().TrimEnd();
}
private static void AppendSection(StringBuilder sb, string name, string content) =>
sb.Append('\n').Append('<').Append(name).Append(">\n").Append(content).Append("\n</").Append(name).Append(">\n");
}

View File

@@ -0,0 +1,68 @@
using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Abstractions.Sessions;
using Volcanic.AgenticCore.Abstractions.Tools;
using Volcanic.AgenticCore.Abstractions.Ui;
using Volcanic.AgenticCore.Features.Agent;
using Volcanic.AgenticCore.Features.Compaction;
using Volcanic.AgenticCore.Features.Extensions;
namespace Volcanic.AgenticCore.Hosting;
/// <summary>Host-wide configuration, bound once through <c>services.AddAgenticCore(...)</c>.</summary>
public sealed class AgenticCoreOptions
{
/// <summary>Root for sessions, extensions, auth.json and settings.json. Defaults to <c>~/.volcanic/agent</c> or <c>$VOLCANIC_AGENT_DIR</c>.</summary>
public string AgentDir { get; set; } = VolcanicPaths.DefaultAgentDir;
/// <summary>Extensions activated for every session, in order. Earlier registrations win name collisions.</summary>
public List<ExtensionRegistration> Extensions { get; } = [];
/// <summary>Scan <c>&lt;cwd&gt;/.volcanic/extensions</c>, <c>&lt;AgentDir&gt;/extensions</c> and settings files for extension assemblies.</summary>
public bool DiscoverExtensionAssemblies { get; set; } = true;
public bool IncludeBuiltInTools { get; set; } = true;
/// <summary><c>provider/model-id</c> used when a session does not specify one.</summary>
public string? DefaultModel { get; set; }
public ThinkingLevel DefaultThinkingLevel { get; set; } = ThinkingLevel.Off;
/// <summary>Replaces the built-in base prompt (tools, guidelines and project context are still appended).</summary>
public string? DefaultSystemPrompt { get; set; }
public CompactionSettings Compaction { get; set; } = new();
public RetrySettings Retry { get; set; } = new();
/// <summary>Custom persistence. Defaults to JSONL files under <see cref="AgentDir"/>/sessions.</summary>
public Func<IServiceProvider, ISessionStore>? SessionStoreFactory { get; set; }
public void AddExtension<T>() where T : Abstractions.Extensions.IExtension => Extensions.Add(ExtensionRegistration.FromType<T>());
public void AddExtension(Abstractions.Extensions.IExtension instance) => Extensions.Add(ExtensionRegistration.FromInstance(instance));
}
/// <summary>Per-conversation configuration.</summary>
public sealed class AgentSessionOptions
{
public required string Cwd { get; init; }
/// <summary>Resume this session instead of creating a new one.</summary>
public string? SessionId { get; init; }
/// <summary>Store grouping key. Defaults to a value derived from <see cref="Cwd"/>; a chat host can pass a chat id.</summary>
public string? Scope { get; init; }
/// <summary><c>provider/model-id</c>.</summary>
public string? Model { get; init; }
public ThinkingLevel? ThinkingLevel { get; init; }
public string? SystemPrompt { get; init; }
public IUserInterface? Ui { get; init; }
/// <summary>Active tool names. Defaults to read, bash, edit, write.</summary>
public IReadOnlyList<string>? ActiveTools { get; init; }
public ToolExecutionMode ToolExecution { get; init; } = ToolExecutionMode.Parallel;
public QueueMode SteeringMode { get; init; } = QueueMode.OneAtATime;
public QueueMode FollowUpMode { get; init; } = QueueMode.OneAtATime;
public bool? AutoCompaction { get; init; }
public RetrySettings? Retry { get; init; }
public ISessionStore? Store { get; init; }
/// <summary>Extensions for this session only, activated after the host-wide ones.</summary>
public IReadOnlyList<ExtensionRegistration>? ExtraExtensions { get; init; }
public bool DiscoverExtensionAssemblies { get; init; } = true;
}

View File

@@ -0,0 +1,20 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Features.Agent;
namespace Volcanic.AgenticCore.Hosting;
public static class ServiceCollectionExtensions
{
/// <summary>Registers <see cref="IAgentSessionFactory"/> and options. Logging is optional; a null logger is used when none is registered.</summary>
public static IServiceCollection AddAgenticCore(this IServiceCollection services, Action<AgenticCoreOptions>? configure = null)
{
services.AddOptions<AgenticCoreOptions>();
if (configure is not null) services.Configure(configure);
services.TryAddSingleton<ILoggerFactory, Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory>();
services.TryAdd(ServiceDescriptor.Singleton(typeof(ILogger<>), typeof(Logger<>)));
services.TryAddSingleton<IAgentSessionFactory, AgentSessionFactory>();
return services;
}
}

View File

@@ -0,0 +1,15 @@
namespace Volcanic.AgenticCore.Hosting;
/// <summary>Well-known locations. Override <see cref="AgenticCoreOptions.AgentDir"/> to relocate everything.</summary>
public sealed class VolcanicPaths(string agentDir)
{
public static string DefaultAgentDir =>
Environment.GetEnvironmentVariable("VOLCANIC_AGENT_DIR")
?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".volcanic", "agent");
public string AgentDir { get; } = agentDir;
public string SessionsDir => Path.Combine(AgentDir, "sessions");
public string ExtensionsDir => Path.Combine(AgentDir, "extensions");
public string AuthFile => Path.Combine(AgentDir, "auth.json");
public string SettingsFile => Path.Combine(AgentDir, "settings.json");
}

View File

@@ -0,0 +1,155 @@
using System.Runtime.CompilerServices;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Json;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Providers;
namespace Volcanic.AgenticCore.Testing;
/// <summary>One scripted assistant response.</summary>
public sealed record FauxTurn
{
public string? Thinking { get; init; }
public string? Text { get; init; }
public List<(string Name, object Args)> ToolCalls { get; init; } = [];
public StopReason StopReason { get; init; } = StopReason.Stop;
public string? ErrorMessage { get; init; }
public Usage? Usage { get; init; }
public TimeSpan Delay { get; init; }
public static FauxTurn Say(string text) => new() { Text = text };
public static FauxTurn Tool(string name, object args) => new() { ToolCalls = [(name, args)], StopReason = StopReason.ToolUse };
public static FauxTurn Error(string message) => new() { ErrorMessage = message, StopReason = StopReason.Error };
public static FauxTurn Length(string text) => new() { Text = text, StopReason = StopReason.Length };
public FauxTurn WithTool(string name, object args) => this with { ToolCalls = [.. ToolCalls, (name, args)], StopReason = StopReason == StopReason.Stop ? StopReason.ToolUse : StopReason };
public FauxTurn WithThinking(string thinking) => this with { Thinking = thinking };
public FauxTurn WithUsage(int input, int output) => this with { Usage = new Usage(input, output, 0, 0) };
public FauxTurn WithDelay(TimeSpan delay) => this with { Delay = delay };
}
/// <summary>
/// Scripted provider for tests and samples. Register it through <see cref="Extension"/> or <see cref="Registration"/>.
/// Responses are dequeued in order; when the script is empty it echoes a fixed sentence.
/// </summary>
public sealed class FauxProvider
{
public const string ProviderId = "faux";
public const string ModelKey = "faux/echo";
private readonly Queue<FauxTurn> _script = new();
private readonly Lock _lock = new();
public static Model EchoModel { get; } = new()
{
Id = "echo", Name = "Faux Echo", Provider = ProviderId, Api = "faux", Reasoning = true,
Input = [ModelInput.Text, ModelInput.Image], ContextWindow = 32_000, MaxTokens = 4_096,
};
/// <summary>Every context the provider was asked to complete, for assertions.</summary>
public List<LlmContext> Requests { get; } = [];
public List<StreamOptions> Options { get; } = [];
public ProviderRegistration Registration => new()
{
Id = ProviderId,
Name = "Faux",
Api = "faux",
Models = [EchoModel with { Provider = "", Api = "" }],
RequiresApiKey = false,
Stream = StreamAsync,
};
public IExtension Extension => new FauxProviderExtension(this);
public FauxProvider Enqueue(params FauxTurn[] turns)
{
lock (_lock) foreach (var t in turns) _script.Enqueue(t);
return this;
}
public int Remaining { get { lock (_lock) return _script.Count; } }
private async IAsyncEnumerable<AssistantMessageEvent> StreamAsync(Model model, LlmContext context, StreamOptions options, [EnumeratorCancellation] CancellationToken ct)
{
FauxTurn turn;
lock (_lock)
{
Requests.Add(context);
Options.Add(options);
turn = _script.Count > 0 ? _script.Dequeue() : FauxTurn.Say("(faux) no scripted response");
}
var builder = new AssistantMessageBuilder(model);
yield return builder.Start();
if (turn.Delay > TimeSpan.Zero)
{
var cancelled = false;
try { await Task.Delay(turn.Delay, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { cancelled = true; }
if (cancelled) { yield return builder.Fail("aborted", aborted: true); yield break; }
}
if (turn.ErrorMessage is not null)
{
yield return builder.Fail(turn.ErrorMessage);
yield break;
}
if (turn.Thinking is not null)
{
yield return builder.StartThinking();
yield return builder.AppendThinking(turn.Thinking);
yield return builder.EndThinking();
}
if (turn.Text is not null)
{
yield return builder.StartText();
foreach (var chunk in Chunk(turn.Text))
{
if (ct.IsCancellationRequested) { yield return builder.Fail("aborted", aborted: true); yield break; }
yield return builder.AppendText(chunk);
await Task.Yield();
}
yield return builder.EndText();
}
var n = 0;
foreach (var (name, args) in turn.ToolCalls)
{
var json = VolcanicJson.Serialize(args);
yield return builder.StartToolCall($"call_{Guid.NewGuid():N}"[..13] + n++, name);
yield return builder.AppendToolCallDelta(json);
yield return builder.EndToolCall();
}
var input = context.Messages.Sum(m => m.Text.Length / 4) + (context.SystemPrompt?.Length ?? 0) / 4;
builder.SetUsage(turn.Usage ?? new Usage(input, (turn.Text?.Length ?? 0) / 4 + 1, 0, 0));
yield return builder.Done(turn.StopReason == StopReason.ToolUse ? StopReason.Stop : turn.StopReason);
}
private static IEnumerable<string> Chunk(string text)
{
var start = 0;
for (var i = 0; i < text.Length; i++)
{
if (text[i] == ' ' || i == text.Length - 1)
{
yield return text[start..(i + 1)];
start = i + 1;
}
}
}
}
public sealed class FauxProviderExtension(FauxProvider provider) : IExtension
{
public string Name => "faux-provider";
public ValueTask ActivateAsync(IExtensionApi api, CancellationToken cancellationToken)
{
api.RegisterProvider(provider.Registration);
return ValueTask.CompletedTask;
}
}

View File

@@ -0,0 +1,303 @@
using Volcanic.AgenticCore.Abstractions.Events;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Tools;
using Volcanic.AgenticCore.Features.Extensions;
using Volcanic.AgenticCore.Testing;
using Volcanic.AgenticCore.Tests.Support;
namespace Volcanic.AgenticCore.Tests.Features;
public class AgentLoopTests
{
private static ITool Echo(string name, Func<string, Task<string>> run, ToolExecutionMode mode = ToolExecutionMode.Parallel) =>
new DelegateTool<EchoArgs>(name, "echo", async (a, _, _) => ToolResult.Text(await run(a.Value)), executionMode: mode);
public sealed record EchoArgs(string Value);
private static IExtension WithTools(params ITool[] tools) => new LambdaExtension("tools", api =>
{
foreach (var t in tools) api.RegisterTool(t);
return ValueTask.CompletedTask;
});
[Fact]
public async Task Single_text_turn_emits_the_expected_event_sequence()
{
await using var host = new TestHost();
host.Faux.Enqueue(FauxTurn.Say("Hello there friend"));
await using var session = await host.CreateSessionAsync();
await session.PromptAsync("hi");
var names = host.EventNames.ToList();
Assert.Equal("AgentStart", names[0]);
Assert.Equal("TurnStart", names[1]);
Assert.Equal(["MessageStart", "MessageEnd"], names.Skip(2).Take(2)); // user message
Assert.Contains("MessageUpdate", names);
Assert.Equal(["TurnEnd", "AgentEnd", "AgentSettled"], names.TakeLast(3));
Assert.Equal("Hello there friend", session.Messages.OfType<AssistantMessage>().Single().Text);
Assert.Equal(2, session.Messages.Count);
Assert.False(session.IsStreaming);
var request = Assert.Single(host.Faux.Requests);
Assert.Contains("Available tools", request.SystemPrompt);
Assert.Equal("hi", request.Messages.Single().Text);
Assert.Contains(request.Tools, t => t.Name == "bash");
}
[Fact]
public async Task Tool_round_trip_feeds_the_result_into_the_next_call()
{
await using var host = new TestHost(null, WithTools(Echo("shout", v => Task.FromResult(v.ToUpperInvariant()))));
host.Faux.Enqueue(FauxTurn.Say("calling").WithTool("shout", new { value = "abc" }), FauxTurn.Say("done"));
await using var session = await host.CreateSessionAsync();
await session.PromptAsync("go");
Assert.Equal(2, host.Faux.Requests.Count);
var second = host.Faux.Requests[1].Messages;
var result = Assert.IsType<ToolResultMessage>(second[^1]);
Assert.Equal("ABC", result.Text);
Assert.False(result.IsError);
Assert.Equal(["TurnStart", "TurnStart"], host.EventNames.Where(n => n == "TurnStart"));
Assert.Contains(host.Events, e => e is AgentSessionEvent.ToolExecutionStart { ToolName: "shout" });
Assert.Contains(host.Events, e => e is AgentSessionEvent.ToolExecutionEnd { IsError: false });
Assert.Equal(4, session.Messages.Count);
Assert.Equal(StopReason.ToolUse, session.Messages.OfType<AssistantMessage>().First().StopReason);
}
[Fact]
public async Task Unknown_tool_and_invalid_arguments_become_error_results()
{
await using var host = new TestHost(null, WithTools(Echo("shout", v => Task.FromResult(v))));
host.Faux.Enqueue(FauxTurn.Tool("nope", new { }).WithTool("shout", new { value = 5 }), FauxTurn.Say("ok"));
await using var session = await host.CreateSessionAsync();
await session.PromptAsync("go");
var results = session.Messages.OfType<ToolResultMessage>().ToList();
Assert.Equal(2, results.Count);
Assert.True(results[0].IsError);
Assert.Contains("not found", results[0].Text);
Assert.True(results[1].IsError);
Assert.Contains("should be string", results[1].Text);
}
[Fact]
public async Task Parallel_tools_end_in_completion_order_but_results_follow_source_order()
{
var slowGate = new TaskCompletionSource();
await using var host = new TestHost(null, WithTools(
Echo("slow", async v => { await slowGate.Task; return "slow:" + v; }),
Echo("fast", v => Task.FromResult("fast:" + v))));
host.Faux.Enqueue(FauxTurn.Tool("slow", new { value = "1" }).WithTool("fast", new { value = "2" }), FauxTurn.Say("ok"));
await using var session = await host.CreateSessionAsync();
var run = session.PromptAsync("go");
// let "fast" finish before releasing "slow"
await WaitUntil(() => host.Events.OfType<AgentSessionEvent.ToolExecutionEnd>().Any(e => e.ToolName == "fast"));
slowGate.SetResult();
await run;
var ends = host.Events.OfType<AgentSessionEvent.ToolExecutionEnd>().Select(e => e.ToolName).ToList();
Assert.Equal(["fast", "slow"], ends);
var results = session.Messages.OfType<ToolResultMessage>().Select(r => r.ToolName).ToList();
Assert.Equal(["slow", "fast"], results);
}
[Fact]
public async Task Sequential_tool_forces_ordered_execution()
{
var order = new List<string>();
await using var host = new TestHost(null, WithTools(
Echo("a", async v => { await Task.Delay(30); lock (order) order.Add("a"); return v; }, ToolExecutionMode.Sequential),
Echo("b", v => { lock (order) order.Add("b"); return Task.FromResult(v); })));
host.Faux.Enqueue(FauxTurn.Tool("a", new { value = "1" }).WithTool("b", new { value = "2" }), FauxTurn.Say("ok"));
await using var session = await host.CreateSessionAsync();
await session.PromptAsync("go");
Assert.Equal(["a", "b"], order);
}
[Fact]
public async Task Steering_is_delivered_before_the_next_model_call_and_follow_up_after()
{
var gate = new TaskCompletionSource();
await using var host = new TestHost(null, WithTools(Echo("wait", async v => { await gate.Task; return v; })));
host.Faux.Enqueue(FauxTurn.Tool("wait", new { value = "x" }), FauxTurn.Say("after steer"), FauxTurn.Say("after follow-up"));
await using var session = await host.CreateSessionAsync();
var run = session.PromptAsync("start");
await WaitUntil(() => session.IsStreaming && host.Events.OfType<AgentSessionEvent.ToolExecutionStart>().Any());
await session.PromptAsync("steer me", behavior: StreamingBehavior.Steer);
await session.PromptAsync("later", behavior: StreamingBehavior.FollowUp);
Assert.Contains(host.Events, e => e is AgentSessionEvent.QueueUpdate { Steering.Count: 1, FollowUp.Count: 1 });
gate.SetResult();
await run;
Assert.Equal(3, host.Faux.Requests.Count);
Assert.Equal("steer me", host.Faux.Requests[1].Messages[^1].Text);
Assert.Equal("later", host.Faux.Requests[2].Messages[^1].Text);
Assert.Single(host.Events.OfType<AgentSessionEvent.AgentEnd>());
Assert.Equal(["start", "steer me", "later"], session.Messages.OfType<UserMessage>().Select(m => m.Text));
}
[Fact]
public async Task Prompt_while_streaming_without_behavior_throws()
{
await using var host = new TestHost();
host.Faux.Enqueue(FauxTurn.Say("slow").WithDelay(TimeSpan.FromMilliseconds(300)));
await using var session = await host.CreateSessionAsync();
var run = session.PromptAsync("start");
await WaitUntil(() => session.IsStreaming);
await Assert.ThrowsAsync<InvalidOperationException>(() => session.PromptAsync("again"));
await run;
}
[Fact]
public async Task Abort_produces_an_aborted_message_and_settles()
{
await using var host = new TestHost();
host.Faux.Enqueue(FauxTurn.Say("never finishes").WithDelay(TimeSpan.FromSeconds(10)));
await using var session = await host.CreateSessionAsync();
var run = session.PromptAsync("start");
await WaitUntil(() => session.IsStreaming);
await session.AbortAsync();
await run;
var assistant = session.Messages.OfType<AssistantMessage>().Single();
Assert.Equal(StopReason.Aborted, assistant.StopReason);
Assert.Contains(host.Events, e => e is AgentSessionEvent.AgentSettled);
Assert.False(session.IsStreaming);
host.Faux.Enqueue(FauxTurn.Say("recovered"));
await session.PromptAsync("again");
Assert.Equal("recovered", session.Messages.OfType<AssistantMessage>().Last().Text);
Assert.Equal(2, host.Faux.Requests[1].Messages.Count); // the aborted attempt is dropped from context
}
[Fact]
public async Task Transient_errors_are_retried_with_events()
{
await using var host = new TestHost();
host.Faux.Enqueue(FauxTurn.Error("HTTP 529 overloaded"), FauxTurn.Error("HTTP 503"), FauxTurn.Say("third time lucky"));
await using var session = await host.CreateSessionAsync();
await session.PromptAsync("go");
Assert.Equal(3, host.Faux.Requests.Count);
var starts = host.Events.OfType<AgentSessionEvent.AutoRetryStart>().ToList();
Assert.Equal([1, 2], starts.Select(s => s.Attempt));
var ends = host.Events.OfType<AgentSessionEvent.AutoRetryEnd>().ToList();
Assert.Equal([false, true], ends.Select(e => e.Success));
Assert.Equal("third time lucky", session.Messages.OfType<AssistantMessage>().Last().Text);
Assert.Equal(2, session.Messages.OfType<AssistantMessage>().Count(a => a.StopReason == StopReason.Error));
}
[Fact]
public async Task Non_retryable_error_ends_the_run()
{
await using var host = new TestHost();
host.Faux.Enqueue(FauxTurn.Error("invalid api key"));
await using var session = await host.CreateSessionAsync();
await session.PromptAsync("go");
Assert.Single(host.Faux.Requests);
Assert.Equal("invalid api key", session.Messages.OfType<AssistantMessage>().Single().ErrorMessage);
Assert.Equal(["TurnEnd", "AgentEnd", "AgentSettled"], host.EventNames.TakeLast(3));
}
[Fact]
public async Task Length_stop_fails_all_tool_calls()
{
await using var host = new TestHost(null, WithTools(Echo("shout", v => Task.FromResult(v))));
host.Faux.Enqueue(FauxTurn.Length("cut").WithTool("shout", new { value = "x" }), FauxTurn.Say("ok"));
await using var session = await host.CreateSessionAsync();
await session.PromptAsync("go");
var result = session.Messages.OfType<ToolResultMessage>().Single();
Assert.True(result.IsError);
Assert.Contains("output token limit", result.Text);
}
[Fact]
public async Task Overflow_error_compacts_and_retries()
{
await using var host = new TestHost(o => o.Compaction = new Volcanic.AgenticCore.Features.Compaction.CompactionSettings(KeepRecentTokens: 50));
await using var session = await host.CreateSessionAsync();
host.Faux.Enqueue(FauxTurn.Say("first answer " + new string('a', 400)));
await session.PromptAsync("first question " + new string('q', 400));
host.Faux.Enqueue(FauxTurn.Say("second answer"));
await session.PromptAsync("second question");
host.Faux.Enqueue(FauxTurn.Error("prompt is too long: context_length_exceeded"), FauxTurn.Say("## Goal\nsummary text"), FauxTurn.Say("after compaction"));
await session.PromptAsync("third question");
Assert.Contains(host.Events, e => e is AgentSessionEvent.CompactionStart { Reason: CompactionReason.Overflow });
Assert.Contains(host.Events, e => e is AgentSessionEvent.CompactionEnd { Result: not null, WillRetry: true });
Assert.Equal("after compaction", session.Messages.OfType<AssistantMessage>().Last().Text);
var lastContext = host.Faux.Requests[^1].Messages;
Assert.Contains(lastContext, m => m.Text.Contains("summary text"));
}
[Fact]
public async Task Threshold_compaction_runs_between_turns()
{
await using var host = new TestHost(o => o.Compaction = new Volcanic.AgenticCore.Features.Compaction.CompactionSettings(ReserveTokens: 31_000, KeepRecentTokens: 10));
await using var session = await host.CreateSessionAsync();
// The threshold is crossed by the first answer's usage, so compaction runs right after the first run.
host.Faux.Enqueue(FauxTurn.Say("one").WithUsage(2_000, 10), FauxTurn.Say("## Goal\nsummarised"));
await session.PromptAsync("q1 " + new string('x', 400));
host.Faux.Enqueue(FauxTurn.Say("two"));
await session.PromptAsync("q2");
Assert.Contains(host.Events, e => e is AgentSessionEvent.CompactionStart { Reason: CompactionReason.Threshold });
Assert.Contains(host.Faux.Requests[^1].Messages, m => m.Text.Contains("summarised"));
}
[Fact]
public async Task Extension_hooks_shape_the_run()
{
var ext = new LambdaExtension("hooks", api =>
{
api.On<BeforeAgentStartEvent>((e, _, _) => { e.SystemPromptOptions.Sections["extra"] = "be terse"; e.InjectedMessages.Add(CustomMessage.FromText("ctx", "injected")); return ValueTask.CompletedTask; });
api.On<ContextEvent>((e, _, _) => { e.Messages.Insert(0, UserMessage.FromText("from context hook")); return ValueTask.CompletedTask; });
api.On<ToolCallEvent>((e, _, _) => { if (e.ToolName == "bash") e.Block("no shell"); return ValueTask.CompletedTask; });
api.On<ToolResultEvent>((e, _, _) => { e.Content.Add(new TextContent(" [audited]")); return ValueTask.CompletedTask; });
api.On<MessageEndEvent>((e, _, _) => { if (e.Message is AssistantMessage a && a.Text == "final") e.Message = a with { Content = [new TextContent("FINAL")] }; return ValueTask.CompletedTask; });
return ValueTask.CompletedTask;
});
await using var host = new TestHost(null, ext);
host.Faux.Enqueue(FauxTurn.Tool("bash", new { command = "rm -rf /" }), FauxTurn.Say("final"));
await using var session = await host.CreateSessionAsync();
await session.PromptAsync("go");
var first = host.Faux.Requests[0];
Assert.Contains("<extra>\nbe terse\n</extra>", first.SystemPrompt);
Assert.Equal("from context hook", first.Messages[0].Text);
Assert.Contains(first.Messages, m => m.Text == "injected");
var result = session.Messages.OfType<ToolResultMessage>().Single();
Assert.True(result.IsError);
Assert.Equal("no shell [audited]", result.Text);
Assert.Equal("FINAL", session.Messages.OfType<AssistantMessage>().Last().Text);
}
[Fact]
public async Task Terminate_result_stops_the_loop_without_another_model_call()
{
var tool = new DelegateTool<EchoArgs>("finish", "ends", (_, _, _) => Task.FromResult(ToolResult.Text("bye") with { Terminate = true }));
await using var host = new TestHost(null, WithTools(tool));
host.Faux.Enqueue(FauxTurn.Tool("finish", new { value = "x" }));
await using var session = await host.CreateSessionAsync();
await session.PromptAsync("go");
Assert.Single(host.Faux.Requests);
Assert.Contains(host.Events, e => e is AgentSessionEvent.AgentSettled);
}
internal static async Task WaitUntil(Func<bool> condition, int timeoutMs = 5000)
{
var start = Environment.TickCount64;
while (!condition())
{
if (Environment.TickCount64 - start > timeoutMs) throw new TimeoutException("Condition not met.");
await Task.Delay(10);
}
}
}

View File

@@ -0,0 +1,246 @@
using Volcanic.AgenticCore.Abstractions.Events;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Abstractions.Sessions;
using Volcanic.AgenticCore.Abstractions.Tools;
using Volcanic.AgenticCore.Abstractions.Ui;
using Volcanic.AgenticCore.Testing;
using Volcanic.AgenticCore.Tests.Support;
namespace Volcanic.AgenticCore.Tests.Features;
public class AgentSessionTests
{
[Fact]
public async Task Slash_commands_dispatch_even_while_streaming()
{
string? received = null;
var ext = new LambdaExtension("cmd", api =>
{
api.RegisterCommand("echo", new CommandDefinition("echoes", (args, ctx, _) => { received = args + "|" + ctx.IsIdle(); return Task.CompletedTask; }));
return ValueTask.CompletedTask;
});
await using var host = new TestHost(null, ext);
host.Faux.Enqueue(FauxTurn.Say("slow").WithDelay(TimeSpan.FromMilliseconds(200)));
await using var session = await host.CreateSessionAsync();
await session.PromptAsync("/echo hello world");
Assert.Equal("hello world|True", received);
Assert.Empty(host.Faux.Requests);
var run = session.PromptAsync("start");
await AgentLoopTests.WaitUntil(() => session.IsStreaming);
await session.PromptAsync("/echo during");
Assert.Equal("during|False", received);
await run;
Assert.Single(session.Commands);
}
[Fact]
public async Task Command_exceptions_surface_as_extension_errors()
{
var ext = new LambdaExtension("cmd", api =>
{
api.RegisterCommand("boom", new CommandDefinition(null, (_, _, _) => throw new InvalidOperationException("kaboom")));
return ValueTask.CompletedTask;
});
await using var host = new TestHost(null, ext);
await using var session = await host.CreateSessionAsync();
await session.PromptAsync("/boom");
Assert.Contains(host.Events, e => e is AgentSessionEvent.ExtensionError { Error: var err } && err.Contains("kaboom"));
}
[Fact]
public async Task Input_event_can_transform_or_swallow_prompts()
{
var ext = new LambdaExtension("input", api =>
{
api.On<InputEvent>((e, _, _) =>
{
if (e.Text == "secret") e.MarkHandled();
else e.Text = e.Text + " (transformed)";
return ValueTask.CompletedTask;
});
return ValueTask.CompletedTask;
});
await using var host = new TestHost(null, ext);
host.Faux.Enqueue(FauxTurn.Say("ok"));
await using var session = await host.CreateSessionAsync();
await session.PromptAsync("secret");
Assert.Empty(host.Faux.Requests);
await session.PromptAsync("hello");
Assert.Equal("hello (transformed)", host.Faux.Requests.Single().Messages[0].Text);
}
[Fact]
public async Task SetModel_persists_and_unavailable_models_are_rejected()
{
await using var host = new TestHost(o => o.DefaultModel = null);
await using var session = await host.CreateSessionAsync();
Assert.Null(session.Model);
host.Faux.Enqueue(FauxTurn.Say("x"));
await session.PromptAsync("no model yet");
var failure = session.Messages.OfType<AssistantMessage>().Single();
Assert.Equal(StopReason.Error, failure.StopReason);
Assert.Contains("No model selected", failure.ErrorMessage);
Assert.False(await session.SetModelAsync("nope/none"));
Assert.True(await session.SetModelAsync(FauxProvider.ModelKey));
Assert.Equal(FauxProvider.ModelKey, session.Model!.Key);
Assert.Contains(host.Events, e => e is AgentSessionEvent.ModelChanged);
Assert.Contains(session.SessionManager.GetBranch(), e => e is ModelChangeEntry { ModelId: "echo" });
await session.SetThinkingLevelAsync(ThinkingLevel.High);
Assert.Equal(ThinkingLevel.High, session.ThinkingLevel);
host.Faux.Enqueue(FauxTurn.Say("y"));
await session.PromptAsync("now");
Assert.Equal(ThinkingLevel.High, host.Faux.Options[^1].Reasoning);
}
[Fact]
public async Task Sessions_persist_and_resume_with_settings()
{
await using var host = new TestHost();
string id;
await using (var session = await host.CreateSessionAsync())
{
host.Faux.Enqueue(FauxTurn.Say("remembered"));
await session.PromptAsync("remember this");
await session.SetThinkingLevelAsync(ThinkingLevel.Medium);
await session.SetSessionNameAsync("My chat");
id = session.SessionId;
}
await using var resumed = await host.CreateSessionAsync(o => o.SessionId = id);
Assert.Equal(id, resumed.SessionId);
Assert.Equal(["remember this", "remembered"], resumed.Messages.Select(m => m.Text));
Assert.Equal(ThinkingLevel.Medium, resumed.ThinkingLevel);
Assert.Equal("My chat", resumed.SessionName);
Assert.Equal(FauxProvider.ModelKey, resumed.Model!.Key);
host.Faux.Enqueue(FauxTurn.Say("continued"));
await resumed.PromptAsync("and more");
Assert.Equal(4, host.Faux.Requests[^1].Messages.Count + 1);
var list = await resumed.ListSessionsAsync();
Assert.Contains(list, s => s.SessionId == id && s.Name == "My chat");
await Assert.ThrowsAsync<KeyNotFoundException>(() => host.CreateSessionAsync(o => o.SessionId = "missing"));
}
[Fact]
public async Task New_and_switch_session_fire_lifecycle_events_and_can_be_cancelled()
{
var lifecycle = new List<string>();
var cancelNext = false;
var ext = new LambdaExtension("life", api =>
{
api.On<SessionStartEvent>((e, _, _) => { lifecycle.Add("start:" + e.Reason); return ValueTask.CompletedTask; });
api.On<SessionShutdownEvent>((e, _, _) => { lifecycle.Add("shutdown:" + e.Reason); return ValueTask.CompletedTask; });
api.On<SessionBeforeSwitchEvent>((e, _, _) => { if (cancelNext) e.Cancel(); return ValueTask.CompletedTask; });
return ValueTask.CompletedTask;
});
await using var host = new TestHost(null, ext);
await using var session = await host.CreateSessionAsync();
host.Faux.Enqueue(FauxTurn.Say("a"));
await session.PromptAsync("first");
var firstId = session.SessionId;
var result = await session.NewSessionAsync();
Assert.False(result.Cancelled);
Assert.NotEqual(firstId, session.SessionId);
Assert.Empty(session.Messages);
cancelNext = true;
Assert.True((await session.SwitchSessionAsync(firstId)).Cancelled);
cancelNext = false;
Assert.False((await session.SwitchSessionAsync(firstId)).Cancelled);
Assert.Equal(firstId, session.SessionId);
Assert.Equal(2, session.Messages.Count);
Assert.Equal(["start:Startup", "shutdown:New", "start:New", "shutdown:Resume", "start:Resume"], lifecycle);
}
[Fact]
public async Task Extensions_can_reach_the_ui_and_send_messages()
{
var ui = new ScriptedUi { NextSelect = "Allow" };
var ext = new LambdaExtension("ui", api =>
{
api.On<ToolCallEvent>(async (e, ctx, ct) =>
{
Assert.True(ctx.HasUi);
var choice = await ctx.Ui.SelectAsync("Run?", ["Allow", "Deny"], null, ct);
if (choice != "Allow") e.Block("denied");
});
api.RegisterCommand("note", new CommandDefinition(null, (args, _, _) =>
{
api.SendMessage(CustomMessage.FromText("note", args), MessageDelivery.Steer, triggerTurn: true);
api.AppendEntry("bookmark");
return Task.CompletedTask;
}));
return ValueTask.CompletedTask;
});
await using var host = new TestHost(null, ext);
host.Faux.Enqueue(FauxTurn.Tool("bash", new { command = "echo hi" }), FauxTurn.Say("done"));
await using var session = await host.CreateSessionAsync(o => o.Ui = ui);
await session.PromptAsync("go");
Assert.Equal("Run?", ui.LastTitle);
Assert.False(session.Messages.OfType<ToolResultMessage>().Single().IsError);
host.Faux.Enqueue(FauxTurn.Say("noted"));
await session.PromptAsync("/note remember milk");
await session.WaitForIdleAsync();
Assert.Contains(session.Messages, m => m is CustomMessage { CustomType: "note" } c && c.Text == "remember milk");
Assert.Equal("noted", session.Messages.OfType<AssistantMessage>().Last().Text);
Assert.Contains(session.SessionManager.GetEntries(), e => e is CustomEntry { CustomType: "bookmark" });
Assert.Contains(host.Events, e => e is AgentSessionEvent.EntryAppended);
}
[Fact]
public async Task Manual_compaction_when_idle_and_context_usage()
{
await using var host = new TestHost(o => o.Compaction = new Volcanic.AgenticCore.Features.Compaction.CompactionSettings(KeepRecentTokens: 1));
await using var session = await host.CreateSessionAsync();
Assert.Null(await session.CompactAsync()); // nothing to compact
host.Faux.Enqueue(FauxTurn.Say("first"));
await session.PromptAsync("one");
host.Faux.Enqueue(FauxTurn.Say("second"));
await session.PromptAsync("two");
host.Faux.Enqueue(FauxTurn.Say("## Goal\nmanual summary"));
var result = await session.CompactAsync("focus on goals");
Assert.NotNull(result);
Assert.Contains("manual summary", result!.Summary);
Assert.Contains("focus on goals", host.Faux.Requests[^1].Messages[0].Text);
Assert.Contains(host.Events, e => e is AgentSessionEvent.CompactionEnd { Reason: CompactionReason.Manual, Result: not null });
var usage = session.GetContextUsage();
Assert.NotNull(usage);
Assert.Equal(FauxProvider.EchoModel.ContextWindow, usage!.ContextWindow);
}
[Fact]
public async Task Active_tools_can_be_restricted_per_session()
{
await using var host = new TestHost();
host.Faux.Enqueue(FauxTurn.Say("x"));
await using var session = await host.CreateSessionAsync(o => o.ActiveTools = ["read", "grep"]);
Assert.Equal(["read", "grep"], session.ActiveTools);
await session.PromptAsync("go");
Assert.Equal(["read", "grep"], host.Faux.Requests[0].Tools.Select(t => t.Name));
Assert.Equal(7, session.Tools.Count);
}
private sealed class ScriptedUi : IUserInterface
{
public string? NextSelect { get; set; }
public string? LastTitle { get; private set; }
public Task<string?> SelectAsync(string title, IReadOnlyList<string> options, UiRequestOptions? o, CancellationToken ct) { LastTitle = title; return Task.FromResult(NextSelect); }
public Task<bool> ConfirmAsync(string title, string message, UiRequestOptions? o, CancellationToken ct) => Task.FromResult(true);
public Task<string?> InputAsync(string title, string? placeholder, UiRequestOptions? o, CancellationToken ct) => Task.FromResult<string?>(null);
public void Notify(string message, NotifyKind kind = NotifyKind.Info) { }
public void SetStatus(string key, string? text) { }
}
}

View File

@@ -0,0 +1,136 @@
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
using Volcanic.AgenticCore.Abstractions.Json;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Sessions;
using Volcanic.AgenticCore.Abstractions.Tools;
using Volcanic.AgenticCore.Features.Compaction;
using Volcanic.AgenticCore.Features.Models;
using Volcanic.AgenticCore.Features.Sessions;
using Volcanic.AgenticCore.Features.SystemPrompt;
using Volcanic.AgenticCore.Testing;
namespace Volcanic.AgenticCore.Tests.Features;
public class SystemPromptBuilderTests
{
[Fact]
public void Renders_tools_guidelines_sections_project_context_and_environment()
{
var dir = Path.Combine(Path.GetTempPath(), "volcanic-prompt", Guid.NewGuid().ToString("N"));
var sub = Path.Combine(dir, "sub");
Directory.CreateDirectory(sub);
File.WriteAllText(Path.Combine(dir, "AGENTS.md"), "Root rules");
File.WriteAllText(Path.Combine(sub, "CLAUDE.md"), "Sub rules");
try
{
var tools = new ITool[]
{
new DelegateTool<object>("alpha", "d", (_, _, _) => Task.FromResult(ToolResult.Text("")), promptSnippet: "alpha: does a", promptGuidelines: ["Use alpha wisely"]),
new DelegateTool<object>("silent", "d", (_, _, _) => Task.FromResult(ToolResult.Text(""))),
};
var builder = new SystemPromptBuilder(sub, () => tools, () => new DateTimeOffset(2026, 9, 25, 0, 0, 0, TimeSpan.Zero));
var options = builder.DefaultOptions("BASE");
options.Sections["extra"] = "more";
var prompt = builder.Render(options);
Assert.StartsWith("BASE", prompt);
Assert.Contains("<tools>\nAvailable tools:\n- alpha: does a\n</tools>", prompt);
Assert.DoesNotContain("silent", prompt);
Assert.Contains("- Use alpha wisely", prompt);
Assert.Contains("<extra>\nmore\n</extra>", prompt);
var ctxStart = prompt.IndexOf("<project_context>", StringComparison.Ordinal);
Assert.True(prompt.IndexOf("Sub rules", StringComparison.Ordinal) < prompt.IndexOf("Root rules", StringComparison.Ordinal));
Assert.True(ctxStart > 0);
Assert.Contains("Current date: 2026-09-25", prompt);
Assert.Contains($"Working directory: {sub}", prompt);
options.SelectedTools.Clear();
options.SelectedTools.Add("silent");
options.IncludeProjectContext = false;
var narrow = builder.Render(options);
Assert.DoesNotContain("<tools>", narrow);
Assert.DoesNotContain("project_context", narrow);
}
finally { Directory.Delete(dir, true); }
}
}
public class CompactorTests
{
private static AssistantMessage Assistant(string text, int? usageTokens = null) => new()
{
Provider = "p", Api = "a", Model = "m", StopReason = StopReason.Stop, Content = [new TextContent(text)],
Usage = usageTokens is { } u ? new Usage(u, 0, 0, 0) : Usage.Zero,
};
[Fact]
public void ShouldCompact_and_ContextTokens()
{
var compactor = new Compactor(new CompactionSettings(ReserveTokens: 1_000), new ModelRegistry(new ApiKeyResolver(null, _ => null)));
var model = FauxProvider.EchoModel with { ContextWindow = 5_000 };
Assert.False(compactor.ShouldCompact(3_999, model));
Assert.True(compactor.ShouldCompact(4_001, model));
Assert.False(compactor.ShouldCompact(null, model));
Assert.False(new Compactor(new CompactionSettings(Enabled: false), new ModelRegistry(new ApiKeyResolver(null, _ => null))).ShouldCompact(999_999, model));
Assert.Null(Compactor.ContextTokens([]));
Assert.Equal(3, Compactor.ContextTokens([UserMessage.FromText("twelve chars")]));
var withUsage = Compactor.ContextTokens([UserMessage.FromText("q"), Assistant("a", 500), UserMessage.FromText("eight ch")]);
Assert.Equal(502, withUsage);
}
[Fact]
public async Task Prepare_keeps_recent_messages_and_never_cuts_before_a_tool_result()
{
var session = await SessionManager.CreateAsync(new InMemorySessionStore(), "s", "/tmp");
await session.AppendMessageAsync(UserMessage.FromText(new string('a', 400)));
await session.AppendMessageAsync(Assistant(new string('b', 400)));
var call = new AssistantMessage { Provider = "p", Api = "a", Model = "m", StopReason = StopReason.ToolUse, Content = [new ToolCallContent("c1", "bash", VolcanicJson.EmptyObject)] };
var callEntry = await session.AppendMessageAsync(call);
await session.AppendMessageAsync(new ToolResultMessage { ToolCallId = "c1", ToolName = "bash", Content = [new TextContent(new string('r', 400))] });
await session.AppendMessageAsync(Assistant("short"));
var compactor = new Compactor(new CompactionSettings(KeepRecentTokens: 120), new ModelRegistry(new ApiKeyResolver(null, _ => null)));
var prep = compactor.Prepare(session);
Assert.Equal(callEntry.Id, prep.FirstKeptEntryId);
Assert.Equal(2, prep.EntriesToSummarize.Count);
Assert.Equal(3, prep.EntriesToKeep.Count);
Assert.Null(prep.PreviousSummary);
var text = Compactor.Serialize(prep.EntriesToSummarize, maxBlockChars: 10);
Assert.Contains("[User]: aaaaaaaaaa… [390 more chars]", text);
Assert.Contains("[Assistant]: bbbbbbbbbb", text);
var full = Compactor.Serialize(session.GetBranch());
Assert.Contains("[Assistant tool calls]: bash({})", full);
Assert.Contains("[Tool result]: rrr", full);
}
[Fact]
public async Task Summarize_uses_the_model_and_extracts_file_lists()
{
var faux = new FauxProvider().Enqueue(FauxTurn.Say("## Goal\nDo X\n<read-files>\na.cs\nb.cs\n</read-files>\n<modified-files>\nc.cs\n</modified-files>"));
var registry = new ModelRegistry(new ApiKeyResolver(null, _ => null));
registry.Register(faux.Registration);
var model = registry.Find(FauxProvider.ModelKey)!;
var session = await SessionManager.CreateAsync(new InMemorySessionStore(), "s", "/tmp");
await session.AppendMessageAsync(UserMessage.FromText("old question"));
await session.AppendMessageAsync(Assistant("old answer"));
await session.AppendMessageAsync(UserMessage.FromText("new"));
var compactor = new Compactor(new CompactionSettings(KeepRecentTokens: 1), registry);
var prep = compactor.Prepare(session);
var result = await compactor.SummarizeAsync(prep, model, "keep it short", CancellationToken.None);
Assert.StartsWith("## Goal", result.Summary);
Assert.Equal(prep.FirstKeptEntryId, result.FirstKeptEntryId);
Assert.Equal(["a.cs", "b.cs"], result.Details!.Value.GetProperty("readFiles").EnumerateArray().Select(e => e.GetString()));
var request = faux.Requests.Single();
Assert.Equal(CompactionPrompts.SystemPrompt, request.SystemPrompt);
Assert.Contains("[User]: old question", request.Messages[0].Text);
Assert.Contains("keep it short", request.Messages[0].Text);
Assert.Equal(Volcanic.AgenticCore.Abstractions.Providers.CacheRetention.None, faux.Options.Single().CacheRetention);
faux.Enqueue(FauxTurn.Error("nope"));
await Assert.ThrowsAsync<InvalidOperationException>(() => compactor.SummarizeAsync(prep, model, null, CancellationToken.None));
}
}

View File

@@ -0,0 +1,77 @@
using Microsoft.Extensions.DependencyInjection;
using Volcanic.AgenticCore.Abstractions.Events;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Features.Agent;
using Volcanic.AgenticCore.Features.Extensions;
using Volcanic.AgenticCore.Features.Sessions;
using Volcanic.AgenticCore.Hosting;
using Volcanic.AgenticCore.Testing;
namespace Volcanic.AgenticCore.Tests.Support;
/// <summary>Builds a DI host with the FauxProvider and an in-memory store, and records session events.</summary>
public sealed class TestHost : IAsyncDisposable
{
public FauxProvider Faux { get; } = new();
public InMemorySessionStore Store { get; } = new();
public ServiceProvider Services { get; }
public IAgentSessionFactory Factory { get; }
public string Cwd { get; }
public List<AgentSessionEvent> Events { get; } = [];
public TestHost(Action<AgenticCoreOptions>? configure = null, params IExtension[] extensions)
{
Cwd = Path.Combine(Path.GetTempPath(), "volcanic-host", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(Cwd);
var services = new ServiceCollection();
services.AddAgenticCore(o =>
{
o.AgentDir = Path.Combine(Cwd, ".agent");
o.DiscoverExtensionAssemblies = false;
o.DefaultModel = FauxProvider.ModelKey;
o.Extensions.Add(ExtensionRegistration.FromInstance(Faux.Extension));
foreach (var e in extensions) o.Extensions.Add(ExtensionRegistration.FromInstance(e));
o.Retry = new RetrySettings { BaseDelay = TimeSpan.FromMilliseconds(5), MaxDelay = TimeSpan.FromMilliseconds(20) };
o.SessionStoreFactory = _ => Store;
configure?.Invoke(o);
});
Services = services.BuildServiceProvider();
Factory = Services.GetRequiredService<IAgentSessionFactory>();
}
public async Task<AgentSession> CreateSessionAsync(Action<AgentSessionOptionsBuilder>? configure = null)
{
var builder = new AgentSessionOptionsBuilder(Cwd);
configure?.Invoke(builder);
var session = await Factory.CreateAsync(builder.Build());
session.Subscribe(e => { lock (Events) Events.Add(e); });
return session;
}
public IEnumerable<string> EventNames => Events.Select(e => e.GetType().Name);
public ValueTask DisposeAsync()
{
Services.Dispose();
try { Directory.Delete(Cwd, true); } catch (Exception) { }
return ValueTask.CompletedTask;
}
public sealed class AgentSessionOptionsBuilder(string cwd)
{
public string? SessionId { get; set; }
public string? Model { get; set; }
public IReadOnlyList<string>? ActiveTools { get; set; }
public Volcanic.AgenticCore.Abstractions.Tools.ToolExecutionMode ToolExecution { get; set; } = Volcanic.AgenticCore.Abstractions.Tools.ToolExecutionMode.Parallel;
public Volcanic.AgenticCore.Abstractions.Ui.IUserInterface? Ui { get; set; }
public bool? AutoCompaction { get; set; }
public List<ExtensionRegistration> Extra { get; } = [];
public QueueMode SteeringMode { get; set; } = QueueMode.OneAtATime;
public AgentSessionOptions Build() => new()
{
Cwd = cwd, SessionId = SessionId, Model = Model, ActiveTools = ActiveTools, ToolExecution = ToolExecution,
Ui = Ui, AutoCompaction = AutoCompaction, ExtraExtensions = Extra, SteeringMode = SteeringMode,
};
}
}