feat(core): agent loop, session facade, factory, hosting and faux provider
This commit is contained in:
109
src/Volcanic.AgenticCore/Features/Agent/AgentSession.Host.cs
Normal file
109
src/Volcanic.AgenticCore/Features/Agent/AgentSession.Host.cs
Normal 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);
|
||||
}
|
||||
311
src/Volcanic.AgenticCore/Features/Agent/AgentSession.Loop.cs
Normal file
311
src/Volcanic.AgenticCore/Features/Agent/AgentSession.Loop.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
168
src/Volcanic.AgenticCore/Features/Agent/AgentSession.Tools.cs
Normal file
168
src/Volcanic.AgenticCore/Features/Agent/AgentSession.Tools.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
378
src/Volcanic.AgenticCore/Features/Agent/AgentSession.cs
Normal file
378
src/Volcanic.AgenticCore/Features/Agent/AgentSession.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
114
src/Volcanic.AgenticCore/Features/Agent/AgentSessionFactory.cs
Normal file
114
src/Volcanic.AgenticCore/Features/Agent/AgentSessionFactory.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
38
src/Volcanic.AgenticCore/Features/Agent/RetryPolicy.cs
Normal file
38
src/Volcanic.AgenticCore/Features/Agent/RetryPolicy.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
141
src/Volcanic.AgenticCore/Features/Compaction/Compactor.cs
Normal file
141
src/Volcanic.AgenticCore/Features/Compaction/Compactor.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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"];
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
68
src/Volcanic.AgenticCore/Hosting/AgenticCoreOptions.cs
Normal file
68
src/Volcanic.AgenticCore/Hosting/AgenticCoreOptions.cs
Normal 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><cwd>/.volcanic/extensions</c>, <c><AgentDir>/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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
15
src/Volcanic.AgenticCore/Hosting/VolcanicPaths.cs
Normal file
15
src/Volcanic.AgenticCore/Hosting/VolcanicPaths.cs
Normal 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");
|
||||
}
|
||||
155
src/Volcanic.AgenticCore/Testing/FauxProvider.cs
Normal file
155
src/Volcanic.AgenticCore/Testing/FauxProvider.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
303
tests/Volcanic.AgenticCore.Tests/Features/AgentLoopTests.cs
Normal file
303
tests/Volcanic.AgenticCore.Tests/Features/AgentLoopTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
246
tests/Volcanic.AgenticCore.Tests/Features/AgentSessionTests.cs
Normal file
246
tests/Volcanic.AgenticCore.Tests/Features/AgentSessionTests.cs
Normal 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) { }
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
77
tests/Volcanic.AgenticCore.Tests/Support/TestHost.cs
Normal file
77
tests/Volcanic.AgenticCore.Tests/Support/TestHost.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user