diff --git a/docs/introduction.mdx b/docs/introduction.mdx index 161dfe0a4..b123561d7 100644 --- a/docs/introduction.mdx +++ b/docs/introduction.mdx @@ -23,6 +23,8 @@ AG-UI provides: communication - **Best practices** for chat, streaming state updates, human-in-the-loop and shared state +- **Thinking transparency** with dedicated thinking message events that expose + agent reasoning processes to users ## Existing Integrations diff --git a/docs/quickstart/introduction.mdx b/docs/quickstart/introduction.mdx index 573eb3b32..9b27b0438 100644 --- a/docs/quickstart/introduction.mdx +++ b/docs/quickstart/introduction.mdx @@ -19,6 +19,7 @@ Think of it like adding a universal translator to your agent. Instead of buildin Agents integrating with AG-UI can: - **Stream responses** - Real-time text that appears as it's generated +- **Show thinking** - Expose internal reasoning through thinking message events - **Call client-side tools** - Your agent can use functions and services defined by clients - **Share state** - Your agent's state is bidirectional shared state - **Execute universally** - Integrate with any AG-UI compatible client application diff --git a/docs/sdk/python/core/events.mdx b/docs/sdk/python/core/events.mdx index 6d5cdc934..2f0e8aca6 100644 --- a/docs/sdk/python/core/events.mdx +++ b/docs/sdk/python/core/events.mdx @@ -22,6 +22,9 @@ class EventType(str, Enum): TEXT_MESSAGE_START = "TEXT_MESSAGE_START" TEXT_MESSAGE_CONTENT = "TEXT_MESSAGE_CONTENT" TEXT_MESSAGE_END = "TEXT_MESSAGE_END" + THINKING_TEXT_MESSAGE_START = "THINKING_TEXT_MESSAGE_START" + THINKING_TEXT_MESSAGE_CONTENT = "THINKING_TEXT_MESSAGE_CONTENT" + THINKING_TEXT_MESSAGE_END = "THINKING_TEXT_MESSAGE_END" TOOL_CALL_START = "TOOL_CALL_START" TOOL_CALL_ARGS = "TOOL_CALL_ARGS" TOOL_CALL_END = "TOOL_CALL_END" @@ -210,6 +213,64 @@ class TextMessageEndEvent(BaseEvent): | ------------ | ----- | ----------------------------------------- | | `message_id` | `str` | Matches the ID from TextMessageStartEvent | +## Thinking Message Events + +These events represent the lifecycle of thinking messages that show an agent's internal reasoning process. + +### ThinkingTextMessageStartEvent + +`from ag_ui.core import ThinkingTextMessageStartEvent` + +Signals the start of a thinking message. + +```python +class ThinkingTextMessageStartEvent(BaseEvent): + type: Literal[EventType.THINKING_TEXT_MESSAGE_START] + thinking_id: str +``` + +| Property | Type | Description | +| ------------- | ----- | ------------------------------------- | +| `thinking_id` | `str` | Unique identifier for the thinking sequence | + +### ThinkingTextMessageContentEvent + +`from ag_ui.core import ThinkingTextMessageContentEvent` + +Represents a chunk of content in a streaming thinking message. + +```python +class ThinkingTextMessageContentEvent(BaseEvent): + type: Literal[EventType.THINKING_TEXT_MESSAGE_CONTENT] + thinking_id: str + delta: str # Non-empty string + + def model_post_init(self, __context): + if len(self.delta) == 0: + raise ValueError("Delta must not be an empty string") +``` + +| Property | Type | Description | +| ------------- | ----- | -------------------------------------------------- | +| `thinking_id` | `str` | Matches the ID from ThinkingTextMessageStartEvent | +| `delta` | `str` | Thinking content chunk (non-empty) | + +### ThinkingTextMessageEndEvent + +`from ag_ui.core import ThinkingTextMessageEndEvent` + +Signals the end of a thinking message. + +```python +class ThinkingTextMessageEndEvent(BaseEvent): + type: Literal[EventType.THINKING_TEXT_MESSAGE_END] + thinking_id: str +``` + +| Property | Type | Description | +| ------------- | ----- | -------------------------------------------------- | +| `thinking_id` | `str` | Matches the ID from ThinkingTextMessageStartEvent | + ## Tool Call Events These events represent the lifecycle of tool calls made by agents. @@ -392,6 +453,9 @@ Event = Annotated[ TextMessageStartEvent, TextMessageContentEvent, TextMessageEndEvent, + ThinkingTextMessageStartEvent, + ThinkingTextMessageContentEvent, + ThinkingTextMessageEndEvent, ToolCallStartEvent, ToolCallArgsEvent, ToolCallEndEvent, diff --git a/docs/sdk/python/core/types.mdx b/docs/sdk/python/core/types.mdx index 4f9180455..82215454f 100644 --- a/docs/sdk/python/core/types.mdx +++ b/docs/sdk/python/core/types.mdx @@ -51,7 +51,7 @@ messages in the system. Represents the possible roles a message sender can have. ```python -Role = Literal["developer", "system", "assistant", "user", "tool"] +Role = Literal["developer", "system", "assistant", "user", "tool", "thinking"] ``` ### DeveloperMessage @@ -155,6 +155,26 @@ class ToolMessage(ConfiguredBaseModel): | `tool_call_id` | `str` | ID of the tool call this message responds to | | `error` | `Optional[str]` | Error message if the tool call failed | +### ThinkingMessage + +`from ag_ui.core import ThinkingMessage` + +Represents a thinking message that shows an agent's internal reasoning process. + +```python +class ThinkingMessage(BaseMessage): + role: Literal["thinking"] + content: str +``` + +| Property | Type | Description | +| ----------- | --------------------- | ----------------------------------------------- | +| `id` | `str` | Unique identifier for the message | +| `role` | `Literal["thinking"]` | Role of the message sender, fixed as "thinking" | +| `content` | `str` | Text content of the thinking (required) | +| `name` | `Optional[str]` | Optional name of the sender | +| `timestamp` | `Optional[int]` | Optional timestamp for temporal tracking | + ### Message `from ag_ui.core import Message` @@ -163,7 +183,7 @@ A union type representing any type of message in the system. ```python Message = Annotated[ - Union[DeveloperMessage, SystemMessage, AssistantMessage, UserMessage, ToolMessage], + Union[DeveloperMessage, SystemMessage, AssistantMessage, UserMessage, ToolMessage, ThinkingMessage], Field(discriminator="role") ] ``` diff --git a/python-sdk/ag_ui/core/events.py b/python-sdk/ag_ui/core/events.py index 2a54a9c8e..9f245429c 100644 --- a/python-sdk/ag_ui/core/events.py +++ b/python-sdk/ag_ui/core/events.py @@ -7,7 +7,7 @@ from pydantic import Field -from .types import ConfiguredBaseModel, Message, State, Role +from .types import ConfiguredBaseModel, Message, State # Text messages can have any role except "tool" TextMessageRole = Literal["developer", "system", "assistant", "user"] @@ -91,6 +91,7 @@ class ThinkingTextMessageStartEvent(BaseEvent): Event indicating the start of a thinking text message. """ type: Literal[EventType.THINKING_TEXT_MESSAGE_START] = EventType.THINKING_TEXT_MESSAGE_START # pyright: ignore[reportIncompatibleVariableOverride] + thinking_id: str class ThinkingTextMessageContentEvent(BaseEvent): """ @@ -98,12 +99,14 @@ class ThinkingTextMessageContentEvent(BaseEvent): """ type: Literal[EventType.THINKING_TEXT_MESSAGE_CONTENT] = EventType.THINKING_TEXT_MESSAGE_CONTENT # pyright: ignore[reportIncompatibleVariableOverride] delta: str = Field(min_length=1) + thinking_id: str class ThinkingTextMessageEndEvent(BaseEvent): """ Event indicating the end of a thinking text message. """ type: Literal[EventType.THINKING_TEXT_MESSAGE_END] = EventType.THINKING_TEXT_MESSAGE_END # pyright: ignore[reportIncompatibleVariableOverride] + thinking_id: str class ToolCallStartEvent(BaseEvent): """ @@ -256,6 +259,9 @@ class StepFinishedEvent(BaseEvent): TextMessageContentEvent, TextMessageEndEvent, TextMessageChunkEvent, + ThinkingTextMessageStartEvent, + ThinkingTextMessageContentEvent, + ThinkingTextMessageEndEvent, ToolCallStartEvent, ToolCallArgsEvent, ToolCallEndEvent, diff --git a/python-sdk/ag_ui/core/types.py b/python-sdk/ag_ui/core/types.py index 47b7ae182..d07ecd64f 100644 --- a/python-sdk/ag_ui/core/types.py +++ b/python-sdk/ag_ui/core/types.py @@ -44,6 +44,7 @@ class BaseMessage(ConfiguredBaseModel): role: str content: Optional[str] = None name: Optional[str] = None + timestamp: Optional[int] = None class DeveloperMessage(BaseMessage): @@ -88,13 +89,19 @@ class ToolMessage(ConfiguredBaseModel): tool_call_id: str error: Optional[str] = None +class ThinkingMessage(BaseMessage): + """ + A thinking message. + """ + role: Literal["thinking"] = "thinking" + content: str Message = Annotated[ - Union[DeveloperMessage, SystemMessage, AssistantMessage, UserMessage, ToolMessage], + Union[DeveloperMessage, SystemMessage, AssistantMessage, UserMessage, ToolMessage, ThinkingMessage], Field(discriminator="role") ] -Role = Literal["developer", "system", "assistant", "user", "tool"] +Role = Literal["developer", "system", "assistant", "user", "tool", "thinking"] class Context(ConfiguredBaseModel): diff --git a/python-sdk/tests/test_events.py b/python-sdk/tests/test_events.py index c73a2537c..6a97f5ba8 100644 --- a/python-sdk/tests/test_events.py +++ b/python-sdk/tests/test_events.py @@ -10,6 +10,9 @@ TextMessageStartEvent, TextMessageContentEvent, TextMessageEndEvent, + ThinkingTextMessageStartEvent, + ThinkingTextMessageContentEvent, + ThinkingTextMessageEndEvent, ToolCallStartEvent, ToolCallArgsEvent, ToolCallEndEvent, @@ -90,6 +93,105 @@ def test_text_message_end(self): self.assertEqual(serialized["type"], "TEXT_MESSAGE_END") self.assertEqual(serialized["messageId"], "msg_123") + def test_thinking_text_message_start(self): + """Test creating and serializing a ThinkingTextMessageStartEvent event""" + event = ThinkingTextMessageStartEvent( + thinking_id="think_123", + timestamp=1648214400000 + ) + self.assertEqual(event.thinking_id, "think_123") + + # Test serialization + serialized = event.model_dump(by_alias=True) + self.assertEqual(serialized["type"], "THINKING_TEXT_MESSAGE_START") + self.assertEqual(serialized["thinkingId"], "think_123") + self.assertEqual(serialized["timestamp"], 1648214400000) + + def test_thinking_text_message_content(self): + """Test creating and serializing a ThinkingTextMessageContentEvent event""" + event = ThinkingTextMessageContentEvent( + thinking_id="think_123", + delta="Let me think about this...", + timestamp=1648214400000 + ) + self.assertEqual(event.thinking_id, "think_123") + self.assertEqual(event.delta, "Let me think about this...") + + # Test serialization + serialized = event.model_dump(by_alias=True) + self.assertEqual(serialized["type"], "THINKING_TEXT_MESSAGE_CONTENT") + self.assertEqual(serialized["thinkingId"], "think_123") + self.assertEqual(serialized["delta"], "Let me think about this...") + + def test_thinking_text_message_end(self): + """Test creating and serializing a ThinkingTextMessageEndEvent event""" + event = ThinkingTextMessageEndEvent( + thinking_id="think_123", + timestamp=1648214400000 + ) + self.assertEqual(event.thinking_id, "think_123") + + # Test serialization + serialized = event.model_dump(by_alias=True) + self.assertEqual(serialized["type"], "THINKING_TEXT_MESSAGE_END") + self.assertEqual(serialized["thinkingId"], "think_123") + + def test_thinking_message_event_lifecycle(self): + """Test a complete thinking message event lifecycle""" + thinking_id = "think_lifecycle_456" + timestamp_base = 1648214400000 + + # Start event + start_event = ThinkingTextMessageStartEvent( + thinking_id=thinking_id, + timestamp=timestamp_base + ) + + # Content events + content_events = [ + ThinkingTextMessageContentEvent( + thinking_id=thinking_id, + delta="First, I need to understand the problem...", + timestamp=timestamp_base + 100 + ), + ThinkingTextMessageContentEvent( + thinking_id=thinking_id, + delta=" Then I should consider the constraints...", + timestamp=timestamp_base + 200 + ), + ThinkingTextMessageContentEvent( + thinking_id=thinking_id, + delta=" Finally, I can formulate a solution.", + timestamp=timestamp_base + 300 + ) + ] + + # End event + end_event = ThinkingTextMessageEndEvent( + thinking_id=thinking_id, + timestamp=timestamp_base + 400 + ) + + # Verify all events have the same thinking_id + all_events = [start_event] + content_events + [end_event] + for event in all_events: + self.assertEqual(event.thinking_id, thinking_id) + + # Verify event types + self.assertEqual(start_event.type, EventType.THINKING_TEXT_MESSAGE_START) + for content_event in content_events: + self.assertEqual(content_event.type, EventType.THINKING_TEXT_MESSAGE_CONTENT) + self.assertEqual(end_event.type, EventType.THINKING_TEXT_MESSAGE_END) + + def test_thinking_content_validation(self): + """Test validation constraints for thinking content events""" + # Content delta cannot be empty + with self.assertRaises(ValueError): + ThinkingTextMessageContentEvent( + thinking_id="think_invalid", + delta="" # Empty delta, should fail + ) + def test_tool_call_start(self): """Test creating and serializing a ToolCallStartEvent event""" event = ToolCallStartEvent( @@ -327,6 +429,22 @@ def test_event_union_deserialization(self): "delta": "Hello!", "timestamp": 1648214400000 }, + { + "type": "THINKING_TEXT_MESSAGE_START", + "thinkingId": "think_start", + "timestamp": 1648214400000 + }, + { + "type": "THINKING_TEXT_MESSAGE_CONTENT", + "thinkingId": "think_content", + "delta": "Let me think...", + "timestamp": 1648214400000 + }, + { + "type": "THINKING_TEXT_MESSAGE_END", + "thinkingId": "think_end", + "timestamp": 1648214400000 + }, { "type": "TOOL_CALL_START", "toolCallId": "call_start", @@ -349,6 +467,9 @@ def test_event_union_deserialization(self): expected_types = [ TextMessageStartEvent, TextMessageContentEvent, + ThinkingTextMessageStartEvent, + ThinkingTextMessageContentEvent, + ThinkingTextMessageEndEvent, ToolCallStartEvent, StateSnapshotEvent, RunErrorEvent @@ -380,6 +501,16 @@ def test_serialization_round_trip(self): message_id="msg_123", delta="Hello, world!" ), + ThinkingTextMessageStartEvent( + thinking_id="think_123" + ), + ThinkingTextMessageContentEvent( + thinking_id="think_123", + delta="Let me consider this..." + ), + ThinkingTextMessageEndEvent( + thinking_id="think_123" + ), ToolCallStartEvent( tool_call_id="call_123", tool_call_name="get_weather" @@ -419,6 +550,13 @@ def test_serialization_round_trip(self): elif isinstance(original_event, TextMessageContentEvent): self.assertEqual(deserialized_event.message_id, original_event.message_id) self.assertEqual(deserialized_event.delta, original_event.delta) + elif isinstance(original_event, ThinkingTextMessageStartEvent): + self.assertEqual(deserialized_event.thinking_id, original_event.thinking_id) + elif isinstance(original_event, ThinkingTextMessageContentEvent): + self.assertEqual(deserialized_event.thinking_id, original_event.thinking_id) + self.assertEqual(deserialized_event.delta, original_event.delta) + elif isinstance(original_event, ThinkingTextMessageEndEvent): + self.assertEqual(deserialized_event.thinking_id, original_event.thinking_id) elif isinstance(original_event, ToolCallStartEvent): self.assertEqual(deserialized_event.tool_call_id, original_event.tool_call_id) self.assertEqual(deserialized_event.tool_call_name, original_event.tool_call_name) diff --git a/python-sdk/tests/test_types.py b/python-sdk/tests/test_types.py index e534aa5ab..207a47f28 100644 --- a/python-sdk/tests/test_types.py +++ b/python-sdk/tests/test_types.py @@ -10,6 +10,7 @@ AssistantMessage, UserMessage, ToolMessage, + ThinkingMessage, Message, RunAgentInput ) @@ -143,6 +144,63 @@ def test_user_message(self): self.assertEqual(serialized["role"], "user") self.assertEqual(serialized["content"], "User query") + def test_thinking_message(self): + """Test creating and serializing a thinking message""" + msg = ThinkingMessage( + id="thinking_123", + content="Let me think about this step by step..." + ) + serialized = msg.model_dump(by_alias=True) + self.assertEqual(serialized["role"], "thinking") + self.assertEqual(serialized["content"], "Let me think about this step by step...") + + def test_thinking_message_with_timestamp(self): + """Test creating a thinking message with timestamp""" + timestamp = 1648214400000 + msg = ThinkingMessage( + id="thinking_456", + content="Analyzing the problem...", + timestamp=timestamp + ) + self.assertEqual(msg.timestamp, timestamp) + + # Test serialization + serialized = msg.model_dump(by_alias=True) + self.assertEqual(serialized["timestamp"], timestamp) + + def test_thinking_message_validation(self): + """Test validation requirements for thinking message""" + # Content is required for thinking messages + with self.assertRaises(ValidationError): + ThinkingMessage( + id="thinking_invalid" + # Missing content field should fail + ) + + def test_base_message_timestamp_field(self): + """Test the optional timestamp field in BaseMessage""" + timestamp = 1648214400000 + + # Test with user message + user_msg = UserMessage( + id="user_timestamp", + content="Hello", + timestamp=timestamp + ) + self.assertEqual(user_msg.timestamp, timestamp) + + # Test with assistant message + assistant_msg = AssistantMessage( + id="asst_timestamp", + content="Hi there", + timestamp=timestamp + ) + self.assertEqual(assistant_msg.timestamp, timestamp) + + # Test serialization preserves timestamp + serialized = user_msg.model_dump(by_alias=True) + self.assertEqual(serialized["timestamp"], timestamp) + def test_message_union_deserialization(self): """Test that the Message union correctly deserializes to the appropriate type""" # Create type adapter for the union @@ -159,7 +217,8 @@ def test_message_union_deserialization(self): "role": "tool", "content": "Tool result", "toolCallId": "call_303" - } + }, + {"id": "thinking_404", "role": "thinking", "content": "Let me analyze this..."} ] expected_types = [ @@ -167,7 +226,8 @@ def test_message_union_deserialization(self): SystemMessage, AssistantMessage, UserMessage, - ToolMessage + ToolMessage, + ThinkingMessage ] for data, expected_type in zip(message_data, expected_types):