-
Notifications
You must be signed in to change notification settings - Fork 0
feat: port OpenAI ChatKit Python SDK to C#/MAF #7
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| using System.Text.Json.Serialization; | ||
|
|
||
| namespace Qyl.ChatKit; | ||
|
|
||
| /// <summary>Whether the action is handled on the client or the server.</summary> | ||
| [JsonConverter(typeof(JsonStringEnumConverter<Handler>))] | ||
| public enum Handler | ||
| { | ||
| [JsonStringEnumMemberName("client")] | ||
| Client, | ||
|
|
||
| [JsonStringEnumMemberName("server")] | ||
| Server | ||
| } | ||
|
|
||
| /// <summary>Visual loading behavior when the action executes.</summary> | ||
| [JsonConverter(typeof(JsonStringEnumConverter<LoadingBehavior>))] | ||
| public enum LoadingBehavior | ||
| { | ||
| [JsonStringEnumMemberName("auto")] | ||
| Auto, | ||
|
|
||
| [JsonStringEnumMemberName("none")] | ||
| None, | ||
|
|
||
| [JsonStringEnumMemberName("self")] | ||
| Self, | ||
|
|
||
| [JsonStringEnumMemberName("container")] | ||
| Container | ||
|
Comment on lines
+9
to
+30
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major Add XML docs for public enum members and public properties. Line 9-30 and Line 36-60 add public API members/properties without member-level XML comments. As per coding guidelines, Also applies to: 36-60 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| /// <summary>Fully resolved action configuration sent over the wire.</summary> | ||
| 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; | ||
| } | ||
|
|
||
| /// <summary>Generic action carrying a type discriminator and payload.</summary> | ||
| public sealed record Action<TType, TPayload> | ||
| where TType : notnull | ||
| { | ||
| [JsonPropertyName("type")] | ||
| public required TType Type { get; init; } | ||
|
|
||
| [JsonPropertyName("payload")] | ||
| public TPayload? Payload { get; init; } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,178 @@ | ||||||||||||||||||
| using System.Threading.Channels; | ||||||||||||||||||
| using Qyl.ChatKit.Widgets; | ||||||||||||||||||
|
|
||||||||||||||||||
| namespace Qyl.ChatKit; | ||||||||||||||||||
|
|
||||||||||||||||||
| /// <summary> | ||||||||||||||||||
| /// Context object passed to agent callbacks, providing access to the store, | ||||||||||||||||||
| /// thread metadata, and a channel for emitting events back to the stream processor. | ||||||||||||||||||
| /// </summary> | ||||||||||||||||||
| public sealed class AgentContext<TContext> | ||||||||||||||||||
| { | ||||||||||||||||||
| public required ThreadMetadata Thread { get; init; } | ||||||||||||||||||
| public required IStore<TContext> 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<ThreadStreamEvent> _events = Channel.CreateUnbounded<ThreadStreamEvent>(); | ||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: No, Channel.CreateUnbounded does not provide backpressure when the consumer is slower than the producer. Unbounded channels have no capacity limit, so writes (including WriteAsync) always succeed synchronously without blocking or waiting, allowing the queue to grow indefinitely in memory until consumed or the process runs out of memory. Citations:
Bind the event channel to prevent unbounded memory growth.
Suggested fix- private readonly Channel<ThreadStreamEvent> _events = Channel.CreateUnbounded<ThreadStreamEvent>();
+ private readonly Channel<ThreadStreamEvent> _events = Channel.CreateBounded<ThreadStreamEvent>(
+ new BoundedChannelOptions(256)
+ {
+ SingleReader = true,
+ SingleWriter = false,
+ FullMode = BoundedChannelFullMode.Wait,
+ });📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||
|
|
||||||||||||||||||
| /// <summary>Reader for the event channel, consumed by the stream processor.</summary> | ||||||||||||||||||
| internal ChannelReader<ThreadStreamEvent> EventReader => _events.Reader; | ||||||||||||||||||
|
|
||||||||||||||||||
| /// <summary>Signal that no more events will be written.</summary> | ||||||||||||||||||
| internal void Complete() => _events.Writer.TryComplete(); | ||||||||||||||||||
|
|
||||||||||||||||||
| /// <summary>Generate a new store-backed id for the given item type.</summary> | ||||||||||||||||||
| public string GenerateId(StoreItemType type, ThreadMetadata? thread = null) => | ||||||||||||||||||
| type == StoreItemType.Thread | ||||||||||||||||||
| ? Store.GenerateThreadId(RequestContext) | ||||||||||||||||||
| : Store.GenerateItemId(type, thread ?? Thread, RequestContext); | ||||||||||||||||||
|
|
||||||||||||||||||
| /// <summary>Stream a widget into the thread by enqueueing widget events.</summary> | ||||||||||||||||||
| 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); | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| /// <summary>Stream an async sequence of widget roots into the thread.</summary> | ||||||||||||||||||
| public async ValueTask StreamWidgetAsync( | ||||||||||||||||||
| IAsyncEnumerable<WidgetRoot> 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); | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| /// <summary>Begin streaming a new workflow item.</summary> | ||||||||||||||||||
| 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); | ||||||||||||||||||
| } | ||||||||||||||||||
|
Comment on lines
+64
to
+78
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do not emit
One way to fix it public sealed class AgentContext<TContext>
{
+ private bool _workflowAdded;
+
public async ValueTask StartWorkflowAsync(Workflow workflow, CancellationToken ct = default)
{
WorkflowItem = new WorkflowItem
{
Id = GenerateId(StoreItemType.Workflow),
@@
ThreadId = Thread.Id,
};
- if (workflow.Type != "reasoning" && workflow.Tasks.Count == 0)
+ _workflowAdded = workflow.Type == "reasoning" || workflow.Tasks.Count > 0;
+ if (!_workflowAdded)
return; // Defer sending added event until we have tasks
await StreamAsync(new ThreadItemAddedEvent { Item = WorkflowItem }, ct);
}
@@
public async ValueTask EndWorkflowAsync(
WorkflowSummary? summary = null, bool expanded = false, CancellationToken ct = default)
{
if (WorkflowItem is null)
return;
@@
WorkflowItem = WorkflowItem with
{
Workflow = WorkflowItem.Workflow with
{
Summary = finalSummary,
Expanded = expanded,
},
};
+ if (!_workflowAdded)
+ await StreamAsync(new ThreadItemAddedEvent { Item = WorkflowItem }, ct);
+
await StreamAsync(new ThreadItemDoneEvent { Item = WorkflowItem }, ct);
+ _workflowAdded = false;
WorkflowItem = null;
}
}Also applies to: 141-165 🤖 Prompt for AI Agents |
||||||||||||||||||
|
|
||||||||||||||||||
| /// <summary>Append a workflow task and stream the appropriate event.</summary> | ||||||||||||||||||
| 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); | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| /// <summary>Update an existing workflow task and stream the delta.</summary> | ||||||||||||||||||
| 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); | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| /// <summary>Finalize the active workflow item, optionally attaching a summary.</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; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| /// <summary>Enqueue a ThreadStreamEvent for downstream processing.</summary> | ||||||||||||||||||
| public async ValueTask StreamAsync(ThreadStreamEvent evt, CancellationToken ct = default) => | ||||||||||||||||||
| await _events.Writer.WriteAsync(evt, ct); | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| /// <summary>Returned from tool methods to indicate a client-side tool call.</summary> | ||||||||||||||||||
| public sealed record ClientToolCall | ||||||||||||||||||
| { | ||||||||||||||||||
| public required string Name { get; init; } | ||||||||||||||||||
| public required Dictionary<string, object?> Arguments { get; init; } | ||||||||||||||||||
| } | ||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,101 @@ | ||||||||||
| using System.Text.Json.Serialization; | ||||||||||
|
|
||||||||||
| namespace Qyl.ChatKit; | ||||||||||
|
|
||||||||||
| /// <summary>Base metadata shared by all attachments.</summary> | ||||||||||
| [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; } | ||||||||||
|
|
||||||||||
| /// <summary> | ||||||||||
| /// Two-phase upload instructions. | ||||||||||
| /// Should be set to null after upload is complete or when using direct upload. | ||||||||||
| /// </summary> | ||||||||||
| [JsonPropertyName("upload_descriptor")] | ||||||||||
| public AttachmentUploadDescriptor? UploadDescriptor { get; init; } | ||||||||||
|
|
||||||||||
| /// <summary> | ||||||||||
| /// The thread the attachment belongs to, if any. | ||||||||||
| /// Added when the user message that contains the attachment is saved to store. | ||||||||||
| /// </summary> | ||||||||||
| [JsonPropertyName("thread_id")] | ||||||||||
| public string? ThreadId { get; init; } | ||||||||||
|
|
||||||||||
| /// <summary> | ||||||||||
| /// Integration-only metadata stored with the attachment. | ||||||||||
| /// Ignored by ChatKit and not returned in server responses. | ||||||||||
| /// </summary> | ||||||||||
| [JsonPropertyName("metadata")] | ||||||||||
|
||||||||||
| [JsonPropertyName("metadata")] | |
| [JsonIgnore] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
int Size limits attachments to ~2GB.
File sizes routinely exceed 2GB. Use long for size fields to match standard .NET file APIs (FileInfo.Length, Stream.Length).
Proposed fix
[JsonPropertyName("size")]
- public required int Size { get; init; }
+ public required long Size { get; init; }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| [JsonPropertyName("size")] | |
| public required int Size { get; init; } | |
| [JsonPropertyName("size")] | |
| public required long Size { get; init; } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/Qyl.ChatKit/Attachments.cs` around lines 73 - 74, Change the Size
property's type from int to long to support files >2GB: update the declaration
public required int Size { get; init; } to use long (public required long Size {
get; init; }), then search for and update all usages, method signatures,
serialization/deserialization expectations, and any comparisons or math that
assume int (e.g., code referencing Size, constructors, DTO mappings, and unit
tests) so they accept long and preserve JSON name "size" via the existing
JsonPropertyName attribute.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| using System.Text.Json; | ||
| using System.Text.Json.Serialization; | ||
|
|
||
| namespace Qyl.ChatKit; | ||
|
|
||
| /// <summary>Shared JSON serializer options for the ChatKit wire protocol.</summary> | ||
| internal static class ChatKitJsonOptions | ||
| { | ||
| public static JsonSerializerOptions Default { get; } = new() | ||
| { | ||
| DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, | ||
| PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, | ||
| }; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🌐 Web query:
Dependabotignore.versionssemantics: does>=11.0.0-0match stable11.0.0and later, and what is the recommended pattern to ignore only .NET SDK 11 prereleases?💡 Result:
No, the Dependabot ignore.versions pattern >=11.0.0-0 does not match stable 11.0.0 and later versions. NuGet versioning uses SemVer with four parts (major.minor.patch.suffix), where stable versions like 11.0.0 are equivalent to 11.0.0.0. Prereleases append a dash suffix, e.g., 11.0.0-preview.1 or 11.0.0-0 (possibly a preview build). The >=11.0.0-0 range targets versions lexicographically greater than or equal to 11.0.0-0, which excludes stable 11.0.0.0 (since 11.0.0.0 < 11.0.0-0 in NuGet comparison, as the dash suffix sorts after numeric). It would match prereleases like 11.0.0-preview.1 (>= 11.0.0-0) but not stables like 11.0.0, 11.0.1, or 12.0.0. To ignore only .NET SDK 11 prereleases in Dependabot (for package-ecosystem: "nuget" or "dotnet-sdk"), use NuGet range syntax in ignore.versions. Since regex/glob is unsupported (open issue
#7885), target known prerelease patterns explicitly: ignore: - dependency-name: "Microsoft.NETCore.App.Ref" # or specific SDK package/global.json versions: ["11.0.0-"] - dependency-name: "Microsoft.NETCore.App.Runtime" versions: ["11.0.0-"] For dotnet-sdk ecosystem (global.json updates), add allow-prerelease: false or ignore via update-types if prereleases are classified as such. For broad prerelease ignoring across ecosystems, list common tags like ["preview", "alpha", "beta", "rc"] (per issue#7885comments), but test as NuGet specifics vary. Official docs recommend package-manager syntax like 11.* for ranges (e.g., ignore all 11.x), confirmed working for NuGet (7.*).Citations:
ignoreversion syntax for Nuget dependabot/dependabot-core#4075🏁 Script executed:
Repository: ANcpLua/netagents
Length of output: 960
Use the clearer prerelease-only pattern
11.0.0-*.The pattern
>=11.0.0-0correctly targets only .NET SDK 11 prereleases; it does not exclude stable 11.x releases (NuGet/SemVer semantics: stable 11.0.0 sorts before 11.0.0-0). However, the pattern11.0.0-*is more idiomatic and explicit for ignoring only prerelease versions.🤖 Prompt for AI Agents