feat(abstractions): message model, provider, tool, extension, session and UI contracts

This commit is contained in:
VolcanicAgenticCore
2026-09-25 12:05:48 +00:00
parent 351bc0a2f6
commit 4169b19764
26 changed files with 1792 additions and 8 deletions

View File

@@ -1 +0,0 @@
namespace Volcanic.AgenticCore.Abstractions; internal static class AssemblyMarker { }

View File

@@ -0,0 +1,76 @@
using System.Text.Json.Serialization;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Abstractions.Sessions;
using Volcanic.AgenticCore.Abstractions.Tools;
namespace Volcanic.AgenticCore.Abstractions.Events;
public enum CompactionReason { Manual, Threshold, Overflow }
public sealed record CompactionResult(
string Summary,
string? FirstKeptEntryId,
int TokensBefore,
int EstimatedTokensAfter,
Usage? Usage = null,
System.Text.Json.JsonElement? Details = null);
/// <summary>
/// Events an <c>AgentSession</c> raises for its host (a UI). Names match pi's JSON event stream so existing
/// client knowledge transfers. Discriminated by <c>type</c> when serialised.
/// </summary>
[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")]
[JsonDerivedType(typeof(AgentStart), "agent_start")]
[JsonDerivedType(typeof(AgentEnd), "agent_end")]
[JsonDerivedType(typeof(AgentSettled), "agent_settled")]
[JsonDerivedType(typeof(TurnStart), "turn_start")]
[JsonDerivedType(typeof(TurnEnd), "turn_end")]
[JsonDerivedType(typeof(MessageStart), "message_start")]
[JsonDerivedType(typeof(MessageUpdate), "message_update")]
[JsonDerivedType(typeof(MessageEnd), "message_end")]
[JsonDerivedType(typeof(ToolExecutionStart), "tool_execution_start")]
[JsonDerivedType(typeof(ToolExecutionUpdate), "tool_execution_update")]
[JsonDerivedType(typeof(ToolExecutionEnd), "tool_execution_end")]
[JsonDerivedType(typeof(QueueUpdate), "queue_update")]
[JsonDerivedType(typeof(CompactionStart), "compaction_start")]
[JsonDerivedType(typeof(CompactionEnd), "compaction_end")]
[JsonDerivedType(typeof(AutoRetryStart), "auto_retry_start")]
[JsonDerivedType(typeof(AutoRetryEnd), "auto_retry_end")]
[JsonDerivedType(typeof(EntryAppended), "entry_appended")]
[JsonDerivedType(typeof(SessionInfoChanged), "session_info_changed")]
[JsonDerivedType(typeof(ThinkingLevelChanged), "thinking_level_changed")]
[JsonDerivedType(typeof(ModelChanged), "model_changed")]
[JsonDerivedType(typeof(ExtensionError), "extension_error")]
[JsonDerivedType(typeof(Notification), "notification")]
public abstract record AgentSessionEvent
{
/// <summary>A low-level agent run started.</summary>
public sealed record AgentStart : AgentSessionEvent;
/// <summary>The run ended. <see cref="WillRetry"/> is true when an automatic retry or overflow recovery follows.</summary>
public sealed record AgentEnd(IReadOnlyList<Message> Messages, bool WillRetry) : AgentSessionEvent;
/// <summary>Nothing more will happen automatically. The safe "idle" signal for a UI.</summary>
public sealed record AgentSettled : AgentSessionEvent;
public sealed record TurnStart(int TurnIndex) : AgentSessionEvent;
public sealed record TurnEnd(int TurnIndex, AssistantMessage Message, IReadOnlyList<ToolResultMessage> ToolResults) : AgentSessionEvent;
public sealed record MessageStart(Message Message) : AgentSessionEvent;
/// <summary>A streaming delta. <see cref="Message"/> is the cumulative assistant message.</summary>
public sealed record MessageUpdate(AssistantMessage Message, AssistantMessageEvent AssistantMessageEvent) : AgentSessionEvent;
/// <summary>The authoritative final message.</summary>
public sealed record MessageEnd(Message Message) : AgentSessionEvent;
public sealed record ToolExecutionStart(string ToolCallId, string ToolName, System.Text.Json.JsonElement Args) : AgentSessionEvent;
public sealed record ToolExecutionUpdate(string ToolCallId, string ToolName, System.Text.Json.JsonElement Args, ToolResult PartialResult) : AgentSessionEvent;
public sealed record ToolExecutionEnd(string ToolCallId, string ToolName, ToolResult Result, bool IsError) : AgentSessionEvent;
public sealed record QueueUpdate(IReadOnlyList<string> Steering, IReadOnlyList<string> FollowUp) : AgentSessionEvent;
public sealed record CompactionStart(CompactionReason Reason) : AgentSessionEvent;
public sealed record CompactionEnd(CompactionReason Reason, CompactionResult? Result, bool Aborted, bool WillRetry, string? ErrorMessage) : AgentSessionEvent;
public sealed record AutoRetryStart(int Attempt, int MaxAttempts, TimeSpan Delay, string ErrorMessage) : AgentSessionEvent;
public sealed record AutoRetryEnd(bool Success, int Attempt, string? FinalError) : AgentSessionEvent;
public sealed record EntryAppended(SessionEntry Entry) : AgentSessionEvent;
public sealed record SessionInfoChanged(string? Name) : AgentSessionEvent;
public sealed record ThinkingLevelChanged(ThinkingLevel Level) : AgentSessionEvent;
public sealed record ModelChanged(Model Model) : AgentSessionEvent;
public sealed record ExtensionError(string ExtensionName, string Event, string Error) : AgentSessionEvent;
/// <summary>A message for the user that is not part of the transcript (for example a rate limit warning).</summary>
public sealed record Notification(string Message, Ui.NotifyKind Kind) : AgentSessionEvent;
}

View File

@@ -0,0 +1,268 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Volcanic.AgenticCore.Abstractions.Events;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Abstractions.Sessions;
using Volcanic.AgenticCore.Abstractions.Tools;
namespace Volcanic.AgenticCore.Abstractions.Extensions.Events;
/// <summary>
/// Base class for extension lifecycle events. Events are mutable "event args": handlers influence the runtime by calling
/// methods or setting properties on the event. Handlers run sequentially in load order, each seeing earlier changes.
/// </summary>
public abstract class ExtensionEvent
{
/// <summary>Snake-case name used in logs and <c>ExtensionError</c> events.</summary>
public abstract string Name { get; }
/// <summary>When true, the dispatcher stops calling further handlers.</summary>
public virtual bool ShortCircuit => false;
}
public enum SessionStartReason { Startup, New, Resume, Fork, Reload }
public enum SessionShutdownReason { Quit, Reload, New, Resume, Fork }
public enum InputSource { User, Extension }
public enum StreamingBehavior { Steer, FollowUp }
public enum ModelSelectSource { Set, Restore }
// ---- Session lifecycle ----
public sealed class SessionStartEvent(SessionStartReason reason, string? previousSessionId = null) : ExtensionEvent
{
public override string Name => "session_start";
public SessionStartReason Reason { get; } = reason;
public string? PreviousSessionId { get; } = previousSessionId;
}
public sealed class SessionShutdownEvent(SessionShutdownReason reason, string? targetSessionId = null) : ExtensionEvent
{
public override string Name => "session_shutdown";
public SessionShutdownReason Reason { get; } = reason;
public string? TargetSessionId { get; } = targetSessionId;
}
public sealed class SessionBeforeSwitchEvent(SessionStartReason reason, string? targetSessionId) : ExtensionEvent
{
public override string Name => "session_before_switch";
public SessionStartReason Reason { get; } = reason;
public string? TargetSessionId { get; } = targetSessionId;
public bool Cancelled { get; private set; }
public void Cancel() => Cancelled = true;
public override bool ShortCircuit => Cancelled;
}
public sealed class SessionInfoChangedEvent(string? name) : ExtensionEvent
{
public override string Name => "session_info_changed";
public string? SessionName { get; } = name;
}
/// <summary>What the compactor intends to do. Handlers can cancel or supply their own summary.</summary>
public sealed record CompactionPreparation(
IReadOnlyList<SessionEntry> EntriesToSummarize,
IReadOnlyList<SessionEntry> EntriesToKeep,
string? FirstKeptEntryId,
int TokensBefore,
string? PreviousSummary);
public sealed class SessionBeforeCompactEvent(CompactionPreparation preparation, CompactionReason reason, string? customInstructions) : ExtensionEvent
{
public override string Name => "session_before_compact";
public CompactionPreparation Preparation { get; } = preparation;
public CompactionReason Reason { get; } = reason;
public string? CustomInstructions { get; } = customInstructions;
public bool Cancelled { get; private set; }
/// <summary>Set to skip the built-in summariser and use this result instead.</summary>
public CompactionResult? Override { get; set; }
public void Cancel() => Cancelled = true;
public override bool ShortCircuit => Cancelled;
}
public sealed class SessionCompactEvent(CompactionEntry entry, CompactionReason reason, bool fromExtension) : ExtensionEvent
{
public override string Name => "session_compact";
public CompactionEntry Entry { get; } = entry;
public CompactionReason Reason { get; } = reason;
public bool FromExtension { get; } = fromExtension;
}
// ---- Input and prompt ----
/// <summary>Fires for user input before it becomes a prompt (after slash-command dispatch). Transform the text, or mark it handled to consume it.</summary>
public sealed class InputEvent(string text, IReadOnlyList<ImageContent>? images, InputSource source, StreamingBehavior? streamingBehavior) : ExtensionEvent
{
public override string Name => "input";
public string Text { get; set; } = text;
public IReadOnlyList<ImageContent>? Images { get; set; } = images;
public InputSource Source { get; } = source;
public StreamingBehavior? StreamingBehavior { get; } = streamingBehavior;
public bool Handled { get; private set; }
public void MarkHandled() => Handled = true;
public override bool ShortCircuit => Handled;
}
/// <summary>Mutable knobs the system prompt builder reads. Extensions edit these in <see cref="BeforeAgentStartEvent"/>.</summary>
public sealed class SystemPromptOptions
{
public string? BasePrompt { get; set; }
/// <summary>Extra named sections appended as <c>&lt;name&gt;…&lt;/name&gt;</c>.</summary>
public Dictionary<string, string> Sections { get; } = new(StringComparer.Ordinal);
public List<string> SelectedTools { get; } = [];
public List<string> Guidelines { get; } = [];
public bool IncludeProjectContext { get; set; } = true;
}
public sealed class BeforeAgentStartEvent(string prompt, IReadOnlyList<ImageContent>? images, SystemPromptOptions options, Func<SystemPromptOptions, string> render) : ExtensionEvent
{
public override string Name => "before_agent_start";
public string Prompt { get; } = prompt;
public IReadOnlyList<ImageContent>? Images { get; } = images;
public SystemPromptOptions SystemPromptOptions { get; } = options;
/// <summary>Replaces the whole system prompt for this run when set.</summary>
public string? SystemPromptOverride { get; set; }
/// <summary>Custom messages injected after the user message, in order.</summary>
public List<CustomMessage> InjectedMessages { get; } = [];
/// <summary>The prompt as it would render with the current options and override.</summary>
public string CurrentSystemPrompt => SystemPromptOverride ?? render(SystemPromptOptions);
}
// ---- Run and turn ----
public sealed class AgentStartEvent : ExtensionEvent { public override string Name => "agent_start"; }
public sealed class AgentEndEvent(IReadOnlyList<Message> messages) : ExtensionEvent
{
public override string Name => "agent_end";
public IReadOnlyList<Message> Messages { get; } = messages;
}
public sealed class AgentSettledEvent : ExtensionEvent { public override string Name => "agent_settled"; }
public sealed class TurnStartEvent(int turnIndex) : ExtensionEvent
{
public override string Name => "turn_start";
public int TurnIndex { get; } = turnIndex;
}
public sealed class TurnEndEvent(int turnIndex, AssistantMessage message, IReadOnlyList<ToolResultMessage> toolResults) : ExtensionEvent
{
public override string Name => "turn_end";
public int TurnIndex { get; } = turnIndex;
public AssistantMessage Message { get; } = message;
public IReadOnlyList<ToolResultMessage> ToolResults { get; } = toolResults;
/// <summary>Set to force one more model request even though the assistant produced no tool calls.</summary>
public bool ContinueRequested { get; set; }
}
/// <summary>Fires before each model request with the messages (system prompt excluded). Edit the list in place.</summary>
public sealed class ContextEvent(List<Message> messages) : ExtensionEvent
{
public override string Name => "context";
public List<Message> Messages { get; } = messages;
}
/// <summary>The serialised provider payload. Replace <see cref="Payload"/> to change what is sent.</summary>
public sealed class BeforeProviderRequestEvent(JsonNode payload) : ExtensionEvent
{
public override string Name => "before_provider_request";
public JsonNode Payload { get; set; } = payload;
}
// ---- Messages ----
public sealed class MessageStartEvent(Message message) : ExtensionEvent
{
public override string Name => "message_start";
public Message Message { get; } = message;
}
public sealed class MessageUpdateEvent(AssistantMessage message, AssistantMessageEvent assistantMessageEvent) : ExtensionEvent
{
public override string Name => "message_update";
public AssistantMessage Message { get; } = message;
public AssistantMessageEvent AssistantMessageEvent { get; } = assistantMessageEvent;
}
/// <summary>A message was finalised. Replace <see cref="Message"/> (same role) to rewrite it before it is persisted.</summary>
public sealed class MessageEndEvent(Message message) : ExtensionEvent
{
public override string Name => "message_end";
public Message Message { get; set; } = message;
}
// ---- Tools ----
/// <summary>Fires before a tool runs. Patch <see cref="Input"/> in place or <see cref="Block"/> it.</summary>
public sealed class ToolCallEvent(string toolCallId, string toolName, JsonObject input) : ExtensionEvent
{
public override string Name => "tool_call";
public string ToolCallId { get; } = toolCallId;
public string ToolName { get; } = toolName;
public JsonObject Input { get; } = input;
public bool IsBlocked { get; private set; }
public string? BlockReason { get; private set; }
public void Block(string? reason = null)
{
IsBlocked = true;
BlockReason = reason ?? "Blocked by extension";
}
public override bool ShortCircuit => IsBlocked;
}
/// <summary>Fires after a tool ran. Edit the result before the model sees it.</summary>
public sealed class ToolResultEvent(string toolCallId, string toolName, JsonElement input, List<ContentBlock> content, JsonElement? details, bool isError, Usage? usage) : ExtensionEvent
{
public override string Name => "tool_result";
public string ToolCallId { get; } = toolCallId;
public string ToolName { get; } = toolName;
public JsonElement Input { get; } = input;
public List<ContentBlock> Content { get; } = content;
public JsonElement? Details { get; set; } = details;
public bool IsError { get; set; } = isError;
public Usage? Usage { get; set; } = usage;
}
public sealed class ToolExecutionStartEvent(string toolCallId, string toolName, JsonElement args) : ExtensionEvent
{
public override string Name => "tool_execution_start";
public string ToolCallId { get; } = toolCallId;
public string ToolName { get; } = toolName;
public JsonElement Args { get; } = args;
}
public sealed class ToolExecutionUpdateEvent(string toolCallId, string toolName, JsonElement args, ToolResult partialResult) : ExtensionEvent
{
public override string Name => "tool_execution_update";
public string ToolCallId { get; } = toolCallId;
public string ToolName { get; } = toolName;
public JsonElement Args { get; } = args;
public ToolResult PartialResult { get; } = partialResult;
}
public sealed class ToolExecutionEndEvent(string toolCallId, string toolName, ToolResult result, bool isError) : ExtensionEvent
{
public override string Name => "tool_execution_end";
public string ToolCallId { get; } = toolCallId;
public string ToolName { get; } = toolName;
public ToolResult Result { get; } = result;
public bool IsError { get; } = isError;
}
// ---- Model ----
public sealed class ModelSelectEvent(Model model, Model? previousModel, ModelSelectSource source) : ExtensionEvent
{
public override string Name => "model_select";
public Model Model { get; } = model;
public Model? PreviousModel { get; } = previousModel;
public ModelSelectSource Source { get; } = source;
}
public sealed class ThinkingLevelSelectEvent(ThinkingLevel level, ThinkingLevel previousLevel) : ExtensionEvent
{
public override string Name => "thinking_level_select";
public ThinkingLevel Level { get; } = level;
public ThinkingLevel PreviousLevel { get; } = previousLevel;
}

View File

@@ -0,0 +1,86 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Abstractions.Tools;
namespace Volcanic.AgenticCore.Abstractions.Extensions;
/// <summary>
/// An extension. One instance is created per <c>AgentSession</c>, so instance fields are per conversation.
/// <see cref="ActivateAsync"/> must only register things; start processes or timers in a <see cref="Events.SessionStartEvent"/> handler.
/// </summary>
public interface IExtension
{
string Name { get; }
ValueTask ActivateAsync(IExtensionApi api, CancellationToken cancellationToken);
}
public delegate ValueTask ExtensionHandler<in TEvent>(TEvent e, IExtensionContext ctx, CancellationToken ct) where TEvent : ExtensionEvent;
public enum MessageDelivery
{
/// <summary>While streaming: after the current turn's tools. Idle: appended and a run starts when <c>triggerTurn</c> is true.</summary>
Steer,
/// <summary>Delivered when the agent has no more tool calls or steering messages.</summary>
FollowUp,
/// <summary>Held and injected together with the next user prompt.</summary>
NextTurn,
}
public sealed record ExecOptions(TimeSpan? Timeout = null, string? WorkingDirectory = null, IReadOnlyDictionary<string, string?>? Environment = null);
public sealed record ExecResult(string Stdout, string Stderr, int ExitCode, bool Killed);
public sealed record ToolInfo(string Name, string Description, JsonElement Parameters, IReadOnlyList<string> PromptGuidelines, string Source);
public sealed record CommandInfo(string Name, string? Description, string Source);
/// <summary>A slash command. <c>args</c> is the raw text after the command name.</summary>
public sealed record CommandDefinition(
string? Description,
Func<string, IExtensionCommandContext, CancellationToken, Task> Handler);
/// <summary>Publish/subscribe channel shared by every extension in a session.</summary>
public interface IEventBus
{
void Emit(string channel, object? data);
IDisposable On(string channel, Action<object?> handler);
}
/// <summary>The registration and action surface handed to <see cref="IExtension.ActivateAsync"/> (pi's <c>pi</c> object).</summary>
public interface IExtensionApi
{
string ExtensionName { get; }
ILogger Logger { get; }
IEventBus Events { get; }
// Registration (allowed during activation).
IDisposable On<TEvent>(ExtensionHandler<TEvent> handler) where TEvent : ExtensionEvent;
void RegisterTool(ITool tool);
void RegisterCommand(string name, CommandDefinition command);
void RegisterProvider(ProviderRegistration provider);
void UnregisterProvider(string providerId);
// Actions (throw ExtensionNotBoundException until the session binds the runtime).
void SendMessage(CustomMessage message, MessageDelivery delivery = MessageDelivery.Steer, bool triggerTurn = true);
void SendUserMessage(string text, IReadOnlyList<ImageContent>? images = null, MessageDelivery delivery = MessageDelivery.Steer);
void AppendEntry(string customType, JsonElement? data = null);
Task<bool> SetModelAsync(Model model, CancellationToken cancellationToken = default);
ThinkingLevel GetThinkingLevel();
void SetThinkingLevel(ThinkingLevel level);
IReadOnlyList<string> GetActiveTools();
void SetActiveTools(IEnumerable<string> names);
IReadOnlyList<ToolInfo> GetAllTools();
IReadOnlyList<CommandInfo> GetCommands();
string? GetSessionName();
void SetSessionName(string? name);
Task<ExecResult> ExecAsync(string command, IReadOnlyList<string> args, ExecOptions? options = null, CancellationToken cancellationToken = default);
}
public sealed class ExtensionNotBoundException(string member)
: InvalidOperationException($"'{member}' is not available during activation. Call it from an event handler, tool or command.");
public sealed class StaleExtensionContextException()
: InvalidOperationException("This extension context belongs to a runtime that was reloaded or replaced.");

View File

@@ -0,0 +1,47 @@
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Abstractions.Sessions;
using Volcanic.AgenticCore.Abstractions.Ui;
namespace Volcanic.AgenticCore.Abstractions.Extensions;
public sealed record ContextUsage(int? Tokens, int ContextWindow, double? Percent);
public sealed record CompactOptions(string? CustomInstructions = null);
public sealed record SwitchResult(bool Cancelled);
public sealed record NewSessionOptions(string? ParentSessionId = null);
/// <summary>Per-dispatch context handed to event handlers, tools and commands.</summary>
public interface IExtensionContext
{
string Cwd { get; }
string SessionId { get; }
/// <summary>False when the host uses <see cref="NullUserInterface"/>; extensions should then avoid dialogs.</summary>
bool HasUi { get; }
IUserInterface Ui { get; }
IReadOnlySessionManager SessionManager { get; }
IModelRegistry ModelRegistry { get; }
Model? Model { get; }
ThinkingLevel ThinkingLevel { get; }
/// <summary>Cancelled when the current run is aborted. <see cref="CancellationToken.None"/> while idle.</summary>
CancellationToken Signal { get; }
ILogger Logger { get; }
bool IsIdle();
void Abort();
bool HasPendingMessages();
ContextUsage? GetContextUsage();
string GetSystemPrompt();
/// <summary>Request compaction. Runs after the current turn when streaming, immediately when idle.</summary>
void Compact(CompactOptions? options = null);
}
/// <summary>Command handlers get these extra operations; they would deadlock if called from event handlers.</summary>
public interface IExtensionCommandContext : IExtensionContext
{
Task WaitForIdleAsync(CancellationToken cancellationToken = default);
Task<SwitchResult> NewSessionAsync(NewSessionOptions? options = null, CancellationToken cancellationToken = default);
Task<SwitchResult> SwitchSessionAsync(string sessionId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,53 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
namespace Volcanic.AgenticCore.Abstractions.Json;
/// <summary>
/// Shared <see cref="JsonSerializerOptions"/> for every wire and persisted shape in the core:
/// camelCase, string enums (camelCase), nulls omitted, polymorphism by discriminator.
/// </summary>
public static class VolcanicJson
{
public static readonly JsonSerializerOptions Options = Create(indented: false);
public static readonly JsonSerializerOptions Indented = Create(indented: true);
private static JsonSerializerOptions Create(bool indented)
{
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = indented,
PropertyNameCaseInsensitive = true,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
return options;
}
/// <summary>An empty JSON object element, safe to store in properties that must never be <c>Undefined</c>.</summary>
public static JsonElement EmptyObject => Parse("{}");
public static JsonElement Parse(string json)
{
using var doc = JsonDocument.Parse(json);
return doc.RootElement.Clone();
}
public static JsonElement ToElement<T>(T value) => JsonSerializer.SerializeToElement(value, Options);
public static string Serialize<T>(T value) => JsonSerializer.Serialize(value, Options);
public static T? Deserialize<T>(string json) => JsonSerializer.Deserialize<T>(json, Options);
public static T? Deserialize<T>(JsonElement element) => element.Deserialize<T>(Options);
public static JsonNode? ToNode<T>(T value) => JsonSerializer.SerializeToNode(value, Options);
public static JsonObject ToObject(JsonElement element) =>
JsonNode.Parse(element.GetRawText()) as JsonObject ?? new JsonObject();
public static JsonElement ToElement(JsonNode? node) =>
node is null ? EmptyObject : Parse(node.ToJsonString(Options));
}

View File

@@ -0,0 +1,29 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Volcanic.AgenticCore.Abstractions.Messages;
/// <summary>A piece of message content. Discriminated on the wire by <c>type</c>.</summary>
[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")]
[JsonDerivedType(typeof(TextContent), "text")]
[JsonDerivedType(typeof(ImageContent), "image")]
[JsonDerivedType(typeof(ThinkingContent), "thinking")]
[JsonDerivedType(typeof(ToolCallContent), "toolCall")]
public abstract record ContentBlock;
public sealed record TextContent(string Text) : ContentBlock;
/// <param name="Data">Base64 encoded bytes.</param>
/// <param name="MimeType">For example <c>image/png</c>.</param>
public sealed record ImageContent(string Data, string MimeType) : ContentBlock;
/// <param name="Signature">Opaque provider replay data (Anthropic signature, OpenAI encrypted reasoning).</param>
/// <param name="Redacted">When true the visible text is empty and the payload lives in <paramref name="Signature"/>.</param>
public sealed record ThinkingContent(string Thinking, string? Signature = null, bool Redacted = false) : ContentBlock;
/// <summary>A tool invocation requested by the assistant.</summary>
public sealed record ToolCallContent(string Id, string Name, JsonElement Arguments) : ContentBlock
{
public T? ArgumentsAs<T>(JsonSerializerOptions? options = null) =>
Arguments.ValueKind == JsonValueKind.Undefined ? default : Arguments.Deserialize<T>(options ?? Json.VolcanicJson.Options);
}

View File

@@ -0,0 +1,109 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Volcanic.AgenticCore.Abstractions.Messages;
/// <summary>A conversation message. Discriminated on the wire by <c>role</c>.</summary>
[JsonPolymorphic(TypeDiscriminatorPropertyName = "role")]
[JsonDerivedType(typeof(UserMessage), "user")]
[JsonDerivedType(typeof(AssistantMessage), "assistant")]
[JsonDerivedType(typeof(ToolResultMessage), "toolResult")]
[JsonDerivedType(typeof(CustomMessage), "custom")]
public abstract record Message
{
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
[JsonIgnore]
public abstract string Role { get; }
/// <summary>Concatenated text of all <see cref="TextContent"/> blocks.</summary>
[JsonIgnore]
public abstract string Text { get; }
protected static string JoinText(IEnumerable<ContentBlock> content) =>
string.Concat(content.OfType<TextContent>().Select(t => t.Text));
}
public sealed record UserMessage : Message
{
[JsonIgnore]
public override string Role => "user";
public required IReadOnlyList<ContentBlock> Content { get; init; }
[JsonIgnore]
public override string Text => JoinText(Content);
public static UserMessage FromText(string text) => new() { Content = [new TextContent(text)] };
public static UserMessage From(string text, IReadOnlyList<ImageContent>? images)
{
var blocks = new List<ContentBlock> { new TextContent(text) };
if (images is { Count: > 0 }) blocks.AddRange(images);
return new UserMessage { Content = blocks };
}
}
public sealed record AssistantMessage : Message
{
[JsonIgnore]
public override string Role => "assistant";
public IReadOnlyList<ContentBlock> Content { get; init; } = [];
public Usage Usage { get; init; } = Usage.Zero;
public StopReason StopReason { get; init; } = StopReason.Pending;
public required string Provider { get; init; }
public required string Api { get; init; }
public required string Model { get; init; }
/// <summary>The concrete model the provider reports having served the request, when it differs from <see cref="Model"/>.</summary>
public string? ResponseModel { get; init; }
public string? ResponseId { get; init; }
public string? ErrorMessage { get; init; }
[JsonIgnore]
public override string Text => JoinText(Content);
[JsonIgnore]
public IReadOnlyList<ToolCallContent> ToolCalls => Content.OfType<ToolCallContent>().ToList();
[JsonIgnore]
public bool IsFailure => StopReason is StopReason.Error or StopReason.Aborted;
}
public sealed record ToolResultMessage : Message
{
[JsonIgnore]
public override string Role => "toolResult";
public required string ToolCallId { get; init; }
public required string ToolName { get; init; }
public IReadOnlyList<ContentBlock> Content { get; init; } = [];
public JsonElement? Details { get; init; }
public Usage? Usage { get; init; }
public bool IsError { get; init; }
[JsonIgnore]
public override string Text => JoinText(Content);
}
/// <summary>
/// A message injected by an extension. It is shown to the model as a user message
/// (unless the transcript normaliser is told otherwise) and can carry structured details for the host.
/// </summary>
public sealed record CustomMessage : Message
{
[JsonIgnore]
public override string Role => "custom";
public required string CustomType { get; init; }
public required IReadOnlyList<ContentBlock> Content { get; init; }
/// <summary>Whether a UI should show the message in the transcript.</summary>
public bool Display { get; init; } = true;
public JsonElement? Details { get; init; }
[JsonIgnore]
public override string Text => JoinText(Content);
public static CustomMessage FromText(string customType, string text, bool display = true) =>
new() { CustomType = customType, Content = [new TextContent(text)], Display = display };
}

View File

@@ -0,0 +1,12 @@
namespace Volcanic.AgenticCore.Abstractions.Messages;
public enum StopReason
{
/// <summary>Only on in-flight partial messages; never persisted.</summary>
Pending,
Stop,
Length,
ToolUse,
Error,
Aborted,
}

View File

@@ -0,0 +1,30 @@
namespace Volcanic.AgenticCore.Abstractions.Messages;
/// <summary>Token accounting for one assistant response. <see cref="Reasoning"/> is a subset of <see cref="Output"/>.</summary>
public sealed record Usage(int Input, int Output, int CacheRead, int CacheWrite, int Reasoning = 0, Cost? Cost = null)
{
public static readonly Usage Zero = new(0, 0, 0, 0);
public Cost Cost { get; init; } = Cost ?? Cost.Zero;
public int TotalTokens => Input + Output + CacheRead + CacheWrite;
public static Usage operator +(Usage a, Usage b) => new(
a.Input + b.Input,
a.Output + b.Output,
a.CacheRead + b.CacheRead,
a.CacheWrite + b.CacheWrite,
a.Reasoning + b.Reasoning,
a.Cost + b.Cost);
}
/// <summary>Cost in USD.</summary>
public sealed record Cost(decimal Input, decimal Output, decimal CacheRead, decimal CacheWrite)
{
public static readonly Cost Zero = new(0, 0, 0, 0);
public decimal Total => Input + Output + CacheRead + CacheWrite;
public static Cost operator +(Cost a, Cost b) =>
new(a.Input + b.Input, a.Output + b.Output, a.CacheRead + b.CacheRead, a.CacheWrite + b.CacheWrite);
}

View File

@@ -0,0 +1,230 @@
using System.Text;
using System.Text.Json;
using Volcanic.AgenticCore.Abstractions.Json;
using Volcanic.AgenticCore.Abstractions.Messages;
namespace Volcanic.AgenticCore.Abstractions.Providers;
/// <summary>
/// Incrementally builds an <see cref="AssistantMessage"/> and produces the matching
/// <see cref="AssistantMessageEvent"/>s so providers only have to push deltas.
/// Not thread-safe; use one builder per stream.
/// </summary>
public sealed class AssistantMessageBuilder
{
private readonly List<ContentBlock> _content = [];
private readonly StringBuilder _buffer = new();
private readonly StringBuilder _signature = new();
private BlockKind _open = BlockKind.None;
private string _toolCallId = "";
private string _toolCallName = "";
private bool _redacted;
private Usage _usage = Usage.Zero;
private string? _responseModel;
private string? _responseId;
private bool _finished;
private enum BlockKind { None, Text, Thinking, ToolCall }
public AssistantMessageBuilder(Model model) : this(model.Provider, model.Api, model.Id) { }
public AssistantMessageBuilder(string provider, string api, string model)
{
Provider = provider;
Api = api;
ModelId = model;
}
public string Provider { get; }
public string Api { get; }
public string ModelId { get; }
public int OpenContentIndex => _open == BlockKind.None ? -1 : _content.Count - 1;
public bool HasOpenBlock => _open != BlockKind.None;
public Usage Usage => _usage;
/// <summary>Snapshot of the message so far (stop reason Pending until finished).</summary>
public AssistantMessage Current => Snapshot(StopReason.Pending, null);
public void SetUsage(Usage usage) => _usage = usage;
public void AddUsage(Usage usage) => _usage += usage;
public void SetResponseModel(string? model) => _responseModel = model;
public void SetResponseId(string? id) => _responseId = id;
public AssistantMessageEvent Start() => new AssistantMessageEvent.Start { Partial = Current };
public AssistantMessageEvent StartText()
{
CloseOpenBlockQuietly();
_open = BlockKind.Text;
_content.Add(new TextContent(""));
return new AssistantMessageEvent.TextStart(_content.Count - 1) { Partial = Current };
}
public AssistantMessageEvent AppendText(string delta)
{
if (_open != BlockKind.Text) StartText();
_buffer.Append(delta);
_content[^1] = new TextContent(_buffer.ToString());
return new AssistantMessageEvent.TextDelta(_content.Count - 1, delta) { Partial = Current };
}
public AssistantMessageEvent EndText()
{
EnsureOpen(BlockKind.Text);
var text = _buffer.ToString();
_content[^1] = new TextContent(text);
Reset();
return new AssistantMessageEvent.TextEnd(_content.Count - 1, text) { Partial = Current };
}
public AssistantMessageEvent StartThinking(bool redacted = false)
{
CloseOpenBlockQuietly();
_open = BlockKind.Thinking;
_redacted = redacted;
_content.Add(new ThinkingContent("", null, redacted));
return new AssistantMessageEvent.ThinkingStart(_content.Count - 1) { Partial = Current };
}
public AssistantMessageEvent AppendThinking(string delta)
{
if (_open != BlockKind.Thinking) StartThinking();
_buffer.Append(delta);
_content[^1] = new ThinkingContent(_buffer.ToString(), SignatureOrNull(), _redacted);
return new AssistantMessageEvent.ThinkingDelta(_content.Count - 1, delta) { Partial = Current };
}
/// <summary>Accumulates opaque signature data without emitting an event.</summary>
public void AppendThinkingSignature(string signature)
{
if (_open != BlockKind.Thinking) StartThinking();
_signature.Append(signature);
_content[^1] = new ThinkingContent(_buffer.ToString(), SignatureOrNull(), _redacted);
}
public AssistantMessageEvent EndThinking()
{
EnsureOpen(BlockKind.Thinking);
var text = _buffer.ToString();
_content[^1] = new ThinkingContent(text, SignatureOrNull(), _redacted);
Reset();
return new AssistantMessageEvent.ThinkingEnd(_content.Count - 1, text) { Partial = Current };
}
public AssistantMessageEvent StartToolCall(string id, string name)
{
CloseOpenBlockQuietly();
_open = BlockKind.ToolCall;
_toolCallId = id;
_toolCallName = name;
_content.Add(new ToolCallContent(id, name, VolcanicJson.EmptyObject));
return new AssistantMessageEvent.ToolCallStart(_content.Count - 1, id, name) { Partial = Current };
}
public AssistantMessageEvent AppendToolCallDelta(string jsonFragment)
{
EnsureOpen(BlockKind.ToolCall);
_buffer.Append(jsonFragment);
return new AssistantMessageEvent.ToolCallDelta(_content.Count - 1, jsonFragment) { Partial = Current };
}
/// <summary>Closes the tool call. Invalid or empty JSON becomes an empty object; the raw text is kept in <paramref name="rawArguments"/>.</summary>
public AssistantMessageEvent EndToolCall(out string rawArguments)
{
EnsureOpen(BlockKind.ToolCall);
rawArguments = _buffer.ToString();
var arguments = ParseArguments(rawArguments);
var call = new ToolCallContent(_toolCallId, _toolCallName, arguments);
_content[^1] = call;
Reset();
return new AssistantMessageEvent.ToolCallEnd(_content.Count - 1, call) { Partial = Current };
}
public AssistantMessageEvent EndToolCall() => EndToolCall(out _);
/// <summary>Closes the tool call with already-parsed arguments (for providers that deliver complete input objects).</summary>
public AssistantMessageEvent EndToolCall(JsonElement arguments)
{
EnsureOpen(BlockKind.ToolCall);
var call = new ToolCallContent(_toolCallId, _toolCallName, arguments.ValueKind == JsonValueKind.Object ? arguments : VolcanicJson.EmptyObject);
_content[^1] = call;
Reset();
return new AssistantMessageEvent.ToolCallEnd(_content.Count - 1, call) { Partial = Current };
}
/// <summary>Emits the terminal <see cref="AssistantMessageEvent.Done"/>. Any open block is closed first.</summary>
public AssistantMessageEvent Done(StopReason reason)
{
if (reason is StopReason.Error or StopReason.Aborted or StopReason.Pending)
throw new ArgumentException($"Done() requires Stop, Length or ToolUse, got {reason}.", nameof(reason));
CloseOpenBlockQuietly();
var effective = reason == StopReason.Stop && _content.OfType<ToolCallContent>().Any() ? StopReason.ToolUse : reason;
_finished = true;
var message = Snapshot(effective, null);
return new AssistantMessageEvent.Done(effective, message) { Partial = message };
}
/// <summary>Emits the terminal <see cref="AssistantMessageEvent.Error"/>, preserving whatever content streamed so far.</summary>
public AssistantMessageEvent Fail(string errorMessage, bool aborted = false)
{
CloseOpenBlockQuietly();
_finished = true;
var reason = aborted ? StopReason.Aborted : StopReason.Error;
var message = Snapshot(reason, errorMessage);
return new AssistantMessageEvent.Error(reason, message) { Partial = message };
}
public bool IsFinished => _finished;
private AssistantMessage Snapshot(StopReason stopReason, string? errorMessage) => new()
{
Content = _content.ToArray(),
Usage = _usage,
StopReason = stopReason,
Provider = Provider,
Api = Api,
Model = ModelId,
ResponseModel = _responseModel,
ResponseId = _responseId,
ErrorMessage = errorMessage,
};
private void CloseOpenBlockQuietly()
{
switch (_open)
{
case BlockKind.Text: EndText(); break;
case BlockKind.Thinking: EndThinking(); break;
case BlockKind.ToolCall: EndToolCall(); break;
}
}
private void EnsureOpen(BlockKind kind)
{
if (_open != kind) throw new InvalidOperationException($"No open {kind} block (open: {_open}).");
}
private void Reset()
{
_open = BlockKind.None;
_buffer.Clear();
_signature.Clear();
_redacted = false;
}
private string? SignatureOrNull() => _signature.Length == 0 ? null : _signature.ToString();
public static JsonElement ParseArguments(string raw)
{
if (string.IsNullOrWhiteSpace(raw)) return VolcanicJson.EmptyObject;
try
{
var element = VolcanicJson.Parse(raw);
return element.ValueKind == JsonValueKind.Object ? element : VolcanicJson.EmptyObject;
}
catch (JsonException)
{
return VolcanicJson.EmptyObject;
}
}
}

View File

@@ -0,0 +1,46 @@
using System.Text.Json.Serialization;
using Volcanic.AgenticCore.Abstractions.Messages;
namespace Volcanic.AgenticCore.Abstractions.Providers;
/// <summary>
/// Streaming events a provider emits while producing one assistant message.
/// Contract: <see cref="Start"/> first, balanced block events, then exactly one <see cref="Done"/> or <see cref="Error"/>.
/// <see cref="Partial"/> is the cumulative message so far and is not serialised on the wire.
/// </summary>
[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")]
[JsonDerivedType(typeof(Start), "start")]
[JsonDerivedType(typeof(TextStart), "text_start")]
[JsonDerivedType(typeof(TextDelta), "text_delta")]
[JsonDerivedType(typeof(TextEnd), "text_end")]
[JsonDerivedType(typeof(ThinkingStart), "thinking_start")]
[JsonDerivedType(typeof(ThinkingDelta), "thinking_delta")]
[JsonDerivedType(typeof(ThinkingEnd), "thinking_end")]
[JsonDerivedType(typeof(ToolCallStart), "toolcall_start")]
[JsonDerivedType(typeof(ToolCallDelta), "toolcall_delta")]
[JsonDerivedType(typeof(ToolCallEnd), "toolcall_end")]
[JsonDerivedType(typeof(Done), "done")]
[JsonDerivedType(typeof(Error), "error")]
public abstract record AssistantMessageEvent
{
[JsonIgnore]
public required AssistantMessage Partial { get; init; }
public sealed record Start : AssistantMessageEvent;
public sealed record TextStart(int ContentIndex) : AssistantMessageEvent;
public sealed record TextDelta(int ContentIndex, string Delta) : AssistantMessageEvent;
public sealed record TextEnd(int ContentIndex, string Content) : AssistantMessageEvent;
public sealed record ThinkingStart(int ContentIndex) : AssistantMessageEvent;
public sealed record ThinkingDelta(int ContentIndex, string Delta) : AssistantMessageEvent;
public sealed record ThinkingEnd(int ContentIndex, string Content) : AssistantMessageEvent;
public sealed record ToolCallStart(int ContentIndex, string Id, string ToolName) : AssistantMessageEvent;
public sealed record ToolCallDelta(int ContentIndex, string Delta) : AssistantMessageEvent;
public sealed record ToolCallEnd(int ContentIndex, ToolCallContent ToolCall) : AssistantMessageEvent;
/// <summary>The stream finished. <see cref="Reason"/> is Stop, Length or ToolUse.</summary>
public sealed record Done(StopReason Reason, AssistantMessage Message) : AssistantMessageEvent;
/// <summary>The stream failed or was aborted. <see cref="Reason"/> is Error or Aborted; <see cref="Message"/> carries <c>ErrorMessage</c>.</summary>
public sealed record Error(StopReason Reason, AssistantMessage Message) : AssistantMessageEvent;
[JsonIgnore]
public bool IsTerminal => this is Done or Error;
}

View File

@@ -0,0 +1,45 @@
using Volcanic.AgenticCore.Abstractions.Messages;
namespace Volcanic.AgenticCore.Abstractions.Providers;
/// <summary>Streams one assistant message for a model. Implementations must not throw; encode failures as <see cref="AssistantMessageEvent.Error"/>.</summary>
public delegate IAsyncEnumerable<AssistantMessageEvent> StreamDelegate(Model model, LlmContext context, StreamOptions options, CancellationToken cancellationToken);
/// <summary>A registered LLM back end: identity, auth hint, models and the stream implementation.</summary>
public sealed record ProviderRegistration
{
/// <summary>Stable id used in model keys (<c>provider/model</c>) and auth lookups.</summary>
public required string Id { get; init; }
public string? Name { get; init; }
public string? BaseUrl { get; init; }
/// <summary>
/// API key spec: a literal, <c>$ENV_VAR</c> / <c>${ENV_VAR}</c>, or <c>!command</c> whose stdout is the key.
/// <c>null</c> falls back to <c>auth.json</c> and then <c>&lt;ID&gt;_API_KEY</c>.
/// </summary>
public string? ApiKey { get; init; }
/// <summary>Default api id for models that do not set one.</summary>
public string? Api { get; init; }
public IReadOnlyDictionary<string, string>? Headers { get; init; }
public IReadOnlyList<Model> Models { get; init; } = [];
public required StreamDelegate Stream { get; init; }
/// <summary>When true the provider is usable without an API key (for example a local CLI or server).</summary>
public bool RequiresApiKey { get; init; } = true;
}
/// <summary>Model lookup and provider-neutral streaming. Available to extensions through <c>IExtensionContext.ModelRegistry</c>.</summary>
public interface IModelRegistry
{
IReadOnlyList<ProviderRegistration> Providers { get; }
IReadOnlyList<Model> ListModels();
/// <summary>Resolve <c>provider/model-id</c>; returns null when unknown.</summary>
Model? Find(string key);
Model? Find(string provider, string modelId);
ProviderRegistration? GetProvider(string providerId);
/// <summary>True when the provider has a resolvable API key or does not need one.</summary>
bool IsAvailable(Model model);
Task<string?> ResolveApiKeyAsync(string providerId, CancellationToken cancellationToken = default);
/// <summary>Stream through the model's provider with the API key injected. Never throws; failures arrive as <see cref="AssistantMessageEvent.Error"/>.</summary>
IAsyncEnumerable<AssistantMessageEvent> StreamAsync(Model model, LlmContext context, StreamOptions options, CancellationToken cancellationToken = default);
/// <summary>Convenience: stream and return the final message.</summary>
Task<AssistantMessage> CompleteAsync(Model model, LlmContext context, StreamOptions options, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,17 @@
using System.Text.Json;
using Volcanic.AgenticCore.Abstractions.Messages;
namespace Volcanic.AgenticCore.Abstractions.Providers;
/// <summary>What a provider is asked to complete: system prompt, transcript and the tools the model may call.</summary>
public sealed record LlmContext(
string? SystemPrompt,
IReadOnlyList<Message> Messages,
IReadOnlyList<ToolDescriptor> Tools)
{
public static LlmContext Simple(string? systemPrompt, string userText) =>
new(systemPrompt, [UserMessage.FromText(userText)], []);
}
/// <summary>A tool as declared to the model.</summary>
public sealed record ToolDescriptor(string Name, string Description, JsonElement Parameters);

View File

@@ -0,0 +1,53 @@
using System.Text.Json.Serialization;
using Volcanic.AgenticCore.Abstractions.Messages;
namespace Volcanic.AgenticCore.Abstractions.Providers;
public enum ThinkingLevel { Off, Minimal, Low, Medium, High, XHigh, Max }
public enum ModelInput { Text, Image }
/// <summary>Price per million tokens in USD.</summary>
public sealed record ModelCost(decimal Input, decimal Output, decimal CacheRead, decimal CacheWrite)
{
public static readonly ModelCost Zero = new(0, 0, 0, 0);
}
/// <summary>
/// Describes one model. <see cref="Provider"/> is the account/auth scope (for example <c>anthropic</c>, <c>openrouter</c>);
/// <see cref="Api"/> is the wire protocol implementation (for example <c>anthropic-messages</c>, <c>openai-completions</c>).
/// </summary>
public sealed record Model
{
public required string Id { get; init; }
public required string Name { get; init; }
public required string Provider { get; init; }
public required string Api { get; init; }
public string? BaseUrl { get; init; }
public bool Reasoning { get; init; }
public IReadOnlyList<ModelInput> Input { get; init; } = [ModelInput.Text];
public ModelCost Cost { get; init; } = ModelCost.Zero;
public int ContextWindow { get; init; } = 128_000;
public int MaxTokens { get; init; } = 8_192;
/// <summary>
/// Maps a thinking level to the provider-specific value. A <c>null</c> value marks the level as unsupported.
/// Absent means every level is supported when <see cref="Reasoning"/> is true.
/// </summary>
public IReadOnlyDictionary<ThinkingLevel, string?>? ThinkingLevelMap { get; init; }
public IReadOnlyDictionary<string, string>? Headers { get; init; }
/// <summary><c>provider/id</c>, the form used by <c>IModelRegistry.Find</c> and settings.</summary>
[JsonIgnore]
public string Key => $"{Provider}/{Id}";
[JsonIgnore]
public bool SupportsImages => Input.Contains(ModelInput.Image);
public Cost CalculateCost(Messages.Usage usage) => new(
usage.Input * Cost.Input / 1_000_000m,
usage.Output * Cost.Output / 1_000_000m,
usage.CacheRead * Cost.CacheRead / 1_000_000m,
usage.CacheWrite * Cost.CacheWrite / 1_000_000m);
}

View File

@@ -0,0 +1,35 @@
using System.Text.Json;
using System.Text.Json.Nodes;
namespace Volcanic.AgenticCore.Abstractions.Providers;
public enum CacheRetention { None, Short, Long }
public sealed record ProviderResponseInfo(int Status, IReadOnlyDictionary<string, string> Headers);
/// <summary>Per-request options handed to <see cref="IProvider.StreamAsync"/>.</summary>
public sealed record StreamOptions
{
public static readonly StreamOptions Default = new();
public string? ApiKey { get; init; }
public double? Temperature { get; init; }
public int? MaxTokens { get; init; }
/// <summary>Provider-neutral thinking effort. <c>null</c> or <see cref="ThinkingLevel.Off"/> disables thinking.</summary>
public ThinkingLevel? Reasoning { get; init; }
public CacheRetention CacheRetention { get; init; } = CacheRetention.Short;
/// <summary>Cache affinity key (for example OpenAI <c>prompt_cache_key</c>).</summary>
public string? SessionId { get; init; }
public TimeSpan? Timeout { get; init; }
/// <summary>Extra HTTP headers. A <c>null</c> value removes a default header.</summary>
public IReadOnlyDictionary<string, string?>? Headers { get; init; }
/// <summary>Called with the serialised request payload before sending; a non-null return replaces it.</summary>
public Func<JsonNode, ValueTask<JsonNode?>>? OnPayload { get; init; }
/// <summary>Called once response headers are available and before the body is consumed.</summary>
public Func<ProviderResponseInfo, ValueTask>? OnResponse { get; init; }
/// <summary>Called for every raw provider stream event; awaited in stream order.</summary>
public Func<JsonElement, ValueTask>? OnProviderStreamEvent { get; init; }
}

View File

@@ -0,0 +1,37 @@
namespace Volcanic.AgenticCore.Abstractions.Sessions;
public sealed record SessionData(SessionHeader Header, IReadOnlyList<SessionEntry> Entries);
public sealed record SessionSummary(
string SessionId,
string? Name,
DateTimeOffset Created,
DateTimeOffset Modified,
int MessageCount,
string? FirstUserText);
/// <summary>
/// Persistence for sessions. <paramref name="scope"/> groups sessions (the default file store derives it from the working directory;
/// a chat host may use a chat id). Implementations must be safe for concurrent use across different sessions.
/// </summary>
public interface ISessionStore
{
Task CreateAsync(string scope, SessionHeader header, CancellationToken cancellationToken = default);
Task AppendAsync(string scope, string sessionId, SessionEntry entry, CancellationToken cancellationToken = default);
Task<SessionData?> LoadAsync(string scope, string sessionId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<SessionSummary>> ListAsync(string scope, CancellationToken cancellationToken = default);
}
/// <summary>Read access to the current session, as seen by extensions.</summary>
public interface IReadOnlySessionManager
{
string SessionId { get; }
string Scope { get; }
string Cwd { get; }
string? Name { get; }
string? LeafId { get; }
IReadOnlyList<SessionEntry> GetEntries();
/// <summary>Entries from the root to the current leaf, in order.</summary>
IReadOnlyList<SessionEntry> GetBranch();
SessionEntry? GetEntry(string id);
}

View File

@@ -0,0 +1,78 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Providers;
namespace Volcanic.AgenticCore.Abstractions.Sessions;
/// <summary>First line of a session file. Not part of the entry tree.</summary>
public sealed record SessionHeader
{
public int Version { get; init; } = 1;
public required string SessionId { get; init; }
public required string Cwd { get; init; }
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
public string? ParentSessionId { get; init; }
}
/// <summary>One node of the session tree. Discriminated on the wire by <c>type</c>.</summary>
[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")]
[JsonDerivedType(typeof(MessageEntry), "message")]
[JsonDerivedType(typeof(ModelChangeEntry), "model_change")]
[JsonDerivedType(typeof(ThinkingLevelChangeEntry), "thinking_level_change")]
[JsonDerivedType(typeof(CompactionEntry), "compaction")]
[JsonDerivedType(typeof(CustomEntry), "custom")]
[JsonDerivedType(typeof(LabelEntry), "label")]
[JsonDerivedType(typeof(SessionInfoEntry), "session_info")]
public abstract record SessionEntry
{
public required string Id { get; init; }
public string? ParentId { get; init; }
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
}
/// <summary>Any message, including <see cref="CustomMessage"/>.</summary>
public sealed record MessageEntry : SessionEntry
{
public required Message Message { get; init; }
}
public sealed record ModelChangeEntry : SessionEntry
{
public required string Provider { get; init; }
public required string ModelId { get; init; }
}
public sealed record ThinkingLevelChangeEntry : SessionEntry
{
public required ThinkingLevel ThinkingLevel { get; init; }
}
/// <summary>Everything before <see cref="FirstKeptEntryId"/> on the branch is replaced by <see cref="Summary"/> when building context.</summary>
public sealed record CompactionEntry : SessionEntry
{
public required string Summary { get; init; }
/// <summary>The oldest entry that stays in context verbatim. Null keeps nothing before the compaction.</summary>
public string? FirstKeptEntryId { get; init; }
public int TokensBefore { get; init; }
public JsonElement? Details { get; init; }
public Usage? Usage { get; init; }
}
/// <summary>Durable extension state that never enters the model context.</summary>
public sealed record CustomEntry : SessionEntry
{
public required string CustomType { get; init; }
public JsonElement? Data { get; init; }
}
public sealed record LabelEntry : SessionEntry
{
public required string TargetId { get; init; }
public string? Label { get; init; }
}
public sealed record SessionInfoEntry : SessionEntry
{
public string? Name { get; init; }
}

View File

@@ -0,0 +1,97 @@
using System.Text.Json;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Json;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Providers;
namespace Volcanic.AgenticCore.Abstractions.Tools;
public enum ToolExecutionMode { Parallel, Sequential }
/// <summary>What a tool returns. <see cref="Content"/> is what the model sees; <see cref="Details"/> is structured data for hosts and UIs.</summary>
public sealed record ToolResult(
IReadOnlyList<ContentBlock> Content,
JsonElement? Details = null,
Usage? Usage = null,
bool Terminate = false)
{
public static ToolResult Text(string text) => new([new TextContent(text)]);
public static ToolResult Text<TDetails>(string text, TDetails details) =>
new([new TextContent(text)], VolcanicJson.ToElement(details));
public string TextContent => string.Concat(Content.OfType<TextContent>().Select(t => t.Text));
}
/// <summary>Everything a tool gets besides its arguments.</summary>
public sealed record ToolInvocation(string ToolCallId, IExtensionContext Context, IProgress<ToolResult>? Progress)
{
public void Report(ToolResult partial) => Progress?.Report(partial);
}
/// <summary>A tool the model can call. Throw to produce an error result; a returned result is never an error.</summary>
public interface ITool
{
string Name { get; }
string Description { get; }
/// <summary>JSON Schema (type: object) of the arguments.</summary>
JsonElement Parameters { get; }
/// <summary>One line for the system prompt's tools section. Without it the tool is only described through the API.</summary>
string? PromptSnippet => null;
/// <summary>Bullet points appended to the system prompt guidelines while the tool is active.</summary>
IReadOnlyList<string> PromptGuidelines => [];
ToolExecutionMode ExecutionMode => ToolExecutionMode.Parallel;
Task<ToolResult> ExecuteAsync(ToolCallContent call, ToolInvocation invocation, CancellationToken cancellationToken);
ToolDescriptor ToDescriptor() => new(Name, Description, Parameters);
}
/// <summary>Typed tool base: arguments are deserialised into <typeparamref name="TArgs"/> and the schema is generated from it.</summary>
public abstract class Tool<TArgs> : ITool
{
private JsonElement? _schema;
public abstract string Name { get; }
public abstract string Description { get; }
public virtual string? PromptSnippet => null;
public virtual IReadOnlyList<string> PromptGuidelines => [];
public virtual ToolExecutionMode ExecutionMode => ToolExecutionMode.Parallel;
public JsonElement Parameters => _schema ??= JsonSchema.For<TArgs>();
public Task<ToolResult> ExecuteAsync(ToolCallContent call, ToolInvocation invocation, CancellationToken cancellationToken)
{
var args = call.Arguments.Deserialize<TArgs>(VolcanicJson.Options)
?? throw new ArgumentException($"Tool '{Name}' received no arguments.");
return ExecuteAsync(args, invocation, cancellationToken);
}
protected abstract Task<ToolResult> ExecuteAsync(TArgs args, ToolInvocation invocation, CancellationToken cancellationToken);
}
/// <summary>Build a tool from lambdas without a class.</summary>
public sealed class DelegateTool<TArgs> : Tool<TArgs>
{
private readonly Func<TArgs, ToolInvocation, CancellationToken, Task<ToolResult>> _execute;
public DelegateTool(string name, string description, Func<TArgs, ToolInvocation, CancellationToken, Task<ToolResult>> execute,
string? promptSnippet = null, IReadOnlyList<string>? promptGuidelines = null, ToolExecutionMode executionMode = ToolExecutionMode.Parallel)
{
Name = name;
Description = description;
_execute = execute;
PromptSnippet = promptSnippet;
PromptGuidelines = promptGuidelines ?? [];
ExecutionMode = executionMode;
}
public override string Name { get; }
public override string Description { get; }
public override string? PromptSnippet { get; }
public override IReadOnlyList<string> PromptGuidelines { get; }
public override ToolExecutionMode ExecutionMode { get; }
protected override Task<ToolResult> ExecuteAsync(TArgs args, ToolInvocation invocation, CancellationToken cancellationToken) =>
_execute(args, invocation, cancellationToken);
}

View File

@@ -0,0 +1,125 @@
using System.ComponentModel;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Schema;
using Volcanic.AgenticCore.Abstractions.Json;
namespace Volcanic.AgenticCore.Abstractions.Tools;
/// <summary>Generates JSON Schema for tool argument types using <see cref="JsonSchemaExporter"/> with LLM-friendly post-processing.</summary>
public static class JsonSchema
{
private static readonly Dictionary<Type, JsonElement> Cache = new();
private static readonly Lock CacheLock = new();
// Strict number handling: the shared Web options allow numbers-from-strings, which the exporter renders as ["string","integer"].
private static readonly JsonSerializerOptions SchemaOptions = new(VolcanicJson.Options)
{
NumberHandling = System.Text.Json.Serialization.JsonNumberHandling.Strict,
TypeInfoResolver = new System.Text.Json.Serialization.Metadata.DefaultJsonTypeInfoResolver(),
};
public static JsonElement For<T>() => For(typeof(T));
public static JsonElement For(Type type)
{
lock (CacheLock)
{
if (Cache.TryGetValue(type, out var cached)) return cached;
}
var exporterOptions = new JsonSchemaExporterOptions
{
TreatNullObliviousAsNonNullable = true,
TransformSchemaNode = Transform,
};
var node = SchemaOptions.GetJsonSchemaAsNode(type, exporterOptions);
if (node is JsonObject root)
{
root.Remove("$schema");
if (!root.ContainsKey("type")) root["type"] = "object";
}
var element = VolcanicJson.Parse(node.ToJsonString());
lock (CacheLock) Cache[type] = element;
return element;
}
/// <summary>Builds a minimal object schema by hand, for tools that do not want a typed argument record.</summary>
public static JsonElement Object(Action<JsonObject> properties, params string[] required)
{
var props = new JsonObject();
properties(props);
var schema = new JsonObject { ["type"] = "object", ["properties"] = props };
if (required.Length > 0) schema["required"] = new JsonArray(required.Select(r => (JsonNode)r).ToArray());
return VolcanicJson.Parse(schema.ToJsonString());
}
public static JsonObject Property(string type, string? description = null)
{
var node = new JsonObject { ["type"] = type };
if (description is not null) node["description"] = description;
return node;
}
private static JsonNode Transform(JsonSchemaExporterContext context, JsonNode schema)
{
if (schema is not JsonObject obj) return schema;
// Collapse ["string","null"] to "string": LLM tool schemas want one type; optionality is expressed through `required`.
if (obj["type"] is JsonArray types)
{
var nonNull = types.Where(t => t?.GetValue<string>() != "null").Select(t => t!.GetValue<string>()).ToList();
obj["type"] = nonNull.Count == 1 ? nonNull[0] : new JsonArray(nonNull.Select(t => (JsonNode)t).ToArray());
}
// Enums are emitted as a bare `enum` list; providers such as OpenAI strict mode want an explicit type.
if (obj["enum"] is JsonArray && !obj.ContainsKey("type")) obj["type"] = "string";
var description = FindDescription(context);
if (description is not null && !obj.ContainsKey("description")) obj["description"] = description;
// Nullable properties are optional: drop them from `required`.
if (obj["properties"] is JsonObject props && obj["required"] is JsonArray required && context.TypeInfo.Properties is { } infos)
{
var optional = infos
.Where(p => IsNullable(p))
.Select(p => p.Name)
.ToHashSet();
var keep = required.Where(r => r is not null && !optional.Contains(r.GetValue<string>())).Select(r => (JsonNode)r!.GetValue<string>()).ToArray();
if (keep.Length == 0) obj.Remove("required");
else obj["required"] = new JsonArray(keep);
}
return obj;
}
private static bool IsNullable(System.Text.Json.Serialization.Metadata.JsonPropertyInfo property)
{
if (property.IsGetNullable) return true;
var provider = property.AttributeProvider;
if (provider is PropertyInfo pi)
{
var info = new NullabilityInfoContext().Create(pi);
return info.WriteState == NullabilityState.Nullable || info.ReadState == NullabilityState.Nullable;
}
return false;
}
private static string? FindDescription(JsonSchemaExporterContext context)
{
var attr = context.PropertyInfo?.AttributeProvider?.GetCustomAttributes(typeof(DescriptionAttribute), true).OfType<DescriptionAttribute>().FirstOrDefault();
if (attr is not null) return attr.Description;
if (context.PropertyInfo?.AttributeProvider is PropertyInfo pi)
{
// Positional record parameters carry attributes on the constructor parameter, not the property.
var ctor = pi.DeclaringType?.GetConstructors().OrderByDescending(c => c.GetParameters().Length).FirstOrDefault();
var param = ctor?.GetParameters().FirstOrDefault(p => string.Equals(p.Name, pi.Name, StringComparison.OrdinalIgnoreCase));
var pattr = param?.GetCustomAttribute<DescriptionAttribute>();
if (pattr is not null) return pattr.Description;
}
if (context.PropertyInfo is null)
return context.TypeInfo.Type.GetCustomAttribute<DescriptionAttribute>()?.Description;
return null;
}
}

View File

@@ -0,0 +1,35 @@
namespace Volcanic.AgenticCore.Abstractions.Ui;
public enum NotifyKind { Info, Warning, Error }
public sealed record UiRequestOptions(TimeSpan? Timeout = null);
/// <summary>
/// The host's user interaction surface, used by extensions (through <c>IExtensionContext.Ui</c>).
/// A chat host implements dialogs with buttons or replies; the default <see cref="NullUserInterface"/> answers "cancelled".
/// </summary>
public interface IUserInterface
{
/// <summary>Ask the user to pick one option. Returns null when cancelled or timed out.</summary>
Task<string?> SelectAsync(string title, IReadOnlyList<string> options, UiRequestOptions? requestOptions, CancellationToken cancellationToken);
/// <summary>Yes/no question. Returns false when cancelled or timed out.</summary>
Task<bool> ConfirmAsync(string title, string message, UiRequestOptions? requestOptions, CancellationToken cancellationToken);
/// <summary>Free text. Returns null when cancelled or timed out.</summary>
Task<string?> InputAsync(string title, string? placeholder, UiRequestOptions? requestOptions, CancellationToken cancellationToken);
/// <summary>Fire-and-forget notification.</summary>
void Notify(string message, NotifyKind kind = NotifyKind.Info);
/// <summary>Set (or clear with null) a keyed status line.</summary>
void SetStatus(string key, string? text);
}
/// <summary>Headless default: every dialog is cancelled, notifications are dropped.</summary>
public sealed class NullUserInterface : IUserInterface
{
public static readonly NullUserInterface Instance = new();
public Task<string?> SelectAsync(string title, IReadOnlyList<string> options, UiRequestOptions? requestOptions, CancellationToken cancellationToken) => Task.FromResult<string?>(null);
public Task<bool> ConfirmAsync(string title, string message, UiRequestOptions? requestOptions, CancellationToken cancellationToken) => Task.FromResult(false);
public Task<string?> InputAsync(string title, string? placeholder, UiRequestOptions? requestOptions, CancellationToken cancellationToken) => Task.FromResult<string?>(null);
public void Notify(string message, NotifyKind kind = NotifyKind.Info) { }
public void SetStatus(string key, string? text) { }
}

View File

@@ -0,0 +1,87 @@
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Providers;
namespace Volcanic.AgenticCore.Tests.Abstractions;
public class AssistantMessageBuilderTests
{
private static AssistantMessageBuilder NewBuilder() => new("faux", "faux-api", "echo");
[Fact]
public void Text_block_events_carry_index_and_cumulative_partial()
{
var b = NewBuilder();
Assert.IsType<AssistantMessageEvent.Start>(b.Start());
var start = Assert.IsType<AssistantMessageEvent.TextStart>(b.StartText());
Assert.Equal(0, start.ContentIndex);
var d1 = Assert.IsType<AssistantMessageEvent.TextDelta>(b.AppendText("Hel"));
Assert.Equal("Hel", d1.Delta);
Assert.Equal("Hel", d1.Partial.Text);
b.AppendText("lo");
var end = Assert.IsType<AssistantMessageEvent.TextEnd>(b.EndText());
Assert.Equal("Hello", end.Content);
Assert.Equal("Hello", end.Partial.Text);
Assert.Equal(StopReason.Pending, end.Partial.StopReason);
}
[Fact]
public void Tool_call_arguments_are_parsed_from_accumulated_deltas()
{
var b = NewBuilder();
b.Start();
var start = Assert.IsType<AssistantMessageEvent.ToolCallStart>(b.StartToolCall("call_1", "bash"));
Assert.Equal("bash", start.ToolName);
b.AppendToolCallDelta("{\"comm");
b.AppendToolCallDelta("and\":\"ls\"}");
var end = Assert.IsType<AssistantMessageEvent.ToolCallEnd>(b.EndToolCall());
Assert.Equal("ls", end.ToolCall.Arguments.GetProperty("command").GetString());
var done = Assert.IsType<AssistantMessageEvent.Done>(b.Done(StopReason.Stop));
Assert.Equal(StopReason.ToolUse, done.Reason);
Assert.Single(done.Message.ToolCalls);
}
[Fact]
public void Invalid_tool_arguments_become_empty_object()
{
var b = NewBuilder();
b.StartToolCall("c", "x");
b.AppendToolCallDelta("{not json");
var end = Assert.IsType<AssistantMessageEvent.ToolCallEnd>(b.EndToolCall(out var raw));
Assert.Equal("{not json", raw);
Assert.Equal(System.Text.Json.JsonValueKind.Object, end.ToolCall.Arguments.ValueKind);
}
[Fact]
public void Done_closes_open_blocks_and_Fail_preserves_content()
{
var b = NewBuilder();
b.AppendText("partial");
var done = Assert.IsType<AssistantMessageEvent.Done>(b.Done(StopReason.Length));
Assert.Equal(StopReason.Length, done.Message.StopReason);
Assert.Equal("partial", done.Message.Text);
var f = NewBuilder();
f.AppendThinking("hmm");
f.SetUsage(new Usage(5, 1, 0, 0));
var error = Assert.IsType<AssistantMessageEvent.Error>(f.Fail("boom"));
Assert.Equal(StopReason.Error, error.Reason);
Assert.Equal("boom", error.Message.ErrorMessage);
Assert.Equal("hmm", Assert.IsType<ThinkingContent>(error.Message.Content[0]).Thinking);
Assert.Equal(6, error.Message.Usage.TotalTokens);
var a = NewBuilder();
Assert.Equal(StopReason.Aborted, Assert.IsType<AssistantMessageEvent.Error>(a.Fail("stop", aborted: true)).Reason);
}
[Fact]
public void Thinking_signature_is_accumulated_silently()
{
var b = NewBuilder();
b.StartThinking();
b.AppendThinking("t");
b.AppendThinkingSignature("ab");
b.AppendThinkingSignature("cd");
var end = Assert.IsType<AssistantMessageEvent.ThinkingEnd>(b.EndThinking());
Assert.Equal("abcd", Assert.IsType<ThinkingContent>(end.Partial.Content[0]).Signature);
}
}

View File

@@ -0,0 +1,82 @@
using System.Text.Json;
using Volcanic.AgenticCore.Abstractions.Json;
using Volcanic.AgenticCore.Abstractions.Messages;
namespace Volcanic.AgenticCore.Tests.Abstractions;
public class MessageJsonTests
{
[Fact]
public void UserMessage_round_trips_with_role_discriminator()
{
var message = UserMessage.From("hello", [new ImageContent("AAAA", "image/png")]);
var json = VolcanicJson.Serialize<Message>(message);
Assert.Contains("\"role\":\"user\"", json);
Assert.Contains("\"type\":\"image\"", json);
var back = VolcanicJson.Deserialize<Message>(json);
var user = Assert.IsType<UserMessage>(back);
Assert.Equal("hello", user.Text);
Assert.Equal(2, user.Content.Count);
}
[Fact]
public void AssistantMessage_round_trips_all_block_types()
{
var message = new AssistantMessage
{
Provider = "faux", Api = "faux", Model = "echo",
Content =
[
new ThinkingContent("thinking...", "sig"),
new TextContent("answer"),
new ToolCallContent("call_1", "bash", VolcanicJson.Parse("""{"command":"ls"}""")),
],
Usage = new Usage(10, 5, 1, 2, 3),
StopReason = StopReason.ToolUse,
};
var json = VolcanicJson.Serialize<Message>(message);
Assert.Contains("\"stopReason\":\"toolUse\"", json);
Assert.Contains("\"type\":\"toolCall\"", json);
var back = Assert.IsType<AssistantMessage>(VolcanicJson.Deserialize<Message>(json));
Assert.Equal("answer", back.Text);
Assert.Single(back.ToolCalls);
Assert.Equal("ls", back.ToolCalls[0].Arguments.GetProperty("command").GetString());
Assert.Equal(18, back.Usage.TotalTokens);
Assert.Equal(StopReason.ToolUse, back.StopReason);
Assert.Equal("sig", Assert.IsType<ThinkingContent>(back.Content[0]).Signature);
}
[Fact]
public void ToolResult_and_Custom_messages_round_trip()
{
var result = new ToolResultMessage
{
ToolCallId = "call_1", ToolName = "bash", IsError = true,
Content = [new TextContent("boom")],
Details = VolcanicJson.Parse("""{"exitCode":1}"""),
};
var custom = CustomMessage.FromText("note", "remember", display: false);
var r = Assert.IsType<ToolResultMessage>(VolcanicJson.Deserialize<Message>(VolcanicJson.Serialize<Message>(result)));
Assert.True(r.IsError);
Assert.Equal(1, r.Details!.Value.GetProperty("exitCode").GetInt32());
var c = Assert.IsType<CustomMessage>(VolcanicJson.Deserialize<Message>(VolcanicJson.Serialize<Message>(custom)));
Assert.Equal("note", c.CustomType);
Assert.False(c.Display);
}
[Fact]
public void Usage_addition_sums_tokens_and_cost()
{
var a = new Usage(1, 2, 3, 4, 1, new Cost(0.1m, 0.2m, 0m, 0m));
var b = new Usage(10, 20, 30, 40);
var sum = a + b;
Assert.Equal(11, sum.Input);
Assert.Equal(22, sum.Output);
Assert.Equal(110, sum.TotalTokens);
Assert.Equal(0.3m, sum.Cost.Total);
}
}

View File

@@ -0,0 +1,44 @@
using Volcanic.AgenticCore.Abstractions.Json;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Abstractions.Sessions;
namespace Volcanic.AgenticCore.Tests.Abstractions;
public class SessionEntryJsonTests
{
[Fact]
public void Entries_round_trip_with_type_discriminator()
{
SessionEntry[] entries =
[
new MessageEntry { Id = "a1", Message = UserMessage.FromText("hi") },
new ModelChangeEntry { Id = "a2", ParentId = "a1", Provider = "anthropic", ModelId = "claude" },
new ThinkingLevelChangeEntry { Id = "a3", ParentId = "a2", ThinkingLevel = ThinkingLevel.High },
new CompactionEntry { Id = "a4", ParentId = "a3", Summary = "sum", FirstKeptEntryId = "a2", TokensBefore = 100 },
new CustomEntry { Id = "a5", ParentId = "a4", CustomType = "bookmark", Data = VolcanicJson.Parse("""{"x":1}""") },
new LabelEntry { Id = "a6", ParentId = "a5", TargetId = "a1", Label = "start" },
new SessionInfoEntry { Id = "a7", ParentId = "a6", Name = "my session" },
];
var expectedTypes = new[] { "message", "model_change", "thinking_level_change", "compaction", "custom", "label", "session_info" };
for (var i = 0; i < entries.Length; i++)
{
var json = VolcanicJson.Serialize(entries[i]);
Assert.Contains($"\"type\":\"{expectedTypes[i]}\"", json);
var back = VolcanicJson.Deserialize<SessionEntry>(json);
Assert.NotNull(back);
Assert.Equal(entries[i].GetType(), back!.GetType());
Assert.Equal(entries[i].Id, back.Id);
}
}
[Fact]
public void Header_round_trips()
{
var header = new SessionHeader { SessionId = "s1", Cwd = "/tmp" };
var back = VolcanicJson.Deserialize<SessionHeader>(VolcanicJson.Serialize(header));
Assert.Equal("s1", back!.SessionId);
Assert.Equal(1, back.Version);
}
}

View File

@@ -0,0 +1,71 @@
using System.ComponentModel;
using System.Text.Json;
using Volcanic.AgenticCore.Abstractions.Json;
using Volcanic.AgenticCore.Abstractions.Tools;
namespace Volcanic.AgenticCore.Tests.Abstractions;
public class ToolSchemaTests
{
public sealed record HelloArgs(
[property: Description("Name to greet")] string Name,
[property: Description("How many times")] int? Times,
string[]? Tags = null);
public enum Mode { Fast, Slow }
public sealed class Nested
{
public required string Path { get; init; }
public Mode Mode { get; init; }
public double Ratio { get; init; }
public bool Verbose { get; init; }
}
[Fact]
public void Record_generates_object_schema_with_required_and_descriptions()
{
var schema = JsonSchema.For<HelloArgs>();
Assert.Equal("object", schema.GetProperty("type").GetString());
var props = schema.GetProperty("properties");
Assert.Equal("string", props.GetProperty("name").GetProperty("type").GetString());
Assert.Equal("Name to greet", props.GetProperty("name").GetProperty("description").GetString());
Assert.Equal("integer", props.GetProperty("times").GetProperty("type").GetString());
Assert.Equal("array", props.GetProperty("tags").GetProperty("type").GetString());
var required = schema.GetProperty("required").EnumerateArray().Select(r => r.GetString()).ToList();
Assert.Equal(["name"], required);
}
[Fact]
public void Class_schema_maps_primitives_and_enums()
{
var schema = JsonSchema.For<Nested>();
var props = schema.GetProperty("properties");
Assert.Equal("number", props.GetProperty("ratio").GetProperty("type").GetString());
Assert.Equal("boolean", props.GetProperty("verbose").GetProperty("type").GetString());
Assert.Equal("string", props.GetProperty("mode").GetProperty("type").GetString());
var values = props.GetProperty("mode").GetProperty("enum").EnumerateArray().Select(e => e.GetString()).ToList();
Assert.Equal(["fast", "slow"], values);
var required = schema.GetProperty("required").EnumerateArray().Select(r => r.GetString()).ToList();
Assert.Contains("path", required);
}
[Fact]
public async Task Typed_tool_deserialises_arguments()
{
var tool = new DelegateTool<HelloArgs>("hello", "greets", (args, _, _) =>
Task.FromResult(ToolResult.Text($"Hello, {args.Name} x{args.Times ?? 1}")));
var call = new Volcanic.AgenticCore.Abstractions.Messages.ToolCallContent("c1", "hello", VolcanicJson.Parse("""{"name":"Ada","times":2}"""));
var result = await ((ITool)tool).ExecuteAsync(call, new ToolInvocation("c1", null!, null), CancellationToken.None);
Assert.Equal("Hello, Ada x2", result.TextContent);
Assert.Equal("hello", ((ITool)tool).ToDescriptor().Name);
}
[Fact]
public void Hand_built_schema_helper()
{
var schema = JsonSchema.Object(p => p["command"] = JsonSchema.Property("string", "Shell command"), "command");
Assert.Equal("Shell command", schema.GetProperty("properties").GetProperty("command").GetProperty("description").GetString());
Assert.Equal(JsonValueKind.Array, schema.GetProperty("required").ValueKind);
}
}

View File

@@ -1,7 +0,0 @@
namespace Volcanic.AgenticCore.Tests;
public class SmokeTests
{
[Fact]
public void ProjectCompiles() => Assert.True(true);
}