forked from admin/VolcanicAgenticCore
feat(abstractions): message model, provider, tool, extension, session and UI contracts
This commit is contained in:
@@ -1 +0,0 @@
|
||||
namespace Volcanic.AgenticCore.Abstractions; internal static class AssemblyMarker { }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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><name>…</name></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;
|
||||
}
|
||||
@@ -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.");
|
||||
@@ -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);
|
||||
}
|
||||
53
src/Volcanic.AgenticCore.Abstractions/Json/VolcanicJson.cs
Normal file
53
src/Volcanic.AgenticCore.Abstractions/Json/VolcanicJson.cs
Normal 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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
109
src/Volcanic.AgenticCore.Abstractions/Messages/Message.cs
Normal file
109
src/Volcanic.AgenticCore.Abstractions/Messages/Message.cs
Normal 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 };
|
||||
}
|
||||
12
src/Volcanic.AgenticCore.Abstractions/Messages/StopReason.cs
Normal file
12
src/Volcanic.AgenticCore.Abstractions/Messages/StopReason.cs
Normal 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,
|
||||
}
|
||||
30
src/Volcanic.AgenticCore.Abstractions/Messages/Usage.cs
Normal file
30
src/Volcanic.AgenticCore.Abstractions/Messages/Usage.cs
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
45
src/Volcanic.AgenticCore.Abstractions/Providers/IProvider.cs
Normal file
45
src/Volcanic.AgenticCore.Abstractions/Providers/IProvider.cs
Normal 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><ID>_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);
|
||||
}
|
||||
@@ -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);
|
||||
53
src/Volcanic.AgenticCore.Abstractions/Providers/Model.cs
Normal file
53
src/Volcanic.AgenticCore.Abstractions/Providers/Model.cs
Normal 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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
97
src/Volcanic.AgenticCore.Abstractions/Tools/ITool.cs
Normal file
97
src/Volcanic.AgenticCore.Abstractions/Tools/ITool.cs
Normal 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);
|
||||
}
|
||||
125
src/Volcanic.AgenticCore.Abstractions/Tools/JsonSchema.cs
Normal file
125
src/Volcanic.AgenticCore.Abstractions/Tools/JsonSchema.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
35
src/Volcanic.AgenticCore.Abstractions/Ui/IUserInterface.cs
Normal file
35
src/Volcanic.AgenticCore.Abstractions/Ui/IUserInterface.cs
Normal 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) { }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace Volcanic.AgenticCore.Tests;
|
||||
|
||||
public class SmokeTests
|
||||
{
|
||||
[Fact]
|
||||
public void ProjectCompiles() => Assert.True(true);
|
||||
}
|
||||
Reference in New Issue
Block a user