diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 0fd1f19..9ed2405 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -5,6 +5,9 @@ updates:
schedule:
interval: "weekly"
day: "wednesday"
+ ignore:
+ - dependency-name: "dotnet-sdk"
+ versions: [">=11.0.0-0"]
- package-ecosystem: "nuget"
directory: "/"
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 2d093e4..f3bc39f 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -13,6 +13,8 @@
+
+
diff --git a/Version.props b/Version.props
index f6c7cba..c9edbff 100644
--- a/Version.props
+++ b/Version.props
@@ -6,5 +6,7 @@
18.3.0
5.3.0
10.4.1
+ 2.9.1
+ 10.4.1
diff --git a/netagents.slnx b/netagents.slnx
index a56c23c..bd4e7fd 100644
--- a/netagents.slnx
+++ b/netagents.slnx
@@ -4,6 +4,7 @@
+
diff --git a/src/Qyl.ChatKit/Actions.cs b/src/Qyl.ChatKit/Actions.cs
new file mode 100644
index 0000000..bc37061
--- /dev/null
+++ b/src/Qyl.ChatKit/Actions.cs
@@ -0,0 +1,61 @@
+using System.Text.Json.Serialization;
+
+namespace Qyl.ChatKit;
+
+/// Whether the action is handled on the client or the server.
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum Handler
+{
+ [JsonStringEnumMemberName("client")]
+ Client,
+
+ [JsonStringEnumMemberName("server")]
+ Server
+}
+
+/// Visual loading behavior when the action executes.
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum LoadingBehavior
+{
+ [JsonStringEnumMemberName("auto")]
+ Auto,
+
+ [JsonStringEnumMemberName("none")]
+ None,
+
+ [JsonStringEnumMemberName("self")]
+ Self,
+
+ [JsonStringEnumMemberName("container")]
+ Container
+}
+
+/// Fully resolved action configuration sent over the wire.
+public sealed record ActionConfig
+{
+ [JsonPropertyName("type")]
+ public required string Type { get; init; }
+
+ [JsonPropertyName("payload")]
+ public object? Payload { get; init; }
+
+ [JsonPropertyName("handler")]
+ public Handler Handler { get; init; } = Handler.Server;
+
+ [JsonPropertyName("loadingBehavior")]
+ public LoadingBehavior LoadingBehavior { get; init; } = LoadingBehavior.Auto;
+
+ [JsonPropertyName("streaming")]
+ public bool Streaming { get; init; } = true;
+}
+
+/// Generic action carrying a type discriminator and payload.
+public sealed record Action
+ where TType : notnull
+{
+ [JsonPropertyName("type")]
+ public required TType Type { get; init; }
+
+ [JsonPropertyName("payload")]
+ public TPayload? Payload { get; init; }
+}
diff --git a/src/Qyl.ChatKit/AgentContext.cs b/src/Qyl.ChatKit/AgentContext.cs
new file mode 100644
index 0000000..296ed05
--- /dev/null
+++ b/src/Qyl.ChatKit/AgentContext.cs
@@ -0,0 +1,178 @@
+using System.Threading.Channels;
+using Qyl.ChatKit.Widgets;
+
+namespace Qyl.ChatKit;
+
+///
+/// Context object passed to agent callbacks, providing access to the store,
+/// thread metadata, and a channel for emitting events back to the stream processor.
+///
+public sealed class AgentContext
+{
+ public required ThreadMetadata Thread { get; init; }
+ public required IStore Store { get; init; }
+ public required TContext RequestContext { get; init; }
+ public TimeProvider TimeProvider { get; init; } = TimeProvider.System;
+
+ public string? PreviousResponseId { get; set; }
+ public ClientToolCall? ClientToolCall { get; set; }
+ public WorkflowItem? WorkflowItem { get; set; }
+ public GeneratedImageItem? GeneratedImageItem { get; set; }
+
+ private readonly Channel _events = Channel.CreateUnbounded();
+
+ /// Reader for the event channel, consumed by the stream processor.
+ internal ChannelReader EventReader => _events.Reader;
+
+ /// Signal that no more events will be written.
+ internal void Complete() => _events.Writer.TryComplete();
+
+ /// Generate a new store-backed id for the given item type.
+ public string GenerateId(StoreItemType type, ThreadMetadata? thread = null) =>
+ type == StoreItemType.Thread
+ ? Store.GenerateThreadId(RequestContext)
+ : Store.GenerateItemId(type, thread ?? Thread, RequestContext);
+
+ /// Stream a widget into the thread by enqueueing widget events.
+ public async ValueTask StreamWidgetAsync(
+ WidgetRoot widget, string? copyText = null, CancellationToken ct = default)
+ {
+ await foreach (var evt in WidgetDiff.StreamWidgetAsync(
+ Thread, widget, copyText,
+ t => Store.GenerateItemId(t, Thread, RequestContext),
+ TimeProvider, ct))
+ {
+ await _events.Writer.WriteAsync(evt, ct);
+ }
+ }
+
+ /// Stream an async sequence of widget roots into the thread.
+ public async ValueTask StreamWidgetAsync(
+ IAsyncEnumerable widgetStream, string? copyText = null,
+ CancellationToken ct = default)
+ {
+ await foreach (var evt in WidgetDiff.StreamWidgetAsync(
+ Thread, widgetStream, copyText,
+ t => Store.GenerateItemId(t, Thread, RequestContext),
+ TimeProvider, ct))
+ {
+ await _events.Writer.WriteAsync(evt, ct);
+ }
+ }
+
+ /// Begin streaming a new workflow item.
+ public async ValueTask StartWorkflowAsync(Workflow workflow, CancellationToken ct = default)
+ {
+ WorkflowItem = new WorkflowItem
+ {
+ Id = GenerateId(StoreItemType.Workflow),
+ CreatedAt = TimeProvider.GetUtcNow().UtcDateTime,
+ Workflow = workflow,
+ ThreadId = Thread.Id,
+ };
+
+ if (workflow.Type != "reasoning" && workflow.Tasks.Count == 0)
+ return; // Defer sending added event until we have tasks
+
+ await StreamAsync(new ThreadItemAddedEvent { Item = WorkflowItem }, ct);
+ }
+
+ /// Append a workflow task and stream the appropriate event.
+ public async ValueTask AddWorkflowTaskAsync(ChatKitTask task, CancellationToken ct = default)
+ {
+ WorkflowItem ??= new WorkflowItem
+ {
+ Id = GenerateId(StoreItemType.Workflow),
+ CreatedAt = TimeProvider.GetUtcNow().UtcDateTime,
+ Workflow = new Workflow { Type = "custom", Tasks = [] },
+ ThreadId = Thread.Id,
+ };
+
+ var tasks = WorkflowItem.Workflow.Tasks.ToList();
+ tasks.Add(task);
+ WorkflowItem = WorkflowItem with
+ {
+ Workflow = WorkflowItem.Workflow with { Tasks = tasks },
+ };
+
+ if (WorkflowItem.Workflow.Type != "reasoning" && tasks.Count == 1)
+ {
+ await StreamAsync(new ThreadItemAddedEvent { Item = WorkflowItem }, ct);
+ }
+ else
+ {
+ await StreamAsync(new ThreadItemUpdatedEvent
+ {
+ ItemId = WorkflowItem.Id,
+ Update = new WorkflowTaskAdded
+ {
+ Task = task,
+ TaskIndex = tasks.Count - 1,
+ },
+ }, ct);
+ }
+ }
+
+ /// Update an existing workflow task and stream the delta.
+ public async ValueTask UpdateWorkflowTaskAsync(
+ ChatKitTask task, int taskIndex, CancellationToken ct = default)
+ {
+ if (WorkflowItem is null)
+ throw new InvalidOperationException("Workflow is not set");
+
+ var tasks = WorkflowItem.Workflow.Tasks.ToList();
+ tasks[taskIndex] = task;
+ WorkflowItem = WorkflowItem with
+ {
+ Workflow = WorkflowItem.Workflow with { Tasks = tasks },
+ };
+
+ await StreamAsync(new ThreadItemUpdatedEvent
+ {
+ ItemId = WorkflowItem.Id,
+ Update = new WorkflowTaskUpdated
+ {
+ Task = task,
+ TaskIndex = taskIndex,
+ },
+ }, ct);
+ }
+
+ /// Finalize the active workflow item, optionally attaching a summary.
+ public async ValueTask EndWorkflowAsync(
+ WorkflowSummary? summary = null, bool expanded = false, CancellationToken ct = default)
+ {
+ if (WorkflowItem is null)
+ return;
+
+ var finalSummary = summary ?? WorkflowItem.Workflow.Summary;
+ if (finalSummary is null)
+ {
+ var delta = TimeProvider.GetUtcNow().UtcDateTime - WorkflowItem.CreatedAt;
+ finalSummary = new DurationSummary { Duration = (int)delta.TotalSeconds };
+ }
+
+ WorkflowItem = WorkflowItem with
+ {
+ Workflow = WorkflowItem.Workflow with
+ {
+ Summary = finalSummary,
+ Expanded = expanded,
+ },
+ };
+
+ await StreamAsync(new ThreadItemDoneEvent { Item = WorkflowItem }, ct);
+ WorkflowItem = null;
+ }
+
+ /// Enqueue a ThreadStreamEvent for downstream processing.
+ public async ValueTask StreamAsync(ThreadStreamEvent evt, CancellationToken ct = default) =>
+ await _events.Writer.WriteAsync(evt, ct);
+}
+
+/// Returned from tool methods to indicate a client-side tool call.
+public sealed record ClientToolCall
+{
+ public required string Name { get; init; }
+ public required Dictionary Arguments { get; init; }
+}
diff --git a/src/Qyl.ChatKit/Attachments.cs b/src/Qyl.ChatKit/Attachments.cs
new file mode 100644
index 0000000..1bd771f
--- /dev/null
+++ b/src/Qyl.ChatKit/Attachments.cs
@@ -0,0 +1,101 @@
+using System.Text.Json.Serialization;
+
+namespace Qyl.ChatKit;
+
+/// Base metadata shared by all attachments.
+[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")]
+[JsonDerivedType(typeof(FileAttachment), "file")]
+[JsonDerivedType(typeof(ImageAttachment), "image")]
+public abstract record AttachmentBase
+{
+ [JsonPropertyName("id")]
+ public required string Id { get; init; }
+
+ [JsonPropertyName("name")]
+ public required string Name { get; init; }
+
+ [JsonPropertyName("mime_type")]
+ public required string MimeType { get; init; }
+
+ ///
+ /// Two-phase upload instructions.
+ /// Should be set to null after upload is complete or when using direct upload.
+ ///
+ [JsonPropertyName("upload_descriptor")]
+ public AttachmentUploadDescriptor? UploadDescriptor { get; init; }
+
+ ///
+ /// The thread the attachment belongs to, if any.
+ /// Added when the user message that contains the attachment is saved to store.
+ ///
+ [JsonPropertyName("thread_id")]
+ public string? ThreadId { get; init; }
+
+ ///
+ /// Integration-only metadata stored with the attachment.
+ /// Ignored by ChatKit and not returned in server responses.
+ ///
+ [JsonPropertyName("metadata")]
+ public Dictionary? Metadata { get; init; }
+}
+
+/// Attachment representing a generic file.
+public sealed record FileAttachment : AttachmentBase;
+
+/// Attachment representing an image resource.
+public sealed record ImageAttachment : AttachmentBase
+{
+ [JsonPropertyName("preview_url")]
+ public required Uri PreviewUrl { get; init; }
+}
+
+/// Two-phase upload instructions.
+public sealed record AttachmentUploadDescriptor
+{
+ [JsonPropertyName("url")]
+ public required Uri Url { get; init; }
+
+ /// The HTTP method to use when uploading the file for two-phase upload.
+ [JsonPropertyName("method")]
+ public required string Method { get; init; }
+
+ /// Optional headers to include in the upload request.
+ [JsonPropertyName("headers")]
+ public Dictionary Headers { get; init; } = new();
+}
+
+/// Metadata needed to initialize an attachment.
+public sealed record AttachmentCreateParams
+{
+ [JsonPropertyName("name")]
+ public required string Name { get; init; }
+
+ [JsonPropertyName("size")]
+ public required int Size { get; init; }
+
+ [JsonPropertyName("mime_type")]
+ public required string MimeType { get; init; }
+}
+
+/// Audio input data for transcription.
+public sealed record AudioInput
+{
+ /// Audio data bytes.
+ [JsonPropertyName("data")]
+ public required byte[] Data { get; init; }
+
+ /// Raw MIME type for the audio payload, e.g. "audio/webm;codecs=opus".
+ [JsonPropertyName("mime_type")]
+ public required string MimeType { get; init; }
+
+ /// Media type for the audio payload, e.g. "audio/webm".
+ [JsonIgnore]
+ public string MediaType => MimeType.Split(';', 2)[0];
+}
+
+/// Input speech transcription result.
+public sealed record TranscriptionResult
+{
+ [JsonPropertyName("text")]
+ public required string Text { get; init; }
+}
diff --git a/src/Qyl.ChatKit/ChatKitJsonOptions.cs b/src/Qyl.ChatKit/ChatKitJsonOptions.cs
new file mode 100644
index 0000000..7156607
--- /dev/null
+++ b/src/Qyl.ChatKit/ChatKitJsonOptions.cs
@@ -0,0 +1,14 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Qyl.ChatKit;
+
+/// Shared JSON serializer options for the ChatKit wire protocol.
+internal static class ChatKitJsonOptions
+{
+ public static JsonSerializerOptions Default { get; } = new()
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
+ };
+}
diff --git a/src/Qyl.ChatKit/ChatKitServer.cs b/src/Qyl.ChatKit/ChatKitServer.cs
new file mode 100644
index 0000000..1d38910
--- /dev/null
+++ b/src/Qyl.ChatKit/ChatKitServer.cs
@@ -0,0 +1,723 @@
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Qyl.ChatKit;
+
+///
+/// Abstract ChatKit server that routes incoming requests to streaming or non-streaming handlers.
+/// Subclasses implement to produce thread stream events.
+///
+public abstract class ChatKitServer(
+ IStore store,
+ IAttachmentStore? attachmentStore = null,
+ TimeProvider? timeProvider = null,
+ ILogger? logger = null)
+{
+ private const int DefaultPageSize = 20;
+ private const string DefaultErrorMessage = "An error occurred when generating a response.";
+
+ protected IStore Store { get; } = store;
+ protected TimeProvider Clock { get; } = timeProvider ?? TimeProvider.System;
+ protected ILogger Logger { get; } = logger ?? NullLogger.Instance;
+
+ // -- Abstract / virtual hooks --
+
+ /// Stream response events for a new or retried user message.
+ public abstract IAsyncEnumerable RespondAsync(
+ ThreadMetadata thread,
+ UserMessageItem? inputUserMessage,
+ TContext context,
+ CancellationToken ct = default);
+
+ /// Persist user feedback for one or more thread items.
+ public virtual ValueTask AddFeedbackAsync(
+ string threadId,
+ IReadOnlyList itemIds,
+ FeedbackKind feedback,
+ TContext context,
+ CancellationToken ct = default) => default;
+
+ /// Transcribe speech audio to text.
+ public virtual ValueTask TranscribeAsync(
+ AudioInput audioInput,
+ TContext context,
+ CancellationToken ct = default) =>
+ throw new NotImplementedException(
+ "TranscribeAsync() must be overridden to support the input.transcribe request.");
+
+ /// Handle a widget or client-dispatched action and yield response events.
+ public virtual IAsyncEnumerable ActionAsync(
+ ThreadMetadata thread,
+ Action action,
+ WidgetItem? sender,
+ TContext context,
+ CancellationToken ct = default) =>
+ throw new NotImplementedException(
+ "ActionAsync() must be overridden to react to actions.");
+
+ /// Handle a synchronous custom action and return a single item update.
+ public virtual ValueTask SyncActionAsync(
+ ThreadMetadata thread,
+ Action action,
+ WidgetItem? sender,
+ TContext context,
+ CancellationToken ct = default) =>
+ throw new NotImplementedException(
+ "SyncActionAsync() must be overridden to react to sync actions.");
+
+ /// Return stream-level runtime options. Allows cancellation by default.
+ public virtual StreamOptions GetStreamOptions(ThreadMetadata thread, TContext context) =>
+ new() { AllowCancel = true };
+
+ ///
+ /// Perform cleanup when a stream is cancelled. The default implementation persists
+ /// non-empty pending assistant messages and adds a hidden context marker.
+ ///
+ public virtual async ValueTask HandleStreamCancelledAsync(
+ ThreadMetadata thread,
+ IReadOnlyList pendingItems,
+ TContext context,
+ CancellationToken ct = default)
+ {
+ foreach (var item in pendingItems)
+ {
+ if (item is not AssistantMessageItem assistant)
+ continue;
+
+ var isEmpty = assistant.Content.Count == 0 ||
+ assistant.Content.All(c => string.IsNullOrWhiteSpace(c.Text));
+ if (!isEmpty)
+ await Store.AddThreadItemAsync(thread.Id, assistant, context);
+ }
+
+ await Store.AddThreadItemAsync(
+ thread.Id,
+ new SdkHiddenContextItem
+ {
+ Id = Store.GenerateItemId(StoreItemType.SdkHiddenContext, thread, context),
+ ThreadId = thread.Id,
+ CreatedAt = Clock.GetUtcNow().UtcDateTime,
+ Content = "The user cancelled the stream. Stop responding to the prior request.",
+ },
+ context);
+ }
+
+ // -- Main entry point --
+
+ /// Parse an incoming request and route it to the appropriate handler.
+ public async ValueTask