-
Notifications
You must be signed in to change notification settings - Fork 448
.NET SDK #38
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
.NET SDK #38
Changes from all commits
a235e78
9d7e4fc
eedfbc3
c4cde13
0f9a779
9be07d8
034ca18
cce5336
d310e7b
c2002df
7f6e3ab
df3cf87
bb5d85d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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. | ||
|
||
<Warning> | ||
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. | ||
|
||
</Warning> | ||
|
||
## 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<Recipe>( | ||
chatClient, | ||
// Define initial state for the agent to begin with if not provided by the run input | ||
new Recipe(), | ||
new StatefulChatClientAgentOptions<Recipe> | ||
{ | ||
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<TState>` that | ||
derives from `ChatClientAgentOptions` and allows you to provide all the same | ||
options as you would to a `ChatClientAgent`. | ||
|
||
```csharp | ||
public record StatefulChatClientAgentOptions<TState> : 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`. | ||
|
||
<Warning> | ||
Beware deriving from this agent, as if you interfere with the methods it | ||
overrides, you may as well just derive from `ChatClientAgent` instead. | ||
</Warning> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<BaseEvent> 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<BaseEvent>` | 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<BaseEvent>( | ||
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<BaseEvent> 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 | ||
|
||
<Note> | ||
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. | ||
</Note> | ||
|
||
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 | ||
) | ||
] | ||
} | ||
} | ||
); | ||
``` | ||
|
||
<Card | ||
title="ChatClientAgent Reference" | ||
icon="cube" | ||
href="/sdk/dotnet/agent-types/chatclientagent" | ||
color="#3B82F6" | ||
iconType="solid" | ||
> | ||
Complete documentation of the `ChatClientAgent` class | ||
</Card> | ||
|
||
### 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<Recipe>( | ||
chatClient, | ||
// Define initial state for the agent to begin with if not provided by the run input | ||
new Recipe(), | ||
new StatefulChatClientAgentOptions<Recipe> | ||
{ | ||
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. | ||
|
||
<Card | ||
title="StatefulChatClientAgent Reference" | ||
icon="cube" | ||
href="/sdk/dotnet/agent-types/statefulchatclientagent" | ||
color="#3B82F6" | ||
iconType="solid" | ||
> | ||
Complete documentation of the `StatefulChatClientAgent` class | ||
</Card> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<IChatClient>(); | ||
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. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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. | ||
|
||
<Note> | ||
The .NET SDK uses polymorphic serialization for events, with the{" "} | ||
<code>type</code> discriminator field handled via attributes. Each event type | ||
is a derived record of <code>BaseEvent</code>. | ||
</Note> | ||
|
||
```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<object> Delta { get; init; } = ImmutableList<object>.Empty; | ||
} | ||
``` | ||
|
||
| Property | Type | Description | | ||
| -------- | ----------------------- | ----------------------------------- | | ||
| `Delta` | `ImmutableList<object>` | Collection of JSON Patch operations | | ||
|
||
### MessagesSnapshotEvent | ||
|
||
Provides a snapshot of all messages in a conversation. | ||
|
||
```csharp | ||
public sealed record MessagesSnapshotEvent : BaseEvent | ||
{ | ||
public required ImmutableList<BaseMessage> Messages { get; init; } = ImmutableList<BaseMessage>.Empty; | ||
} | ||
``` | ||
|
||
| Property | Type | Description | | ||
| ---------- | ---------------------------- | ----------------------------- | | ||
| `Messages` | `ImmutableList<BaseMessage>` | 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 | |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,98 @@ | ||
--- | ||
title: "Overview" | ||
description: "Core concepts in the Agent User Interaction Protocol .NET SDK" | ||
--- | ||
|
||
<Card | ||
title="Nuget Availability TBD" | ||
icon="exclamation" | ||
color="#F97316" | ||
iconType="solid" | ||
> | ||
For now, it is possible to clone the code and add it as a project reference. | ||
|
||
</Card> | ||
|
||
```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 | ||
|
||
<Card | ||
title="Types Reference" | ||
icon="cube" | ||
href="/sdk/dotnet/types" | ||
color="#3B82F6" | ||
iconType="solid" | ||
> | ||
Complete documentation of all types in the AGUIDotnet package | ||
</Card> | ||
|
||
## 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 | ||
|
||
<Card | ||
title="Events Reference" | ||
icon="cube" | ||
href="/sdk/dotnet/events" | ||
color="#3B82F6" | ||
iconType="solid" | ||
> | ||
Complete documentation of all events in the AGUIDotnet package | ||
</Card> | ||
|
||
## 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 | ||
|
||
<Card | ||
title="Agents Reference" | ||
icon="cube" | ||
href="/sdk/dotnet/agents" | ||
color="#3B82F6" | ||
iconType="solid" | ||
> | ||
Complete documentation of all agent functionality in the AGUIDotnet package | ||
</Card> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<BaseMessage> Messages { get; init; } = []; | ||
public required ImmutableList<Tool> Tools { get; init; } = []; | ||
public required ImmutableList<Context> 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<BaseMessage>` | Collection of messages in the conversation | | ||
| `Tools` | `ImmutableList<Tool>` | Collection of tools available to the agent | | ||
| `Context` | `ImmutableList<Context>` | 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. | ||
|
||
<Note> | ||
All message types inherit from <code>BaseMessage</code>. The <code>role</code>{" "} | ||
property is determined by the concrete message type and is not a property of{" "} | ||
<code>BaseMessage</code> itself. This polymorphic approach allows for | ||
type-safe handling of different message roles in the .NET SDK. | ||
</Note> | ||
|
||
### 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<ToolCall> 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<ToolCall>` | 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. |
Large diffs are not rendered by default.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
{ | ||
"dotnet.defaultSolution": "AGUIDotnet.sln", | ||
"cSpell.words": ["AGUI"] | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
<Project Sdk="Microsoft.NET.Sdk"> | ||
|
||
<PropertyGroup> | ||
<TargetFramework>net9.0</TargetFramework> | ||
<ImplicitUsings>enable</ImplicitUsings> | ||
<Nullable>enable</Nullable> | ||
<IsPackable>false</IsPackable> | ||
</PropertyGroup> | ||
|
||
<ItemGroup> | ||
<PackageReference Include="coverlet.collector" Version="6.0.2" /> | ||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" /> | ||
<PackageReference Include="xunit" Version="2.9.2" /> | ||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" /> | ||
</ItemGroup> | ||
|
||
<ItemGroup> | ||
<ProjectReference Include="../AGUIDotnet/AGUIDotnet.csproj" /> | ||
</ItemGroup> | ||
|
||
<ItemGroup> | ||
<Using Include="Xunit" /> | ||
</ItemGroup> | ||
|
||
</Project> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<RunAgentInput, ChannelWriter<BaseEvent>, CancellationToken, Task> func) : IAGUIAgent | ||
{ | ||
public async Task RunAsync(RunAgentInput input, ChannelWriter<BaseEvent> 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(); | ||
} | ||
} |
Large diffs are not rendered by default.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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>(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>(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<TextContent>(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<TextContent>(chatMessage.Contents[0]); | ||
Assert.IsType<FunctionCallContent>(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<FunctionResultContent>(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; } | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<FunctionCallContent>().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<FunctionResultContent>(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) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
using Microsoft.Extensions.AI; | ||
using Microsoft.Extensions.DependencyInjection; | ||
|
||
namespace AGUIDotnet.Tests; | ||
|
||
internal sealed class TestChatClient( | ||
IKeyedServiceProvider serviceProvider, | ||
Func<IKeyedServiceProvider, IEnumerable<ChatMessage>, ChatOptions?, CancellationToken, Task<ChatResponse>> getResponseAsync | ||
) : IChatClient | ||
{ | ||
public void Dispose() | ||
{ | ||
} | ||
|
||
public async Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> 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<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default) | ||
{ | ||
throw new NotImplementedException(); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,46 @@ | ||
using Microsoft.Extensions.AI; | ||
using Microsoft.Extensions.DependencyInjection; | ||
|
||
namespace AGUIDotnet.Tests; | ||
|
||
/// <summary> | ||
/// A test implementation of IChatClient that allows simulating streaming responses. | ||
/// </summary> | ||
internal sealed class TestStreamingChatClient : IChatClient | ||
{ | ||
private readonly IKeyedServiceProvider _serviceProvider; | ||
private readonly Func<IKeyedServiceProvider, IEnumerable<ChatMessage>, ChatOptions?, CancellationToken, IAsyncEnumerable<ChatResponseUpdate>> _getStreamingResponseAsync; | ||
|
||
/// <summary> | ||
/// Creates a new TestStreamingChatClient that allows configuring both sync and streaming responses. | ||
/// </summary> | ||
/// <param name="serviceProvider">The service provider to use for service resolution</param> | ||
/// <param name="getResponseAsync">Function to generate synchronous responses</param> | ||
/// <param name="getStreamingResponseAsync">Function to generate streaming responses</param> | ||
public TestStreamingChatClient( | ||
IKeyedServiceProvider serviceProvider, | ||
Func<IKeyedServiceProvider, IEnumerable<ChatMessage>, ChatOptions?, CancellationToken, IAsyncEnumerable<ChatResponseUpdate>> getStreamingResponseAsync) | ||
{ | ||
_serviceProvider = serviceProvider; | ||
_getStreamingResponseAsync = getStreamingResponseAsync; | ||
} | ||
|
||
public void Dispose() | ||
{ | ||
} | ||
|
||
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default) | ||
{ | ||
throw new NotImplementedException("This client is intended for streaming responses only. Use GetStreamingResponseAsync instead."); | ||
} | ||
|
||
public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> 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); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
<Project Sdk="Microsoft.NET.Sdk"> | ||
|
||
<PropertyGroup> | ||
<TargetFramework>net9.0</TargetFramework> | ||
<ImplicitUsings>enable</ImplicitUsings> | ||
<Nullable>enable</Nullable> | ||
<WarningsAsErrors>CA2007</WarningsAsErrors> | ||
</PropertyGroup> | ||
|
||
<ItemGroup> | ||
<PackageReference Include="Microsoft.Extensions.AI" Version="9.5.0" /> | ||
<PackageReference Include="JsonPatch.Net" Version="3.3.0" /> | ||
</ItemGroup> | ||
|
||
<ItemGroup> | ||
<FrameworkReference Include="Microsoft.AspNetCore.App" /> | ||
</ItemGroup> | ||
|
||
</Project> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
{ | ||
/// <summary> | ||
/// Runs the provided agent asynchronously to completion, yielding the events produced by the agent. | ||
/// </summary> | ||
/// <param name="agent">The <see cref="IAGUIAgent"/> instance to invoke</param> | ||
/// <param name="input">The <see cref="RunAgentInput"/> input to pass to the agent</param> | ||
/// <param name="cancellationToken">The cancellation token to cancel the run</param> | ||
/// <returns>An <see cref="IAsyncEnumerable{T}"/> with the events produced by the agent</returns> | ||
public static async IAsyncEnumerable<BaseEvent> RunToCompletionAsync( | ||
this IAGUIAgent agent, | ||
RunAgentInput input, | ||
[EnumeratorCancellation] CancellationToken cancellationToken = default | ||
) | ||
{ | ||
ArgumentNullException.ThrowIfNull(agent, nameof(agent)); | ||
ArgumentNullException.ThrowIfNull(input, nameof(input)); | ||
|
||
var channel = Channel.CreateUnbounded<BaseEvent>(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); | ||
} | ||
} |
Large diffs are not rendered by default.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,93 @@ | ||
using System.Threading.Channels; | ||
using AGUIDotnet.Events; | ||
using AGUIDotnet.Types; | ||
|
||
namespace AGUIDotnet.Agent; | ||
|
||
/// <summary> | ||
/// Bare-bones agent reference implementation that echoes the last message received. | ||
/// This agent is useful for testing and debugging purposes. | ||
/// </summary> | ||
public sealed class EchoAgent : IAGUIAgent | ||
{ | ||
public async Task RunAsync( | ||
RunAgentInput input, | ||
ChannelWriter<BaseEvent> 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(); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
using System.Threading.Channels; | ||
using AGUIDotnet.Events; | ||
using AGUIDotnet.Types; | ||
|
||
namespace AGUIDotnet.Agent; | ||
|
||
/// <summary> | ||
/// Interface for an AG-UI agent. | ||
/// </summary> | ||
public interface IAGUIAgent | ||
{ | ||
/// <summary> | ||
/// Runs the agent with the provided input. | ||
/// </summary> | ||
/// <remarks> | ||
/// <para> | ||
/// This method represents the ENTIRE lifecycle of the agent's invocation for a run. | ||
/// </para> | ||
/// <para> | ||
/// Implementations should NOT return until execution is complete, the <paramref name="events"/> channel is not guaranteed to be usable after this method returns. | ||
/// </para> | ||
/// </remarks> | ||
/// <param name="input">The input to the agent</param> | ||
/// <param name="events">A channel writer for emitting AG-UI protocol events</param> | ||
/// <param name="cancellationToken">Optional cancellation token for cancelling execution</param> | ||
/// <returns>An asynchronous task that will complete when agent execution completes for a run</returns> | ||
Task RunAsync(RunAgentInput input, ChannelWriter<BaseEvent> events, CancellationToken cancellationToken = default); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<TState> : ChatClientAgentOptions where TState : notnull | ||
{ | ||
/// <summary> | ||
/// The name to give to the function for retrieving the current shared state of the agent. | ||
/// </summary> | ||
public string StateRetrievalFunctionName { get; init; } = "retrieve_state"; | ||
|
||
/// <summary> | ||
/// The description to give to the function for retrieving the current shared state of the agent. | ||
/// </summary> | ||
public string StateRetrievalFunctionDescription { get; init; } = "Retrieves the current shared state of the agent."; | ||
|
||
/// <summary> | ||
/// The name to give to the function for updating the current shared state of the agent. | ||
/// </summary> | ||
public string StateUpdateFunctionName { get; init; } = "update_state"; | ||
|
||
/// <summary> | ||
/// The description to give to the function for updating the current shared state of the agent. | ||
/// </summary> | ||
public string StateUpdateFunctionDescription { get; init; } = "Updates the current shared state of the agent."; | ||
|
||
/// <summary> | ||
/// When <see cref="ChatClientAgentOptions.EmitBackendToolCalls"/> is <c>true</c>, this controls whether the frontend should be made aware of the state functions being called. | ||
/// </summary> | ||
public bool EmitStateFunctionsToFrontend { get; init; } = true; | ||
} | ||
|
||
/// <summary> | ||
/// Much like <see cref="ChatClientAgent"/> but tailored for scenarios where the agent and frontend collaborate on shared state. | ||
/// </summary> | ||
/// <remarks> | ||
/// 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. | ||
/// </remarks> | ||
/// <typeparam name="TState"></typeparam> | ||
public class StatefulChatClientAgent<TState> : ChatClientAgent where TState : notnull | ||
{ | ||
private TState _currentState = default!; | ||
private readonly StatefulChatClientAgentOptions<TState> _agentOptions; | ||
|
||
public StatefulChatClientAgent(IChatClient chatClient, TState initialState, StatefulChatClientAgentOptions<TState> 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<string> PrepareSystemMessage(RunAgentInput input, string systemMessage, ImmutableList<Context> 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 $""" | ||
<persona> | ||
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. | ||
</persona> | ||
<tools> | ||
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. | ||
</tools> | ||
<rules> | ||
- 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. | ||
</rules> | ||
<underlying_agent> | ||
{coreMessage} | ||
</underlying_agent> | ||
"""; | ||
} | ||
|
||
protected override async ValueTask<ImmutableList<AIFunction>> PrepareBackendTools(ImmutableList<AIFunction> backendTools, RunAgentInput input, ChannelWriter<BaseEvent> 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<object>()], | ||
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), | ||
}, cancellationToken).ConfigureAwait(false); | ||
} | ||
}, | ||
name: _agentOptions.StateUpdateFunctionName, | ||
description: _agentOptions.StateUpdateFunctionDescription | ||
) | ||
]; | ||
} | ||
|
||
protected override async ValueTask<bool> 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<BaseEvent> 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<TState>(_jsonSerOpts); | ||
if (state is not null) | ||
{ | ||
_currentState = state; | ||
} | ||
} | ||
} | ||
catch (JsonException) | ||
{ | ||
|
||
} | ||
|
||
await events.WriteAsync(new StateSnapshotEvent | ||
{ | ||
Snapshot = _currentState, | ||
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), | ||
}, cancellationToken).ConfigureAwait(false); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; } | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
using System.Text.Json.Serialization; | ||
|
||
namespace AGUIDotnet.Events; | ||
|
||
/// <summary> | ||
/// Used for application-specific custom events. | ||
/// </summary> | ||
public sealed record CustomEvent : BaseEvent | ||
{ | ||
[JsonPropertyName("name")] | ||
public required string Name { get; init; } | ||
|
||
[JsonPropertyName("value")] | ||
public required object Value { get; init; } | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,31 @@ | ||
using System; | ||
|
||
namespace AGUIDotnet.Events; | ||
|
||
public static class EventHelpers | ||
{ | ||
public static IEnumerable<BaseEvent> 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(), | ||
} | ||
]; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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"; | ||
} |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -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<BaseMessage> Messages { get; init; } = []; | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [nitpick] Consider using a consistent pattern for initializing immutable collections—for example, replacing '[]' with 'ImmutableList.Empty' to match the convention used in other parts of the code.
Suggested change
Copilot uses AI. Check for mistakes. Positive FeedbackNegative Feedback There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @copilot |
||||||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
using System.Text.Json.Serialization; | ||
|
||
namespace AGUIDotnet.Events; | ||
|
||
/// <summary> | ||
/// Used to pass through events from external systems. | ||
/// </summary> | ||
public sealed record RawEvent : BaseEvent | ||
{ | ||
[JsonPropertyName("event")] | ||
public required object Event { get; init; } | ||
|
||
[JsonPropertyName("source")] | ||
public string? Source { get; init; } | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; } | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; } | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; } | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
using System.Collections.Immutable; | ||
using System.Text.Json.Serialization; | ||
|
||
namespace AGUIDotnet.Events; | ||
|
||
public sealed record StateDeltaEvent : BaseEvent | ||
{ | ||
/// <summary> | ||
/// A collection of JSON-patch operations that describe the changes to the state. | ||
/// </summary> | ||
[JsonPropertyName("delta")] | ||
public required ImmutableList<object> Delta { get; init; } = []; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; } | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; } | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; } | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; } | ||
|
||
/// <summary> | ||
/// The chunk of text content to append to the message. | ||
/// </summary> | ||
[JsonPropertyName("delta")] | ||
public required string Delta { get; init; } | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; } | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; } | ||
|
||
/// <summary> | ||
/// The JSON-encoded next chunk of the tool call arguments. | ||
/// </summary> | ||
[JsonPropertyName("delta")] | ||
public required string Delta { get; init; } | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; } | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; } | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
{ | ||
/// <summary> | ||
/// Maps AGUI messages to chat client messages. | ||
/// </summary> | ||
/// <param name="agUIMessages">The <see cref="BaseMessage"/> collection to map</param> | ||
/// <returns>The <see cref="ChatMessage"/> collection to provide to <see cref="IChatClient"/></returns> | ||
/// <exception cref="NotSupportedException">An unexpected message type was encountered</exception> | ||
public static ImmutableList<ChatMessage> MapAGUIMessagesToChatClientMessages( | ||
this IEnumerable<BaseMessage> 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<ImmutableDictionary<string, object?>>(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()}") | ||
})]; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
using System; | ||
using System.Text.Json; | ||
using AGUIDotnet.Types; | ||
using Microsoft.Extensions.AI; | ||
|
||
namespace AGUIDotnet.Integrations.ChatClient; | ||
|
||
/// <summary> | ||
/// Integrates a frontend tool from AGUI into the <see cref="FunctionInvokingChatClient"/> pipeline. | ||
/// </summary> | ||
/// <param name="tool">The AGUI tool definition provided to an agent</param> | ||
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<object?> 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<object?>(null); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
{ | ||
/// <summary> | ||
/// Simple extension method to map an AGUI agent endpoint that uses server sent events to the provided route builder | ||
/// </summary> | ||
/// <param name="builder">The <see cref="IEndpointRouteBuilder"/> to map the POST endpoint to</param> | ||
/// <param name="id">The ID of the agent which also becomes the mapped endpoint pattern</param> | ||
/// <param name="agentFactory">Factory to resolve the agent instance</param> | ||
/// <returns>An <see cref="IEndpointConventionBuilder"/></returns> | ||
public static IEndpointConventionBuilder MapAgentEndpoint( | ||
this IEndpointRouteBuilder builder, | ||
string id, | ||
Func<IServiceProvider, IAGUIAgent> agentFactory | ||
) | ||
{ | ||
return builder.MapPost( | ||
id, | ||
async ( | ||
[FromBody] RunAgentInput input, | ||
HttpContext context, | ||
IOptions<Microsoft.AspNetCore.Http.Json.JsonOptions> 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; | ||
} | ||
} | ||
} | ||
); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
using System.Collections.Immutable; | ||
using System.Text.Json.Serialization; | ||
|
||
namespace AGUIDotnet.Types; | ||
|
||
/// <summary> | ||
/// Represents a message from an assistant. | ||
/// </summary> | ||
public sealed record AssistantMessage : BaseMessage | ||
{ | ||
[JsonPropertyName("content")] | ||
public string? Content { get; init; } | ||
|
||
[JsonPropertyName("name")] | ||
public string? Name { get; init; } | ||
|
||
[JsonPropertyName("toolCalls")] | ||
public ImmutableList<ToolCall> ToolCalls { get; init; } = []; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
using System.Text.Json.Serialization; | ||
|
||
namespace AGUIDotnet.Types; | ||
|
||
/// <summary> | ||
/// Base class for all message types. | ||
/// </summary> | ||
[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; } | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
using System.Text.Json.Serialization; | ||
|
||
namespace AGUIDotnet.Types; | ||
|
||
/// <summary> | ||
/// Represents a context object to provide additional information to the agent. | ||
/// </summary> | ||
public sealed record Context | ||
{ | ||
[JsonPropertyName("description")] | ||
public required string Description { get; init; } | ||
|
||
[JsonPropertyName("value")] | ||
public required string Value { get; init; } | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
using System.Text.Json.Serialization; | ||
|
||
namespace AGUIDotnet.Types; | ||
|
||
/// <summary> | ||
/// Represents a message from a developer. | ||
/// </summary> | ||
public sealed record DeveloperMessage : BaseMessage | ||
{ | ||
[JsonPropertyName("content")] | ||
public required string Content { get; init; } | ||
|
||
[JsonPropertyName("name")] | ||
public string? Name { get; init; } | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
using System.Text.Json; | ||
using System.Text.Json.Serialization; | ||
|
||
namespace AGUIDotnet.Types; | ||
|
||
/// <summary> | ||
/// Represents a function call made by the agent. | ||
/// </summary> | ||
public sealed record FunctionCall | ||
{ | ||
[JsonPropertyName("name")] | ||
public required string Name { get; init; } | ||
|
||
/// <summary> | ||
/// The JSON-encoded arguments (as a string) for the function call. | ||
/// </summary> | ||
[JsonPropertyName("arguments")] | ||
public required string Arguments { get; init; } | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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"; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,50 @@ | ||
using System.Collections.Immutable; | ||
using System.Text.Json; | ||
using System.Text.Json.Serialization; | ||
|
||
namespace AGUIDotnet.Types; | ||
|
||
public sealed record RunAgentInput | ||
{ | ||
/// <summary> | ||
/// ID of the conversation thread. | ||
/// </summary> | ||
[JsonPropertyName("threadId")] | ||
public required string ThreadId { get; init; } | ||
|
||
/// <summary> | ||
/// ID of the current run. | ||
/// </summary> | ||
[JsonPropertyName("runId")] | ||
public required string RunId { get; init; } | ||
|
||
/// <summary> | ||
/// The current state of the agent, at the time of the agent being called. | ||
/// </summary> | ||
[JsonPropertyName("state")] | ||
public required JsonElement State { get; init; } | ||
|
||
/// <summary> | ||
/// The messages that are part of the conversation thread. | ||
/// </summary> | ||
[JsonPropertyName("messages")] | ||
public required ImmutableList<BaseMessage> Messages { get; init; } = []; | ||
|
||
/// <summary> | ||
/// Tools available to the agent from the caller. | ||
/// </summary> | ||
[JsonPropertyName("tools")] | ||
public required ImmutableList<Tool> Tools { get; init; } = []; | ||
|
||
/// <summary> | ||
/// Context items that provide additional information to the agent. | ||
/// </summary> | ||
[JsonPropertyName("context")] | ||
public required ImmutableList<Context> Context { get; init; } = []; | ||
|
||
/// <summary> | ||
/// Additional forwarded properties that are passed to the agent. | ||
/// </summary> | ||
[JsonPropertyName("forwardedProps")] | ||
public required JsonElement ForwardedProps { get; init; } | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
using System.Text.Json.Serialization; | ||
|
||
namespace AGUIDotnet.Types; | ||
|
||
/// <summary> | ||
/// Represents a system message. | ||
/// </summary> | ||
public sealed record SystemMessage : BaseMessage | ||
{ | ||
[JsonPropertyName("content")] | ||
public required string Content { get; init; } | ||
|
||
[JsonPropertyName("name")] | ||
public string? Name { get; init; } | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
using System.Text.Json; | ||
using System.Text.Json.Serialization; | ||
|
||
namespace AGUIDotnet.Types; | ||
|
||
/// <summary> | ||
/// Defines a tool that can be called by an agent. | ||
/// </summary> | ||
public sealed record Tool | ||
{ | ||
[JsonPropertyName("name")] | ||
public required string Name { get; init; } | ||
|
||
[JsonPropertyName("description")] | ||
public required string Description { get; init; } | ||
|
||
/// <summary> | ||
/// The JSON schema for the parameters that this tool accepts | ||
/// </summary> | ||
[JsonPropertyName("parameters")] | ||
public required JsonElement Parameters { get; init; } | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
using System.Text.Json.Serialization; | ||
|
||
namespace AGUIDotnet.Types; | ||
|
||
/// <summary> | ||
/// Represents a tool call made by an agent. | ||
/// </summary> | ||
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; } | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
using System.Text.Json.Serialization; | ||
|
||
namespace AGUIDotnet.Types; | ||
|
||
/// <summary> | ||
/// Represents a message that is a response from a tool. | ||
/// </summary> | ||
/// <remarks> | ||
/// <para> | ||
/// 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. | ||
/// </para> | ||
/// <para> | ||
/// NOTE: This message is received in a subsequent run after the one the agent requested the tool call in. | ||
/// </para> | ||
/// </remarks> | ||
public sealed record ToolMessage : BaseMessage | ||
{ | ||
[JsonPropertyName("toolCallId")] | ||
public required string ToolCallId { get; init; } | ||
|
||
[JsonPropertyName("content")] | ||
public required string Content { get; init; } | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] The empty catch block may hide potential JSON deserialization issues; consider adding a comment or logging the exception to aid future troubleshooting.
Copilot uses AI. Check for mistakes.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is deliberate, for now it's just to avoid failure to extract the context causing an actual problem, it just swallows the exception, but we could perhaps surface it somehow so the consumer decides what behaviour to exhibit.