Python: [Breaking] Simplified Content types to a single class with classmethod constructors. (#3252)

* ported Content to a new model

* fixed linting

* fixes

* fixed data format handling

* fix for 3.10 mypy

* fix

* fix int test
This commit is contained in:
Eduard van Valkenburg
2026-01-20 23:09:39 +01:00
committed by GitHub
Unverified
parent 73761aa4a3
commit 83e6229c11
132 changed files with 3949 additions and 4741 deletions
@@ -8,12 +8,8 @@ from collections.abc import Awaitable, Callable, Sequence
from agent_framework import (
ChatMessage,
DataContent,
FunctionCallContent,
FunctionResultContent,
Content,
Role,
TextContent,
UriContent,
)
from chatkit.types import (
AssistantMessageItem,
@@ -91,8 +87,8 @@ class ThreadItemConverter:
if isinstance(content_part, UserMessageTextContent):
text_content += content_part.text
# Convert attachments to DataContent or UriContent
data_contents: list[DataContent | UriContent] = []
# Convert attachments to Content
data_contents: list[Content] = []
if item.attachments:
for attachment in item.attachments:
content = await self.attachment_to_message_content(attachment)
@@ -108,9 +104,9 @@ class ThreadItemConverter:
user_message = ChatMessage(role=Role.USER, text=text_content.strip())
else:
# Build contents list with both text and attachments
contents: list[TextContent | DataContent | UriContent] = []
contents: list[Content] = []
if text_content.strip():
contents.append(TextContent(text=text_content.strip()))
contents.append(Content.from_text(text=text_content.strip()))
contents.extend(data_contents)
user_message = ChatMessage(role=Role.USER, contents=contents)
@@ -126,7 +122,7 @@ class ThreadItemConverter:
return messages
async def attachment_to_message_content(self, attachment: Attachment) -> DataContent | UriContent | None:
async def attachment_to_message_content(self, attachment: Attachment) -> Content | None:
"""Convert a ChatKit attachment to Agent Framework content.
This method is called internally by `user_message_to_input()` to handle attachments.
@@ -169,14 +165,14 @@ class ThreadItemConverter:
if self.attachment_data_fetcher is not None:
try:
data = await self.attachment_data_fetcher(attachment.id)
return DataContent(data=data, media_type=attachment.mime_type)
return Content.from_data(data=data, media_type=attachment.mime_type)
except Exception as e:
# If fetch fails, fall through to URL-based approach
logger.debug(f"Failed to fetch attachment data for {attachment.id}: {e}")
# For ImageAttachment, try to use preview_url
if isinstance(attachment, ImageAttachment) and attachment.preview_url:
return UriContent(uri=str(attachment.preview_url), media_type=attachment.mime_type)
return Content.from_uri(uri=str(attachment.preview_url), media_type=attachment.mime_type)
# For FileAttachment without data fetcher, skip the attachment
# Subclasses can override this method to provide custom handling
@@ -220,7 +216,7 @@ class ThreadItemConverter:
"""
return ChatMessage(role=Role.SYSTEM, text=f"<HIDDEN_CONTEXT>{item.content}</HIDDEN_CONTEXT>")
def tag_to_message_content(self, tag: UserMessageTagContent) -> TextContent:
def tag_to_message_content(self, tag: UserMessageTagContent) -> Content:
"""Convert a ChatKit tag (@-mention) to Agent Framework content.
This method is called internally by `user_message_to_input()` to handle tags.
@@ -248,10 +244,10 @@ class ThreadItemConverter:
type="input_tag", id="tag_1", text="john", data={"name": "John Doe"}, interactive=False
)
content = converter.tag_to_message_content(tag)
# Returns: TextContent(text="<TAG>Name:John Doe</TAG>")
# Returns: Content.from_text(text="<TAG>Name:John Doe</TAG>")
"""
name = getattr(tag.data, "name", tag.text if hasattr(tag, "text") else "unknown")
return TextContent(text=f"<TAG>Name:{name}</TAG>")
return Content.from_text(text=f"<TAG>Name:{name}</TAG>")
def task_to_input(self, item: TaskItem) -> ChatMessage | list[ChatMessage] | None:
"""Convert a ChatKit TaskItem to Agent Framework ChatMessage(s).
@@ -448,7 +444,7 @@ class ThreadItemConverter:
function_call_msg = ChatMessage(
role=Role.ASSISTANT,
contents=[
FunctionCallContent(
Content.from_function_call(
call_id=item.call_id,
name=item.name,
arguments=json.dumps(item.arguments),
@@ -460,7 +456,7 @@ class ThreadItemConverter:
function_result_msg = ChatMessage(
role=Role.TOOL,
contents=[
FunctionResultContent(
Content.from_function_result(
call_id=item.call_id,
result=json.dumps(item.output) if item.output is not None else "",
)
@@ -6,7 +6,7 @@ import uuid
from collections.abc import AsyncIterable, AsyncIterator, Callable
from datetime import datetime
from agent_framework import AgentResponseUpdate, TextContent
from agent_framework import AgentResponseUpdate
from chatkit.types import (
AssistantMessageContent,
AssistantMessageContentPartTextDelta,
@@ -77,7 +77,7 @@ async def stream_agent_response(
if update.contents:
for content in update.contents:
# Handle text content - only TextContent has a text attribute
if isinstance(content, TextContent) and content.text is not None:
if content.type == "text" and content.text is not None:
# Yield incremental text delta for streaming display
yield ThreadItemUpdated(
type="thread.item.updated",
@@ -5,7 +5,7 @@
from unittest.mock import Mock
import pytest
from agent_framework import ChatMessage, Role, TextContent
from agent_framework import ChatMessage, Role
from chatkit.types import UserMessageTextContent
from agent_framework_chatkit import ThreadItemConverter, simple_to_agent_input
@@ -133,7 +133,7 @@ class TestThreadItemConverter:
)
result = converter.tag_to_message_content(tag)
assert isinstance(result, TextContent)
assert result.type == "text"
# Since data is a dict, getattr won't work, so it will fall back to text
assert result.text == "<TAG>Name:john</TAG>"
@@ -150,7 +150,7 @@ class TestThreadItemConverter:
)
result = converter.tag_to_message_content(tag)
assert isinstance(result, TextContent)
assert result.type == "text"
assert result.text == "<TAG>Name:jane</TAG>"
async def test_attachment_to_message_content_file_without_fetcher(self, converter):
@@ -169,7 +169,6 @@ class TestThreadItemConverter:
async def test_attachment_to_message_content_image_with_preview_url(self, converter):
"""Test that ImageAttachment with preview_url creates UriContent."""
from agent_framework import UriContent
from chatkit.types import ImageAttachment
attachment = ImageAttachment(
@@ -181,13 +180,12 @@ class TestThreadItemConverter:
)
result = await converter.attachment_to_message_content(attachment)
assert isinstance(result, UriContent)
assert result.type == "uri"
assert result.uri == "https://example.com/photo.jpg"
assert result.media_type == "image/jpeg"
async def test_attachment_to_message_content_with_data_fetcher(self):
"""Test attachment conversion with data fetcher."""
from agent_framework import DataContent
from chatkit.types import FileAttachment
# Mock data fetcher
@@ -204,14 +202,13 @@ class TestThreadItemConverter:
)
result = await converter.attachment_to_message_content(attachment)
assert isinstance(result, DataContent)
assert result.type == "data"
assert result.media_type == "application/pdf"
async def test_to_agent_input_with_image_attachment(self):
"""Test converting user message with text and image attachment."""
from datetime import datetime
from agent_framework import UriContent
from chatkit.types import ImageAttachment, UserMessageItem
attachment = ImageAttachment(
@@ -241,11 +238,11 @@ class TestThreadItemConverter:
assert len(message.contents) == 2
# First content should be text
assert isinstance(message.contents[0], TextContent)
assert message.contents[0].type == "text"
assert message.contents[0].text == "Check out this photo!"
# Second content should be UriContent for the image
assert isinstance(message.contents[1], UriContent)
assert message.contents[1].type == "uri"
assert message.contents[1].uri == "https://example.com/photo.jpg"
assert message.contents[1].media_type == "image/jpeg"
@@ -253,7 +250,6 @@ class TestThreadItemConverter:
"""Test converting user message with file attachment using data fetcher."""
from datetime import datetime
from agent_framework import DataContent
from chatkit.types import FileAttachment, UserMessageItem
attachment = FileAttachment(
@@ -285,10 +281,10 @@ class TestThreadItemConverter:
assert len(message.contents) == 2
# First content should be text
assert isinstance(message.contents[0], TextContent)
assert message.contents[0].type == "text"
# Second content should be DataContent for the file
assert isinstance(message.contents[1], DataContent)
assert message.contents[1].type == "data"
assert message.contents[1].media_type == "application/pdf"
def test_task_to_input(self, converter):
@@ -4,7 +4,7 @@
from unittest.mock import Mock
from agent_framework import AgentResponseUpdate, Role, TextContent
from agent_framework import AgentResponseUpdate, Content, Role
from chatkit.types import (
ThreadItemAddedEvent,
ThreadItemDoneEvent,
@@ -34,7 +34,7 @@ class TestStreamAgentResponse:
"""Test streaming single text update."""
async def single_update_stream():
yield AgentResponseUpdate(role=Role.ASSISTANT, contents=[TextContent(text="Hello world")])
yield AgentResponseUpdate(role=Role.ASSISTANT, contents=[Content.from_text(text="Hello world")])
events = []
async for event in stream_agent_response(single_update_stream(), thread_id="test_thread"):
@@ -59,8 +59,8 @@ class TestStreamAgentResponse:
"""Test streaming multiple text updates."""
async def multiple_updates_stream():
yield AgentResponseUpdate(role=Role.ASSISTANT, contents=[TextContent(text="Hello ")])
yield AgentResponseUpdate(role=Role.ASSISTANT, contents=[TextContent(text="world!")])
yield AgentResponseUpdate(role=Role.ASSISTANT, contents=[Content.from_text(text="Hello ")])
yield AgentResponseUpdate(role=Role.ASSISTANT, contents=[Content.from_text(text="world!")])
events = []
async for event in stream_agent_response(multiple_updates_stream(), thread_id="test_thread"):
@@ -91,7 +91,7 @@ class TestStreamAgentResponse:
return f"custom_{item_type}_123"
async def single_update_stream():
yield AgentResponseUpdate(role=Role.ASSISTANT, contents=[TextContent(text="Test")])
yield AgentResponseUpdate(role=Role.ASSISTANT, contents=[Content.from_text(text="Test")])
events = []
async for event in stream_agent_response(
@@ -125,9 +125,10 @@ class TestStreamAgentResponse:
async def test_stream_non_text_content(self):
"""Test streaming updates with non-text content."""
# Mock a content object without text attribute
non_text_content = Mock()
non_text_content = Mock(spec=Content)
non_text_content.type = "image"
# Don't set text attribute
del non_text_content.text
non_text_content.text = None
async def non_text_stream():
yield AgentResponseUpdate(role=Role.ASSISTANT, contents=[non_text_content])