Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
fix ag-ui error
  • Loading branch information
GreenShadeZhang committed Oct 10, 2025
commit d1783d1ad90f88235ee5f4bde2bdce0bd17ef80a
79 changes: 73 additions & 6 deletions src/Plugins/BotSharp.Plugin.AgUi/Controllers/AgUiController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;

namespace BotSharp.Plugin.AgUi.Controllers;
Expand All @@ -37,10 +38,11 @@ public AgUiController(ILogger<AgUiController> logger, IServiceProvider services)
_options = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true,
PropertyNamingPolicy = null, // Use [JsonPropertyName] attributes for snake_case naming (AG UI protocol requirement)
WriteIndented = false, // SSE format should be compact, not indented
AllowTrailingCommas = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
}

Expand Down Expand Up @@ -148,6 +150,11 @@ public async Task Chat([FromBody] AgUiMessageInput input)
conv.States.SetState("ag_ui_tools", toolsJson);
}

// AG-UI Protocol: First event MUST be RUN_STARTED
var runId = input.RunId ?? Guid.NewGuid().ToString();
var threadId = input.ThreadId ?? conversationId;
await SendRunStartedEvent(outputStream, threadId, runId);

// Send state snapshot if state exists
if (input.State != null && input.State.Any())
{
Expand All @@ -166,14 +173,38 @@ await conv.SendMessage(
var finalState = new Dictionary<string, object>();
foreach (var state in conv.States.GetStates())
{
finalState[state.Key] = state.Value ?? string.Empty;
// Try to parse JSON strings back to objects/arrays for proper client-side handling
var value = state.Value ?? string.Empty;
try
{
// If the value looks like JSON, try to deserialize it
if (value is string strValue &&
(strValue.StartsWith("{") || strValue.StartsWith("[")))
{
using var doc = JsonDocument.Parse(strValue);
finalState[state.Key] = doc.RootElement.Clone();
}
else
{
finalState[state.Key] = value;
}
}
catch
{
// If parsing fails, use the value as-is
finalState[state.Key] = value;
}
}
await SendStateSnapshotEvent(outputStream, finalState);

// AG-UI Protocol: Last event MUST be RUN_FINISHED
await SendRunFinishedEvent(outputStream, threadId, runId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing AG-UI chat request");
await SendErrorEvent(outputStream, ex.Message);
// AG-UI Protocol: Send RUN_ERROR on exception
await SendRunErrorEvent(outputStream, ex.Message, ex.GetType().Name);
}
finally
{
Expand Down Expand Up @@ -279,9 +310,12 @@ private async Task SendToolResultEvent(Stream outputStream, string toolCallId, s

private async Task SendStateSnapshotEvent(Stream outputStream, Dictionary<string, object> state)
{
// Ensure state is not null and properly structured
var snapshot = state ?? new Dictionary<string, object>();

var stateEvent = new StateSnapshotEvent
{
Snapshot = state
Snapshot = snapshot
};
await SendEvent(outputStream, stateEvent);
}
Expand All @@ -296,9 +330,42 @@ private async Task SendErrorEvent(Stream outputStream, string errorMessage)
await SendEvent(outputStream, errorEvent);
}

private async Task SendRunStartedEvent(Stream outputStream, string threadId, string runId)
{
var runStartedEvent = new RunStartedEvent
{
ThreadId = threadId,
RunId = runId
};
await SendEvent(outputStream, runStartedEvent);
}

private async Task SendRunFinishedEvent(Stream outputStream, string threadId, string runId)
{
var runFinishedEvent = new RunFinishedEvent
{
ThreadId = threadId,
RunId = runId
};
await SendEvent(outputStream, runFinishedEvent);
}

private async Task SendRunErrorEvent(Stream outputStream, string message, string code)
{
var runErrorEvent = new RunErrorEvent
{
Message = message,
Code = code
};
await SendEvent(outputStream, runErrorEvent);
}

private async Task SendEvent(Stream outputStream, AgUiEvent eventData)
{
var json = JsonSerializer.Serialize(eventData, eventData.GetType(), _options);

// Log the actual JSON being sent for debugging
_logger.LogDebug("[AG-UI SSE] Sending event: {Json}", json);

var buffer = Encoding.UTF8.GetBytes($"data: {json}\n\n");
await outputStream.WriteAsync(buffer, 0, buffer.Length);
Expand Down
67 changes: 62 additions & 5 deletions src/Plugins/BotSharp.Plugin.AgUi/Models/AgUiEvent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public TextMessageStartEvent()
[JsonPropertyName("role")]
public string Role { get; set; } = "assistant";

[JsonPropertyName("message_id")]
[JsonPropertyName("messageId")]
public string MessageId { get; set; } = string.Empty;
}

Expand All @@ -39,7 +39,7 @@ public TextMessageContentEvent()
Type = AgUiEventType.TextMessageContent;
}

[JsonPropertyName("message_id")]
[JsonPropertyName("messageId")]
public string MessageId { get; set; } = string.Empty;

[JsonPropertyName("delta")]
Expand All @@ -56,7 +56,7 @@ public TextMessageEndEvent()
Type = AgUiEventType.TextMessageEnd;
}

[JsonPropertyName("message_id")]
[JsonPropertyName("messageId")]
public string MessageId { get; set; } = string.Empty;
}

Expand Down Expand Up @@ -129,7 +129,8 @@ public ToolCallResultEvent()
}

/// <summary>
/// State snapshot event
/// State snapshot event - provides complete state snapshot
/// The snapshot can contain nested objects, arrays, and primitive values
/// </summary>
public class StateSnapshotEvent : AgUiEvent
{
Expand All @@ -138,8 +139,13 @@ public StateSnapshotEvent()
Type = AgUiEventType.StateSnapshot;
}

/// <summary>
/// Complete state snapshot as a plain object/dictionary
/// Can contain nested objects, arrays, strings, numbers, booleans, and null
/// Must be JSON-serializable for proper client-side processing
/// </summary>
[JsonPropertyName("snapshot")]
public Dictionary<string, object> Snapshot { get; set; } = new();
public object Snapshot { get; set; } = new Dictionary<string, object>();
}

/// <summary>
Expand Down Expand Up @@ -175,3 +181,54 @@ public ErrorEvent()
[JsonPropertyName("message")]
public string Message { get; set; } = string.Empty;
}

/// <summary>
/// Run started event - MUST be the first event in AG-UI protocol
/// </summary>
public class RunStartedEvent : AgUiEvent
{
public RunStartedEvent()
{
Type = AgUiEventType.RunStarted;
}

[JsonPropertyName("threadId")]
public string ThreadId { get; set; } = string.Empty;

[JsonPropertyName("runId")]
public string RunId { get; set; } = string.Empty;
}

/// <summary>
/// Run finished event - MUST be the last event in AG-UI protocol
/// </summary>
public class RunFinishedEvent : AgUiEvent
{
public RunFinishedEvent()
{
Type = AgUiEventType.RunFinished;
}

[JsonPropertyName("threadId")]
public string ThreadId { get; set; } = string.Empty;

[JsonPropertyName("runId")]
public string RunId { get; set; } = string.Empty;
}

/// <summary>
/// Run error event - sent when an error occurs during the run
/// </summary>
public class RunErrorEvent : AgUiEvent
{
public RunErrorEvent()
{
Type = AgUiEventType.RunError;
}

[JsonPropertyName("message")]
public string Message { get; set; } = string.Empty;

[JsonPropertyName("code")]
public string Code { get; set; } = string.Empty;
}
38 changes: 27 additions & 11 deletions src/Plugins/BotSharp.Plugin.AgUi/Models/AgUiEventType.cs
Original file line number Diff line number Diff line change
@@ -1,23 +1,39 @@
namespace BotSharp.Plugin.AgUi.Models;

/// <summary>
/// AG-UI protocol event types
/// AG-UI protocol event types (SCREAMING_SNAKE_CASE format as per AG UI protocol standard)
/// </summary>
public static class AgUiEventType
{
public const string TextMessageStart = "text_message_start";
public const string TextMessageContent = "text_message_content";
public const string TextMessageEnd = "text_message_end";
// Text message events
public const string TextMessageStart = "TEXT_MESSAGE_START";
public const string TextMessageContent = "TEXT_MESSAGE_CONTENT";
public const string TextMessageEnd = "TEXT_MESSAGE_END";
public const string TextMessageChunk = "TEXT_MESSAGE_CHUNK";

public const string ToolCallStart = "tool_call_start";
public const string ToolCallArgs = "tool_call_args";
public const string ToolCallEnd = "tool_call_end";
// Tool call events
public const string ToolCallStart = "TOOL_CALL_START";
public const string ToolCallArgs = "TOOL_CALL_ARGS";
public const string ToolCallEnd = "TOOL_CALL_END";
public const string ToolCallChunk = "TOOL_CALL_CHUNK";
public const string ToolCallResult = "TOOL_CALL_RESULT";

public const string ToolCallResult = "tool_call_result";
// State management events
public const string StateSnapshot = "STATE_SNAPSHOT";
public const string StateDelta = "STATE_DELTA";
public const string MessagesSnapshot = "MESSAGES_SNAPSHOT";

public const string StateSnapshot = "state_snapshot";
// Special events
public const string Raw = "RAW";
public const string Custom = "CUSTOM";

public const string Custom = "custom";
// Lifecycle events
public const string RunStarted = "RUN_STARTED";
public const string RunFinished = "RUN_FINISHED";
public const string RunError = "RUN_ERROR";
public const string StepStarted = "STEP_STARTED";
public const string StepFinished = "STEP_FINISHED";

public const string Error = "error";
// Error event
public const string Error = "ERROR";
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ public class AgUiMessageInput
[JsonPropertyName("conversation_id")]
public string? ConversationId { get; set; }

[JsonPropertyName("threadId")]
public string? ThreadId { get; set; }

[JsonPropertyName("runId")]
public string? RunId { get; set; }

[JsonPropertyName("messages")]
public List<AgUiMessage> Messages { get; set; } = new();

Expand Down