forked from admin/VolcanicAgenticCore
feat(core): IChatChannel contract and AgentSessionOptions.Channel
Adds the host-agnostic IChatChannel contract (ChatFile, ChatDelivery, ChatChannelLimits, ChatDeliveryException) to Abstractions/Ui, AgentSessionOptions.Channel, AgentSession.Channel and IExtensionContext.Channel. IExtensionHost.Channel is an internal plumbing default member that ExtensionContext forwards. Test support: FakeChatChannel and the TestHost builder's Channel.
This commit is contained in:
@@ -22,6 +22,8 @@ public interface IExtensionContext
|
||||
/// <summary>False when the host uses <see cref="NullUserInterface"/>; extensions should then avoid dialogs.</summary>
|
||||
bool HasUi { get; }
|
||||
IUserInterface Ui { get; }
|
||||
/// <summary>The session's chat channel, or null when the host has none (then <c>send</c> isn't registered).</summary>
|
||||
IChatChannel? Channel => null;
|
||||
IReadOnlySessionManager SessionManager { get; }
|
||||
IModelRegistry ModelRegistry { get; }
|
||||
Model? Model { get; }
|
||||
|
||||
38
src/Volcanic.AgenticCore.Abstractions/Ui/IChatChannel.cs
Normal file
38
src/Volcanic.AgenticCore.Abstractions/Ui/IChatChannel.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
namespace Volcanic.AgenticCore.Abstractions.Ui;
|
||||
|
||||
/// <summary>How a file should be delivered. <see cref="Auto"/> lets the channel choose.</summary>
|
||||
public enum ChatFileKind { Auto, Photo, Document }
|
||||
|
||||
/// <param name="Path">Absolute path of an existing file; the caller has already confined and validated it.</param>
|
||||
/// <param name="Kind">Photo, document, or let the channel choose.</param>
|
||||
/// <param name="Caption">Plain text shown with the file, at most <see cref="ChatChannelLimits.MaxCaptionLength"/>.</param>
|
||||
public sealed record ChatFile(string Path, ChatFileKind Kind = ChatFileKind.Auto, string? Caption = null);
|
||||
|
||||
/// <param name="SentAs">How a file was actually delivered (Photo or Document); null for text.</param>
|
||||
/// <param name="MessageId">Host message id(s), for logs and tool details.</param>
|
||||
public sealed record ChatDelivery(ChatFileKind? SentAs, string? MessageId);
|
||||
|
||||
/// <summary>Upload limits of a channel, in bytes and characters.</summary>
|
||||
public sealed record ChatChannelLimits(long MaxPhotoBytes, long MaxDocumentBytes, int MaxCaptionLength);
|
||||
|
||||
/// <summary>
|
||||
/// Delivers content into the conversation's chat, outside the normal assistant-message rendering. Implementations throw
|
||||
/// only <see cref="ChatDeliveryException"/> and <see cref="OperationCanceledException"/>, are safe to call from any thread,
|
||||
/// and work while no run is active. Nothing delivered is persisted by the core.
|
||||
/// </summary>
|
||||
public interface IChatChannel
|
||||
{
|
||||
ChatChannelLimits Limits { get; }
|
||||
|
||||
/// <summary>Markdown, rendered like assistant text (split when long). Not added to the session transcript.</summary>
|
||||
Task<ChatDelivery> SendTextAsync(string markdown, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Deliver a file. <see cref="ChatFileKind.Auto"/> lets the channel choose, and it may fall back from photo to
|
||||
/// document. Throws <see cref="ChatDeliveryException"/> with a message fit for the model or the user.
|
||||
/// </summary>
|
||||
Task<ChatDelivery> SendFileAsync(ChatFile file, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>A delivery failed; the message is fit for the model or the user.</summary>
|
||||
public sealed class ChatDeliveryException(string message, Exception? inner = null) : Exception(message, inner);
|
||||
@@ -16,6 +16,7 @@ public sealed partial class AgentSession : IExtensionHost
|
||||
{
|
||||
string IExtensionHost.Scope => _session.Scope;
|
||||
IUserInterface IExtensionHost.Ui => _ui;
|
||||
IChatChannel? IExtensionHost.Channel => _options.Channel;
|
||||
IReadOnlySessionManager IExtensionHost.SessionManager => _session;
|
||||
IModelRegistry IExtensionHost.ModelRegistry => _models;
|
||||
Model? IExtensionHost.Model => _model;
|
||||
|
||||
@@ -102,6 +102,8 @@ public sealed partial class AgentSession : IAsyncDisposable
|
||||
public string Cwd => _options.Cwd;
|
||||
/// <summary>The session's time zone: the session option, else the host default, else the machine's zone.</summary>
|
||||
public TimeZoneInfo TimeZone => _options.TimeZone ?? _coreOptions.DefaultTimeZone ?? TimeZoneInfo.Local;
|
||||
/// <summary>The host's chat channel for this session (e.g. for scheduled deliveries), or null.</summary>
|
||||
public IChatChannel? Channel => _options.Channel;
|
||||
public Model? Model => _model;
|
||||
public ThinkingLevel ThinkingLevel => _thinkingLevel;
|
||||
public bool IsStreaming { get { lock (_stateLock) return _runTask is { IsCompleted: false }; } }
|
||||
|
||||
@@ -15,6 +15,7 @@ internal class ExtensionContext(ExtensionRunner runner, IExtensionHost host, Fun
|
||||
public string SessionId => Host.SessionId;
|
||||
public bool HasUi => Host.Ui is not NullUserInterface;
|
||||
public IUserInterface Ui => Host.Ui;
|
||||
public IChatChannel? Channel => Host.Channel;
|
||||
public IReadOnlySessionManager SessionManager => Host.SessionManager;
|
||||
public IModelRegistry ModelRegistry => Host.ModelRegistry;
|
||||
public Model? Model => Host.Model;
|
||||
|
||||
@@ -15,6 +15,8 @@ public interface IExtensionHost
|
||||
/// <summary>The session store scope, e.g. <c>telegram-42</c>.</summary>
|
||||
string Scope { get; }
|
||||
IUserInterface Ui { get; }
|
||||
/// <summary>The session's chat channel, or null when the host has none (then <c>send</c> isn't registered).</summary>
|
||||
IChatChannel? Channel => null;
|
||||
IReadOnlySessionManager SessionManager { get; }
|
||||
IModelRegistry ModelRegistry { get; }
|
||||
Model? Model { get; }
|
||||
|
||||
@@ -63,6 +63,8 @@ public sealed class AgentSessionOptions
|
||||
/// <summary>The conversation's time zone. Falls back to <see cref="AgenticCoreOptions.DefaultTimeZone"/>, then <see cref="TimeZoneInfo.Local"/>.</summary>
|
||||
public TimeZoneInfo? TimeZone { get; init; }
|
||||
public IUserInterface? Ui { get; init; }
|
||||
/// <summary>Delivers files and texts into the conversation's chat; enables the <c>send</c> tool.</summary>
|
||||
public IChatChannel? Channel { get; init; }
|
||||
/// <summary>Active tool names. Defaults to read, bash, edit, write.</summary>
|
||||
public IReadOnlyList<string>? ActiveTools { get; init; }
|
||||
public ToolExecutionMode ToolExecution { get; init; } = ToolExecutionMode.Parallel;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
using Volcanic.AgenticCore.Abstractions.Tools;
|
||||
using Volcanic.AgenticCore.Abstractions.Ui;
|
||||
using Volcanic.AgenticCore.Features.Extensions;
|
||||
using Volcanic.AgenticCore.Hosting;
|
||||
using Volcanic.AgenticCore.Testing;
|
||||
using Volcanic.AgenticCore.Tests.Support;
|
||||
|
||||
namespace Volcanic.AgenticCore.Tests.Features;
|
||||
|
||||
public class AgentSessionFactoryTests
|
||||
{
|
||||
public sealed record ProbeArgs(string Value);
|
||||
|
||||
private static (LambdaExtension Extension, Func<IChatChannel?> Seen) Probe()
|
||||
{
|
||||
IChatChannel? seen = null;
|
||||
var probe = new DelegateTool<ProbeArgs>("probe", "p", (_, inv, _) => { seen = inv.Context.Channel; return Task.FromResult(ToolResult.Text("ok")); });
|
||||
return (new LambdaExtension("probe", api => { api.RegisterTool(probe); return ValueTask.CompletedTask; }), () => seen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Session_and_extension_context_expose_the_channel()
|
||||
{
|
||||
var channel = new FakeChatChannel();
|
||||
var (extension, seen) = Probe();
|
||||
await using var host = new TestHost(null, extension);
|
||||
host.Faux.Enqueue(FauxTurn.Tool("probe", new { value = "x" }), FauxTurn.Say("done"));
|
||||
await using var session = await host.CreateSessionAsync(b => b.Channel = channel);
|
||||
|
||||
await session.PromptAsync("go");
|
||||
|
||||
Assert.Same(channel, session.Channel);
|
||||
Assert.Same(channel, seen());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Without_a_channel_both_are_null()
|
||||
{
|
||||
var (extension, seen) = Probe();
|
||||
await using var host = new TestHost(null, extension);
|
||||
host.Faux.Enqueue(FauxTurn.Tool("probe", new { value = "x" }), FauxTurn.Say("done"));
|
||||
await using var session = await host.CreateSessionAsync();
|
||||
|
||||
await session.PromptAsync("go");
|
||||
|
||||
Assert.Null(session.Channel);
|
||||
Assert.Null(seen());
|
||||
}
|
||||
}
|
||||
29
tests/Volcanic.AgenticCore.Tests/Support/FakeChatChannel.cs
Normal file
29
tests/Volcanic.AgenticCore.Tests/Support/FakeChatChannel.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using Volcanic.AgenticCore.Abstractions.Ui;
|
||||
|
||||
namespace Volcanic.AgenticCore.Tests.Support;
|
||||
|
||||
/// <summary>Records deliveries. <see cref="Failure"/> makes every file delivery fail.</summary>
|
||||
public sealed class FakeChatChannel : IChatChannel
|
||||
{
|
||||
public const long MB = 1024 * 1024;
|
||||
public ChatChannelLimits Limits { get; init; } = new(10 * MB, 50 * MB, 1024);
|
||||
public List<ChatFile> Files { get; } = [];
|
||||
public List<string> Texts { get; } = [];
|
||||
public Exception? Failure { get; set; }
|
||||
/// <summary>What an <see cref="ChatFileKind.Auto"/> file is reported as.</summary>
|
||||
public ChatFileKind AutoSendsAs { get; set; } = ChatFileKind.Photo;
|
||||
|
||||
public Task<ChatDelivery> SendTextAsync(string markdown, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (Texts) Texts.Add(markdown);
|
||||
return Task.FromResult(new ChatDelivery(null, "t1"));
|
||||
}
|
||||
|
||||
public Task<ChatDelivery> SendFileAsync(ChatFile file, CancellationToken cancellationToken)
|
||||
{
|
||||
if (Failure is not null) return Task.FromException<ChatDelivery>(Failure);
|
||||
int count;
|
||||
lock (Files) { Files.Add(file); count = Files.Count; }
|
||||
return Task.FromResult(new ChatDelivery(file.Kind == ChatFileKind.Auto ? AutoSendsAs : file.Kind, "m" + count));
|
||||
}
|
||||
}
|
||||
@@ -70,12 +70,13 @@ public sealed class TestHost : IAsyncDisposable
|
||||
public List<ExtensionRegistration> Extra { get; } = [];
|
||||
public QueueMode SteeringMode { get; set; } = QueueMode.OneAtATime;
|
||||
public TimeZoneInfo? TimeZone { get; set; }
|
||||
public Volcanic.AgenticCore.Abstractions.Ui.IChatChannel? Channel { get; set; }
|
||||
|
||||
public AgentSessionOptions Build() => new()
|
||||
{
|
||||
Cwd = cwd, SessionId = SessionId, Model = Model, ActiveTools = ActiveTools, ToolExecution = ToolExecution,
|
||||
Ui = Ui, AutoCompaction = AutoCompaction, ExtraExtensions = Extra, SteeringMode = SteeringMode,
|
||||
TimeZone = TimeZone,
|
||||
TimeZone = TimeZone, Channel = Channel,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user