Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion docs/concepts/messages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ interface BaseMessage {
role: string // The role of the sender (user, assistant, system, tool)
content?: string // Optional text content of the message
name?: string // Optional name of the sender
attachments?: FileAttachment[] // Optional remote files referenced by the message
}

interface FileAttachment {
url: string // data URL (data:<mime>;base64,...) containing the file bytes
}
```

Expand All @@ -41,9 +46,14 @@ Messages from the end user to the agent:
interface UserMessage {
id: string
role: "user"
content: string // Text input from the user
content?: string // Optional text input from the user
attachments?: FileAttachment[] // Optional remote files shared with the agent
name?: string // Optional user identifier
}

> **Validation rule:** a user message must include either non-empty `content` or at least one `attachment`. This allows file-only interactions while keeping the protocol backward compatible with text-only chats.

> **Implementation note:** in this release, attachments must be provided as `data:` URLs (typically base64-encoded payloads). Remote HTTP(S) links will be supported in a future iteration once upload flows are standardized.
```

### Assistant Messages
Expand Down
26 changes: 24 additions & 2 deletions sdks/python/ag_ui/core/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from typing import Annotated, Any, List, Literal, Optional, Union

from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, model_validator
from pydantic.alias_generators import to_camel


Expand Down Expand Up @@ -44,6 +44,20 @@ class BaseMessage(ConfiguredBaseModel):
role: str
content: Optional[str] = None
name: Optional[str] = None
attachments: Optional[List["FileAttachment"]] = None


class FileAttachment(ConfiguredBaseModel):
"""
Remote file metadata associated with a message.
"""
url: str

@model_validator(mode="after")
def validate_data_url(cls, values: "FileAttachment") -> "FileAttachment":
if not values.url.startswith("data:"):
raise ValueError("Attachment url must be a data URL (data:<mime>;base64,...)")
return values


class DeveloperMessage(BaseMessage):
Expand Down Expand Up @@ -75,7 +89,15 @@ class UserMessage(BaseMessage):
A user message.
"""
role: Literal["user"] = "user" # pyright: ignore[reportIncompatibleVariableOverride]
content: str
content: Optional[str] = None

@model_validator(mode="after")
def ensure_body(cls, values: "UserMessage") -> "UserMessage":
has_content = bool((values.content or "").strip())
has_attachments = bool(values.attachments)
if not has_content and not has_attachments:
raise ValueError("User messages must include content or at least one attachment.")
return values


class ToolMessage(ConfiguredBaseModel):
Expand Down
15 changes: 13 additions & 2 deletions sdks/python/tests/test_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from datetime import datetime
from pydantic import ValidationError, TypeAdapter

from ag_ui.core.types import Message, UserMessage, AssistantMessage, FunctionCall, ToolCall
from ag_ui.core.types import Message, UserMessage, AssistantMessage, FunctionCall, ToolCall, FileAttachment
from ag_ui.core.events import (
EventType,
BaseEvent,
Expand Down Expand Up @@ -176,7 +176,10 @@ def test_state_delta(self):
def test_messages_snapshot(self):
"""Test creating and serializing a MessagesSnapshotEvent event"""
messages = [
UserMessage(id="user_1", content="Hello"),
UserMessage(
id="user_1",
attachments=[FileAttachment(url="data:text/plain;base64,ZmFrZQ==")],
),
AssistantMessage(id="asst_1", content="Hi there", tool_calls=[
ToolCall(
id="call_1",
Expand All @@ -194,12 +197,20 @@ def test_messages_snapshot(self):
self.assertEqual(len(event.messages), 2)
self.assertEqual(event.messages[0].id, "user_1")
self.assertEqual(event.messages[1].tool_calls[0].function.name, "get_weather")
self.assertEqual(
str(event.messages[0].attachments[0].url),
"data:text/plain;base64,ZmFrZQ==",
)

# Test serialization
serialized = event.model_dump(by_alias=True)
self.assertEqual(serialized["type"], "MESSAGES_SNAPSHOT")
self.assertEqual(len(serialized["messages"]), 2)
self.assertEqual(serialized["messages"][0]["role"], "user")
self.assertEqual(
serialized["messages"][0]["attachments"][0]["url"],
"data:text/plain;base64,ZmFrZQ==",
)
self.assertEqual(serialized["messages"][1]["toolCalls"][0]["function"]["name"], "get_weather")

def test_raw_event(self):
Expand Down
59 changes: 54 additions & 5 deletions sdks/python/tests/test_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
AssistantMessage,
UserMessage,
ToolMessage,
FileAttachment,
Message,
RunAgentInput
)
Expand All @@ -26,14 +27,15 @@ def test_function_call_creation(self):

def test_message_serialization(self):
"""Test serialization of a basic message"""
attachment = FileAttachment(url="data:text/plain;base64,SGVsbG8=")
user_msg = UserMessage(
id="msg_123",
content="Hello, world!"
attachments=[attachment]
)
serialized = user_msg.model_dump(by_alias=True)
self.assertEqual(serialized["id"], "msg_123")
self.assertEqual(serialized["role"], "user")
self.assertEqual(serialized["content"], "Hello, world!")
self.assertEqual(serialized["attachments"][0]["url"], str(attachment.url))

def test_tool_call_serialization(self):
"""Test camel case serialization for ConfiguredBaseModel subclasses"""
Expand Down Expand Up @@ -143,6 +145,23 @@ def test_user_message(self):
self.assertEqual(serialized["role"], "user")
self.assertEqual(serialized["content"], "User query")

def test_user_message_with_attachments_only(self):
"""User messages should allow attachments without text."""
attachment = FileAttachment(url="data:image/png;base64,somepngbytes")
msg = UserMessage(
id="user_attachments",
attachments=[attachment]
)
self.assertIsNone(msg.content)
self.assertEqual(msg.attachments[0].url, attachment.url)

def test_user_message_requires_body(self):
"""User messages must include content or attachments."""
with self.assertRaises(ValidationError):
UserMessage(
id="user_empty",
)

def test_message_union_deserialization(self):
"""Test that the Message union correctly deserializes to the appropriate type"""
# Create type adapter for the union
Expand All @@ -153,7 +172,11 @@ def test_message_union_deserialization(self):
{"id": "dev_123", "role": "developer", "content": "Developer note"},
{"id": "sys_456", "role": "system", "content": "System instruction"},
{"id": "asst_789", "role": "assistant", "content": "Assistant response"},
{"id": "user_101", "role": "user", "content": "User query"},
{
"id": "user_101",
"role": "user",
"attachments": [{"url": "data:application/json;base64,eyJrZXkiOiAiZGF0YSJ9"}]
},
{
"id": "tool_202",
"role": "tool",
Expand All @@ -175,7 +198,7 @@ def test_message_union_deserialization(self):
self.assertIsInstance(msg, expected_type)
self.assertEqual(msg.id, data["id"])
self.assertEqual(msg.role, data["role"])
self.assertEqual(msg.content, data["content"])
self.assertEqual(msg.content, data.get("content"))

def test_message_union_with_tool_calls(self):
"""Test the Message union with an assistant message containing tool calls"""
Expand Down Expand Up @@ -203,6 +226,25 @@ def test_message_union_with_tool_calls(self):
self.assertEqual(len(msg.tool_calls), 1)
self.assertEqual(msg.tool_calls[0].function.name, "search_data")

def test_message_union_with_attachments(self):
"""Test that attachments are preserved in the message union."""
message_adapter = TypeAdapter(Message)

data = {
"id": "user_attachments",
"role": "user",
"attachments": [
{
"url": "data:image/png;base64,aW1hZ2U=",
}
]
}

msg = message_adapter.validate_python(data)
self.assertIsInstance(msg, UserMessage)
self.assertEqual(len(msg.attachments), 1)
self.assertEqual(str(msg.attachments[0].url), "data:image/png;base64,aW1hZ2U=")

def test_run_agent_input_deserialization(self):
"""Test deserializing RunAgentInput JSON with diverse message types"""
# Create JSON data for RunAgentInput with diverse messages
Expand All @@ -221,7 +263,10 @@ def test_run_agent_input_deserialization(self):
{
"id": "user_001",
"role": "user",
"content": "Can you help me analyze this data?"
"content": "Can you help me analyze this data?",
"attachments": [
{"url": "data:text/csv;base64,YSxiLGM="}
]
},
# Developer message
{
Expand Down Expand Up @@ -321,6 +366,10 @@ def test_run_agent_input_deserialization(self):
# Verify specific message content
self.assertEqual(run_agent_input.messages[0].content, "You are a helpful assistant.")
self.assertEqual(run_agent_input.messages[1].content, "Can you help me analyze this data?")
self.assertEqual(
str(run_agent_input.messages[1].attachments[0].url),
"data:text/csv;base64,YSxiLGM=",
)

# Verify assistant message with tool call
assistant_msg = run_agent_input.messages[3]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,33 @@ describe("Agent Mutations", () => {
expect(mockSubscriber.onNewToolCall).not.toHaveBeenCalled();
});

it("should add a user message with attachments", async () => {
const attachment = {
url: "data:image/png;base64,somepngbytes",
};

const userMessage: Message = {
id: "user-msg-attachments",
role: "user",
attachments: [attachment],
};

agent.addMessage(userMessage);

expect(agent.messages.at(-1)).toBe(userMessage);

await waitForAsyncNotifications();

expect(mockSubscriber.onNewMessage).toHaveBeenCalledWith({
message: userMessage,
messages: agent.messages,
state: agent.state,
agent,
});
expect(mockSubscriber.onMessagesChanged).toHaveBeenCalled();
expect((agent.messages.at(-1) as Message).attachments).toEqual([attachment]);
});

it("should add an assistant message without tool calls", async () => {
const assistantMessage: Message = {
id: "assistant-msg-1",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { Subject } from "rxjs";
import { toArray } from "rxjs/operators";
import { firstValueFrom } from "rxjs";
import {
BaseEvent,
EventType,
MessagesSnapshotEvent,
RunAgentInput,
Message,
TextMessageContentEvent,
} from "@ag-ui/core";
import { defaultApplyEvents } from "../default";
import { AbstractAgent } from "@/agent";

const FAKE_AGENT = null as unknown as AbstractAgent;

describe("defaultApplyEvents attachments", () => {
it("retains attachments from message snapshots", async () => {
const attachments = [
{
url: "data:image/png;base64,somepngbytes",
},
];

const snapshotMessages: Message[] = [
{
id: "msg-1",
role: "user",
attachments,
},
];

const events$ = new Subject<BaseEvent>();
const initialInput: RunAgentInput = {
threadId: "thread",
runId: "run",
messages: [],
state: {},
tools: [],
context: [],
forwardedProps: {},
};

const result$ = defaultApplyEvents(initialInput, events$, FAKE_AGENT, []);
const stateUpdatesPromise = firstValueFrom(result$.pipe(toArray()));

events$.next({
type: EventType.MESSAGES_SNAPSHOT,
messages: snapshotMessages,
} as MessagesSnapshotEvent);

events$.complete();

const stateUpdates = await stateUpdatesPromise;

expect(stateUpdates.length).toBe(1);
const message = stateUpdates[0].messages?.[0] as Message;
expect(message.attachments).toEqual(attachments);
});

it("keeps attachments when message content updates", async () => {
const attachments = [
{
url: "data:text/csv;base64,YSwxLDM=",
},
];

const events$ = new Subject<BaseEvent>();
const initialInput: RunAgentInput = {
threadId: "thread",
runId: "run",
messages: [],
state: {},
tools: [],
context: [],
forwardedProps: {},
};

const result$ = defaultApplyEvents(initialInput, events$, FAKE_AGENT, []);
const stateUpdatesPromise = firstValueFrom(result$.pipe(toArray()));

events$.next({
type: EventType.MESSAGES_SNAPSHOT,
messages: [
{
id: "msg-attachment",
role: "user",
content: "Initial",
attachments,
},
],
} as MessagesSnapshotEvent);

events$.next({
type: EventType.TEXT_MESSAGE_CONTENT,
messageId: "msg-attachment",
delta: " update",
} as TextMessageContentEvent);

events$.complete();

const stateUpdates = await stateUpdatesPromise;
const finalMessage = stateUpdates[stateUpdates.length - 1].messages?.[0] as Message;
expect(finalMessage.attachments).toEqual(attachments);
expect(finalMessage.content).toBe("Initial update");
});
});
2 changes: 1 addition & 1 deletion sdks/typescript/packages/client/src/legacy/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ export type LegacyRunError = z.infer<typeof LegacyRunError>;
export const LegacyTextMessageSchema = z.object({
id: z.string(),
role: z.string(),
content: z.string(),
content: z.string().optional(),
parentMessageId: z.string().optional(),
});

Expand Down
Loading