diff --git a/docs/docs.json b/docs/docs.json
index cf9de8db..2d3d9ea3 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -80,6 +80,29 @@
"pages": ["sdk/python/encoder/overview"]
}
]
+ },
+ {
+ "group": ".NET",
+ "pages": [
+ "sdk/dotnet/overview",
+ "sdk/dotnet/types",
+ "sdk/dotnet/events",
+ "sdk/dotnet/json",
+ {
+ "group": "Agents",
+ "pages": [
+ "sdk/dotnet/agents",
+ {
+ "group": "IChatClient",
+ "pages": [
+ "/sdk/dotnet/agent-types/chatclientagent",
+ "/sdk/dotnet/agent-types/statefulchatclientagent"
+ ]
+ }
+ ]
+ },
+ "sdk/dotnet/aspnet"
+ ]
}
]
}
@@ -95,6 +118,11 @@
"anchor": "Python SDK",
"href": "https://docs.ag-ui.com/sdk/python/core/overview",
"icon": "python"
+ },
+ {
+ "anchor": ".NET SDK",
+ "href": "https://docs.ag-ui.com/sdk/dotnet/core/overview",
+ "icon": "microsoft"
}
]
}
diff --git a/docs/sdk/dotnet/agent-types/chatclientagent.mdx b/docs/sdk/dotnet/agent-types/chatclientagent.mdx
new file mode 100644
index 00000000..ca419133
--- /dev/null
+++ b/docs/sdk/dotnet/agent-types/chatclientagent.mdx
@@ -0,0 +1,522 @@
+---
+title: "ChatClientAgent"
+description:
+ "Agent User Interaction Protocol .NET SDK ChatClientAgent reference"
+---
+
+# The ChatClientAgent
+
+The `ChatClientAgent` is a simple agent implementation that uses an underlying
+`IChatClient` to power the common scenario of running an agent that involves an
+LLM conversational flow.
+
+It handles emitting the core lifecycle events and managing both frontend and
+backend tool calls, making it an easy way to get started with building agents
+using the existing `IChatClient` compatible providers and backend tools you
+already have.
+
+## Usage
+
+```csharp
+var myProviderIChatClient = GetProviderIChatClient();
+
+var chatClient = new ChatClientBuilder(myProviderIChatClient)
+ .UseFunctionInvocation()
+ .Build();
+
+var agent = new ChatClientAgent(
+ chatClient,
+ new ChatClientAgentOptions
+ {
+ // Options for configuring behaviour
+ }
+)
+
+await foreach(var emittedEvent in
+ agent.RunToCompletionAsync(runAgentInput, cancellationToken))
+{
+ // Handle emitted events
+}
+```
+
+This example shows how to create the barest `ChatClientAgent` that uses the
+provided `IChatClient` and simply acts as a proxy for the client to emit the
+Agent User Interaction Protocol events, while supporting frontend tool calls.
+
+## Options
+
+The agent can be configured using the (optional) `ChatClientAgentOptions`:
+
+```csharp
+public record ChatClientAgentOptions
+{
+ public ChatOptions? ChatOptions { get; init; }
+
+ public bool PreserveInboundSystemMessages { get; init; } = true;
+
+ public string? SystemMessage { get; init; }
+
+ public bool PerformAiContextExtraction { get; init; } = false;
+
+ public bool IncludeContextInSystemMessage { get; init; } = false;
+
+ public bool StripSystemMessagesWhenEmittingMessageSnapshots { get; init; } = true;
+
+ public bool EmitBackendToolCalls { get; init; } = true;
+}
+```
+
+| Property | Type | Description |
+| ------------------------------------------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `ChatOptions` | `ChatOptions?` | The same options you're used to passing when using the `IChatClient` directly - configure LLM behaviour and register tools. |
+| `PreserveInboundSystemMessages` | `bool` | If true, the agent will preserve system messages from the input and not override them with its own. Defaults to true. |
+| `SystemMessage` | `string?` | If set, this will override the system message passed in the `RunAgentInput`. Defaults to null, meaning it uses the input. |
+| `PerformAiContextExtraction` | `bool` | If true, the `IChatClient` will be used to try to extract `Context` from the input system message. Defaults to false. |
+| `IncludeContextInSystemMessage` | `bool` | If true, the agent will include items in the final `Context` collection in the system message passed to the LLM. By default if and only if a custom `SystemMessage` has been provided. Defaults to false. |
+| `StripSystemMessagesWhenEmittingMessageSnapshots` | `bool` | If true, the agent will strip system messages from the message snapshots it emits. Defaults to true. |
+| `EmitBackendToolCalls` | `bool` | If true, the agent will emit events for backend tool calls. Defaults to true. |
+
+## AI Context Extraction
+
+`RunAgentInput` has a `Context` property that allows frontends to provide
+additional context data to the agent.
+
+However, some frontends (like [CopilotKit](https://www.copilotkit.ai)) may not
+provide context via this mechanism, and instead will be submitting it as part of
+the system message/s passed to the agent in the messages collection.
+
+If you set `PerformAiContextExtraction` to true, the agent will use the
+underlying `IChatClient` to try and extract context from the system message, and
+make that context available to the agent as if it was provided in the
+`RunAgentInput`.
+
+You can use this in conjunction with `IncludeContextInSystemMessage` to ensure
+that the context is included in your custom system message (via the
+`SystemMessage` property), which can be useful for situations where your
+frontend has a lot of ambient context that is not yet being explicity provided
+to the agent.
+
+
+ This feature relies on structured responses from the underlying LLM, so support is not universal.
+
+ It can also be temperamental, so by default the agent will try once to extract context and if it fails due to a JSON parsing error, it will simply fallback to the provided context in the `RunAgentInput`.
+
+
+
+## Function Calling
+
+In order to use function calling, the following must be true:
+
+1. The `IChatClient` you provide must support function calling using the
+ standard `FunctionInvokingChatClient` mechanism provided by the
+ `Microsoft.Extensions.AI` package.
+2. The LLM you are using must support function calling.
+
+
+ Due to the way function calling works in `Microsoft.Extensions.AI`, and the
+ necessity to support frontend tool calls, the agent will forcefully disable
+ multiple tool calls in a single message, and therefore does not support
+ parallel tool calls.
+
+If your `IChatClient` or LLM forces this behaviour, the agent may behave
+unexpectedly.
+
+
+
+### Frontend Tool Calls
+
+If your frontend passes tools in the `RunAgentInput`, these will be exposed to
+the LLM and if it calls them, the agent will emit the necessary Agent User
+Interaction Protocol events to request the tool call.
+
+The run will then end, and the frontend will be expected to handle the tool
+call, produce a tool call result message, and then call the agent again on a new
+run in the same thread with the tool call result message in the `messages`
+collection.
+
+```mermaid
+sequenceDiagram
+ participant Frontend
+ participant Agent as ChatClientAgent
+ participant LLM as IChatClient/LLM
+
+ Frontend->>Agent: RunAgentAsync(RunAgentInput with tools)
+ activate Agent
+ Agent->>LLM: Send messages with tool definitions
+ activate LLM
+ LLM-->>Agent: Response with tool call
+ deactivate LLM
+ Agent-->>Frontend: Emit Tool Call Events
+ deactivate Agent
+
+ Frontend->>Frontend: Execute tool call
+
+ Frontend->>Agent: RunAgentAsync(new run with tool result message)
+ activate Agent
+ Agent->>LLM: Send messages including tool result
+ activate LLM
+ LLM-->>Agent: Response
+ deactivate LLM
+ Agent-->>Frontend: Emit Other Events
+ deactivate Agent
+```
+
+### Backend Tool Calls
+
+The agent supports backend tool calls by using the same tool registration
+mechanisms as regular `IChatClient` interactions.
+
+You can register backend tools using the `ChatOptions` property in the
+`ChatClientAgentOptions` as follows:
+
+```csharp
+static DateTimeOffset GetCurrentDateTime() =>
+ DateTimeOffset.UtcNow;
+
+var myProviderIChatClient = GetProviderIChatClient();
+
+var chatClient = new ChatClientBuilder(myProviderIChatClient)
+ .UseFunctionInvocation()
+ .Build();
+
+var agent = new ChatClientAgent(
+ chatClient,
+ new ChatClientAgentOptions {
+ ChatOptions = new ChatOptions {
+ Tools = [
+ AiFunctionFactory.Create(
+ GetCurrentDateTime,
+ "GetCurrentDateTime",
+ "Returns the current date and time in UTC"
+ )
+ ]
+ }
+ }
+);
+```
+
+This exposes the `GetCurrentDateTime` function to the LLM, and will use the
+standard function calling mechanism in the `Microsoft.Extensions.AI` system.
+
+#### Backend Tool Call Visibility to the Frontend
+
+When the LLM calls a backend tool, you can use the `EmitBackendToolCalls` option
+on the `ChatClientAgentOptions` to control whether the agent emits information
+to the frontend about the backend tool call.
+
+
+When this option is set to `true`, the agent will emit the necessary events, and issue a message snapshot upon completion of the run to communicate results.
+
+Use with caution, as this means the frontend will have full visibility into not
+only the tool call names, but the arguments and the serialized results of the
+tool calls - which could contain sensitive information.
+
+A filtration mechanism is provided if you derive your own agent from
+`ChatClientAgent`.
+
+
+
+```mermaid
+sequenceDiagram
+ participant Frontend
+ participant Agent as ChatClientAgent
+ participant LLM as IChatClient/LLM
+ participant BackendTool as Backend Tool
+
+ Frontend->>Agent: RunAgentAsync(RunAgentInput)
+ activate Agent
+ Agent->>LLM: Send messages with backend tool definitions
+ activate LLM
+ LLM-->>Agent: Response with backend tool call
+ deactivate LLM
+
+ Agent->>BackendTool: Execute tool call internally
+ activate BackendTool
+ BackendTool-->>Agent: Return tool call result
+ deactivate BackendTool
+
+ Agent->>LLM: Send messages including tool result
+ activate LLM
+ LLM-->>Agent: Response
+ deactivate LLM
+
+ Agent-->>Frontend: Emit Regular Events
+ note right of Frontend: Frontend has no visibility into backend tool call
+ deactivate Agent
+```
+
+## Deriving Your Own Agent
+
+When you need more control over the agent's behaviour, or want to use it as a
+base for your own agent, you can derive from the `ChatClientAgent` class.
+
+It provides the following members for overriding:
+
+```csharp
+protected virtual ValueTask> PrepareBackendTools
+(
+ ImmutableList backendTools,
+ RunAgentInput input,
+ ChannelWriter events,
+ CancellationToken cancellationToken = default
+)
+
+protected virtual ValueTask> PrepareFrontendTools
+(
+ ImmutableList frontendTools,
+ RunAgentInput input,
+ CancellationToken cancellationToken = default
+)
+
+protected virtual async Task> PrepareContext
+(
+ RunAgentInput input,
+ CancellationToken cancellationToken = default
+)
+
+protected virtual ValueTask PrepareSystemMessage
+(
+ RunAgentInput input,
+ string systemMessage,
+ ImmutableList context
+)
+
+protected virtual async ValueTask> MapAGUIMessagesToChatClientMessages
+(
+ RunAgentInput input,
+ ImmutableList context,
+ CancellationToken cancellationToken = default
+)
+
+protected virtual ValueTask ShouldEmitBackendToolCallData
+(
+ string functionName
+)
+
+protected virtual async ValueTask OnRunStartedAsync
+(
+ RunAgentInput input,
+ ChannelWriter events,
+ CancellationToken cancellationToken = default
+)
+```
+
+Each and every one of these methods has default implementations that drive their
+behaviour from the `ChatClientAgentOptions` and represent default behaviour for
+managing a bare-bones conversational agent with function calling support.
+
+### Method Invocation Flow
+
+The agent flow looks as follows:
+
+```mermaid
+sequenceDiagram
+ participant Caller as Caller
+ participant Agent as ChatClientAgent
+ participant ChatClient as IChatClient
+ participant Events as ChannelWriter
+
+ Caller->>Agent: RunAsync
+ Agent->>Agent: PrepareBackendTools
+ Agent->>Agent: PrepareFrontendTools
+ Agent->>Agent: PrepareContext
+ Agent->>Agent: MapAGUIMessagesToChatClientMessages & PrepareSystemMessage
+ Agent->>Agent: OnRunStartedAsync
+ Agent->>ChatClient: GetStreamingResponseAsync
+ loop For each update in streaming response
+ Agent->>Events: Write Events
+ end
+ Agent->>Events: Handle Lifecycle End Events
+ Agent->>Events: Complete()
+```
+
+### `PrepareBackendTools`
+
+This method is called to prepare the backend tools that will be available to the
+agent.
+
+| Argument | Type | Description |
+| ------------------- | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `backendTools` | `ImmutableList` | The backend tools (if any) that were discovered via the provided `ChatOptions`. |
+| `input` | `RunAgentInput` | The AG-UI RunAgentInput passed to the agent. |
+| `events` | `ChannelWriter` | The channel to write events to—useful for when you want your tools to have access to this (e.g., for dispatching state updates, custom events, etc.). |
+| `cancellationToken` | `CancellationToken` | The cancellation token for the operation. |
+
+An example of how this is extended by the `StatefulChatClientAgent`:
+
+```csharp
+protected override async ValueTask> PrepareBackendTools(
+ ImmutableList backendTools,
+ RunAgentInput input,
+ ChannelWriter events,
+ CancellationToken cancellationToken = default
+)
+ {
+ return [
+ .. await base.PrepareBackendTools(
+ backendTools,
+ input,
+ events,
+ cancellationToken
+ ),
+
+ AIFunctionFactory.Create(
+ RetrieveState,
+ name: "retrieve_state",
+ description: "Retrieves the current shared state of the agent."
+ ),
+
+ AIFunctionFactory.Create(
+ async (TState newState) => {
+ var delta = _currentState.CreatePatch(newState, _jsonSerOpts);
+ if (delta.Operations.Count > 0) {
+ UpdateState(newState);
+ await events.WriteAsync(new StateDeltaEvent {
+ Delta = [.. delta.Operations.Cast()],
+ Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ }, cancellationToken);
+ }
+ },
+ name: "update_state",
+ description: "Updates the current shared state of the agent."
+ )
+ ];
+ }
+```
+
+This shows how easily you can extend the core backend tools to add your own for
+your specific agent in conjunction with the ones provided by the `ChatOptions`
+to provide agent-specific functionality.
+
+You have access to the events channel writer here, so you can emit events as
+needed.
+
+### `PrepareFrontendTools`
+
+This method allows you to modify the frontend tools that will be available to
+the agent.
+
+| Argument | Type | Description |
+| ------------------- | ----------------------------- | ---------------------------------------------------------------------------------- |
+| `frontendTools` | `ImmutableList` | The frontend tools (if any) that were discovered via the provided `RunAgentInput`. |
+| `input` | `RunAgentInput` | The AG-UI RunAgentInput passed to the agent. |
+| `cancellationToken` | `CancellationToken` | The cancellation token for the operation. |
+
+An example of how you could use this to filter out frontend tools that you do
+not want to expose:
+
+```csharp
+protected override ValueTask> PrepareFrontendTools(
+ ImmutableList frontendTools,
+ RunAgentInput input,
+ CancellationToken cancellationToken = default
+)
+{
+ return new ValueTask>(
+ frontendTools.Where(tool => tool.Name != "unwanted_tool").ToImmutableList()
+ );
+}
+```
+
+### `PrepareContext`
+
+This method allows you to prepare the context collection that was passed in the
+`RunAgentInput` in the `Context` property.
+
+The default implementation takes its cue from the `PerformAiContextExtraction`
+property on the `ChatClientAgentOptions` as to what to do with the provided
+context.
+
+If your backend system wants to provide additional context, you can override
+this method to do so.
+
+| Argument | Type | Description |
+| ------------------- | ------------------- | -------------------------------------------- |
+| `input` | `RunAgentInput` | The AG-UI RunAgentInput passed to the agent. |
+| `cancellationToken` | `CancellationToken` | The cancellation token for the operation. |
+
+Usage:
+
+```csharp
+protected override async Task> PrepareContext(
+ RunAgentInput input,
+ CancellationToken cancellationToken = default
+)
+{
+ return [
+ // Include the base context with AI extraction if enabled
+ .. await base.PrepareContext(input, cancellationToken),
+
+ // Add custom context
+ new Context {
+ Description = "Custom context",
+ Value = await ResolveCustomContextValueAsync(cancellationToken)
+ }
+ ]
+}
+```
+
+### `MapAGUIMessagesToChatClientMessages`
+
+This method is responsible for taking the Agent User Interaction Protocol
+Messages provided in the `RunAgentInput` and mapping them to the `IChatClient`
+compatible types.
+
+The default implementation maps the messages using sensible defaults, and
+depending on whether a `SystemMessage` or `PreserveInboundSystemMessages` is
+set, it will also call the `PrepareSystemMessage` method to get the final system
+message to use.
+
+
+ The agent will NOT automatically call `PrepareSystemMessage` if you override
+ this method and do not call it yourself, so if you want to make use of the
+ method, be sure to call it in your override.
+
+
+| Argument | Type | Description |
+| ------------------- | ------------------------ | ------------------------------------------------------------------------ |
+| `input` | `RunAgentInput` | The AG-UI RunAgentInput passed to the agent. |
+| `context` | `ImmutableList` | The context collection that was prepared in the `PrepareContext` method. |
+| `cancellationToken` | `CancellationToken` | The cancellation token for the operation. |
+
+Most of the time it isn't envisioned you'll need to override this method, but it
+is here if you need to do something custom with the messages.
+
+### `PrepareSystemMessage`
+
+This method receives the custom system message provided in the options, or
+whatever system message you pass in when calling the method yourself, and
+provides a way to modify it before it is used by the LLM.
+
+| Argument | Type | Description |
+| --------------- | ------------------------ | -------------------------------------------------------------------------------- |
+| `input` | `RunAgentInput` | The AG-UI RunAgentInput passed to the agent. |
+| `systemMessage` | `string` | The system message provided in the options or passed in when calling the method. |
+| `context` | `ImmutableList` | The context collection that was prepared in the `PrepareContext` method. |
+
+### `ShouldEmitBackendToolCallData`
+
+When `ChatClientAgentOptions.EmitBackendToolCalls` is set to `true`, this method
+is called to determine whether the agent should emit the necessary data and do
+the handling needed to communicate information about this tool call to the
+frontend.
+
+| Argument | Type | Description |
+| -------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------- |
+| `functionName` | `string` | The name of the function that was called by the LLM. This is used to determine if the agent should emit data for this tool call. |
+
+### `OnRunStartedAsync`
+
+This method is called when all the preparation work has been completed, and the
+run is about to start by calling the `IChatClient` to begin streaming response
+updates.
+
+The default implementation emits the `RunStartedEvent` lifecycle event for you,
+so your override may want to call the base implementation first to avoid having
+to emit it yourself.
+
+| Argument | Type | Description |
+| ------------------- | -------------------------- | ------------------------------------------------------------------------------ |
+| `input` | `RunAgentInput` | The AG-UI RunAgentInput passed to the agent. |
+| `events` | `ChannelWriter` | The channel to write events to—useful for when you want to emit custom events. |
+| `cancellationToken` | `CancellationToken` | The cancellation token for the operation. |
diff --git a/docs/sdk/dotnet/agent-types/statefulchatclientagent.mdx b/docs/sdk/dotnet/agent-types/statefulchatclientagent.mdx
new file mode 100644
index 00000000..21578c88
--- /dev/null
+++ b/docs/sdk/dotnet/agent-types/statefulchatclientagent.mdx
@@ -0,0 +1,103 @@
+---
+title: "StatefulChatClientAgent"
+description:
+ "Agent User Interaction Protocol .NET SDK StatefulChatClientAgent reference"
+---
+
+# The StatefulChatClientAgent
+
+The `StatefulChatClientAgent` is a specialised agent implementation that derives
+from `ChatClientAgent` in order to provide frontend-agent state collaboration.
+
+It relies on `ChatClientAgent` to provide all the same core functionality, but
+tweaks the system message to direct the LLM to actively read and write to the
+shared state of the agent, while registering new backend tools that allow the
+agent to do so.
+
+It emits the necessary Agent User Interaction Protocol events to handle state
+synchronisation between the frontend and the agent, allowing for a rapid
+bootstrapping of this common scenario.
+
+
+This agent is experimental and may not provide the best results in all scenarios.
+
+It relies heavily on a hardcoded system message that wraps the configured one,
+and so may sometimes fail to always produce the expected behaviour or interact
+with the shared state in all cases.
+
+It is recommended to use this agent to bootstrap your proof of concept, and then
+use it as a reference to implement your own agent from `ChatClientAgent` or
+`IAGUIAgent` that is tailored to your specific use case.
+
+
+
+## Usage
+
+```csharp
+record Recipe(
+ string Name,
+ string Description,
+ string Ingredients,
+ string Instructions
+);
+
+var myProviderIChatClient = GetProviderIChatClient();
+var chatClient = new ChatClientBuilder(myProviderIChatClient)
+ .UseFunctionInvocation()
+ .Build();
+
+var agent = new StatefulChatClientAgent(
+ chatClient,
+ // Define initial state for the agent to begin with if not provided by the run input
+ new Recipe(),
+ new StatefulChatClientAgentOptions
+ {
+ SystemMessage = "You can override the system message passed in the RunAgentInput",
+ ChatOptions = new ChatOptions
+ {
+ Tools = [
+ AiFunctionFactory.Create(
+ MyDotnetDelegate
+ )
+ ]
+ }
+ }
+)
+```
+
+## Options
+
+The agent accepts an options type `StateChatClientAgentOptions` that
+derives from `ChatClientAgentOptions` and allows you to provide all the same
+options as you would to a `ChatClientAgent`.
+
+```csharp
+public record StatefulChatClientAgentOptions : ChatClientAgentOptions where TState : notnull
+{
+ public string StateRetrievalFunctionName { get; init; } = "retrieve_state";
+ public string StateRetrievalFunctionDescription { get; init; } = "Retrieves the current shared state of the agent.";
+ public string StateUpdateFunctionName { get; init; } = "update_state";
+ public string StateUpdateFunctionDescription { get; init; } = "Updates the current shared state of the agent.";
+ public bool EmitStateFunctionsToFrontend { get; init; } = true;
+}
+```
+
+| Option | Default Value | Description |
+| ----------------------------------- | ---------------------------------------------------- | --------------------------------------------------------------- |
+| `StateRetrievalFunctionName` | `"retrieve_state"` | Name of the function to retrieve the current shared state. |
+| `StateRetrievalFunctionDescription` | `"Retrieves the current shared state of the agent."` | Description for the state retrieval function. |
+| `StateUpdateFunctionName` | `"update_state"` | Name of the function to update the current shared state. |
+| `StateUpdateFunctionDescription` | `"Updates the current shared state of the agent."` | Description for the state update function. |
+| `EmitStateFunctionsToFrontend` | `true` | Whether to notify the frontend when state functions are called. |
+
+## Extending the Agent
+
+The agent can also be extended by deriving from it, but it does not currently
+offer any additional functionality beyond what `ChatClientAgent` provides, but
+it does allow you to override the same methods as you would be able to when
+deriving from `ChatClientAgent`.
+
+
+ Beware deriving from this agent, as if you interfere with the methods it
+ overrides, you may as well just derive from `ChatClientAgent` instead.
+
diff --git a/docs/sdk/dotnet/agents.mdx b/docs/sdk/dotnet/agents.mdx
new file mode 100644
index 00000000..1e37c89e
--- /dev/null
+++ b/docs/sdk/dotnet/agents.mdx
@@ -0,0 +1,288 @@
+---
+title: "Overview"
+description: "Agent User Interaction Protocol .NET SDK Agent functionality"
+---
+
+# Agents
+
+The Agent User Interaction Protocol SDK provides out-of-the-box agent
+implementations that you can customise and build upon, as well as a low-level
+interface for implementing your own agents from scratch.
+
+## Low-Level
+
+You can implement your own agents using the low-level interface, which gives you
+the most flexibility and control over the agent's behaviour.
+
+Reasons you might want to use the interface directly include:
+
+- You need to implement a custom agent that uses a different agent framework.
+- You need to implement a custom agent that doesn't fit the standard LLM-backed
+ conversational flow.
+
+### The `IAGUIAgent` Interface
+
+```csharp
+public interface IAGUIAgent
+{
+ Task RunAsync(
+ RunAgentInput input,
+ ChannelWriter events,
+ CancellationToken cancellationToken = default
+ );
+}
+```
+
+All of the agents in the SDK implement this interface, which provides a simple
+and extensible contract for running Agent User Interaction Protocol compatible
+agents with the `RunAsync` method that takes the following parameters:
+
+| Property | Type | Description |
+| ------------------- | -------------------------- | -------------------------------------- |
+| `input` | `RunAgentInput` | Input parameters for running the agent |
+| `events` | `ChannelWriter` | Channel to write events to |
+| `cancellationToken` | `CancellationToken` | Token to cancel the operation |
+
+#### Invocation Helpers
+
+You can construct your own channel to pass to the `RunAsync` method as follows:
+
+```csharp
+var channel = Channel.CreateUnbounded(
+ new UnboundedChannelOptions {
+ SingleReader = true,
+ SingleWriter = false,
+ AllowSynchronousContinuations = true,
+ }
+);
+
+_ = Task.Run(async () => {
+ await agent.RunAsync(
+ input,
+ channel.Writer,
+ cancellationToken
+ );
+});
+
+await foreach (var emittedEvent in
+ channel.Reader.ReadAllAsync(cancellationToken))
+{
+ // Handle the emitted event
+}
+```
+
+The SDK provides an extension method that takes care of this for you:
+
+```csharp
+await foreach(var emittedEvent in
+ agent.RunToCompletionAsync(input, cancellationToken))
+{
+ // Handle the emitted event
+}
+```
+
+### `EchoAgent` Example
+
+The SDK includes a simple `EchoAgent` that implements the interface:
+
+````csharp
+public sealed class EchoAgent : IAGUIAgent
+{
+ public async Task RunAsync(
+ RunAgentInput input,
+ ChannelWriter events,
+ CancellationToken ct = default
+ )
+ {
+ await events.WriteAsync(
+ new RunStartedEvent
+ {
+ ThreadId = input.ThreadId,
+ RunId = input.RunId,
+ Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ },
+ ct
+ );
+
+ var lastMessage = input.Messages.LastOrDefault();
+
+ await Task.Delay(500, ct);
+
+ switch (lastMessage)
+ {
+ case SystemMessage system:
+ foreach (var ev in EventHelpers.SendSimpleMessage(
+ $"Echoing system message:\n\n```\n{system.Content}\n```\n"
+ ))
+ {
+ await events.WriteAsync(ev, ct);
+ }
+ break;
+
+ case UserMessage user:
+ foreach (var ev in EventHelpers.SendSimpleMessage(
+ $"Echoing user message:\n\n```\n{user.Content}\n```\n"
+ ))
+ {
+ await events.WriteAsync(ev, ct);
+ }
+ break;
+
+ case AssistantMessage assistant:
+ foreach (var ev in EventHelpers.SendSimpleMessage(
+ $"Echoing assistant message:\n\n```\n{assistant.Content}\n```\n"
+ ))
+ {
+ await events.WriteAsync(ev, ct);
+ }
+ break;
+
+ case ToolMessage tool:
+ foreach (var ev in EventHelpers.SendSimpleMessage(
+ $"Echoing tool message for tool call '{tool.ToolCallId}':\n\n```\n{tool.Content}\n```\n"
+ ))
+ {
+ await events.WriteAsync(ev, ct);
+ }
+ break;
+
+ default:
+ foreach (var ev in EventHelpers.SendSimpleMessage(
+ $"Unknown message type: {lastMessage?.GetType().Name ?? "null"}"
+ ))
+ {
+ await events.WriteAsync(ev, ct);
+ }
+ break;
+ }
+
+ await events.WriteAsync(
+ new RunFinishedEvent
+ {
+ ThreadId = input.ThreadId,
+ RunId = input.RunId,
+ Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ },
+ ct
+ );
+
+ events.Complete();
+ }
+}
+````
+
+This simple agent echoes the last message it receives, but demonstrates the core
+lifecycle events and how to emit Agent User Interaction Protocol events using
+the provided channel writer.
+
+## `Microsoft.Extensions.AI` Integration
+
+
+ The SDK uses the `Microsoft.Extensions.AI` package, not the
+ `Microsoft.Extensions.AI.Abstractions` package, as it needs some of the
+ concrete implementations to handle function calling and other features.
+
+
+The SDK takes a dependency on the
+[Microsoft.Extensions.AI](https://learn.microsoft.com/en-us/dotnet/ai/microsoft-extensions-ai)
+package which means you can use the `IChatClient` abstractions to power your
+agents - and bring all the LLM providers you already use.
+
+### The `ChatClientAgent` Class
+
+This class can be used standalone or as a base class for your own agents. It
+provides for the core scenario of running an agent that involves an LLM
+conversational flow, and handles the boilerplate of emitting lifecycle events
+and handling backend and frontend tool calls.
+
+```csharp
+var myProviderIChatClient = GetProviderIChatClient();
+
+var chatClient = new ChatClientBuilder(myProviderIChatClient)
+ .UseFunctionInvocation()
+ .Build();
+
+var agent = new ChatClientAgent(
+ chatClient,
+ new ChatClientAgentOptions
+ {
+ SystemMessage = "You can override the system message passed in the RunAgentInput",
+ ChatOptions = new ChatOptions
+ {
+ Tools = [
+ AiFunctionFactory.Create(
+ MyDotnetDelegate
+ )
+ ]
+ }
+ }
+);
+```
+
+
+ Complete documentation of the `ChatClientAgent` class
+
+
+### The `StatefulChatClientAgent` Class
+
+This agent extends the `ChatClientAgent` class and is tailored for scenarios
+where the agent collaborates with the frontend to maintain a shared state.
+
+An example of this could be where you have some state in the frontend that
+represents the task the user is working on, and you want the agent to be able to
+read and write to that state.
+
+```csharp
+record Recipe(
+ string Name,
+ string Description,
+ string Ingredients,
+ string Instructions
+);
+
+var myProviderIChatClient = GetProviderIChatClient();
+var chatClient = new ChatClientBuilder(myProviderIChatClient)
+ .UseFunctionInvocation()
+ .Build();
+
+var agent = new StatefulChatClientAgent(
+ chatClient,
+ // Define initial state for the agent to begin with if not provided by the run input
+ new Recipe(),
+ new StatefulChatClientAgentOptions
+ {
+ SystemMessage = "You can override the system message passed in the RunAgentInput",
+ ChatOptions = new ChatOptions
+ {
+ Tools = [
+ AiFunctionFactory.Create(
+ MyDotnetDelegate
+ )
+ ]
+ }
+ }
+)
+```
+
+This agent will use your provided system message, and tweak it to direct the LLM
+to read and write to the shared state of type `Recipe` that you provide.
+
+It registers new backend tools that allow the agent to do so, and emits the
+necessary Agent User Interaction Protocol events to keep the frontend in sync
+with the agent's state.
+
+
+ Complete documentation of the `StatefulChatClientAgent` class
+
diff --git a/docs/sdk/dotnet/aspnet.mdx b/docs/sdk/dotnet/aspnet.mdx
new file mode 100644
index 00000000..d9150cf0
--- /dev/null
+++ b/docs/sdk/dotnet/aspnet.mdx
@@ -0,0 +1,51 @@
+---
+title: "ASP.NET"
+description: "Agent User Interaction Protocol .NET SDK ASP.NET integration"
+---
+
+The most common way to integrate Agent User Interaction Protocol agents
+currently is over HTTP which frontends like
+[CopilotKit](https://www.copilotkit.ai) can use via the AG-UI
+[HttpAgent](/concepts/agents#httpagent) agent implementation.
+
+For that reason the .NET SDK provides a simple extension method to register a
+[Minimal API](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/overview)
+POST endpoint that can be used to expose agents:
+
+```csharp
+builder.Services.ConfigureHttpJsonOptions(opts =>
+{
+ // Necessary as the type discriminator is not the first property in the JSON objects used by the AG-UI protocol.
+ opts.SerializerOptions.AllowOutOfOrderMetadataProperties = true;
+ opts.SerializerOptions.WriteIndented = false;
+ opts.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
+
+ // Necessary as consumers of the AG-UI protocol (e.g. Zod-powered schemas) will not accept null values for optional properties.
+ // So we need to ensure that null values are not serialized.
+ opts.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
+});
+
+builder.Services.AddChatClient(
+ new ChatClientBuilder(myProviderIChatClient)
+ .UseFunctionInvocation()
+);
+
+var app = builder.Build();
+
+var agentsGroup = app.MapGroup("/agents");
+
+agentsGroup.MapAgentEndpoint(
+ "my-agent-name",
+ sp => {
+ var chatClient = sp.GetRequiredService();
+ return new ChatClientAgent(chatClient);
+ }
+);
+```
+
+This will create a POST endpoint at `/agents/my-agent-name` that accepts the
+`RunAgentInput` body and returns a server-sent-events stream of the Agent User
+Interaction Protocol events by invoking the provided agent.
+
+You can configure the endpoint like any other one, including adding all the same
+authentication / authorization / policy behaviour you would expect from ASP.NET.
diff --git a/docs/sdk/dotnet/events.mdx b/docs/sdk/dotnet/events.mdx
new file mode 100644
index 00000000..86cdf906
--- /dev/null
+++ b/docs/sdk/dotnet/events.mdx
@@ -0,0 +1,340 @@
+---
+title: "Events"
+description:
+ "Documentation for the events used in the Agent User Interaction Protocol .NET
+ SDK"
+---
+
+# Events
+
+The Agent User Interaction Protocol .NET SDK uses a streaming event-based
+architecture. Events are the fundamental units of communication between agents
+and the frontend. This section documents the event types and their properties.
+
+## EventTypes Enum
+
+The `EventTypes` class defines all possible event types in the system:
+
+```csharp
+public static class EventTypes
+{
+ public const string TEXT_MESSAGE_START = "TEXT_MESSAGE_START";
+ public const string TEXT_MESSAGE_CONTENT = "TEXT_MESSAGE_CONTENT";
+ public const string TEXT_MESSAGE_END = "TEXT_MESSAGE_END";
+ public const string TOOL_CALL_START = "TOOL_CALL_START";
+ public const string TOOL_CALL_ARGS = "TOOL_CALL_ARGS";
+ public const string TOOL_CALL_END = "TOOL_CALL_END";
+ public const string STATE_SNAPSHOT = "STATE_SNAPSHOT";
+ public const string STATE_DELTA = "STATE_DELTA";
+ public const string MESSAGES_SNAPSHOT = "MESSAGES_SNAPSHOT";
+ public const string RAW = "RAW";
+ public const string CUSTOM = "CUSTOM";
+ public const string RUN_STARTED = "RUN_STARTED";
+ public const string RUN_FINISHED = "RUN_FINISHED";
+ public const string RUN_ERROR = "RUN_ERROR";
+ public const string STEP_STARTED = "STEP_STARTED";
+ public const string STEP_FINISHED = "STEP_FINISHED";
+}
+```
+
+## BaseEvent
+
+All events inherit from the `BaseEvent` type, which provides common properties
+shared across all event types.
+
+
+ The .NET SDK uses polymorphic serialization for events, with the{" "}
+ type
discriminator field handled via attributes. Each event type
+ is a derived record of BaseEvent
.
+
+
+```csharp
+public abstract record BaseEvent
+{
+ public long? Timestamp { get; init; }
+ public object? RawEvent { get; init; }
+}
+```
+
+| Property | Type | Description |
+| ----------- | --------- | ------------------------------------------------- |
+| `Timestamp` | `long?` | Timestamp when the event was created |
+| `RawEvent` | `object?` | Original event data if this event was transformed |
+
+## Lifecycle Events
+
+These events represent the lifecycle of an agent run.
+
+### RunStartedEvent
+
+Signals the start of an agent run.
+
+```csharp
+public sealed record RunStartedEvent : BaseEvent
+{
+ public required string ThreadId { get; init; }
+ public required string RunId { get; init; }
+}
+```
+
+| Property | Type | Description |
+| ---------- | -------- | ----------------------------- |
+| `ThreadId` | `string` | ID of the conversation thread |
+| `RunId` | `string` | ID of the agent run |
+
+### RunFinishedEvent
+
+Signals the successful completion of an agent run.
+
+```csharp
+public sealed record RunFinishedEvent : BaseEvent
+{
+ public required string ThreadId { get; init; }
+ public required string RunId { get; init; }
+}
+```
+
+| Property | Type | Description |
+| ---------- | -------- | ----------------------------- |
+| `ThreadId` | `string` | ID of the conversation thread |
+| `RunId` | `string` | ID of the agent run |
+
+### RunErrorEvent
+
+Signals an error during an agent run.
+
+```csharp
+public sealed record RunErrorEvent : BaseEvent
+{
+ public required string Message { get; init; }
+ public string? Code { get; init; }
+}
+```
+
+| Property | Type | Description |
+| --------- | --------- | ------------- |
+| `Message` | `string` | Error message |
+| `Code` | `string?` | Error code |
+
+### StepStartedEvent
+
+Signals the start of a step within an agent run.
+
+```csharp
+public sealed record StepStartedEvent : BaseEvent
+{
+ public required string StepName { get; init; }
+}
+```
+
+| Property | Type | Description |
+| ---------- | -------- | ---------------- |
+| `StepName` | `string` | Name of the step |
+
+### StepFinishedEvent
+
+Signals the completion of a step within an agent run.
+
+```csharp
+public sealed record StepFinishedEvent : BaseEvent
+{
+ public required string StepName { get; init; }
+}
+```
+
+| Property | Type | Description |
+| ---------- | -------- | ---------------- |
+| `StepName` | `string` | Name of the step |
+
+## Text Message Events
+
+These events represent the lifecycle of text messages in a conversation.
+
+### TextMessageStartEvent
+
+Signals the start of a text message.
+
+```csharp
+public sealed record TextMessageStartEvent : BaseEvent
+{
+ public required string MessageId { get; init; }
+ public string Role => MessageRoles.Assistant;
+}
+```
+
+| Property | Type | Description |
+| ----------- | -------- | --------------------------------- |
+| `MessageId` | `string` | Unique identifier for the message |
+| `Role` | `string` | Role is always "assistant" |
+
+### TextMessageContentEvent
+
+Represents a chunk of content in a streaming text message.
+
+```csharp
+public sealed record TextMessageContentEvent : BaseEvent
+{
+ public required string MessageId { get; init; }
+ public required string Delta { get; init; }
+}
+```
+
+| Property | Type | Description |
+| ----------- | -------- | ----------------------------------------- |
+| `MessageId` | `string` | Matches the ID from TextMessageStartEvent |
+| `Delta` | `string` | Text content chunk (non-empty) |
+
+### TextMessageEndEvent
+
+Signals the end of a text message.
+
+```csharp
+public sealed record TextMessageEndEvent : BaseEvent
+{
+ public required string MessageId { get; init; }
+}
+```
+
+| Property | Type | Description |
+| ----------- | -------- | ----------------------------------------- |
+| `MessageId` | `string` | Matches the ID from TextMessageStartEvent |
+
+## Tool Call Events
+
+These events represent the lifecycle of tool calls made by agents.
+
+### ToolCallStartEvent
+
+Signals the start of a tool call.
+
+```csharp
+public sealed record ToolCallStartEvent : BaseEvent
+{
+ public required string ToolCallId { get; init; }
+ public required string ToolCallName { get; init; }
+ public string? ParentMessageId { get; init; }
+}
+```
+
+| Property | Type | Description |
+| ----------------- | --------- | ----------------------------------- |
+| `ToolCallId` | `string` | Unique identifier for the tool call |
+| `ToolCallName` | `string` | Name of the tool being called |
+| `ParentMessageId` | `string?` | ID of the parent message |
+
+### ToolCallArgsEvent
+
+Represents a chunk of argument data for a tool call.
+
+```csharp
+public sealed record ToolCallArgsEvent : BaseEvent
+{
+ public required string ToolCallId { get; init; }
+ public required string Delta { get; init; }
+}
+```
+
+| Property | Type | Description |
+| ------------ | -------- | -------------------------------------- |
+| `ToolCallId` | `string` | Matches the ID from ToolCallStartEvent |
+| `Delta` | `string` | Argument data chunk |
+
+### ToolCallEndEvent
+
+Signals the end of a tool call.
+
+```csharp
+public sealed record ToolCallEndEvent : BaseEvent
+{
+ public required string ToolCallId { get; init; }
+}
+```
+
+| Property | Type | Description |
+| ------------ | -------- | -------------------------------------- |
+| `ToolCallId` | `string` | Matches the ID from ToolCallStartEvent |
+
+## State Management Events
+
+These events are used to manage agent state.
+
+### StateSnapshotEvent
+
+Provides a complete snapshot of an agent's state.
+
+```csharp
+public sealed record StateSnapshotEvent : BaseEvent
+{
+ public required object Snapshot { get; init; }
+}
+```
+
+| Property | Type | Description |
+| ---------- | -------- | ----------------------- |
+| `Snapshot` | `object` | Complete state snapshot |
+
+### StateDeltaEvent
+
+Provides a partial update to an agent's state using JSON Patch.
+
+```csharp
+public sealed record StateDeltaEvent : BaseEvent
+{
+ public required ImmutableList Delta { get; init; } = ImmutableList.Empty;
+}
+```
+
+| Property | Type | Description |
+| -------- | ----------------------- | ----------------------------------- |
+| `Delta` | `ImmutableList` | Collection of JSON Patch operations |
+
+### MessagesSnapshotEvent
+
+Provides a snapshot of all messages in a conversation.
+
+```csharp
+public sealed record MessagesSnapshotEvent : BaseEvent
+{
+ public required ImmutableList Messages { get; init; } = ImmutableList.Empty;
+}
+```
+
+| Property | Type | Description |
+| ---------- | ---------------------------- | ----------------------------- |
+| `Messages` | `ImmutableList` | Collection of message objects |
+
+## Special Events
+
+### RawEvent
+
+Used to pass through events from external systems.
+
+```csharp
+public sealed record RawEvent : BaseEvent
+{
+ public required object Event { get; init; }
+ public string? Source { get; init; }
+}
+```
+
+| Property | Type | Description |
+| -------- | --------- | ------------------- |
+| `Event` | `object` | Original event data |
+| `Source` | `string?` | Source of the event |
+
+### CustomEvent
+
+Used for application-specific custom events.
+
+```csharp
+public sealed record CustomEvent : BaseEvent
+{
+ public required string Name { get; init; }
+ public required object Value { get; init; }
+}
+```
+
+| Property | Type | Description |
+| -------- | -------- | ------------------------------- |
+| `Name` | `string` | Name of the custom event |
+| `Value` | `object` | Value associated with the event |
diff --git a/docs/sdk/dotnet/json.mdx b/docs/sdk/dotnet/json.mdx
new file mode 100644
index 00000000..fd4431a0
--- /dev/null
+++ b/docs/sdk/dotnet/json.mdx
@@ -0,0 +1,35 @@
+---
+title: "JSON"
+description: "Working with JSON in the .NET SDK"
+---
+
+# JSON Handling
+
+The Agent User Interaction Protocol .NET SDK uses the `System.Text.Json` types
+for handling JSON data.
+
+In order to provide the best handling when talking to the frontend, the
+following `JsonSerializerOptions` are recommended:
+
+```csharp
+new JsonSerializerOptions(JsonSerializerDefaults.Web) {
+ AllowOutOfOrderMetadataProperties = true,
+ WriteIndented = false,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+}
+```
+
+`System.Text.Json` by default when handling polymorphic types expects the type
+discriminator (a metadata property) to be one of the first properties in the
+JSON object - which is not guaranteed by either the frontends you interact with
+or the JSON specification itself.
+
+Setting `AllowOutOfOrderMetadataProperties` to `true` allows the serializer to
+handle this case correctly.
+
+Some frontends (like Zod-powered schemas) may not accept null values for
+optional properties, so setting `DefaultIgnoreCondition` to
+`JsonIgnoreCondition.WhenWritingNull` ensures that null values are not
+serialized.
diff --git a/docs/sdk/dotnet/overview.mdx b/docs/sdk/dotnet/overview.mdx
new file mode 100644
index 00000000..80f0c13c
--- /dev/null
+++ b/docs/sdk/dotnet/overview.mdx
@@ -0,0 +1,98 @@
+---
+title: "Overview"
+description: "Core concepts in the Agent User Interaction Protocol .NET SDK"
+---
+
+
+For now, it is possible to clone the code and add it as a project reference.
+
+
+
+```bash
+ dotnet add package AGUIDotnet
+```
+
+# AGUIDotnet
+
+The Agent User Interaction Protocol SDK for .NET provides the core types and
+baseline agent implementations for building AG-UI compatible agents using the
+[Microsoft.Extensions.AI](https://learn.microsoft.com/en-us/dotnet/ai/microsoft-extensions-ai)
+`IChatClient` abstractions.
+
+## .NET Version Support
+
+The AGUIDotnet package currently targets the .NET 9 SDK, and includes a
+framework reference for `Microsoft.AspNetCore.App` in order to provide helpful
+integrations for hosting agents via endpoints.
+
+## Types
+
+Core data structures that represent building blocks of the system:
+
+- [RunAgentInput](/sdk/dotnet/types#runagentinput) - Input parameters for
+ running agents
+- [BaseMessage](/sdk/dotnet/types#message-types) - Message types that AG-UI
+ provides
+
+
+ Complete documentation of all types in the AGUIDotnet package
+
+
+## Events
+
+Events that power communication between agents and frontends:
+
+- [Lifecycle Events](sdk/dotnet/events#lifecycle-events) - Run and step tracking
+- [Text Message Events](sdk/dotnet/events#text-message-events) - Assistant
+ message streaming
+- [Tool Call Events](sdk/dotnet/events#tool-call-events) - Function call
+ lifecycle
+- [State Management Events](sdk/dotnet/events#state-management-events) - Agent
+ state updates
+- [Special Events](sdk/dotnet/events#special-events) - Raw and custom events
+
+
+ Complete documentation of all events in the AGUIDotnet package
+
+
+## Agents
+
+While you can use just the [Types](/sdk/dotnet/types) and
+[Events](/sdk/dotnet/events) directly, the SDK has several agent abstractions
+and implementations available.
+
+- [IAGUIAgent](/sdk/dotnet/agents#the-iaguiagent-interface) - Low-level
+ interface for implementing your own agents from scratch
+- [ChatClientAgent](/sdk/dotnet/agents#chat-client-agent) - A customisable
+ conversational agent backed by an instance of `IChatClient` that supports
+ backend & frontend tool calls
+- [StatefulChatClientAgent](/sdk/dotnet/agents/#stateful-chat-client-agent) - A
+ derived type of `ChatClientAgent`, provides the same functionality and
+ customisation, while providing collaboration via shared agentic state
+
+
+ Complete documentation of all agent functionality in the AGUIDotnet package
+
diff --git a/docs/sdk/dotnet/types.mdx b/docs/sdk/dotnet/types.mdx
new file mode 100644
index 00000000..6a029ddc
--- /dev/null
+++ b/docs/sdk/dotnet/types.mdx
@@ -0,0 +1,265 @@
+---
+title: "Types"
+description:
+ "Documentation for the core types used in the Agent User Interaction Protocol
+ .NET SDK"
+---
+
+# Core Types
+
+The Agent User Interaction Protocol .NET SDK is built on a set of core types
+that represent the fundamental structures used throughout the system. This page
+documents these types and their properties.
+
+## RunAgentInput
+
+Input parameters for running an agent. In the HTTP API, this is the body of the
+`POST` request.
+
+```csharp
+public sealed record RunAgentInput
+{
+ public required string ThreadId { get; init; }
+ public required string RunId { get; init; }
+ public required JsonElement State { get; init; }
+ public required ImmutableList Messages { get; init; } = [];
+ public required ImmutableList Tools { get; init; } = [];
+ public required ImmutableList Context { get; init; } = [];
+ public required JsonElement ForwardedProps { get; init; }
+}
+```
+
+| Property | Type | Description |
+| ---------------- | ---------------------------- | --------------------------------------------------- |
+| `ThreadId` | `string` | ID of the conversation thread |
+| `RunId` | `string` | ID of the current run |
+| `State` | `JsonElement` | Current state of the agent |
+| `Messages` | `ImmutableList` | Collection of messages in the conversation |
+| `Tools` | `ImmutableList` | Collection of tools available to the agent |
+| `Context` | `ImmutableList` | Collection of context objects provided to the agent |
+| `ForwardedProps` | `JsonElement` | Additional properties forwarded to the agent |
+
+## Message Types
+
+The SDK includes several message types that represent different kinds of
+messages in the system.
+
+
+ All message types inherit from BaseMessage
. The role
{" "}
+ property is determined by the concrete message type and is not a property of{" "}
+ BaseMessage
itself. This polymorphic approach allows for
+ type-safe handling of different message roles in the .NET SDK.
+
+
+### BaseMessage
+
+A base class for all message types in the system.
+
+```csharp
+[JsonPolymorphic(TypeDiscriminatorPropertyName = "role")]
+[JsonDerivedType(typeof(DeveloperMessage), MessageRoles.Developer)]
+[JsonDerivedType(typeof(SystemMessage), MessageRoles.System)]
+[JsonDerivedType(typeof(AssistantMessage), MessageRoles.Assistant)]
+[JsonDerivedType(typeof(UserMessage), MessageRoles.User)]
+[JsonDerivedType(typeof(ToolMessage), MessageRoles.Tool)]
+public abstract record BaseMessage
+{
+ public required string Id { get; init; }
+}
+```
+
+### MessageRoles
+
+Represents the possible roles a message sender can have.
+
+```csharp
+public static class MessageRoles
+{
+ public const string Developer = "developer";
+ public const string System = "system";
+ public const string Assistant = "assistant";
+ public const string User = "user";
+ public const string Tool = "tool";
+}
+```
+
+### DeveloperMessage
+
+Represents a message from a developer.
+
+```csharp
+public sealed record DeveloperMessage : BaseMessage
+{
+ public required string Content { get; init; }
+ public string? Name { get; init; }
+}
+```
+
+| Property | Type | Description |
+| --------- | --------- | ------------------------------------------------ |
+| `Id` | `string` | Unique identifier for the message |
+| `role` | `string` | Role of the message sender, fixed as "developer" |
+| `Content` | `string` | Text content of the message (required) |
+| `Name` | `string?` | Optional name of the sender |
+
+### SystemMessage
+
+Represents a system message.
+
+```csharp
+public sealed record SystemMessage : BaseMessage
+{
+ public required string Content { get; init; }
+ public string? Name { get; init; }
+}
+```
+
+| Property | Type | Description |
+| --------- | --------- | --------------------------------------------- |
+| `Id` | `string` | Unique identifier for the message |
+| `role` | `string` | Role of the message sender, fixed as "system" |
+| `Content` | `string` | Text content of the message (required) |
+| `Name` | `string?` | Optional name of the sender |
+
+### AssistantMessage
+
+Represents a message from an assistant.
+
+```csharp
+public sealed record AssistantMessage : BaseMessage
+{
+ public string? Content { get; init; }
+ public string? Name { get; init; }
+ public ImmutableList ToolCalls { get; init; } = [];
+}
+```
+
+| Property | Type | Description |
+| ----------- | ------------------------- | ------------------------------------------------ |
+| `Id` | `string` | Unique identifier for the message |
+| `role` | `string` | Role of the message sender, fixed as "assistant" |
+| `Content` | `string?` | Text content of the message |
+| `Name` | `string?` | Name of the sender |
+| `ToolCalls` | `ImmutableList` | Tool calls made in this message |
+
+### UserMessage
+
+Represents a message from a user.
+
+```csharp
+public sealed record UserMessage : BaseMessage
+{
+ public required string Content { get; init; }
+ public string? Name { get; init; }
+}
+```
+
+| Property | Type | Description |
+| --------- | --------- | ------------------------------------------- |
+| `Id` | `string` | Unique identifier for the message |
+| `role` | `string` | Role of the message sender, fixed as "user" |
+| `Content` | `string` | Text content of the message (required) |
+| `Name` | `string?` | Optional name of the sender |
+
+### ToolMessage
+
+Represents a message from a tool.
+
+```csharp
+public sealed record ToolMessage : BaseMessage
+{
+ public required string ToolCallId { get; init; }
+ public required string Content { get; init; }
+}
+```
+
+| Property | Type | Description |
+| ------------ | -------- | -------------------------------------------- |
+| `Id` | `string` | Unique identifier for the message |
+| `role` | `string` | Role of the message sender, fixed as "tool" |
+| `ToolCallId` | `string` | ID of the tool call this message responds to |
+| `Content` | `string` | Text content of the message |
+
+### ToolCall
+
+Represents a tool call made by an agent.
+
+```csharp
+public sealed record ToolCall
+{
+ public required string Id { get; init; }
+ public string Type => "function";
+ public required FunctionCall Function { get; init; }
+}
+```
+
+| Property | Type | Description |
+| ---------- | -------------- | ---------------------------------------- |
+| `Id` | `string` | Unique identifier for the tool call |
+| `Type` | `string` | Type of the tool call, always "function" |
+| `Function` | `FunctionCall` | Details about the function being called |
+
+#### FunctionCall
+
+Represents function name and arguments in a tool call.
+
+```csharp
+public sealed record FunctionCall
+{
+ public required string Name { get; init; }
+ public required string Arguments { get; init; }
+}
+```
+
+| Property | Type | Description |
+| ----------- | -------- | ------------------------------------------------ |
+| `Name` | `string` | Name of the function to call |
+| `Arguments` | `string` | JSON-encoded string of arguments to the function |
+
+## Context
+
+Represents a piece of contextual information provided to an agent.
+
+```csharp
+public sealed record Context
+{
+ public required string Description { get; init; }
+ public required string Value { get; init; }
+}
+```
+
+| Property | Type | Description |
+| ------------- | -------- | ------------------------------------------- |
+| `Description` | `string` | Description of what this context represents |
+| `Value` | `string` | The actual context value |
+
+## Tool
+
+Defines a tool that can be called by an agent.
+
+```csharp
+public sealed record Tool
+{
+ public required string Name { get; init; }
+ public required string Description { get; init; }
+ public required JsonElement Parameters { get; init; }
+}
+```
+
+| Property | Type | Description |
+| ------------- | ------------- | ------------------------------------------------ |
+| `Name` | `string` | Name of the tool |
+| `Description` | `string` | Description of what the tool does |
+| `Parameters` | `JsonElement` | JSON Schema defining the parameters for the tool |
+
+## State
+
+Represents the state of an agent during execution.
+
+```csharp
+// State is represented as a JsonElement for flexibility
+JsonElement State
+```
+
+The state type is flexible and can hold any data structure needed by the agent
+implementation.
diff --git a/dotnet-sdk/.gitignore b/dotnet-sdk/.gitignore
new file mode 100644
index 00000000..f5840fe1
--- /dev/null
+++ b/dotnet-sdk/.gitignore
@@ -0,0 +1,481 @@
+# Created by https://www.toptal.com/developers/gitignore/api/visualstudio,visualstudiocode,dotnetcore,windows,macos
+# Edit at https://www.toptal.com/developers/gitignore?templates=visualstudio,visualstudiocode,dotnetcore,windows,macos
+
+### DotnetCore ###
+# .NET Core build folders
+bin/
+obj/
+
+# Common node modules locations
+/node_modules
+/wwwroot/node_modules
+
+### macOS ###
+# General
+.DS_Store
+.AppleDouble
+.LSOverride
+
+# Icon must end with two \r
+Icon
+
+
+# Thumbnails
+._*
+
+# Files that might appear in the root of a volume
+.DocumentRevisions-V100
+.fseventsd
+.Spotlight-V100
+.TemporaryItems
+.Trashes
+.VolumeIcon.icns
+.com.apple.timemachine.donotpresent
+
+# Directories potentially created on remote AFP share
+.AppleDB
+.AppleDesktop
+Network Trash Folder
+Temporary Items
+.apdisk
+
+### macOS Patch ###
+# iCloud generated files
+*.icloud
+
+### VisualStudioCode ###
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+!.vscode/*.code-snippets
+
+# Local History for Visual Studio Code
+.history/
+
+# Built Visual Studio Code Extensions
+*.vsix
+
+### VisualStudioCode Patch ###
+# Ignore all local history of files
+.history
+.ionide
+
+### Windows ###
+# Windows thumbnail cache files
+Thumbs.db
+Thumbs.db:encryptable
+ehthumbs.db
+ehthumbs_vista.db
+
+# Dump file
+*.stackdump
+
+# Folder config file
+[Dd]esktop.ini
+
+# Recycle Bin used on file shares
+$RECYCLE.BIN/
+
+# Windows Installer files
+*.cab
+*.msi
+*.msix
+*.msm
+*.msp
+
+# Windows shortcuts
+*.lnk
+
+### VisualStudio ###
+## Ignore Visual Studio temporary files, build results, and
+## files generated by popular Visual Studio add-ons.
+##
+## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
+
+# User-specific files
+*.rsuser
+*.suo
+*.user
+*.userosscache
+*.sln.docstates
+
+# User-specific files (MonoDevelop/Xamarin Studio)
+*.userprefs
+
+# Mono auto generated files
+mono_crash.*
+
+# Build results
+[Dd]ebug/
+[Dd]ebugPublic/
+[Rr]elease/
+[Rr]eleases/
+x64/
+x86/
+[Ww][Ii][Nn]32/
+[Aa][Rr][Mm]/
+[Aa][Rr][Mm]64/
+bld/
+[Bb]in/
+[Oo]bj/
+[Ll]og/
+[Ll]ogs/
+
+# Visual Studio 2015/2017 cache/options directory
+.vs/
+# Uncomment if you have tasks that create the project's static files in wwwroot
+#wwwroot/
+
+# Visual Studio 2017 auto generated files
+Generated\ Files/
+
+# MSTest test Results
+[Tt]est[Rr]esult*/
+[Bb]uild[Ll]og.*
+
+# NUnit
+*.VisualState.xml
+TestResult.xml
+nunit-*.xml
+
+# Build Results of an ATL Project
+[Dd]ebugPS/
+[Rr]eleasePS/
+dlldata.c
+
+# Benchmark Results
+BenchmarkDotNet.Artifacts/
+
+# .NET Core
+project.lock.json
+project.fragment.lock.json
+artifacts/
+
+# ASP.NET Scaffolding
+ScaffoldingReadMe.txt
+
+# StyleCop
+StyleCopReport.xml
+
+# Files built by Visual Studio
+*_i.c
+*_p.c
+*_h.h
+*.ilk
+*.meta
+*.obj
+*.iobj
+*.pch
+*.pdb
+*.ipdb
+*.pgc
+*.pgd
+*.rsp
+*.sbr
+*.tlb
+*.tli
+*.tlh
+*.tmp
+*.tmp_proj
+*_wpftmp.csproj
+*.log
+*.tlog
+*.vspscc
+*.vssscc
+.builds
+*.pidb
+*.svclog
+*.scc
+
+# Chutzpah Test files
+_Chutzpah*
+
+# Visual C++ cache files
+ipch/
+*.aps
+*.ncb
+*.opendb
+*.opensdf
+*.sdf
+*.cachefile
+*.VC.db
+*.VC.VC.opendb
+
+# Visual Studio profiler
+*.psess
+*.vsp
+*.vspx
+*.sap
+
+# Visual Studio Trace Files
+*.e2e
+
+# TFS 2012 Local Workspace
+$tf/
+
+# Guidance Automation Toolkit
+*.gpState
+
+# ReSharper is a .NET coding add-in
+_ReSharper*/
+*.[Rr]e[Ss]harper
+*.DotSettings.user
+
+# TeamCity is a build add-in
+_TeamCity*
+
+# DotCover is a Code Coverage Tool
+*.dotCover
+
+# AxoCover is a Code Coverage Tool
+.axoCover/*
+!.axoCover/settings.json
+
+# Coverlet is a free, cross platform Code Coverage Tool
+coverage*.json
+coverage*.xml
+coverage*.info
+
+# Visual Studio code coverage results
+*.coverage
+*.coveragexml
+
+# NCrunch
+_NCrunch_*
+.*crunch*.local.xml
+nCrunchTemp_*
+
+# MightyMoose
+*.mm.*
+AutoTest.Net/
+
+# Web workbench (sass)
+.sass-cache/
+
+# Installshield output folder
+[Ee]xpress/
+
+# DocProject is a documentation generator add-in
+DocProject/buildhelp/
+DocProject/Help/*.HxT
+DocProject/Help/*.HxC
+DocProject/Help/*.hhc
+DocProject/Help/*.hhk
+DocProject/Help/*.hhp
+DocProject/Help/Html2
+DocProject/Help/html
+
+# Click-Once directory
+publish/
+
+# Publish Web Output
+*.[Pp]ublish.xml
+*.azurePubxml
+# Note: Comment the next line if you want to checkin your web deploy settings,
+# but database connection strings (with potential passwords) will be unencrypted
+*.pubxml
+*.publishproj
+
+# Microsoft Azure Web App publish settings. Comment the next line if you want to
+# checkin your Azure Web App publish settings, but sensitive information contained
+# in these scripts will be unencrypted
+PublishScripts/
+
+# NuGet Packages
+*.nupkg
+# NuGet Symbol Packages
+*.snupkg
+# The packages folder can be ignored because of Package Restore
+**/[Pp]ackages/*
+# except build/, which is used as an MSBuild target.
+!**/[Pp]ackages/build/
+# Uncomment if necessary however generally it will be regenerated when needed
+#!**/[Pp]ackages/repositories.config
+# NuGet v3's project.json files produces more ignorable files
+*.nuget.props
+*.nuget.targets
+
+# Microsoft Azure Build Output
+csx/
+*.build.csdef
+
+# Microsoft Azure Emulator
+ecf/
+rcf/
+
+# Windows Store app package directories and files
+AppPackages/
+BundleArtifacts/
+Package.StoreAssociation.xml
+_pkginfo.txt
+*.appx
+*.appxbundle
+*.appxupload
+
+# Visual Studio cache files
+# files ending in .cache can be ignored
+*.[Cc]ache
+# but keep track of directories ending in .cache
+!?*.[Cc]ache/
+
+# Others
+ClientBin/
+~$*
+*~
+*.dbmdl
+*.dbproj.schemaview
+*.jfm
+*.pfx
+*.publishsettings
+orleans.codegen.cs
+
+# Including strong name files can present a security risk
+# (https://github.com/github/gitignore/pull/2483#issue-259490424)
+#*.snk
+
+# Since there are multiple workflows, uncomment next line to ignore bower_components
+# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
+#bower_components/
+
+# RIA/Silverlight projects
+Generated_Code/
+
+# Backup & report files from converting an old project file
+# to a newer Visual Studio version. Backup files are not needed,
+# because we have git ;-)
+_UpgradeReport_Files/
+Backup*/
+UpgradeLog*.XML
+UpgradeLog*.htm
+ServiceFabricBackup/
+*.rptproj.bak
+
+# SQL Server files
+*.mdf
+*.ldf
+*.ndf
+
+# Business Intelligence projects
+*.rdl.data
+*.bim.layout
+*.bim_*.settings
+*.rptproj.rsuser
+*- [Bb]ackup.rdl
+*- [Bb]ackup ([0-9]).rdl
+*- [Bb]ackup ([0-9][0-9]).rdl
+
+# Microsoft Fakes
+FakesAssemblies/
+
+# GhostDoc plugin setting file
+*.GhostDoc.xml
+
+# Node.js Tools for Visual Studio
+.ntvs_analysis.dat
+node_modules/
+
+# Visual Studio 6 build log
+*.plg
+
+# Visual Studio 6 workspace options file
+*.opt
+
+# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
+*.vbw
+
+# Visual Studio 6 auto-generated project file (contains which files were open etc.)
+*.vbp
+
+# Visual Studio 6 workspace and project file (working project files containing files to include in project)
+*.dsw
+*.dsp
+
+# Visual Studio 6 technical files
+
+# Visual Studio LightSwitch build output
+**/*.HTMLClient/GeneratedArtifacts
+**/*.DesktopClient/GeneratedArtifacts
+**/*.DesktopClient/ModelManifest.xml
+**/*.Server/GeneratedArtifacts
+**/*.Server/ModelManifest.xml
+_Pvt_Extensions
+
+# Paket dependency manager
+.paket/paket.exe
+paket-files/
+
+# FAKE - F# Make
+.fake/
+
+# CodeRush personal settings
+.cr/personal
+
+# Python Tools for Visual Studio (PTVS)
+__pycache__/
+*.pyc
+
+# Cake - Uncomment if you are using it
+# tools/**
+# !tools/packages.config
+
+# Tabs Studio
+*.tss
+
+# Telerik's JustMock configuration file
+*.jmconfig
+
+# BizTalk build output
+*.btp.cs
+*.btm.cs
+*.odx.cs
+*.xsd.cs
+
+# OpenCover UI analysis results
+OpenCover/
+
+# Azure Stream Analytics local run output
+ASALocalRun/
+
+# MSBuild Binary and Structured Log
+*.binlog
+
+# NVidia Nsight GPU debugger configuration file
+*.nvuser
+
+# MFractors (Xamarin productivity tool) working folder
+.mfractor/
+
+# Local History for Visual Studio
+.localhistory/
+
+# Visual Studio History (VSHistory) files
+.vshistory/
+
+# BeatPulse healthcheck temp database
+healthchecksdb
+
+# Backup folder for Package Reference Convert tool in Visual Studio 2017
+MigrationBackup/
+
+# Ionide (cross platform F# VS Code tools) working folder
+.ionide/
+
+# Fody - auto-generated XML schema
+FodyWeavers.xsd
+
+# VS Code files for those working on multiple tools
+*.code-workspace
+
+# Local History for Visual Studio Code
+
+# Windows Installer files from build outputs
+
+# JetBrains Rider
+*.sln.iml
+
+### VisualStudio Patch ###
+# Additional files built by Visual Studio
+
+# End of https://www.toptal.com/developers/gitignore/api/visualstudio,visualstudiocode,dotnetcore,windows,macos
\ No newline at end of file
diff --git a/dotnet-sdk/.vscode/settings.json b/dotnet-sdk/.vscode/settings.json
new file mode 100644
index 00000000..747fab04
--- /dev/null
+++ b/dotnet-sdk/.vscode/settings.json
@@ -0,0 +1,4 @@
+{
+ "dotnet.defaultSolution": "AGUIDotnet.sln",
+ "cSpell.words": ["AGUI"]
+}
diff --git a/dotnet-sdk/AGUIDotnet.Tests/AGUIDotnet.Tests.csproj b/dotnet-sdk/AGUIDotnet.Tests/AGUIDotnet.Tests.csproj
new file mode 100644
index 00000000..0f4c881e
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet.Tests/AGUIDotnet.Tests.csproj
@@ -0,0 +1,25 @@
+
+
+
+ net9.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet-sdk/AGUIDotnet.Tests/AgentExtensionsTests.cs b/dotnet-sdk/AGUIDotnet.Tests/AgentExtensionsTests.cs
new file mode 100644
index 00000000..408bd98c
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet.Tests/AgentExtensionsTests.cs
@@ -0,0 +1,102 @@
+using System.Collections.Immutable;
+using System.Text.Json;
+using System.Threading.Channels;
+using AGUIDotnet.Agent;
+using AGUIDotnet.Events;
+using AGUIDotnet.Types;
+
+namespace AGUIDotnet.Tests;
+
+// Simple agent for testing purposes that allows us to delegate the RunAsync method to a provided function.
+public class DelegatingAgent(Func, CancellationToken, Task> func) : IAGUIAgent
+{
+ public async Task RunAsync(RunAgentInput input, ChannelWriter events, CancellationToken cancellationToken = default)
+ {
+ await func(input, events, cancellationToken).ConfigureAwait(false);
+ }
+}
+
+public class AgentExtensionsTests
+{
+ [Fact]
+ public void RunToCompletionAsyncSupportsNoOp()
+ {
+ var agent = new DelegatingAgent((input, events, cancellationToken) =>
+ {
+ return Task.CompletedTask;
+ });
+ var input = new RunAgentInput
+ {
+ ThreadId = "test-thread",
+ RunId = "test-run",
+ State = JsonDocument.Parse("{}").RootElement,
+ Messages = [],
+ Context = [],
+ ForwardedProps = JsonDocument.Parse("{}").RootElement,
+ Tools = []
+ };
+
+ var events = agent.RunToCompletionAsync(input).ToBlockingEnumerable().ToImmutableList();
+
+ Assert.Empty(events);
+ }
+
+ [Fact]
+ public void RunToCompletionAsyncHandlesAgentChannelCompletion()
+ {
+ var agent = new DelegatingAgent((input, events, cancellationToken) =>
+ {
+ // Complete the channel ourselves
+ events.Complete();
+
+ return Task.CompletedTask;
+ });
+
+ var input = new RunAgentInput
+ {
+ ThreadId = "test-thread",
+ RunId = "test-run",
+ State = JsonDocument.Parse("{}").RootElement,
+ Messages = [],
+ Context = [],
+ ForwardedProps = JsonDocument.Parse("{}").RootElement,
+ Tools = []
+ };
+
+ var events = agent.RunToCompletionAsync(input).ToBlockingEnumerable().ToImmutableList();
+
+ Assert.Empty(events);
+ }
+
+ [Theory]
+ [InlineData("test-thread", "test-run")]
+ [InlineData("another-thread", "another-run")]
+ public void RunToCompletionAsyncDispatchesExactInputToAgent(string threadId, string runId)
+ {
+ var input = new RunAgentInput
+ {
+ ThreadId = threadId,
+ RunId = runId,
+ State = JsonDocument.Parse("{}").RootElement,
+ Messages = [],
+ Context = [],
+ ForwardedProps = JsonDocument.Parse("{}").RootElement,
+ Tools = []
+ };
+
+ var agent = new DelegatingAgent((actualInput, events, cancellationToken) =>
+ {
+ Assert.Equal(input.ThreadId, actualInput.ThreadId);
+ Assert.Equal(input.RunId, actualInput.RunId);
+ Assert.Equal(input.State.ToString(), actualInput.State.ToString());
+ Assert.Equal(input.Messages, actualInput.Messages);
+ Assert.Equal(input.Context, actualInput.Context);
+ Assert.Equal(input.ForwardedProps.ToString(), actualInput.ForwardedProps.ToString());
+ Assert.Equal(input.Tools, actualInput.Tools);
+
+ return Task.CompletedTask;
+ });
+
+ var events = agent.RunToCompletionAsync(input).ToBlockingEnumerable().ToImmutableList();
+ }
+}
diff --git a/dotnet-sdk/AGUIDotnet.Tests/ChatClientAgentTests.cs b/dotnet-sdk/AGUIDotnet.Tests/ChatClientAgentTests.cs
new file mode 100644
index 00000000..f3afe857
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet.Tests/ChatClientAgentTests.cs
@@ -0,0 +1,453 @@
+using System.Collections.Immutable;
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+using System.Threading.Channels;
+using AGUIDotnet.Agent;
+using AGUIDotnet.Events;
+using AGUIDotnet.Types;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit;
+
+namespace AGUIDotnet.Tests;
+
+public class ChatClientAgentTests
+{
+ [Fact]
+ public async Task UserMessage_ShouldReceiveAssistantResponse()
+ {
+ // Arrange
+ var userMessage = new UserMessage
+ {
+ Id = "user-msg-1",
+ Content = "Hello, assistant!",
+ Name = "User"
+ };
+
+ var input = new RunAgentInput
+ {
+ ThreadId = "thread-1",
+ RunId = "run-1",
+ Messages = ImmutableList.Create(userMessage),
+ Tools = ImmutableList.Empty,
+ Context = ImmutableList.Empty,
+ State = JsonDocument.Parse("{}").RootElement,
+ ForwardedProps = JsonDocument.Parse("{}").RootElement
+ };
+
+ // Setup the response from the chat client
+ var responseText = "Hello, I'm an assistant!";
+ var messageId = "assistant-msg-1";
+
+ // Create a test chat client that simulates streaming response
+ var serviceProvider = new ServiceCollection().BuildServiceProvider();
+
+ // Create a streaming chat client that returns a simulated response
+ var chatClient = new TestStreamingChatClient(
+ serviceProvider,
+ (sp, messages, options, ct) => GenerateStreamingResponseAsync(messageId, responseText, ct));
+
+ // Local async iterator method to generate streaming response
+ async IAsyncEnumerable GenerateStreamingResponseAsync(string msgId, string text, [EnumeratorCancellation] CancellationToken ct)
+ {
+ // Simulate a delay for realistic streaming behavior
+ await Task.Delay(10, ct);
+
+ // Yield a single update with text content
+ yield return new ChatResponseUpdate
+ {
+ MessageId = msgId,
+ Contents = new[] { new TextContent(text) }
+ };
+ }
+
+ // Create the agent under test
+ var agent = new ChatClientAgent(chatClient);
+
+ // Act - collect all events using the extension method
+ var eventList = new List();
+ await foreach (var evt in agent.RunToCompletionAsync(input))
+ {
+ eventList.Add(evt);
+ }
+
+ // Assert
+ Assert.Contains(eventList, e => e is RunStartedEvent);
+ Assert.Contains(eventList, e => e is RunFinishedEvent);
+
+ var textStartEvent = Assert.Single(eventList.OfType());
+ Assert.Equal(messageId, textStartEvent.MessageId);
+
+ var textContentEvent = Assert.Single(eventList.OfType());
+ Assert.Equal(messageId, textContentEvent.MessageId);
+ Assert.Equal(responseText, textContentEvent.Delta);
+
+ var textEndEvent = Assert.Single(eventList.OfType());
+ Assert.Equal(messageId, textEndEvent.MessageId);
+ }
+
+ [Fact]
+ public async Task SystemMessage_ShouldBePreservedInChatRequest()
+ {
+ // Arrange
+ var systemMessage = new SystemMessage
+ {
+ Id = "sys-msg-1",
+ Content = "You are a helpful assistant.",
+ };
+
+ var userMessage = new UserMessage
+ {
+ Id = "user-msg-1",
+ Content = "Hello, assistant!",
+ Name = "User"
+ };
+
+ var input = new RunAgentInput
+ {
+ ThreadId = "thread-1",
+ RunId = "run-1",
+ Messages = ImmutableList.Create(systemMessage, userMessage),
+ Tools = ImmutableList.Empty,
+ Context = ImmutableList.Empty,
+ State = JsonDocument.Parse("{}").RootElement,
+ ForwardedProps = JsonDocument.Parse("{}").RootElement
+ };
+
+ // Track the messages passed to the chat client
+ List passedMessages = new List();
+
+ // Setup the response from the chat client
+ var responseText = "Hello, I'm an assistant!";
+ var messageId = "assistant-msg-1";
+
+ // Create a test chat client that simulates streaming response
+ var serviceProvider = new ServiceCollection().BuildServiceProvider();
+
+ // Create a streaming chat client that returns a simulated response and captures the messages
+ var chatClient = new TestStreamingChatClient(
+ serviceProvider,
+ (sp, messages, options, ct) =>
+ {
+ // Capture the messages passed to the client
+ passedMessages.AddRange(messages);
+ return GenerateStreamingResponseAsync(messageId, responseText, ct);
+ });
+
+ // Local async iterator method to generate streaming response
+ async IAsyncEnumerable GenerateStreamingResponseAsync(string msgId, string text, [EnumeratorCancellation] CancellationToken ct)
+ {
+ await Task.Delay(10, ct);
+ yield return new ChatResponseUpdate
+ {
+ MessageId = msgId,
+ Contents = new[] { new TextContent(text) }
+ };
+ }
+
+ // Create the agent under test with default options (which preserves system messages)
+ var agent = new ChatClientAgent(chatClient);
+
+ // Act - collect all events using the extension method
+ var eventList = new List();
+ await foreach (var evt in agent.RunToCompletionAsync(input))
+ {
+ eventList.Add(evt);
+ }
+
+ // Assert
+ // Verify that a system message was passed to the chat client
+ Assert.Contains(passedMessages, m => m.Role == ChatRole.System);
+ var systemMsg = passedMessages.First(m => m.Role == ChatRole.System);
+ Assert.Equal(systemMessage.Content, ((TextContent)systemMsg.Contents[0]).Text);
+
+ // Verify that a user message was passed to the chat client
+ Assert.Contains(passedMessages, m => m.Role == ChatRole.User);
+ var userMsg = passedMessages.First(m => m.Role == ChatRole.User);
+ Assert.Equal(userMessage.Content, ((TextContent)userMsg.Contents[0]).Text);
+
+ // Verify we got expected events
+ Assert.Contains(eventList, e => e is RunStartedEvent);
+ Assert.Contains(eventList, e => e is RunFinishedEvent);
+ Assert.Contains(eventList, e => e is TextMessageStartEvent);
+ }
+
+ [Fact]
+ public async Task SystemMessage_ShouldBeDiscardedWhenPreserveInboundSystemMessagesIsFalse()
+ {
+ // Arrange
+ var systemMessage = new SystemMessage
+ {
+ Id = "sys-msg-1",
+ Content = "You are a helpful assistant.",
+ };
+
+ var userMessage = new UserMessage
+ {
+ Id = "user-msg-1",
+ Content = "Hello, assistant!",
+ Name = "User"
+ };
+
+ var input = new RunAgentInput
+ {
+ ThreadId = "thread-1",
+ RunId = "run-1",
+ Messages = ImmutableList.Create(systemMessage, userMessage),
+ Tools = ImmutableList.Empty,
+ Context = ImmutableList.Empty,
+ State = JsonDocument.Parse("{}").RootElement,
+ ForwardedProps = JsonDocument.Parse("{}").RootElement
+ };
+
+ // Track the messages passed to the chat client
+ List passedMessages = new List();
+
+ // Setup the response from the chat client
+ var responseText = "Hello, I'm an assistant!";
+ var messageId = "assistant-msg-1";
+
+ // Create a test chat client that simulates streaming response
+ var serviceProvider = new ServiceCollection().BuildServiceProvider();
+
+ // Create a streaming chat client that returns a simulated response and captures the messages
+ var chatClient = new TestStreamingChatClient(
+ serviceProvider,
+ (sp, messages, options, ct) =>
+ {
+ // Capture the messages passed to the client
+ passedMessages.AddRange(messages);
+ return GenerateStreamingResponseAsync(messageId, responseText, ct);
+ });
+
+ // Local async iterator method to generate streaming response
+ async IAsyncEnumerable GenerateStreamingResponseAsync(string msgId, string text, [EnumeratorCancellation] CancellationToken ct)
+ {
+ await Task.Delay(10, ct);
+ yield return new ChatResponseUpdate
+ {
+ MessageId = msgId,
+ Contents = new[] { new TextContent(text) }
+ };
+ }
+
+ // Create the agent under test with PreserveInboundSystemMessages set to false
+ var agentOptions = new ChatClientAgentOptions
+ {
+ PreserveInboundSystemMessages = false
+ };
+ var agent = new ChatClientAgent(chatClient, agentOptions);
+
+ // Act - collect all events using the extension method
+ var eventList = new List();
+ await foreach (var evt in agent.RunToCompletionAsync(input))
+ {
+ eventList.Add(evt);
+ }
+
+ // Assert
+ // Verify that no system message was passed to the chat client
+ Assert.DoesNotContain(passedMessages, m => m.Role == ChatRole.System);
+
+ // Verify that a user message was passed to the chat client
+ Assert.Contains(passedMessages, m => m.Role == ChatRole.User);
+ var userMsg = passedMessages.First(m => m.Role == ChatRole.User);
+ Assert.Equal(userMessage.Content, ((TextContent)userMsg.Contents[0]).Text);
+
+ // Verify we got expected events
+ Assert.Contains(eventList, e => e is RunStartedEvent);
+ Assert.Contains(eventList, e => e is RunFinishedEvent);
+ Assert.Contains(eventList, e => e is TextMessageStartEvent);
+ }
+
+ [Fact]
+ public async Task SystemMessage_ShouldBeOverriddenWhenSystemMessageOptionIsProvided()
+ {
+ // Arrange
+ var systemMessage = new SystemMessage
+ {
+ Id = "sys-msg-1",
+ Content = "You are a helpful assistant.",
+ };
+
+ var userMessage = new UserMessage
+ {
+ Id = "user-msg-1",
+ Content = "Hello, assistant!",
+ Name = "User"
+ };
+
+ var input = new RunAgentInput
+ {
+ ThreadId = "thread-1",
+ RunId = "run-1",
+ Messages = ImmutableList.Create(systemMessage, userMessage),
+ Tools = ImmutableList.Empty,
+ Context = ImmutableList.Empty,
+ State = JsonDocument.Parse("{}").RootElement,
+ ForwardedProps = JsonDocument.Parse("{}").RootElement
+ };
+
+ // Track the messages passed to the chat client
+ List passedMessages = new List();
+
+ // Setup the response from the chat client
+ var responseText = "Hello, I'm an assistant!";
+ var messageId = "assistant-msg-1";
+ var overrideSystemMessage = "You are an AI coding assistant.";
+
+ // Create a test chat client that simulates streaming response
+ var serviceProvider = new ServiceCollection().BuildServiceProvider();
+
+ // Create a streaming chat client that returns a simulated response and captures the messages
+ var chatClient = new TestStreamingChatClient(
+ serviceProvider,
+ (sp, messages, options, ct) =>
+ {
+ // Capture the messages passed to the client
+ passedMessages.AddRange(messages);
+ return GenerateStreamingResponseAsync(messageId, responseText, ct);
+ });
+
+ // Local async iterator method to generate streaming response
+ async IAsyncEnumerable GenerateStreamingResponseAsync(string msgId, string text, [EnumeratorCancellation] CancellationToken ct)
+ {
+ await Task.Delay(10, ct);
+ yield return new ChatResponseUpdate
+ {
+ MessageId = msgId,
+ Contents = new[] { new TextContent(text) }
+ };
+ }
+
+ // Create the agent under test with a custom system message
+ var agentOptions = new ChatClientAgentOptions
+ {
+ SystemMessage = overrideSystemMessage
+ };
+ var agent = new ChatClientAgent(chatClient, agentOptions);
+
+ // Act - collect all events using the extension method
+ var eventList = new List();
+ await foreach (var evt in agent.RunToCompletionAsync(input))
+ {
+ eventList.Add(evt);
+ }
+
+ // Assert
+ // Verify that a system message was passed to the chat client
+ Assert.Contains(passedMessages, m => m.Role == ChatRole.System);
+ var systemMsg = passedMessages.First(m => m.Role == ChatRole.System);
+
+ // Verify the system message was overridden
+ Assert.Equal(overrideSystemMessage, ((TextContent)systemMsg.Contents[0]).Text);
+ Assert.NotEqual(systemMessage.Content, ((TextContent)systemMsg.Contents[0]).Text);
+
+ // Verify that a user message was passed to the chat client
+ Assert.Contains(passedMessages, m => m.Role == ChatRole.User);
+ var userMsg = passedMessages.First(m => m.Role == ChatRole.User);
+ Assert.Equal(userMessage.Content, ((TextContent)userMsg.Contents[0]).Text);
+
+ // Verify we got expected events
+ Assert.Contains(eventList, e => e is RunStartedEvent);
+ Assert.Contains(eventList, e => e is RunFinishedEvent);
+ }
+
+ [Fact]
+ public async Task FrontendToolCall_ShouldBeEmittedWithEvents()
+ {
+ // Arrange
+ var userMessage = new UserMessage
+ {
+ Id = "user-msg-1",
+ Content = "Call the search tool please",
+ Name = "User"
+ };
+
+ // Create a frontend tool to be passed to the agent
+ var searchTool = new Tool
+ {
+ Name = "search",
+ Description = "Search for information",
+ Parameters = JsonDocument.Parse(@"{
+ ""type"": ""object"",
+ ""properties"": {
+ ""query"": {
+ ""type"": ""string"",
+ ""description"": ""The search query""
+ }
+ },
+ ""required"": [""query""]
+ }").RootElement
+ };
+
+ var input = new RunAgentInput
+ {
+ ThreadId = "thread-1",
+ RunId = "run-1",
+ Messages = ImmutableList.Create(userMessage),
+ Tools = ImmutableList.Create(searchTool),
+ Context = ImmutableList.Empty,
+ State = JsonDocument.Parse("{}").RootElement,
+ ForwardedProps = JsonDocument.Parse("{}").RootElement
+ };
+
+ // Create a test chat client that simulates a tool call
+ var serviceProvider = new ServiceCollection().BuildServiceProvider();
+
+ // Set up the tool call details
+ var toolCallId = "tool-call-1";
+ var messageId = "assistant-msg-1";
+ var toolName = "search";
+ var toolArgs = @"{""query"":""test search""}";
+
+ // Create a streaming chat client that returns a simulated tool call
+ var chatClient = new TestStreamingChatClient(
+ serviceProvider,
+ (sp, messages, options, ct) => GenerateToolCallResponseAsync(messageId, toolCallId, toolName, toolArgs, ct));
+
+ // Local async iterator method to generate a tool call response
+ async IAsyncEnumerable GenerateToolCallResponseAsync(
+ string msgId, string callId, string name, string args, [EnumeratorCancellation] CancellationToken ct)
+ {
+ await Task.Delay(10, ct);
+
+ // Yield an update with a function call content
+ yield return new ChatResponseUpdate
+ {
+ MessageId = msgId,
+ Contents = new[] { new FunctionCallContent(
+ callId,
+ name,
+ JsonSerializer.Deserialize>(args)
+ )}
+ };
+ }
+
+ // Create the agent under test
+ var agent = new ChatClientAgent(chatClient);
+
+ // Act - collect all events using the extension method
+ var eventList = new List();
+ await foreach (var evt in agent.RunToCompletionAsync(input))
+ {
+ eventList.Add(evt);
+ }
+
+ // Assert
+ // Verify the tool call events were emitted
+ var toolCallStartEvent = Assert.Single(eventList.OfType());
+ Assert.Equal(toolCallId, toolCallStartEvent.ToolCallId);
+ Assert.Equal(toolName, toolCallStartEvent.ToolCallName);
+
+ var toolCallArgsEvent = Assert.Single(eventList.OfType());
+ Assert.Equal(toolCallId, toolCallArgsEvent.ToolCallId);
+ // The actual JSON might have different formatting, so deserialize and compare the content
+ var expectedArgs = JsonSerializer.Deserialize>(toolArgs);
+ var actualArgs = JsonSerializer.Deserialize>(toolCallArgsEvent.Delta);
+ Assert.Equal(expectedArgs?["query"]?.ToString(), actualArgs?["query"]?.ToString());
+
+ var toolCallEndEvent = Assert.Single(eventList.OfType());
+ Assert.Equal(toolCallId, toolCallEndEvent.ToolCallId);
+ }
+}
\ No newline at end of file
diff --git a/dotnet-sdk/AGUIDotnet.Tests/ChatClientMessageMapperTests.cs b/dotnet-sdk/AGUIDotnet.Tests/ChatClientMessageMapperTests.cs
new file mode 100644
index 00000000..2dafd5c9
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet.Tests/ChatClientMessageMapperTests.cs
@@ -0,0 +1,217 @@
+using System.Collections.Immutable;
+using System.Text.Json;
+using AGUIDotnet.Integrations.ChatClient;
+using AGUIDotnet.Types;
+using Microsoft.Extensions.AI;
+using Xunit;
+
+namespace AGUIDotnet.Tests;
+
+public class ChatClientMessageMapperTests
+{
+ [Theory]
+ [InlineData("test content", "test name")]
+ [InlineData("", null)]
+ public void MapSystemMessage_ShouldMapCorrectly(string content, string? name)
+ {
+ // Arrange
+ var message = new SystemMessage
+ {
+ Id = "sys1",
+ Content = content,
+ Name = name
+ };
+
+ // Act
+ var result = new[] { message }.MapAGUIMessagesToChatClientMessages();
+
+ // Assert
+ Assert.Single(result);
+ var chatMessage = result[0];
+ Assert.Equal(ChatRole.System, chatMessage.Role);
+ var textContent = Assert.Single(chatMessage.Contents);
+ Assert.IsType(textContent);
+ Assert.Equal(content, ((TextContent)textContent).Text);
+ Assert.Equal(name, chatMessage.AuthorName);
+ Assert.Equal(message.Id, chatMessage.MessageId);
+ }
+
+ [Theory]
+ [InlineData("test content", "test name")]
+ [InlineData("", null)]
+ public void MapUserMessage_ShouldMapCorrectly(string content, string? name)
+ {
+ // Arrange
+ var message = new UserMessage
+ {
+ Id = "usr1",
+ Content = content,
+ Name = name
+ };
+
+ // Act
+ var result = new[] { message }.MapAGUIMessagesToChatClientMessages();
+
+ // Assert
+ Assert.Single(result);
+ var chatMessage = result[0];
+ Assert.Equal(ChatRole.User, chatMessage.Role);
+ var textContent = Assert.Single(chatMessage.Contents);
+ Assert.IsType(textContent);
+ Assert.Equal(content, ((TextContent)textContent).Text);
+ Assert.Equal(name, chatMessage.AuthorName);
+ Assert.Equal(message.Id, chatMessage.MessageId);
+ }
+
+ [Theory]
+ [InlineData("test content")]
+ [InlineData("")]
+ [InlineData(null)]
+ public void MapAssistantMessage_ShouldMapCorrectly(string? content)
+ {
+ // Arrange
+ var message = new AssistantMessage
+ {
+ Id = "asst1",
+ Content = content
+ };
+
+ // Act
+ var result = new[] { message }.MapAGUIMessagesToChatClientMessages();
+
+ // Assert
+ Assert.Single(result);
+ var chatMessage = result[0];
+ Assert.Equal(ChatRole.Assistant, chatMessage.Role);
+ Assert.Equal(message.Id, chatMessage.MessageId);
+
+ if (string.IsNullOrWhiteSpace(content))
+ {
+ Assert.Empty(chatMessage.Contents);
+ }
+ else
+ {
+ Assert.Single(chatMessage.Contents);
+ Assert.IsType(chatMessage.Contents[0]);
+ Assert.Equal(content, ((TextContent)chatMessage.Contents[0]).Text);
+ }
+ }
+
+ [Fact]
+ public void MapAssistantMessage_WithToolCalls_ShouldMapCorrectly()
+ {
+ // Arrange
+ var message = new AssistantMessage
+ {
+ Id = "asst2",
+ Content = "test content",
+ ToolCalls =
+ [
+ new ToolCall
+ {
+ Id = "testId",
+ Function = new FunctionCall
+ {
+ Name = "testFunc",
+ Arguments = "{\"param1\": \"value1\"}"
+ }
+ }
+ ]
+ };
+
+ // Act
+ var result = new[] { message }.MapAGUIMessagesToChatClientMessages();
+
+ // Assert
+ Assert.Single(result);
+ var chatMessage = result[0];
+ Assert.Equal(2, chatMessage.Contents.Count);
+ Assert.IsType(chatMessage.Contents[0]);
+ Assert.IsType(chatMessage.Contents[1]);
+
+ var functionCall = (FunctionCallContent)chatMessage.Contents[1];
+ Assert.Equal("testId", functionCall.CallId);
+ Assert.Equal("testFunc", functionCall.Name);
+ Assert.NotNull(functionCall.Arguments);
+ Assert.True(functionCall.Arguments!.TryGetValue("param1", out var paramValue));
+ Assert.NotNull(paramValue);
+ var jsonElement = (JsonElement)paramValue;
+ var stringValue = jsonElement.GetString();
+ Assert.NotNull(stringValue);
+ Assert.Equal("value1", stringValue);
+ }
+
+ [Fact]
+ public void MapToolMessage_ShouldMapCorrectly()
+ {
+ // Arrange
+ var message = new ToolMessage
+ {
+ Id = "tool1",
+ ToolCallId = "testId",
+ Content = "test content"
+ };
+
+ // Act
+ var result = new[] { message }.MapAGUIMessagesToChatClientMessages();
+
+ // Assert
+ Assert.Single(result);
+ var chatMessage = result[0];
+ Assert.Equal(ChatRole.Tool, chatMessage.Role);
+ Assert.Equal(message.Id, chatMessage.MessageId);
+ Assert.Single(chatMessage.Contents);
+ Assert.IsType(chatMessage.Contents[0]);
+
+ var functionResult = (FunctionResultContent)chatMessage.Contents[0];
+ Assert.Equal("testId", functionResult.CallId);
+ Assert.Equal("test content", functionResult.Result);
+ }
+
+ [Fact]
+ public void MapMessages_ShouldFilterOutIrrelevantTypes()
+ {
+ // Arrange
+ var messages = new BaseMessage[]
+ {
+ new SystemMessage { Id = "sys1", Content = "system" },
+ new DeveloperMessage { Id = "dev1", Content = "developer" }, // Should be filtered out
+ new UserMessage { Id = "usr1", Content = "user" },
+ new AssistantMessage { Id = "asst1", Content = "assistant" },
+ new ToolMessage { Id = "tool1", ToolCallId = "toolId", Content = "tool" },
+ };
+
+ // Act
+ var result = messages.MapAGUIMessagesToChatClientMessages();
+
+ // Assert
+ Assert.Equal(4, result.Count);
+ Assert.Collection(result,
+ msg => Assert.Equal(ChatRole.System, msg.Role),
+ msg => Assert.Equal(ChatRole.User, msg.Role),
+ msg => Assert.Equal(ChatRole.Assistant, msg.Role),
+ msg => Assert.Equal(ChatRole.Tool, msg.Role)
+ );
+ }
+
+ [Fact]
+ public void MapMessages_WithUnsupportedType_ShouldStripMessage()
+ {
+ // Arrange
+ var messages = new BaseMessage[]
+ {
+ new CustomMessage { Id = "custom1", Content = "custom" } // This should be stripped
+ };
+
+ // Act
+ var result = messages.MapAGUIMessagesToChatClientMessages();
+
+ // Assert
+ Assert.Empty(result);
+ }
+
+ private record CustomMessage : BaseMessage
+ {
+ public required string Content { get; init; }
+ }
+}
\ No newline at end of file
diff --git a/dotnet-sdk/AGUIDotnet.Tests/FrontendToolTests.cs b/dotnet-sdk/AGUIDotnet.Tests/FrontendToolTests.cs
new file mode 100644
index 00000000..d7f80e6e
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet.Tests/FrontendToolTests.cs
@@ -0,0 +1,134 @@
+using System.Collections.Immutable;
+using System.Text.Json;
+using AGUIDotnet.Integrations.ChatClient;
+using AGUIDotnet.Types;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace AGUIDotnet.Tests;
+
+public class FrontendToolTests
+{
+ [Fact]
+ public void FrontendToolMirrorsAGUIToolDefinition()
+ {
+ var agUiTool = new Tool
+ {
+ Name = "TestTool",
+ Description = "A test tool",
+ Parameters = JsonDocument.Parse("{}").RootElement
+ };
+
+ var frontendTool = new FrontendTool(agUiTool);
+ Assert.Equal(agUiTool.Name, frontendTool.Name);
+ Assert.Equal(agUiTool.Description, frontendTool.Description);
+ Assert.Equal(agUiTool.Parameters, frontendTool.JsonSchema);
+ }
+
+ [Fact]
+ public async Task FrontendToolInvokeCoreTerminatesInvocationLoop()
+ {
+ var agUiTool = new Tool
+ {
+ Name = "TestTool",
+ Description = "A test tool",
+ Parameters = JsonDocument.Parse("{}").RootElement
+ };
+
+ var frontendTool = new FrontendTool(agUiTool);
+ var serviceProvider = new ServiceCollection()
+ .BuildServiceProvider();
+
+ var callId = Guid.NewGuid().ToString();
+
+ var innerChatClient = new TestChatClient(
+ serviceProvider,
+ async (sp, messages, options, cancellationToken) =>
+ {
+ // Simulate the AI requesting the tool be invoked
+ return new ChatResponse(
+ new ChatMessage(
+ ChatRole.Assistant,
+ [
+ new FunctionCallContent(
+ callId,
+ frontendTool.Name,
+ null
+ ),
+ // Also request the backend function to be called
+ // (it should not be called)
+ new FunctionCallContent(
+ Guid.NewGuid().ToString(),
+ "backend",
+ null
+ )
+ ]
+ )
+ )
+ {
+ FinishReason = ChatFinishReason.ToolCalls
+ };
+ }
+ );
+
+ var chatClient = new FunctionInvokingChatClient(innerChatClient);
+
+ /*
+ The FrontendTool indicates via the current invocation context that it should terminate
+ when it is invoked.
+
+ We have no direct access to this, as by the time the work is done, the current context has been
+ cleared, so we make the backend tool fail the test if it gets invoked.
+ */
+ static void BackendFunction()
+ {
+ Assert.Fail("Backend function should not have been invoked");
+ }
+
+ var resp = await chatClient.GetResponseAsync(
+ new ChatMessage(ChatRole.User, "Hello"),
+ new ChatOptions
+ {
+ Tools = [
+ frontendTool,
+ AIFunctionFactory.Create(
+ BackendFunction,
+ name: "backend"
+ )
+ ],
+ ToolMode = ChatToolMode.Auto,
+ // For the purposes of this test, ensure the function invoking chat client
+ // believes it can handle multiple tool calls in a single response.
+ AllowMultipleToolCalls = true
+ }
+ );
+
+ Assert.Equal(2, resp.Messages.Count);
+ var funcCallMsg = resp.Messages[0];
+ Assert.Equal(ChatRole.Assistant, funcCallMsg.Role);
+ // We expect two function call contents on the first message:
+
+ var funcCalls = funcCallMsg.Contents.OfType().ToImmutableList();
+ Assert.Equal(funcCallMsg.Contents, funcCalls);
+
+ var frontendToolCall = Assert.Single(funcCalls, fc => fc.CallId == callId);
+ Assert.Equal(frontendTool.Name, frontendToolCall.Name);
+ Assert.Null(frontendToolCall.Arguments);
+
+ // The second function call should be the backend function
+ var backendToolCall = Assert.Single(funcCalls, fc => fc.CallId != callId);
+ Assert.Equal("backend", backendToolCall.Name);
+
+ // The second message should be a tool result message
+ var dummyResultMsg = resp.Messages[1];
+ Assert.Equal(ChatRole.Tool, dummyResultMsg.Role);
+
+ // It should *only* contain the dummy frontend tool result
+ // (the underlying FunctionInvokingChatClient will have emitted the "result" as we have to intercept it this way)
+ var resContent = Assert.IsType(Assert.Single(dummyResultMsg.Contents));
+ Assert.Equal(callId, resContent.CallId);
+
+ // The result isn't important, the frontend tool returns a null, but the
+ // AI abstraction sticks a success string message in there (presumably so the LLM can infer that it ran)
+ }
+}
diff --git a/dotnet-sdk/AGUIDotnet.Tests/TestChatClient.cs b/dotnet-sdk/AGUIDotnet.Tests/TestChatClient.cs
new file mode 100644
index 00000000..0f75f58a
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet.Tests/TestChatClient.cs
@@ -0,0 +1,29 @@
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace AGUIDotnet.Tests;
+
+internal sealed class TestChatClient(
+ IKeyedServiceProvider serviceProvider,
+ Func, ChatOptions?, CancellationToken, Task> getResponseAsync
+ ) : IChatClient
+{
+ public void Dispose()
+ {
+ }
+
+ public async Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
+ {
+ return await getResponseAsync(serviceProvider, messages, options, cancellationToken);
+ }
+
+ public object? GetService(Type serviceType, object? serviceKey = null)
+ {
+ return serviceProvider.GetKeyedService(serviceType, serviceKey);
+ }
+
+ public IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+}
diff --git a/dotnet-sdk/AGUIDotnet.Tests/TestStreamingChatClient.cs b/dotnet-sdk/AGUIDotnet.Tests/TestStreamingChatClient.cs
new file mode 100644
index 00000000..d0337cac
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet.Tests/TestStreamingChatClient.cs
@@ -0,0 +1,46 @@
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace AGUIDotnet.Tests;
+
+///
+/// A test implementation of IChatClient that allows simulating streaming responses.
+///
+internal sealed class TestStreamingChatClient : IChatClient
+{
+ private readonly IKeyedServiceProvider _serviceProvider;
+ private readonly Func, ChatOptions?, CancellationToken, IAsyncEnumerable> _getStreamingResponseAsync;
+
+ ///
+ /// Creates a new TestStreamingChatClient that allows configuring both sync and streaming responses.
+ ///
+ /// The service provider to use for service resolution
+ /// Function to generate synchronous responses
+ /// Function to generate streaming responses
+ public TestStreamingChatClient(
+ IKeyedServiceProvider serviceProvider,
+ Func, ChatOptions?, CancellationToken, IAsyncEnumerable> getStreamingResponseAsync)
+ {
+ _serviceProvider = serviceProvider;
+ _getStreamingResponseAsync = getStreamingResponseAsync;
+ }
+
+ public void Dispose()
+ {
+ }
+
+ public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException("This client is intended for streaming responses only. Use GetStreamingResponseAsync instead.");
+ }
+
+ public IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
+ {
+ return _getStreamingResponseAsync(_serviceProvider, messages, options, cancellationToken);
+ }
+
+ public object? GetService(Type serviceType, object? serviceKey = null)
+ {
+ return _serviceProvider.GetKeyedService(serviceType, serviceKey);
+ }
+}
diff --git a/dotnet-sdk/AGUIDotnet.sln b/dotnet-sdk/AGUIDotnet.sln
new file mode 100644
index 00000000..5d362ed7
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet.sln
@@ -0,0 +1,62 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AGUIDotnet", "AGUIDotnet\AGUIDotnet.csproj", "{18BEEBF5-C3C4-4BAC-856C-ACE5C635B0E5}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AGUIDotnetWebApiExample", "AGUIDotnetWebApiExample\AGUIDotnetWebApiExample.csproj", "{C1EBFBA2-0D51-4CFA-8C1D-7E9952B23AAD}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AGUIDotnet.Tests", "AGUIDotnet.Tests\AGUIDotnet.Tests.csproj", "{4A32E00E-80DD-4C2A-8483-C008D595DFEF}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
+ Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {18BEEBF5-C3C4-4BAC-856C-ACE5C635B0E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {18BEEBF5-C3C4-4BAC-856C-ACE5C635B0E5}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {18BEEBF5-C3C4-4BAC-856C-ACE5C635B0E5}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {18BEEBF5-C3C4-4BAC-856C-ACE5C635B0E5}.Debug|x64.Build.0 = Debug|Any CPU
+ {18BEEBF5-C3C4-4BAC-856C-ACE5C635B0E5}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {18BEEBF5-C3C4-4BAC-856C-ACE5C635B0E5}.Debug|x86.Build.0 = Debug|Any CPU
+ {18BEEBF5-C3C4-4BAC-856C-ACE5C635B0E5}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {18BEEBF5-C3C4-4BAC-856C-ACE5C635B0E5}.Release|Any CPU.Build.0 = Release|Any CPU
+ {18BEEBF5-C3C4-4BAC-856C-ACE5C635B0E5}.Release|x64.ActiveCfg = Release|Any CPU
+ {18BEEBF5-C3C4-4BAC-856C-ACE5C635B0E5}.Release|x64.Build.0 = Release|Any CPU
+ {18BEEBF5-C3C4-4BAC-856C-ACE5C635B0E5}.Release|x86.ActiveCfg = Release|Any CPU
+ {18BEEBF5-C3C4-4BAC-856C-ACE5C635B0E5}.Release|x86.Build.0 = Release|Any CPU
+ {C1EBFBA2-0D51-4CFA-8C1D-7E9952B23AAD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {C1EBFBA2-0D51-4CFA-8C1D-7E9952B23AAD}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C1EBFBA2-0D51-4CFA-8C1D-7E9952B23AAD}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {C1EBFBA2-0D51-4CFA-8C1D-7E9952B23AAD}.Debug|x64.Build.0 = Debug|Any CPU
+ {C1EBFBA2-0D51-4CFA-8C1D-7E9952B23AAD}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {C1EBFBA2-0D51-4CFA-8C1D-7E9952B23AAD}.Debug|x86.Build.0 = Debug|Any CPU
+ {C1EBFBA2-0D51-4CFA-8C1D-7E9952B23AAD}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {C1EBFBA2-0D51-4CFA-8C1D-7E9952B23AAD}.Release|Any CPU.Build.0 = Release|Any CPU
+ {C1EBFBA2-0D51-4CFA-8C1D-7E9952B23AAD}.Release|x64.ActiveCfg = Release|Any CPU
+ {C1EBFBA2-0D51-4CFA-8C1D-7E9952B23AAD}.Release|x64.Build.0 = Release|Any CPU
+ {C1EBFBA2-0D51-4CFA-8C1D-7E9952B23AAD}.Release|x86.ActiveCfg = Release|Any CPU
+ {C1EBFBA2-0D51-4CFA-8C1D-7E9952B23AAD}.Release|x86.Build.0 = Release|Any CPU
+ {4A32E00E-80DD-4C2A-8483-C008D595DFEF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {4A32E00E-80DD-4C2A-8483-C008D595DFEF}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {4A32E00E-80DD-4C2A-8483-C008D595DFEF}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {4A32E00E-80DD-4C2A-8483-C008D595DFEF}.Debug|x64.Build.0 = Debug|Any CPU
+ {4A32E00E-80DD-4C2A-8483-C008D595DFEF}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {4A32E00E-80DD-4C2A-8483-C008D595DFEF}.Debug|x86.Build.0 = Debug|Any CPU
+ {4A32E00E-80DD-4C2A-8483-C008D595DFEF}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {4A32E00E-80DD-4C2A-8483-C008D595DFEF}.Release|Any CPU.Build.0 = Release|Any CPU
+ {4A32E00E-80DD-4C2A-8483-C008D595DFEF}.Release|x64.ActiveCfg = Release|Any CPU
+ {4A32E00E-80DD-4C2A-8483-C008D595DFEF}.Release|x64.Build.0 = Release|Any CPU
+ {4A32E00E-80DD-4C2A-8483-C008D595DFEF}.Release|x86.ActiveCfg = Release|Any CPU
+ {4A32E00E-80DD-4C2A-8483-C008D595DFEF}.Release|x86.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+EndGlobal
diff --git a/dotnet-sdk/AGUIDotnet/AGUIDotnet.csproj b/dotnet-sdk/AGUIDotnet/AGUIDotnet.csproj
new file mode 100644
index 00000000..4051da8c
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet/AGUIDotnet.csproj
@@ -0,0 +1,19 @@
+
+
+
+ net9.0
+ enable
+ enable
+ CA2007
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/dotnet-sdk/AGUIDotnet/Agent/AgentExtensions.cs b/dotnet-sdk/AGUIDotnet/Agent/AgentExtensions.cs
new file mode 100644
index 00000000..ddc04b3b
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet/Agent/AgentExtensions.cs
@@ -0,0 +1,63 @@
+using System.Runtime.CompilerServices;
+using System.Threading.Channels;
+using AGUIDotnet.Events;
+using AGUIDotnet.Types;
+
+namespace AGUIDotnet.Agent;
+
+public static class AgentExtensions
+{
+ ///
+ /// Runs the provided agent asynchronously to completion, yielding the events produced by the agent.
+ ///
+ /// The instance to invoke
+ /// The input to pass to the agent
+ /// The cancellation token to cancel the run
+ /// An with the events produced by the agent
+ public static async IAsyncEnumerable RunToCompletionAsync(
+ this IAGUIAgent agent,
+ RunAgentInput input,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default
+ )
+ {
+ ArgumentNullException.ThrowIfNull(agent, nameof(agent));
+ ArgumentNullException.ThrowIfNull(input, nameof(input));
+
+ var channel = Channel.CreateUnbounded(new UnboundedChannelOptions
+ {
+ SingleReader = true,
+ SingleWriter = false,
+ AllowSynchronousContinuations = true
+ });
+
+
+ var agentTask = Task.Run(async () =>
+ {
+ try
+ {
+ await agent.RunAsync(input, channel.Writer, cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ // Always complete the channel when exiting the agent's RunAsync method to ensure that the reader can finish processing.
+ try
+ {
+ channel.Writer.Complete();
+ }
+ catch (ChannelClosedException)
+ {
+ // Channel was already closed by the agent, we can ignore this.
+ }
+ }
+ }, cancellationToken);
+
+ // Enumerate the events produced by the agent and yield them to the caller.
+ await foreach (var ev in channel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
+ {
+ yield return ev;
+ }
+
+ // Make sure we truly let the agent finish just in case the channel was closed before the agent completed.
+ await agentTask.ConfigureAwait(false);
+ }
+}
diff --git a/dotnet-sdk/AGUIDotnet/Agent/ChatClientAgent.cs b/dotnet-sdk/AGUIDotnet/Agent/ChatClientAgent.cs
new file mode 100644
index 00000000..8858d36e
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet/Agent/ChatClientAgent.cs
@@ -0,0 +1,659 @@
+using System.Collections.Concurrent;
+using System.Collections.Immutable;
+using System.Diagnostics;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Threading.Channels;
+using AGUIDotnet.Events;
+using AGUIDotnet.Integrations.ChatClient;
+using AGUIDotnet.Types;
+using Microsoft.Extensions.AI;
+
+namespace AGUIDotnet.Agent;
+
+public record ChatClientAgentOptions
+{
+ ///
+ /// Options to provide when using the provided chat client.
+ ///
+ public ChatOptions? ChatOptions { get; init; }
+
+ ///
+ /// Whether to preserve inbound system messages passed to the agent.
+ ///
+ public bool PreserveInboundSystemMessages { get; init; } = true;
+
+ ///
+ /// The system message to use for the agent, setting this will override any system messages passed in the input.
+ ///
+ public string? SystemMessage { get; init; }
+
+ ///
+ ///
+ /// Sometimes the agent isn't provided with all or any context in the collection, and it needs to be extracted from passed system messages.
+ ///
+ ///
+ /// Switching this on will cause the agent to perform an initial typed extraction of the context if available, and then use that context for the agent run.
+ ///
+ ///
+ /// This is useful e.g. for frontends like CopilotKit that do not make useCopilotReadable context available to agents, instead relying on shared agent state - it does however provide the context in the system message.
+ ///
+ ///
+ public bool PerformAiContextExtraction { get; init; } = false;
+
+ ///
+ /// When overriding the system message, whether to include provided or extracted context in the system message.
+ ///
+ public bool IncludeContextInSystemMessage { get; init; } = false;
+
+ ///
+ /// When emitting message snapshots back to the frontend, whether to strip out system messages from the snapshot.
+ ///
+ public bool StripSystemMessagesWhenEmittingMessageSnapshots { get; init; } = true;
+
+ // todo: introduce a filter mechanism to allow for selective tool call event emission?
+ ///
+ /// Whether to emit tool call events for backend tools that are invoked by the agent.
+ ///
+ ///
+ ///
+ /// This will cause the agent to emit tool call events AND a messages snapshot with the results to the frontend for ALL backend tools.
+ ///
+ ///
+ /// This means the frontend will see EVERYTHING about that tool call, including the arguments, the result, and any errors that may have occurred.
+ ///
+ ///
+ public bool EmitBackendToolCalls { get; init; } = true;
+}
+
+///
+/// A basic, opinionated kitchen-sink agent that uses a chat client to process the entirety of the invoked run.
+///
+///
+///
+/// Often you need nothing more than a chat client with available tools to process the run, and this agent provides a simple single-step agentic "flow" that provides that.
+///
+///
+/// Provides configuration options to control behaviour, as well as the ability to derive from it to override defaults and hooks for further customisation.
+///
+///
+public class ChatClientAgent : IAGUIAgent
+{
+ private readonly IChatClient _chatClient;
+ private readonly ChatClientAgentOptions _agentOptions;
+ protected static readonly JsonSerializerOptions _jsonSerOpts = new(JsonSerializerDefaults.Web)
+ {
+ WriteIndented = false,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
+ };
+
+ public UsageDetails Usage { get; private set; } = new();
+
+ public ChatClientAgent(
+ IChatClient chatClient,
+ ChatClientAgentOptions? agentOptions = null
+ )
+ {
+ ArgumentNullException.ThrowIfNull(chatClient, nameof(chatClient));
+
+ _chatClient = chatClient;
+ _agentOptions = agentOptions ?? new();
+ }
+
+ public async Task RunAsync(RunAgentInput input, ChannelWriter events, CancellationToken cancellationToken = default)
+ {
+ /*
+ Prep the chat options for the chat client.
+ */
+ var chatOpts =
+ _agentOptions.ChatOptions ?? new ChatOptions
+ {
+ // Support function calls by default.
+ ToolMode = ChatToolMode.Auto,
+ };
+
+ // Ensure we have an empty tools list to start with.
+ chatOpts.Tools ??= [];
+
+ /*
+ Prepare the backend tools by filtering out any frontend tools (there shouldn't be any, but just in case),
+ and allow the derived type to modify the backend tools if needed.
+ */
+ var backendTools = (await PrepareBackendTools(
+ [.. chatOpts.Tools.OfType().Where(f => f is not FrontendTool)],
+ input,
+ events,
+ cancellationToken
+ ).ConfigureAwait(false)).Where(t => t is not FrontendTool).ToImmutableList();
+
+ var backendToolNames = backendTools.Select(t => t.Name).ToImmutableHashSet();
+
+ var frontendTools = await PrepareFrontendTools(
+ [.. input.Tools.Select(t => new FrontendTool(t))],
+ input,
+ cancellationToken).ConfigureAwait(false);
+
+ var frontendToolNames = frontendTools.Select(t => t.Name).ToImmutableHashSet();
+
+ {
+ var conflictingTools = frontendToolNames.Intersect(backendToolNames);
+
+ if (!conflictingTools.IsEmpty)
+ {
+ throw new InvalidOperationException(
+ $"Some frontend and backend tools conflict by name: {string.Join(", ", conflictingTools)}. " +
+ "Please ensure that frontend tool names do not conflict with backend tool names."
+ );
+ }
+ }
+
+ if (frontendTools.IsEmpty && backendTools.IsEmpty)
+ {
+ chatOpts.Tools = null;
+ chatOpts.AllowMultipleToolCalls = null;
+ }
+ else
+ {
+ chatOpts.Tools = [.. backendTools, .. frontendTools];
+ chatOpts.AllowMultipleToolCalls = false;
+ }
+
+ var context = await PrepareContext(input, cancellationToken).ConfigureAwait(false);
+ var mappedMessages = await MapAGUIMessagesToChatClientMessages(input, context, cancellationToken).ConfigureAwait(false);
+
+ /*
+ Track tool calls that we have encountered so we know what to do when seeing result content emitted
+ from the underlying chat client.
+ */
+ var knownFrontendToolCalls = new Dictionary();
+ var knownBackendToolCalls = new Dictionary();
+
+ // Handle the run starting
+ await OnRunStartedAsync(input, events, cancellationToken).ConfigureAwait(false);
+
+ // With the assumption that our primary response will be an assistant message, track the one we're currently building.
+ AssistantMessage? currentResponse = null;
+
+ /*
+ As the agent processes this run, we may need to emit message snapshots back to the frontend which
+ may differ from the original messages provided in the input as we've likely been producing text / tool calls as we go.
+
+ We want to strip out the inbound system messages as the frontend will re-communicate those on subsequent runs.
+
+ NOTE: CopilotKit seems to dupe system messages when receiving them back in a message snapshot, so this also helps guard against that.
+ */
+ var agUiMessages = _agentOptions.StripSystemMessagesWhenEmittingMessageSnapshots ?
+ [.. input.Messages.Where(m => m is not SystemMessage)]
+ : input.Messages.ToList();
+
+ // Tracks whether something in this run has necessitated a message sync to the frontend (mostly emitting backend tool call results).
+ var needsMessageSync = false;
+
+ // todo: introduce a RunContext type to hold all the run data to shuttle around to specific virtual methods to provide greater extensibility? (might just be easier to have people implement their own agent from the interface)
+
+ await foreach (var update in _chatClient.GetStreamingResponseAsync(mappedMessages, chatOpts, cancellationToken).ConfigureAwait(false))
+ {
+ foreach (var content in update.Contents)
+ {
+ switch (content)
+ {
+ case TextContent text:
+ {
+ /*
+ If this chunk update is not the same message as the current response, and we've encountered text content then
+ this implies the need to end the previous text response and start a new one.
+ */
+ if (currentResponse is not null && update.MessageId != currentResponse.Id)
+ {
+ await events.WriteAsync(new TextMessageEndEvent
+ {
+ MessageId = currentResponse.Id,
+ Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ }, cancellationToken).ConfigureAwait(false);
+
+ agUiMessages.Add(currentResponse);
+ currentResponse = null;
+ }
+
+ // If this is the first text content and we don't have a current response ID, we need to start a new text message.
+ if (currentResponse is null)
+ {
+ currentResponse = new AssistantMessage
+ {
+ Id = update.MessageId ?? Guid.NewGuid().ToString(),
+ Content = "",
+ ToolCalls = [],
+ };
+
+ await events.WriteAsync(new TextMessageStartEvent
+ {
+ MessageId = currentResponse.Id,
+ Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ }, cancellationToken).ConfigureAwait(false);
+ }
+
+ Debug.Assert(currentResponse is not null, "Handling text content without a current response message");
+
+ // Only emit text content if it is not empty (we allow whitespace as that may have been chunked, and still be valid).
+ if (!string.IsNullOrEmpty(text.Text))
+ {
+ await events.WriteAsync(new TextMessageContentEvent
+ {
+ MessageId = currentResponse.Id,
+ Delta = text.Text,
+ Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ }, cancellationToken).ConfigureAwait(false);
+
+ // Append the text to the current response message we're building to mirror the events we're sending out.
+ currentResponse = currentResponse with
+ {
+ Content = currentResponse.Content + text.Text
+ };
+ }
+ }
+ break;
+
+ case FunctionCallContent functionCall:
+ {
+ // We need to end the current text message if we have one, in order to dispatch a frontend tool call.
+ if (currentResponse is not null)
+ {
+ await events.WriteAsync(new TextMessageEndEvent
+ {
+ MessageId = currentResponse.Id,
+ Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ }, cancellationToken).ConfigureAwait(false);
+
+ agUiMessages.Add(currentResponse);
+ currentResponse = null;
+ }
+
+ // We need to track the frontend calls so we can avoid communicating their results as they don't technically exist in the backend.
+ if (frontendToolNames.Contains(functionCall.Name))
+ {
+ knownFrontendToolCalls.Add(functionCall.CallId, functionCall.Name);
+ }
+
+ if (backendToolNames.Contains(functionCall.Name))
+ {
+ // We MUST track the tool call, so we can determine what function name it correlates with when we receive the result.
+ knownBackendToolCalls.Add(functionCall.CallId, functionCall.Name);
+
+ // If we don't want the frontend to see backend tool calls, skip over this content item.
+ if (!_agentOptions.EmitBackendToolCalls ||
+ !await ShouldEmitBackendToolCallData(functionCall.Name).ConfigureAwait(false))
+ {
+ continue;
+ }
+ }
+
+ /*
+ The FunctionInvokingChatClient has already rolled up the arguments for the function call,
+ so we don't need to stream the arguments in chunks, just dispatch a complete start -> args -> end sequence.
+ */
+ await events.WriteAsync(new ToolCallStartEvent
+ {
+ ToolCallId = functionCall.CallId,
+ ToolCallName = functionCall.Name,
+ ParentMessageId = update.MessageId,
+ Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ }, cancellationToken).ConfigureAwait(false);
+
+ await events.WriteAsync(new ToolCallArgsEvent
+ {
+ ToolCallId = functionCall.CallId,
+ // todo: we might want to provide a way to get at the wider serialization options from ambient context?
+ // todo: an alternative might be not exposing the underlying channel writer, and instead providing a structured type for emitting common events.
+ Delta = JsonSerializer.Serialize(functionCall.Arguments, _jsonSerOpts),
+ Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ }, cancellationToken).ConfigureAwait(false);
+
+ await events.WriteAsync(new ToolCallEndEvent
+ {
+ ToolCallId = functionCall.CallId,
+ Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ }, cancellationToken).ConfigureAwait(false);
+
+ agUiMessages.Add(new AssistantMessage
+ {
+ Id = string.IsNullOrEmpty(update.MessageId) ? Guid.NewGuid().ToString() : update.MessageId,
+ // todo: this ~ might ~ not be 100%, I'm unclear whether we might need to represent / concat text across multiple content items for a single message in the context of tool calls (especially if support for multiple tools is added).
+ Content = string.IsNullOrEmpty(update.Text) ? null : update.Text,
+ ToolCalls = [new ToolCall
+ {
+ Id = functionCall.CallId,
+ Function = new FunctionCall {
+ Name = functionCall.Name,
+ Arguments = JsonSerializer.Serialize(functionCall.Arguments, _jsonSerOpts)
+ }
+ }]
+ });
+ }
+
+ break;
+
+ // We only need to emit tool messages for backend tool calls, and only if we've been asked to
+ case FunctionResultContent funcResult when _agentOptions.EmitBackendToolCalls:
+ {
+ // Ignore this as it's a frontend tool call result, we don't emit these (it's fake).
+ if (knownFrontendToolCalls.ContainsKey(funcResult.CallId))
+ {
+ continue;
+ }
+
+ if (!knownBackendToolCalls.TryGetValue(funcResult.CallId, out var toolName))
+ {
+ throw new KeyNotFoundException($"Encountered a tool result for a backend tool call that we're not tracking: '{funcResult.CallId}'.");
+ }
+
+ // We don't want the frontend to see this particular backend tool call result, so skip it.
+ if (!await ShouldEmitBackendToolCallData(toolName).ConfigureAwait(false))
+ {
+ continue;
+ }
+
+ if (currentResponse is not null)
+ {
+ await events.WriteAsync(new TextMessageEndEvent
+ {
+ MessageId = currentResponse.Id,
+ Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ }, cancellationToken).ConfigureAwait(false);
+
+ agUiMessages.Add(currentResponse);
+ currentResponse = null;
+ }
+
+ agUiMessages.Add(new ToolMessage
+ {
+ Id = update.MessageId ?? Guid.NewGuid().ToString(),
+ // todo handle exception if the result was exceptional?
+ Content = JsonSerializer.Serialize(funcResult.Result, _jsonSerOpts),
+ ToolCallId = funcResult.CallId,
+ });
+
+ /*
+ When we exit the streaming loop, we need to ensure that we emit a message snapshot
+ todo:
+ This was the only place putting this that led to "correct" behaviour, but unsure if it has to be done at the end of the run
+ - admittedly doing it mid-run feels like it would confuse any frontend, and it did... awaiting a mechanism to dispatch tool call results as events instead
+ */
+ needsMessageSync = true;
+ }
+
+ break;
+
+ case DataContent data:
+ {
+ // todo: the AG-UI protocol does not current support data content, ignore these for now.
+ }
+ break;
+
+ // Track usage stats so the agent can be queried post-run for them
+ case UsageContent usage:
+ {
+ Usage.Add(usage.Details);
+ }
+ break;
+ }
+ }
+ }
+
+ // We exited the streaming loop, so end the current text message if we have one.
+ if (currentResponse is not null)
+ {
+ await events.WriteAsync(new TextMessageEndEvent
+ {
+ MessageId = currentResponse.Id,
+ Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ }, cancellationToken).ConfigureAwait(false);
+
+ agUiMessages.Add(currentResponse);
+ currentResponse = null;
+ }
+
+ if (needsMessageSync)
+ {
+ // Dispatch an update to push the tool message to the frontend.
+ await events.WriteAsync(new MessagesSnapshotEvent
+ {
+ Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ Messages = [.. agUiMessages]
+ }, cancellationToken).ConfigureAwait(false);
+ }
+
+ // todo: we could do with handling for run failure to dispatch error event (unsure how best to handle beyond a catch-all exception handler which feels blunt)
+ await events.WriteAsync(new RunFinishedEvent
+ {
+ ThreadId = input.ThreadId,
+ RunId = input.RunId,
+ Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ }, cancellationToken).ConfigureAwait(false);
+
+ events.Complete();
+ }
+
+ ///
+ /// When overridden in a derived class, allows for the agent to prepare the context for the run.
+ ///
+ ///
+ /// Default implementation will perform AI-assisted context extraction if the agent is configured to do so.
+ ///
+ /// The input provided to the agent for the run
+ /// The final context collection to use for the run
+ protected virtual async Task> PrepareContext(
+ RunAgentInput input,
+ CancellationToken cancellationToken = default
+ )
+ {
+ var systemMessages = input.Messages.OfType().ToImmutableList();
+
+ // If the agent is not configured to perform AI context extraction, or there are no system messages,
+ // we can simply return the context provided in the input.
+ if (!_agentOptions.PerformAiContextExtraction || systemMessages.IsEmpty)
+ {
+ return input.Context;
+ }
+
+ var extractedContext = await _chatClient.GetResponseAsync>(
+ new ChatMessage(
+ ChatRole.System,
+ $$"""
+
+ You are an expert at extracting context from a provided system message.
+
+
+
+ - You MUST always respond in JSON according to the provided schema.
+ - You MUST correlate and deduplicate context from the provided system message and any existing context.
+ - You MUST preserve the `name` and `value` properties of the context VERBATIM wherever possible.
+
+
+
+
+
+ 1. The name of the user: John Doe
+ 2. The user's age: 30
+
+
+ ```json
+ [
+ {
+ "name": "The name of the user,
+ "value": "John Doe"
+ },
+ {
+ "name": "The user's age",
+ "value": "30"
+ }
+ ]
+ ```
+
+
+
+
+
+ ```json
+ {{JsonSerializer.Serialize(input.Context, _jsonSerOpts)}}
+ ```
+
+
+
+ {{string.Join("\n", systemMessages.Select(m => m.Content))}}
+
+ """
+ ),
+ cancellationToken: cancellationToken
+ ).ConfigureAwait(false);
+
+ if (extractedContext.Usage is not null)
+ {
+ Usage.Add(extractedContext.Usage);
+ }
+
+ try
+ {
+ return extractedContext.Result;
+ }
+ catch (JsonException)
+ {
+ // todo: handle this? Perhaps get it to self-heal via the LLM, for now just ignore it and leave the context as-is
+ return input.Context;
+ }
+ }
+
+ ///
+ /// When overridden in a derived class, allows for customisation of the system message used for the agent run.
+ ///
+ ///
+ /// This is only used when the system message is overridden by the agent options. Default behaviour is to honour the agent option for including context in the system message, falling back to the provided system message if not set.
+ ///
+ /// The input to the agent for the run
+ /// The system message to use
+ /// The final context prepared for the agent
+ /// The final system message to use
+ protected virtual ValueTask PrepareSystemMessage(
+ RunAgentInput input,
+ string systemMessage,
+ ImmutableList context
+ )
+ {
+ if (!_agentOptions.IncludeContextInSystemMessage)
+ {
+ return ValueTask.FromResult(systemMessage);
+ }
+
+ return ValueTask.FromResult(
+ $"{systemMessage}\n\nThe following context is available to you:\n```{JsonSerializer.Serialize(context, _jsonSerOpts)}```"
+ );
+ }
+
+ ///
+ /// When overridden in a derived class, allows manual mapping of AG-UI messages to chat client messages.
+ ///
+ /// The input provided to the agent for the run invocation
+ /// The final context either lifted from the input or via the LLM-assisted extraction
+ /// The collection of to use with the chat client for the run
+ protected virtual async ValueTask> MapAGUIMessagesToChatClientMessages(
+ RunAgentInput input,
+ ImmutableList context,
+ CancellationToken cancellationToken = default
+ )
+ {
+ return ChatClientMessageMapper.MapAGUIMessagesToChatClientMessages(
+ (_agentOptions.SystemMessage, _agentOptions.PreserveInboundSystemMessages) switch
+ {
+ // No agent-specific system message, and we do not want to preserve inbound system messages.
+ (null, false) => [.. input.Messages.Where(m => m is not SystemMessage)],
+
+ // We have an agent-specific system message, which overrides any inbound system messages regardless of the preserve setting.
+ (string sysMessage, _) when !string.IsNullOrWhiteSpace(sysMessage) =>
+ [.. input.Messages.Where(m => m is not SystemMessage)
+ .Prepend(new SystemMessage
+ {
+ Id = Guid.NewGuid().ToString(),
+ Content = await PrepareSystemMessage(input, sysMessage, context).ConfigureAwait(false)
+ })],
+
+ // Fallback to just preserving inbound messages as-is.
+ _ => input.Messages
+ }
+ );
+ }
+
+ ///
+ /// When overridden in a derived class, allows for customisation of the frontend tools provided to the agent for the run.
+ ///
+ ///
+ ///
+ /// This is useful for hiding frontend tools from the agent, or modifying their description if the agent struggles to understand its usage and you do not control the frontend.
+ ///
+ ///
+ /// Caution is advised not to modify tool names, parameters, or to add new tools as this is likely to cause either silent or real failures when attempts are made to call them.
+ ///
+ ///
+ /// The frontend tools already discovered from the run input
+ /// The run input provided to the agent
+ /// The final collection of frontend tools the agent is to be aware of for this run
+ protected virtual ValueTask> PrepareFrontendTools(
+ ImmutableList frontendTools,
+ RunAgentInput input,
+ CancellationToken cancellationToken = default
+ )
+ {
+ return ValueTask.FromResult(frontendTools);
+ }
+
+ ///
+ /// When overridden in a derived class, allows for customisation of the backend tools available for the given run.
+ ///
+ /// The backend tools already available via the provided chat client options
+ /// The run input provided to the agent
+ /// The events channel writer to push AG-UI events into
+ /// The backend tools to make available to the agent for the run
+ protected virtual ValueTask> PrepareBackendTools(
+ ImmutableList backendTools,
+ RunAgentInput input,
+ ChannelWriter events,
+ CancellationToken cancellationToken = default
+ )
+ {
+ // By default, zero modification.
+ return ValueTask.FromResult(backendTools);
+ }
+
+ ///
+ /// When overridden in a derived class, allows for customisation of whether to emit backend tool call data for the given function name.
+ ///
+ ///
+ ///
+ /// NOTE: This DOES NOT override - if that is set to false , this method will not be called at all.
+ ///
+ ///
+ /// The name of the backend tool being asked about
+ /// Whether to emit necessary data to the frontend about this tool call
+ protected virtual ValueTask ShouldEmitBackendToolCallData(string functionName)
+ => ValueTask.FromResult(_agentOptions.EmitBackendToolCalls);
+
+ ///
+ /// When overridden in a derived class, allows for customisation of the handling for a run starting.
+ ///
+ ///
+ /// The default implementation will emit a to the provided events channel.
+ ///
+ /// The input to the agent for the current run.
+ /// The events channel writer to push events into
+ protected virtual async ValueTask OnRunStartedAsync(
+ RunAgentInput input,
+ ChannelWriter events,
+ CancellationToken cancellationToken = default
+ )
+ {
+ await events.WriteAsync(new RunStartedEvent
+ {
+ ThreadId = input.ThreadId,
+ RunId = input.RunId,
+ Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
+ }, cancellationToken).ConfigureAwait(false);
+ }
+}
diff --git a/dotnet-sdk/AGUIDotnet/Agent/EchoAgent.cs b/dotnet-sdk/AGUIDotnet/Agent/EchoAgent.cs
new file mode 100644
index 00000000..878f968a
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet/Agent/EchoAgent.cs
@@ -0,0 +1,93 @@
+using System.Threading.Channels;
+using AGUIDotnet.Events;
+using AGUIDotnet.Types;
+
+namespace AGUIDotnet.Agent;
+
+///
+/// Bare-bones agent reference implementation that echoes the last message received.
+/// This agent is useful for testing and debugging purposes.
+///
+public sealed class EchoAgent : IAGUIAgent
+{
+ public async Task RunAsync(
+ RunAgentInput input,
+ ChannelWriter events,
+ CancellationToken ct = default
+ )
+ {
+ await events.WriteAsync(
+ new RunStartedEvent
+ {
+ ThreadId = input.ThreadId,
+ RunId = input.RunId,
+ Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ },
+ ct
+ ).ConfigureAwait(false);
+
+ var lastMessage = input.Messages.LastOrDefault();
+
+ await Task.Delay(500, ct).ConfigureAwait(false);
+
+ switch (lastMessage)
+ {
+ case SystemMessage system:
+ foreach (var ev in EventHelpers.SendSimpleMessage(
+ $"Echoing system message:\n\n```\n{system.Content}\n```\n"
+ ))
+ {
+ await events.WriteAsync(ev, ct).ConfigureAwait(false);
+ }
+ break;
+
+ case UserMessage user:
+ foreach (var ev in EventHelpers.SendSimpleMessage(
+ $"Echoing user message:\n\n```\n{user.Content}\n```\n"
+ ))
+ {
+ await events.WriteAsync(ev, ct).ConfigureAwait(false);
+ }
+ break;
+
+ case AssistantMessage assistant:
+ foreach (var ev in EventHelpers.SendSimpleMessage(
+ $"Echoing assistant message:\n\n```\n{assistant.Content}\n```\n"
+ ))
+ {
+ await events.WriteAsync(ev, ct).ConfigureAwait(false);
+ }
+ break;
+
+ case ToolMessage tool:
+ foreach (var ev in EventHelpers.SendSimpleMessage(
+ $"Echoing tool message for tool call '{tool.ToolCallId}':\n\n```\n{tool.Content}\n```\n"
+ ))
+ {
+ await events.WriteAsync(ev, ct).ConfigureAwait(false);
+ }
+ break;
+
+ default:
+ foreach (var ev in EventHelpers.SendSimpleMessage(
+ $"Unknown message type: {lastMessage?.GetType().Name ?? "null"}"
+ ))
+ {
+ await events.WriteAsync(ev, ct).ConfigureAwait(false);
+ }
+ break;
+ }
+
+ await events.WriteAsync(
+ new RunFinishedEvent
+ {
+ ThreadId = input.ThreadId,
+ RunId = input.RunId,
+ Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ },
+ ct
+ ).ConfigureAwait(false);
+
+ events.Complete();
+ }
+}
diff --git a/dotnet-sdk/AGUIDotnet/Agent/IAGUIAgent.cs b/dotnet-sdk/AGUIDotnet/Agent/IAGUIAgent.cs
new file mode 100644
index 00000000..57491871
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet/Agent/IAGUIAgent.cs
@@ -0,0 +1,28 @@
+using System.Threading.Channels;
+using AGUIDotnet.Events;
+using AGUIDotnet.Types;
+
+namespace AGUIDotnet.Agent;
+
+///
+/// Interface for an AG-UI agent.
+///
+public interface IAGUIAgent
+{
+ ///
+ /// Runs the agent with the provided input.
+ ///
+ ///
+ ///
+ /// This method represents the ENTIRE lifecycle of the agent's invocation for a run.
+ ///
+ ///
+ /// Implementations should NOT return until execution is complete, the channel is not guaranteed to be usable after this method returns.
+ ///
+ ///
+ /// The input to the agent
+ /// A channel writer for emitting AG-UI protocol events
+ /// Optional cancellation token for cancelling execution
+ /// An asynchronous task that will complete when agent execution completes for a run
+ Task RunAsync(RunAgentInput input, ChannelWriter events, CancellationToken cancellationToken = default);
+}
diff --git a/dotnet-sdk/AGUIDotnet/Agent/StatefulChatClientAgent.cs b/dotnet-sdk/AGUIDotnet/Agent/StatefulChatClientAgent.cs
new file mode 100644
index 00000000..251dd8ce
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet/Agent/StatefulChatClientAgent.cs
@@ -0,0 +1,200 @@
+using System.Collections.Immutable;
+using System.Text.Json;
+using System.Threading.Channels;
+using AGUIDotnet.Events;
+using AGUIDotnet.Types;
+using Json.Patch;
+using Microsoft.Extensions.AI;
+
+namespace AGUIDotnet.Agent;
+
+public record StatefulChatClientAgentOptions : ChatClientAgentOptions where TState : notnull
+{
+ ///
+ /// The name to give to the function for retrieving the current shared state of the agent.
+ ///
+ public string StateRetrievalFunctionName { get; init; } = "retrieve_state";
+
+ ///
+ /// The description to give to the function for retrieving the current shared state of the agent.
+ ///
+ public string StateRetrievalFunctionDescription { get; init; } = "Retrieves the current shared state of the agent.";
+
+ ///
+ /// The name to give to the function for updating the current shared state of the agent.
+ ///
+ public string StateUpdateFunctionName { get; init; } = "update_state";
+
+ ///
+ /// The description to give to the function for updating the current shared state of the agent.
+ ///
+ public string StateUpdateFunctionDescription { get; init; } = "Updates the current shared state of the agent.";
+
+ ///
+ /// When is true , this controls whether the frontend should be made aware of the state functions being called.
+ ///
+ public bool EmitStateFunctionsToFrontend { get; init; } = true;
+}
+
+///
+/// Much like but tailored for scenarios where the agent and frontend collaborate on shared state.
+///
+///
+/// This agent is NOT guaranteed to be thread-safe, nor is it resilient to shared use across multiple threads / runs, a separate instance should be used for each invocation.
+///
+///
+public class StatefulChatClientAgent : ChatClientAgent where TState : notnull
+{
+ private TState _currentState = default!;
+ private readonly StatefulChatClientAgentOptions _agentOptions;
+
+ public StatefulChatClientAgent(IChatClient chatClient, TState initialState, StatefulChatClientAgentOptions agentOptions) : base(chatClient, agentOptions)
+ {
+ if (agentOptions?.SystemMessage is null)
+ {
+ throw new ArgumentException("System message must be provided for a stateful agent.", nameof(agentOptions));
+ }
+
+ if (string.IsNullOrWhiteSpace(agentOptions.StateRetrievalFunctionName))
+ {
+ throw new ArgumentException("State retrieval function name must be provided for a stateful agent.", nameof(agentOptions));
+ }
+
+ if (string.IsNullOrWhiteSpace(agentOptions.StateRetrievalFunctionDescription))
+ {
+ throw new ArgumentException("State retrieval function description must be provided for a stateful agent.", nameof(agentOptions));
+ }
+
+ if (string.IsNullOrWhiteSpace(agentOptions.StateUpdateFunctionName))
+ {
+ throw new ArgumentException("State update function name must be provided for a stateful agent.", nameof(agentOptions));
+ }
+
+ if (string.IsNullOrWhiteSpace(agentOptions.StateUpdateFunctionDescription))
+ {
+ throw new ArgumentException("State update function description must be provided for a stateful agent.", nameof(agentOptions));
+ }
+
+ _agentOptions = agentOptions;
+ _currentState = initialState;
+ }
+
+ private TState RetrieveState()
+ {
+ return _currentState;
+ }
+
+ private void UpdateState(TState newState)
+ {
+ _currentState = newState;
+
+ }
+
+ protected override async ValueTask PrepareSystemMessage(RunAgentInput input, string systemMessage, ImmutableList context)
+ {
+ var coreMessage = await base.PrepareSystemMessage(input, systemMessage, context).ConfigureAwait(false);
+
+ // Hijack the original system message to include some context to the LLM about the stateful nature of this agent.
+ // Nudging it to use the state collaboration tools available to it.
+ return $"""
+
+ You are a stateful agent that wraps an existing agent, allowing it to collaborate with a human in the frontend on shared state to achieve a goal.
+
+
+
+ You may have a variety of tools available to you to help achieve your goal, and state collaboration is one of them.
+
+ You can retrieve the current shared state of the agent using the `retrieve_state` tool, and update the shared state using the `update_state` tool.
+
+
+
+ - Wherever necessary (e.g. it is aligned with your stated goal), you MUST make use of the state collaboration tools.
+ - Inspect the state of the agent to understand both the current state and the schema / purpose of the state in alignment with the agent's goal.
+ - Liberally use the `update_state` tool to update the shared state as you progress towards your goal.
+ - Avoid making assumptions about the state, always retrieve it first.
+ - Avoid making unnecessary updates to the state, e.g. if the user intent does not require it.
+
+
+
+ {coreMessage}
+
+ """;
+ }
+
+ protected override async ValueTask> PrepareBackendTools(ImmutableList backendTools, RunAgentInput input, ChannelWriter events, CancellationToken cancellationToken = default)
+ {
+ return [
+ .. await base.PrepareBackendTools(backendTools, input, events, cancellationToken).ConfigureAwait(false),
+ AIFunctionFactory.Create(
+ RetrieveState,
+ name: _agentOptions.StateRetrievalFunctionName,
+ description: _agentOptions.StateRetrievalFunctionDescription
+ ),
+ AIFunctionFactory.Create(
+ async (TState newState) => {
+ var delta = _currentState.CreatePatch(newState, _jsonSerOpts);
+ if (delta.Operations.Count > 0) {
+ UpdateState(newState);
+ await events.WriteAsync(new StateDeltaEvent {
+ Delta = [.. delta.Operations.Cast()],
+ Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ }, cancellationToken).ConfigureAwait(false);
+ }
+ },
+ name: _agentOptions.StateUpdateFunctionName,
+ description: _agentOptions.StateUpdateFunctionDescription
+ )
+ ];
+ }
+
+ protected override async ValueTask ShouldEmitBackendToolCallData(string functionName)
+ {
+ // Short if we're not emitting backend tool calls at all.
+ if (!_agentOptions.EmitBackendToolCalls)
+ {
+ return false;
+ }
+
+ bool isStateFunction =
+ functionName == _agentOptions.StateRetrievalFunctionName ||
+ functionName == _agentOptions.StateUpdateFunctionName;
+
+ // If the function is a state function, only request to emit if the agent options allow it.
+ if (isStateFunction)
+ {
+ return _agentOptions.EmitStateFunctionsToFrontend;
+ }
+
+ // Let the base handle it otherwise.
+ return await base.ShouldEmitBackendToolCallData(functionName).ConfigureAwait(false);
+ }
+
+ protected override async ValueTask OnRunStartedAsync(RunAgentInput input, ChannelWriter events, CancellationToken cancellationToken = default)
+ {
+ // Allow the base behaviour of emitting the RunStartedEvent
+ await base.OnRunStartedAsync(input, events, cancellationToken).ConfigureAwait(false);
+
+ // Take the initial state from the input if possible
+ try
+ {
+ if (input.State.ValueKind == JsonValueKind.Object)
+ {
+ var state = input.State.Deserialize(_jsonSerOpts);
+ if (state is not null)
+ {
+ _currentState = state;
+ }
+ }
+ }
+ catch (JsonException)
+ {
+
+ }
+
+ await events.WriteAsync(new StateSnapshotEvent
+ {
+ Snapshot = _currentState,
+ Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ }, cancellationToken).ConfigureAwait(false);
+ }
+}
diff --git a/dotnet-sdk/AGUIDotnet/Events/BaseEvent.cs b/dotnet-sdk/AGUIDotnet/Events/BaseEvent.cs
new file mode 100644
index 00000000..73d688c7
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet/Events/BaseEvent.cs
@@ -0,0 +1,30 @@
+using System.Text.Json.Serialization;
+
+namespace AGUIDotnet.Events;
+
+[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")]
+[JsonDerivedType(typeof(RunStartedEvent), EventTypes.RUN_STARTED)]
+[JsonDerivedType(typeof(RunFinishedEvent), EventTypes.RUN_FINISHED)]
+[JsonDerivedType(typeof(RunErrorEvent), EventTypes.RUN_ERROR)]
+[JsonDerivedType(typeof(StepStartedEvent), EventTypes.STEP_STARTED)]
+[JsonDerivedType(typeof(StepFinishedEvent), EventTypes.STEP_FINISHED)]
+[JsonDerivedType(typeof(TextMessageStartEvent), EventTypes.TEXT_MESSAGE_START)]
+[JsonDerivedType(typeof(TextMessageContentEvent), EventTypes.TEXT_MESSAGE_CONTENT)]
+[JsonDerivedType(typeof(TextMessageEndEvent), EventTypes.TEXT_MESSAGE_END)]
+[JsonDerivedType(typeof(ToolCallStartEvent), EventTypes.TOOL_CALL_START)]
+[JsonDerivedType(typeof(ToolCallArgsEvent), EventTypes.TOOL_CALL_ARGS)]
+[JsonDerivedType(typeof(ToolCallEndEvent), EventTypes.TOOL_CALL_END)]
+[JsonDerivedType(typeof(StateSnapshotEvent), EventTypes.STATE_SNAPSHOT)]
+[JsonDerivedType(typeof(StateDeltaEvent), EventTypes.STATE_DELTA)]
+[JsonDerivedType(typeof(CustomEvent), EventTypes.CUSTOM)]
+[JsonDerivedType(typeof(RawEvent), EventTypes.RAW)]
+[JsonDerivedType(typeof(MessagesSnapshotEvent), EventTypes.MESSAGES_SNAPSHOT)]
+
+public abstract record BaseEvent
+{
+ [JsonPropertyName("timestamp")]
+ public long? Timestamp { get; init; }
+
+ [JsonPropertyName("rawEvent")]
+ public object? RawEvent { get; init; }
+}
diff --git a/dotnet-sdk/AGUIDotnet/Events/CustomEvent.cs b/dotnet-sdk/AGUIDotnet/Events/CustomEvent.cs
new file mode 100644
index 00000000..7d79e839
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet/Events/CustomEvent.cs
@@ -0,0 +1,15 @@
+using System.Text.Json.Serialization;
+
+namespace AGUIDotnet.Events;
+
+///
+/// Used for application-specific custom events.
+///
+public sealed record CustomEvent : BaseEvent
+{
+ [JsonPropertyName("name")]
+ public required string Name { get; init; }
+
+ [JsonPropertyName("value")]
+ public required object Value { get; init; }
+}
\ No newline at end of file
diff --git a/dotnet-sdk/AGUIDotnet/Events/EventHelpers.cs b/dotnet-sdk/AGUIDotnet/Events/EventHelpers.cs
new file mode 100644
index 00000000..a88807b2
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet/Events/EventHelpers.cs
@@ -0,0 +1,31 @@
+using System;
+
+namespace AGUIDotnet.Events;
+
+public static class EventHelpers
+{
+ public static IEnumerable SendSimpleMessage(string message, string? messageId = null)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(message, nameof(message));
+
+ messageId ??= Guid.NewGuid().ToString();
+
+ return [
+ new TextMessageStartEvent {
+ MessageId = messageId,
+ Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ },
+
+ new TextMessageContentEvent {
+ MessageId = messageId,
+ Delta = message,
+ Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ },
+
+ new TextMessageEndEvent {
+ MessageId = messageId,
+ Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ }
+ ];
+ }
+}
diff --git a/dotnet-sdk/AGUIDotnet/Events/EventTypes.cs b/dotnet-sdk/AGUIDotnet/Events/EventTypes.cs
new file mode 100644
index 00000000..86b794a9
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet/Events/EventTypes.cs
@@ -0,0 +1,23 @@
+using System;
+
+namespace AGUIDotnet.Events;
+
+public static class EventTypes
+{
+ public const string TEXT_MESSAGE_START = "TEXT_MESSAGE_START";
+ public const string TEXT_MESSAGE_CONTENT = "TEXT_MESSAGE_CONTENT";
+ public const string TEXT_MESSAGE_END = "TEXT_MESSAGE_END";
+ public const string TOOL_CALL_START = "TOOL_CALL_START";
+ public const string TOOL_CALL_ARGS = "TOOL_CALL_ARGS";
+ public const string TOOL_CALL_END = "TOOL_CALL_END";
+ public const string STATE_SNAPSHOT = "STATE_SNAPSHOT";
+ public const string STATE_DELTA = "STATE_DELTA";
+ public const string MESSAGES_SNAPSHOT = "MESSAGES_SNAPSHOT";
+ public const string RAW = "RAW";
+ public const string CUSTOM = "CUSTOM";
+ public const string RUN_STARTED = "RUN_STARTED";
+ public const string RUN_FINISHED = "RUN_FINISHED";
+ public const string RUN_ERROR = "RUN_ERROR";
+ public const string STEP_STARTED = "STEP_STARTED";
+ public const string STEP_FINISHED = "STEP_FINISHED";
+}
diff --git a/dotnet-sdk/AGUIDotnet/Events/MessagesSnapshotEvent.cs b/dotnet-sdk/AGUIDotnet/Events/MessagesSnapshotEvent.cs
new file mode 100644
index 00000000..e5d8ccd4
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet/Events/MessagesSnapshotEvent.cs
@@ -0,0 +1,11 @@
+using System.Collections.Immutable;
+using System.Text.Json.Serialization;
+using AGUIDotnet.Types;
+
+namespace AGUIDotnet.Events;
+
+public sealed record MessagesSnapshotEvent : BaseEvent
+{
+ [JsonPropertyName("messages")]
+ public required ImmutableList Messages { get; init; } = [];
+}
\ No newline at end of file
diff --git a/dotnet-sdk/AGUIDotnet/Events/RawEvent.cs b/dotnet-sdk/AGUIDotnet/Events/RawEvent.cs
new file mode 100644
index 00000000..884f7e35
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet/Events/RawEvent.cs
@@ -0,0 +1,15 @@
+using System.Text.Json.Serialization;
+
+namespace AGUIDotnet.Events;
+
+///
+/// Used to pass through events from external systems.
+///
+public sealed record RawEvent : BaseEvent
+{
+ [JsonPropertyName("event")]
+ public required object Event { get; init; }
+
+ [JsonPropertyName("source")]
+ public string? Source { get; init; }
+}
diff --git a/dotnet-sdk/AGUIDotnet/Events/RunErrorEvent.cs b/dotnet-sdk/AGUIDotnet/Events/RunErrorEvent.cs
new file mode 100644
index 00000000..6179d8ef
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet/Events/RunErrorEvent.cs
@@ -0,0 +1,12 @@
+using System.Text.Json.Serialization;
+
+namespace AGUIDotnet.Events;
+
+public sealed record RunErrorEvent : BaseEvent
+{
+ [JsonPropertyName("message")]
+ public required string Message { get; init; }
+
+ [JsonPropertyName("code")]
+ public string? Code { get; init; }
+}
\ No newline at end of file
diff --git a/dotnet-sdk/AGUIDotnet/Events/RunFinishedEvent.cs b/dotnet-sdk/AGUIDotnet/Events/RunFinishedEvent.cs
new file mode 100644
index 00000000..d28743ab
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet/Events/RunFinishedEvent.cs
@@ -0,0 +1,12 @@
+using System.Text.Json.Serialization;
+
+namespace AGUIDotnet.Events;
+
+public sealed record RunFinishedEvent : BaseEvent
+{
+ [JsonPropertyName("threadId")]
+ public required string ThreadId { get; init; }
+
+ [JsonPropertyName("runId")]
+ public required string RunId { get; init; }
+}
\ No newline at end of file
diff --git a/dotnet-sdk/AGUIDotnet/Events/RunStartedEvent.cs b/dotnet-sdk/AGUIDotnet/Events/RunStartedEvent.cs
new file mode 100644
index 00000000..45fb6f3f
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet/Events/RunStartedEvent.cs
@@ -0,0 +1,12 @@
+using System.Text.Json.Serialization;
+
+namespace AGUIDotnet.Events;
+
+public sealed record RunStartedEvent : BaseEvent
+{
+ [JsonPropertyName("threadId")]
+ public required string ThreadId { get; init; }
+
+ [JsonPropertyName("runId")]
+ public required string RunId { get; init; }
+}
\ No newline at end of file
diff --git a/dotnet-sdk/AGUIDotnet/Events/StateDeltaEvent.cs b/dotnet-sdk/AGUIDotnet/Events/StateDeltaEvent.cs
new file mode 100644
index 00000000..63f10a24
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet/Events/StateDeltaEvent.cs
@@ -0,0 +1,13 @@
+using System.Collections.Immutable;
+using System.Text.Json.Serialization;
+
+namespace AGUIDotnet.Events;
+
+public sealed record StateDeltaEvent : BaseEvent
+{
+ ///
+ /// A collection of JSON-patch operations that describe the changes to the state.
+ ///
+ [JsonPropertyName("delta")]
+ public required ImmutableList Delta { get; init; } = [];
+}
\ No newline at end of file
diff --git a/dotnet-sdk/AGUIDotnet/Events/StateSnapshotEvent.cs b/dotnet-sdk/AGUIDotnet/Events/StateSnapshotEvent.cs
new file mode 100644
index 00000000..0ba61cca
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet/Events/StateSnapshotEvent.cs
@@ -0,0 +1,9 @@
+using System.Text.Json.Serialization;
+
+namespace AGUIDotnet.Events;
+
+public sealed record StateSnapshotEvent : BaseEvent
+{
+ [JsonPropertyName("snapshot")]
+ public required object Snapshot { get; init; }
+}
\ No newline at end of file
diff --git a/dotnet-sdk/AGUIDotnet/Events/StepFinishedEvent.cs b/dotnet-sdk/AGUIDotnet/Events/StepFinishedEvent.cs
new file mode 100644
index 00000000..6131359f
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet/Events/StepFinishedEvent.cs
@@ -0,0 +1,9 @@
+using System.Text.Json.Serialization;
+
+namespace AGUIDotnet.Events;
+
+public sealed record StepFinishedEvent : BaseEvent
+{
+ [JsonPropertyName("stepName")]
+ public required string StepName { get; init; }
+}
\ No newline at end of file
diff --git a/dotnet-sdk/AGUIDotnet/Events/StepStartedEvent.cs b/dotnet-sdk/AGUIDotnet/Events/StepStartedEvent.cs
new file mode 100644
index 00000000..e3a69e84
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet/Events/StepStartedEvent.cs
@@ -0,0 +1,9 @@
+using System.Text.Json.Serialization;
+
+namespace AGUIDotnet.Events;
+
+public sealed record StepStartedEvent : BaseEvent
+{
+ [JsonPropertyName("stepName")]
+ public required string StepName { get; init; }
+}
diff --git a/dotnet-sdk/AGUIDotnet/Events/TextMessageContentEvent.cs b/dotnet-sdk/AGUIDotnet/Events/TextMessageContentEvent.cs
new file mode 100644
index 00000000..37fa4cbf
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet/Events/TextMessageContentEvent.cs
@@ -0,0 +1,15 @@
+using System.Text.Json.Serialization;
+
+namespace AGUIDotnet.Events;
+
+public sealed record TextMessageContentEvent : BaseEvent
+{
+ [JsonPropertyName("messageId")]
+ public required string MessageId { get; init; }
+
+ ///
+ /// The chunk of text content to append to the message.
+ ///
+ [JsonPropertyName("delta")]
+ public required string Delta { get; init; }
+}
diff --git a/dotnet-sdk/AGUIDotnet/Events/TextMessageEndEvent.cs b/dotnet-sdk/AGUIDotnet/Events/TextMessageEndEvent.cs
new file mode 100644
index 00000000..cc4cf271
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet/Events/TextMessageEndEvent.cs
@@ -0,0 +1,9 @@
+using System.Text.Json.Serialization;
+
+namespace AGUIDotnet.Events;
+
+public sealed record TextMessageEndEvent : BaseEvent
+{
+ [JsonPropertyName("messageId")]
+ public required string MessageId { get; init; }
+}
\ No newline at end of file
diff --git a/dotnet-sdk/AGUIDotnet/Events/TextMessageStartEvent.cs b/dotnet-sdk/AGUIDotnet/Events/TextMessageStartEvent.cs
new file mode 100644
index 00000000..2b0517f0
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet/Events/TextMessageStartEvent.cs
@@ -0,0 +1,15 @@
+using System.Text.Json.Serialization;
+using AGUIDotnet.Types;
+
+namespace AGUIDotnet.Events;
+
+public sealed record TextMessageStartEvent : BaseEvent
+{
+ [JsonPropertyName("messageId")]
+ public required string MessageId { get; init; }
+
+ [JsonPropertyName("role")]
+#pragma warning disable CA1822 // Mark members as static
+ public string Role => MessageRoles.Assistant;
+#pragma warning restore CA1822 // Mark members as static
+}
diff --git a/dotnet-sdk/AGUIDotnet/Events/ToolCallArgsEvent.cs b/dotnet-sdk/AGUIDotnet/Events/ToolCallArgsEvent.cs
new file mode 100644
index 00000000..0fac6db3
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet/Events/ToolCallArgsEvent.cs
@@ -0,0 +1,15 @@
+using System.Text.Json.Serialization;
+
+namespace AGUIDotnet.Events;
+
+public sealed record ToolCallArgsEvent : BaseEvent
+{
+ [JsonPropertyName("toolCallId")]
+ public required string ToolCallId { get; init; }
+
+ ///
+ /// The JSON-encoded next chunk of the tool call arguments.
+ ///
+ [JsonPropertyName("delta")]
+ public required string Delta { get; init; }
+}
diff --git a/dotnet-sdk/AGUIDotnet/Events/ToolCallEndEvent.cs b/dotnet-sdk/AGUIDotnet/Events/ToolCallEndEvent.cs
new file mode 100644
index 00000000..140156f1
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet/Events/ToolCallEndEvent.cs
@@ -0,0 +1,9 @@
+using System.Text.Json.Serialization;
+
+namespace AGUIDotnet.Events;
+
+public sealed record ToolCallEndEvent : BaseEvent
+{
+ [JsonPropertyName("toolCallId")]
+ public required string ToolCallId { get; init; }
+}
\ No newline at end of file
diff --git a/dotnet-sdk/AGUIDotnet/Events/ToolCallStartEvent.cs b/dotnet-sdk/AGUIDotnet/Events/ToolCallStartEvent.cs
new file mode 100644
index 00000000..8c69d0e0
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet/Events/ToolCallStartEvent.cs
@@ -0,0 +1,15 @@
+using System.Text.Json.Serialization;
+
+namespace AGUIDotnet.Events;
+
+public sealed record ToolCallStartEvent : BaseEvent
+{
+ [JsonPropertyName("toolCallId")]
+ public required string ToolCallId { get; init; }
+
+ [JsonPropertyName("toolCallName")]
+ public required string ToolCallName { get; init; }
+
+ [JsonPropertyName("parentMessageId")]
+ public string? ParentMessageId { get; init; }
+}
\ No newline at end of file
diff --git a/dotnet-sdk/AGUIDotnet/Integrations/ChatClient/ChatClientMessageMapper.cs b/dotnet-sdk/AGUIDotnet/Integrations/ChatClient/ChatClientMessageMapper.cs
new file mode 100644
index 00000000..f2fcfa84
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet/Integrations/ChatClient/ChatClientMessageMapper.cs
@@ -0,0 +1,76 @@
+using System;
+using System.Collections.Immutable;
+using System.Text.Json;
+using AGUIDotnet.Types;
+using Microsoft.Extensions.AI;
+
+namespace AGUIDotnet.Integrations.ChatClient;
+
+public static class ChatClientMessageMapper
+{
+ ///
+ /// Maps AGUI messages to chat client messages.
+ ///
+ /// The collection to map
+ /// The collection to provide to
+ /// An unexpected message type was encountered
+ public static ImmutableList MapAGUIMessagesToChatClientMessages(
+ this IEnumerable agUIMessages
+ )
+ {
+ return [.. agUIMessages
+ // Filter messages that are relevant for chat clients
+ .Where(msg => msg is SystemMessage or UserMessage or AssistantMessage or ToolMessage)
+ .Select(msg => msg switch
+ {
+ SystemMessage sys => new ChatMessage(
+ role: ChatRole.System,
+ content: sys.Content
+ )
+ {
+ MessageId = sys.Id,
+ AuthorName = sys.Name
+ },
+
+ UserMessage usr => new ChatMessage(
+ role: ChatRole.User,
+ content: usr.Content
+ )
+ {
+ MessageId = usr.Id,
+ AuthorName = usr.Name
+ },
+
+ AssistantMessage asst => new ChatMessage(
+ role: ChatRole.Assistant,
+ contents: [
+ .. string.IsNullOrWhiteSpace(asst.Content)
+ ? (AIContent[])[]
+ : [new TextContent(asst.Content)],
+ ..asst.ToolCalls.Select(tc => new FunctionCallContent(
+ callId: tc.Id,
+ name: tc.Function.Name,
+ arguments: JsonSerializer.Deserialize>(tc.Function.Arguments)
+ ))
+ ]
+ ) {
+ MessageId = asst.Id,
+ AuthorName = asst.Name
+ },
+
+ ToolMessage tool => new ChatMessage(
+ role: ChatRole.Tool,
+ contents: [
+ new FunctionResultContent(
+ callId: tool.ToolCallId,
+ result: tool.Content
+ )
+ ]
+ ) {
+ MessageId = tool.Id,
+ },
+
+ _ => throw new NotSupportedException($"Unsupported message type: {msg.GetType()}")
+ })];
+ }
+}
diff --git a/dotnet-sdk/AGUIDotnet/Integrations/ChatClient/FrontendTool.cs b/dotnet-sdk/AGUIDotnet/Integrations/ChatClient/FrontendTool.cs
new file mode 100644
index 00000000..28268bb2
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet/Integrations/ChatClient/FrontendTool.cs
@@ -0,0 +1,42 @@
+using System;
+using System.Text.Json;
+using AGUIDotnet.Types;
+using Microsoft.Extensions.AI;
+
+namespace AGUIDotnet.Integrations.ChatClient;
+
+///
+/// Integrates a frontend tool from AGUI into the pipeline.
+///
+/// The AGUI tool definition provided to an agent
+public sealed class FrontendTool(Tool tool) : AIFunction
+{
+ public override string Name => tool.Name;
+ public override string Description => tool.Description;
+ public override JsonElement JsonSchema => tool.Parameters;
+
+ protected override ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken)
+ {
+ /*
+ The FunctionInvokingChatClient sets up a function invocation loop where it intercepts function calls
+ in order to invoke the appropriate .NET function.
+
+ However, in doing so it expects the function to return a value, which we cannot do in a re-entrant way
+ within the context of the same run.
+
+ This function's "invocation" then is a signal to the FunctionInvokingChatClient that it should terminate the invocation loop
+ and return out, which allows us to intervene.
+
+ It does unfortunately mean that multiple tool call support is not possible without either:
+
+ - Finding a way to register a regular AiTool with the abstraction that supports serialising the JSON schema (the base one does not)
+ - Or, implementing a custom variation of the FunctionInvokingChatClient that has better support for distributed and asynchronous function calling.
+ */
+ if (FunctionInvokingChatClient.CurrentContext is not null)
+ {
+ FunctionInvokingChatClient.CurrentContext.Terminate = true;
+ }
+
+ return ValueTask.FromResult(null);
+ }
+}
diff --git a/dotnet-sdk/AGUIDotnet/Integrations/RouteBuilderExtensions.cs b/dotnet-sdk/AGUIDotnet/Integrations/RouteBuilderExtensions.cs
new file mode 100644
index 00000000..21f82c60
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet/Integrations/RouteBuilderExtensions.cs
@@ -0,0 +1,59 @@
+using System;
+using AGUIDotnet.Agent;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.AI;
+using AGUIDotnet.Types;
+using AGUIDotnet.Events;
+using Microsoft.Extensions.Options;
+using System.Text.Json;
+
+namespace AGUIDotnet.Integrations;
+
+public static class RouteBuilderExtensions
+{
+ ///
+ /// Simple extension method to map an AGUI agent endpoint that uses server sent events to the provided route builder
+ ///
+ /// The to map the POST endpoint to
+ /// The ID of the agent which also becomes the mapped endpoint pattern
+ /// Factory to resolve the agent instance
+ /// An
+ public static IEndpointConventionBuilder MapAgentEndpoint(
+ this IEndpointRouteBuilder builder,
+ string id,
+ Func agentFactory
+ )
+ {
+ return builder.MapPost(
+ id,
+ async (
+ [FromBody] RunAgentInput input,
+ HttpContext context,
+ IOptions jsonOptions
+ ) =>
+ {
+ context.Response.ContentType = "text/event-stream";
+ await context.Response.Body.FlushAsync().ConfigureAwait(true);
+
+ var serOptions = jsonOptions.Value.SerializerOptions;
+ var agent = agentFactory(context.RequestServices);
+
+ await foreach (var ev in agent.RunToCompletionAsync(input, context.RequestAborted).ConfigureAwait(true))
+ {
+ var serializedEvent = JsonSerializer.Serialize(ev, serOptions);
+ await context.Response.WriteAsync($"data: {serializedEvent}\n\n").ConfigureAwait(true);
+ await context.Response.Body.FlushAsync().ConfigureAwait(true);
+
+ // If the event is a RunFinishedEvent, we can break the loop.
+ if (ev is RunFinishedEvent)
+ {
+ break;
+ }
+ }
+ }
+ );
+ }
+}
diff --git a/dotnet-sdk/AGUIDotnet/Types/AssistantMessage.cs b/dotnet-sdk/AGUIDotnet/Types/AssistantMessage.cs
new file mode 100644
index 00000000..934c1224
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet/Types/AssistantMessage.cs
@@ -0,0 +1,19 @@
+using System.Collections.Immutable;
+using System.Text.Json.Serialization;
+
+namespace AGUIDotnet.Types;
+
+///
+/// Represents a message from an assistant.
+///
+public sealed record AssistantMessage : BaseMessage
+{
+ [JsonPropertyName("content")]
+ public string? Content { get; init; }
+
+ [JsonPropertyName("name")]
+ public string? Name { get; init; }
+
+ [JsonPropertyName("toolCalls")]
+ public ImmutableList ToolCalls { get; init; } = [];
+}
diff --git a/dotnet-sdk/AGUIDotnet/Types/BaseMessage.cs b/dotnet-sdk/AGUIDotnet/Types/BaseMessage.cs
new file mode 100644
index 00000000..0d2145b9
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet/Types/BaseMessage.cs
@@ -0,0 +1,19 @@
+using System.Text.Json.Serialization;
+
+namespace AGUIDotnet.Types;
+
+///
+/// Base class for all message types.
+///
+[JsonPolymorphic(TypeDiscriminatorPropertyName = "role")]
+[JsonDerivedType(typeof(DeveloperMessage), MessageRoles.Developer)]
+[JsonDerivedType(typeof(SystemMessage), MessageRoles.System)]
+[JsonDerivedType(typeof(AssistantMessage), MessageRoles.Assistant)]
+[JsonDerivedType(typeof(UserMessage), MessageRoles.User)]
+[JsonDerivedType(typeof(ToolMessage), MessageRoles.Tool)]
+public abstract record BaseMessage
+{
+ [JsonPropertyName("id")]
+ [JsonPropertyOrder(-1)]
+ public required string Id { get; init; }
+}
diff --git a/dotnet-sdk/AGUIDotnet/Types/Context.cs b/dotnet-sdk/AGUIDotnet/Types/Context.cs
new file mode 100644
index 00000000..142ec5aa
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet/Types/Context.cs
@@ -0,0 +1,15 @@
+using System.Text.Json.Serialization;
+
+namespace AGUIDotnet.Types;
+
+///
+/// Represents a context object to provide additional information to the agent.
+///
+public sealed record Context
+{
+ [JsonPropertyName("description")]
+ public required string Description { get; init; }
+
+ [JsonPropertyName("value")]
+ public required string Value { get; init; }
+}
diff --git a/dotnet-sdk/AGUIDotnet/Types/DeveloperMessage.cs b/dotnet-sdk/AGUIDotnet/Types/DeveloperMessage.cs
new file mode 100644
index 00000000..0e0b6d0c
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet/Types/DeveloperMessage.cs
@@ -0,0 +1,15 @@
+using System.Text.Json.Serialization;
+
+namespace AGUIDotnet.Types;
+
+///
+/// Represents a message from a developer.
+///
+public sealed record DeveloperMessage : BaseMessage
+{
+ [JsonPropertyName("content")]
+ public required string Content { get; init; }
+
+ [JsonPropertyName("name")]
+ public string? Name { get; init; }
+}
diff --git a/dotnet-sdk/AGUIDotnet/Types/FunctionCall.cs b/dotnet-sdk/AGUIDotnet/Types/FunctionCall.cs
new file mode 100644
index 00000000..0b126db2
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet/Types/FunctionCall.cs
@@ -0,0 +1,19 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace AGUIDotnet.Types;
+
+///
+/// Represents a function call made by the agent.
+///
+public sealed record FunctionCall
+{
+ [JsonPropertyName("name")]
+ public required string Name { get; init; }
+
+ ///
+ /// The JSON-encoded arguments (as a string) for the function call.
+ ///
+ [JsonPropertyName("arguments")]
+ public required string Arguments { get; init; }
+}
diff --git a/dotnet-sdk/AGUIDotnet/Types/MessageRoles.cs b/dotnet-sdk/AGUIDotnet/Types/MessageRoles.cs
new file mode 100644
index 00000000..21d8c1c7
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet/Types/MessageRoles.cs
@@ -0,0 +1,12 @@
+using System;
+
+namespace AGUIDotnet.Types;
+
+public static class MessageRoles
+{
+ public const string System = "system";
+ public const string User = "user";
+ public const string Assistant = "assistant";
+ public const string Tool = "tool";
+ public const string Developer = "developer";
+}
diff --git a/dotnet-sdk/AGUIDotnet/Types/RunAgentInput.cs b/dotnet-sdk/AGUIDotnet/Types/RunAgentInput.cs
new file mode 100644
index 00000000..90d53056
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet/Types/RunAgentInput.cs
@@ -0,0 +1,50 @@
+using System.Collections.Immutable;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace AGUIDotnet.Types;
+
+public sealed record RunAgentInput
+{
+ ///
+ /// ID of the conversation thread.
+ ///
+ [JsonPropertyName("threadId")]
+ public required string ThreadId { get; init; }
+
+ ///
+ /// ID of the current run.
+ ///
+ [JsonPropertyName("runId")]
+ public required string RunId { get; init; }
+
+ ///
+ /// The current state of the agent, at the time of the agent being called.
+ ///
+ [JsonPropertyName("state")]
+ public required JsonElement State { get; init; }
+
+ ///
+ /// The messages that are part of the conversation thread.
+ ///
+ [JsonPropertyName("messages")]
+ public required ImmutableList Messages { get; init; } = [];
+
+ ///
+ /// Tools available to the agent from the caller.
+ ///
+ [JsonPropertyName("tools")]
+ public required ImmutableList Tools { get; init; } = [];
+
+ ///
+ /// Context items that provide additional information to the agent.
+ ///
+ [JsonPropertyName("context")]
+ public required ImmutableList Context { get; init; } = [];
+
+ ///
+ /// Additional forwarded properties that are passed to the agent.
+ ///
+ [JsonPropertyName("forwardedProps")]
+ public required JsonElement ForwardedProps { get; init; }
+}
\ No newline at end of file
diff --git a/dotnet-sdk/AGUIDotnet/Types/SystemMessage.cs b/dotnet-sdk/AGUIDotnet/Types/SystemMessage.cs
new file mode 100644
index 00000000..b2a008df
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet/Types/SystemMessage.cs
@@ -0,0 +1,15 @@
+using System.Text.Json.Serialization;
+
+namespace AGUIDotnet.Types;
+
+///
+/// Represents a system message.
+///
+public sealed record SystemMessage : BaseMessage
+{
+ [JsonPropertyName("content")]
+ public required string Content { get; init; }
+
+ [JsonPropertyName("name")]
+ public string? Name { get; init; }
+}
diff --git a/dotnet-sdk/AGUIDotnet/Types/Tool.cs b/dotnet-sdk/AGUIDotnet/Types/Tool.cs
new file mode 100644
index 00000000..120c40f8
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet/Types/Tool.cs
@@ -0,0 +1,22 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace AGUIDotnet.Types;
+
+///
+/// Defines a tool that can be called by an agent.
+///
+public sealed record Tool
+{
+ [JsonPropertyName("name")]
+ public required string Name { get; init; }
+
+ [JsonPropertyName("description")]
+ public required string Description { get; init; }
+
+ ///
+ /// The JSON schema for the parameters that this tool accepts
+ ///
+ [JsonPropertyName("parameters")]
+ public required JsonElement Parameters { get; init; }
+}
\ No newline at end of file
diff --git a/dotnet-sdk/AGUIDotnet/Types/ToolCall.cs b/dotnet-sdk/AGUIDotnet/Types/ToolCall.cs
new file mode 100644
index 00000000..407b8047
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet/Types/ToolCall.cs
@@ -0,0 +1,20 @@
+using System.Text.Json.Serialization;
+
+namespace AGUIDotnet.Types;
+
+///
+/// Represents a tool call made by an agent.
+///
+public sealed record ToolCall
+{
+ [JsonPropertyName("id")]
+ public required string Id { get; init; }
+
+ [JsonPropertyName("type")]
+#pragma warning disable CA1822 // Mark members as static
+ public string Type => "function";
+#pragma warning restore CA1822 // Mark members as static
+
+ [JsonPropertyName("function")]
+ public required FunctionCall Function { get; init; }
+}
diff --git a/dotnet-sdk/AGUIDotnet/Types/ToolMessage.cs b/dotnet-sdk/AGUIDotnet/Types/ToolMessage.cs
new file mode 100644
index 00000000..473bbc9b
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet/Types/ToolMessage.cs
@@ -0,0 +1,23 @@
+using System.Text.Json.Serialization;
+
+namespace AGUIDotnet.Types;
+
+///
+/// Represents a message that is a response from a tool.
+///
+///
+///
+/// This is used for scenarios where the agent has requested the frontend to call a tool, and the frontend is dispatching the result of the call to the agent.
+///
+///
+/// NOTE: This message is received in a subsequent run after the one the agent requested the tool call in.
+///
+///
+public sealed record ToolMessage : BaseMessage
+{
+ [JsonPropertyName("toolCallId")]
+ public required string ToolCallId { get; init; }
+
+ [JsonPropertyName("content")]
+ public required string Content { get; init; }
+}
diff --git a/dotnet-sdk/AGUIDotnet/Types/UserMessage.cs b/dotnet-sdk/AGUIDotnet/Types/UserMessage.cs
new file mode 100644
index 00000000..cfa1293c
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnet/Types/UserMessage.cs
@@ -0,0 +1,15 @@
+using System.Text.Json.Serialization;
+
+namespace AGUIDotnet.Types;
+
+///
+/// Represents a message sent by the user.
+///
+public sealed record UserMessage : BaseMessage
+{
+ [JsonPropertyName("content")]
+ public required string Content { get; init; }
+
+ [JsonPropertyName("name")]
+ public string? Name { get; init; }
+}
diff --git a/dotnet-sdk/AGUIDotnetWebApiExample/AGUIDotnetWebApiExample.csproj b/dotnet-sdk/AGUIDotnetWebApiExample/AGUIDotnetWebApiExample.csproj
new file mode 100644
index 00000000..cd143f36
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnetWebApiExample/AGUIDotnetWebApiExample.csproj
@@ -0,0 +1,20 @@
+
+
+
+ net9.0
+ enable
+ enable
+ d1dd059f-2abb-4d28-8e24-7f34cf887f7c
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/dotnet-sdk/AGUIDotnetWebApiExample/Program.cs b/dotnet-sdk/AGUIDotnetWebApiExample/Program.cs
new file mode 100644
index 00000000..21e6329d
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnetWebApiExample/Program.cs
@@ -0,0 +1,228 @@
+using Microsoft.AspNetCore.Mvc;
+using AGUIDotnet.Types;
+using AGUIDotnet.Events;
+using AGUIDotnet.Agent;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Microsoft.Extensions.Options;
+using System.Threading.Channels;
+using System.ClientModel.Primitives;
+using System.Web;
+using Azure.AI.OpenAI;
+using System.ClientModel;
+using Microsoft.Extensions.AI;
+using System.Collections.Immutable;
+using System.ComponentModel;
+using AGUIDotnet.Integrations;
+
+var builder = WebApplication.CreateBuilder(args);
+
+// Add services to the container.
+// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
+builder.Services.AddOpenApi();
+
+builder.Services.ConfigureHttpJsonOptions(opts =>
+{
+ // Necessary as the type discriminator is not the first property in the JSON objects used by the AG-UI protocol.
+ opts.SerializerOptions.AllowOutOfOrderMetadataProperties = true;
+ opts.SerializerOptions.WriteIndented = false;
+ opts.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
+
+ // Necessary as consumers of the AG-UI protocol (e.g. Zod-powered schemas) will not accept null values for optional properties.
+ // So we need to ensure that null values are not serialized.
+ opts.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
+});
+
+builder.Services.AddChatClient(
+ new AzureOpenAIClient(
+ new Uri(builder.Configuration["AzureOpenAI:Endpoint"]!),
+ new ApiKeyCredential(builder.Configuration["AzureOpenAI:ApiKey"]!),
+ new AzureOpenAIClientOptions
+ {
+ Transport = new ApiVersionSelectorTransport(builder.Configuration["AzureOpenAI:ApiVersion"]!)
+ }
+ ).GetChatClient(builder.Configuration["AzureOpenAI:Model"]).AsIChatClient()
+)
+.UseFunctionInvocation();
+
+var app = builder.Build();
+
+// Configure the HTTP request pipeline.
+if (app.Environment.IsDevelopment())
+{
+ app.MapOpenApi();
+}
+
+app.UseHttpsRedirection();
+
+var agentsGroup = app.MapGroup("/agents");
+
+agentsGroup.MapPost("echo", async ([FromBody] RunAgentInput input, HttpContext context, IOptions jsonOptions) =>
+{
+ context.Response.ContentType = "text/event-stream";
+ await context.Response.Body.FlushAsync();
+
+ var serOpts = jsonOptions.Value.SerializerOptions;
+ var agent = new EchoAgent();
+
+ await foreach (var ev in agent.RunToCompletionAsync(input, context.RequestAborted))
+ {
+ var serializedEvent = JsonSerializer.Serialize(ev, serOpts);
+ await context.Response.WriteAsync($"data: {serializedEvent}\n\n");
+ await context.Response.Body.FlushAsync();
+
+ // If the event is a RunFinishedEvent, we can break the loop.
+ if (ev is RunFinishedEvent)
+ {
+ break;
+ }
+ }
+});
+
+agentsGroup.MapPost("chatbot", async ([FromBody] RunAgentInput input, HttpContext context, IOptions jsonOptions) =>
+{
+ context.Response.ContentType = "text/event-stream";
+ await context.Response.Body.FlushAsync();
+
+ var serOpts = jsonOptions.Value.SerializerOptions;
+
+ var azureOpenAiClient = new AzureOpenAIClient(
+ new Uri(app.Configuration["AzureOpenAI:Endpoint"]!),
+ new ApiKeyCredential(app.Configuration["AzureOpenAI:ApiKey"]!),
+ new AzureOpenAIClientOptions
+ {
+ Transport = new ApiVersionSelectorTransport(app.Configuration["AzureOpenAI:ApiVersion"]!)
+ }
+ );
+
+ var chatClient = new ChatClientBuilder(azureOpenAiClient.GetChatClient(app.Configuration["AzureOpenAI:Model"]!).AsIChatClient())
+ .UseFunctionInvocation()
+ .Build();
+
+ static DateTimeOffset GetCurrentDateTime() => DateTimeOffset.UtcNow;
+
+ var agent = new ChatClientAgent(chatClient, new ChatClientAgentOptions
+ {
+ SystemMessage = """
+
+ You are a helpful assistant acting as a general-purpose chatbot.
+
+
+
+ - Where achieving the goal requires chaining multiple steps available as tools, you must use the tools in the logical order to achieve the goal.
+
+ """,
+
+ PerformAiContextExtraction = true,
+ IncludeContextInSystemMessage = true,
+
+ ChatOptions = new ChatOptions
+ {
+ Tools = [
+ AIFunctionFactory.Create(
+ GetCurrentDateTime,
+ name: "getCurrentDateTimeUtc",
+ description: "Returns the current date and time in UTC."
+ )
+ ]
+ }
+ });
+
+ await foreach (var ev in agent.RunToCompletionAsync(input, context.RequestAborted))
+ {
+ var serializedEvent = JsonSerializer.Serialize(ev, serOpts);
+ await context.Response.WriteAsync($"data: {serializedEvent}\n\n");
+ await context.Response.Body.FlushAsync();
+
+ // If the event is a RunFinishedEvent, we can break the loop.
+ if (ev is RunFinishedEvent)
+ {
+ break;
+ }
+ }
+
+ var finalUsage = agent.Usage;
+});
+
+agentsGroup.MapAgentEndpoint(
+ "recipe",
+ sp =>
+ {
+ var chatClient = sp.GetRequiredService();
+ return new StatefulChatClientAgent(
+ chatClient,
+ new Recipe(),
+ new StatefulChatClientAgentOptions
+ {
+ SystemMessage = """
+
+ You are a helpful assistant that collaborates with the user to help create a recipe aligned with their requests and requirements.
+
+
+
+ - You have a tool for setting the background colour on the frontend, whenever setting a recipe, please set the background to be inspired by it.
+
+ """,
+ PerformAiContextExtraction = true,
+ IncludeContextInSystemMessage = true,
+
+ EmitBackendToolCalls = true,
+ EmitStateFunctionsToFrontend = true,
+
+ ChatOptions = new ChatOptions
+ {
+ Tools = [
+ AIFunctionFactory.Create(
+ async () => {
+ await Task.Delay(2000); // simulated delay
+ return DateTimeOffset.UtcNow;
+ },
+ name: "getCurrentDateTimeUtc",
+ description: "Returns the current date and time in UTC."
+ )
+ ]
+ }
+ }
+ );
+ }
+);
+
+app.Run();
+
+record Recipe
+{
+ public string Name { get; init; } = "";
+ public string Description { get; init; } = "";
+ public ImmutableList Ingredients { get; init; } = [];
+
+ public ImmutableList Instructions { get; init; } = [];
+}
+
+record Ingredient
+{
+ public required string Name { get; init; }
+ public required string Quantity { get; init; }
+}
+
+record Instruction
+{
+ public required string Step { get; init; }
+ public required string EstimatedTime { get; init; }
+
+ [Description("A description of the step, supports Markdown formatting.")]
+ public required string Description { get; init; }
+}
+
+class ApiVersionSelectorTransport(string apiVersion) : HttpClientPipelineTransport
+{
+ protected override void OnSendingRequest(PipelineMessage message, HttpRequestMessage httpRequest)
+ {
+ var uriBuilder = new UriBuilder(httpRequest.RequestUri!);
+ var query = HttpUtility.ParseQueryString(uriBuilder.Query);
+ query["api-version"] = apiVersion;
+ uriBuilder.Query = query.ToString();
+ httpRequest.RequestUri = uriBuilder.Uri;
+
+ base.OnSendingRequest(message, httpRequest);
+ }
+}
\ No newline at end of file
diff --git a/dotnet-sdk/AGUIDotnetWebApiExample/Properties/launchSettings.json b/dotnet-sdk/AGUIDotnetWebApiExample/Properties/launchSettings.json
new file mode 100644
index 00000000..a94edafb
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnetWebApiExample/Properties/launchSettings.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:5196",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "https://localhost:7228;http://localhost:5196",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/dotnet-sdk/AGUIDotnetWebApiExample/appsettings.Development.json b/dotnet-sdk/AGUIDotnetWebApiExample/appsettings.Development.json
new file mode 100644
index 00000000..ff66ba6b
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnetWebApiExample/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/dotnet-sdk/AGUIDotnetWebApiExample/appsettings.json b/dotnet-sdk/AGUIDotnetWebApiExample/appsettings.json
new file mode 100644
index 00000000..4d566948
--- /dev/null
+++ b/dotnet-sdk/AGUIDotnetWebApiExample/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/dotnet-sdk/README.md b/dotnet-sdk/README.md
new file mode 100644
index 00000000..4e80ec0a
--- /dev/null
+++ b/dotnet-sdk/README.md
@@ -0,0 +1,5 @@
+# Agent User Interaction Protocol .NET SDK
+
+The .NET SDK for the [Agent User Interaction Protocol](https://ag-ui.com).
+
+For more information visit the [official documentation](https://docs.ag-ui.com/).