66
77import json
88import logging
9+ from collections .abc import AsyncIterable , AsyncIterator
910from typing import Any , Dict , List , Optional , Tuple
10- from ag_ui .core import RunAgentInput , AssistantMessage , ToolCall , FunctionCall , ToolMessage
11+ from ag_ui .core import (
12+ RunAgentInput ,
13+ AssistantMessage ,
14+ ToolCall ,
15+ FunctionCall ,
16+ ToolMessage ,
17+ TextInputContent ,
18+ ImageInputContent ,
19+ AudioInputContent ,
20+ VideoInputContent ,
21+ DocumentInputContent ,
22+ BinaryInputContent ,
23+ InputContentDataSource ,
24+ InputContentUrlSource ,
25+ )
1126
1227from .config import STATE_MANAGEMENT_TOOL_NAME , STATE_MANAGEMENT_TOOL_FULL_NAME
1328
@@ -89,7 +104,150 @@ def strip_mcp_prefix(tool_name: str) -> str:
89104 return tool_name
90105
91106
92- def process_messages (input_data : RunAgentInput ) -> Tuple [str , bool ]:
107+ SUPPORTED_IMAGE_MEDIA_TYPES = {
108+ "image/jpeg" ,
109+ "image/png" ,
110+ "image/gif" ,
111+ "image/webp" ,
112+ }
113+
114+
115+ def _normalized_media_type (value : Optional [str ]) -> Optional [str ]:
116+ if not isinstance (value , str ):
117+ return None
118+ media_type = value .split (";" , 1 )[0 ].strip ().lower ()
119+ return media_type or None
120+
121+
122+ def _require_non_empty_string (value : Any , field : str ) -> str :
123+ if not isinstance (value , str ) or not value :
124+ raise ValueError (f"{ field } must be a non-empty string" )
125+ return value
126+
127+
128+ def _require_remote_url (value : Any , field : str ) -> str :
129+ from urllib .parse import urlsplit
130+
131+ url = _require_non_empty_string (value , field )
132+ parsed = urlsplit (url )
133+ if parsed .scheme not in {"http" , "https" } or not parsed .netloc :
134+ raise ValueError (f"{ field } must be a valid http or https URL" )
135+ return url
136+
137+
138+ def _image_block (source : Any , field : str ) -> Dict [str , Any ]:
139+ media_type = _normalized_media_type (getattr (source , "mime_type" , None ))
140+ if isinstance (source , InputContentDataSource ):
141+ if media_type not in SUPPORTED_IMAGE_MEDIA_TYPES :
142+ raise ValueError (
143+ f"{ field } .mime_type must be image/jpeg, image/png, image/gif, or image/webp"
144+ )
145+ return {
146+ "type" : "image" ,
147+ "source" : {
148+ "type" : "base64" ,
149+ "media_type" : media_type ,
150+ "data" : _require_non_empty_string (source .value , f"{ field } .value" ),
151+ },
152+ }
153+ if isinstance (source , InputContentUrlSource ):
154+ if media_type is not None and media_type not in SUPPORTED_IMAGE_MEDIA_TYPES :
155+ raise ValueError (f"{ field } .mime_type is not a supported image type" )
156+ return {
157+ "type" : "image" ,
158+ "source" : {
159+ "type" : "url" ,
160+ "url" : _require_remote_url (source .value , f"{ field } .value" ),
161+ },
162+ }
163+ raise ValueError (f"{ field } must be a data or URL source" )
164+
165+
166+ def _document_block (source : Any , field : str ) -> Dict [str , Any ]:
167+ media_type = _normalized_media_type (getattr (source , "mime_type" , None ))
168+ if isinstance (source , InputContentDataSource ):
169+ if media_type != "application/pdf" :
170+ raise ValueError (f"{ field } .mime_type must be application/pdf" )
171+ return {
172+ "type" : "document" ,
173+ "source" : {
174+ "type" : "base64" ,
175+ "media_type" : "application/pdf" ,
176+ "data" : _require_non_empty_string (source .value , f"{ field } .value" ),
177+ },
178+ }
179+ if isinstance (source , InputContentUrlSource ):
180+ if media_type is not None and media_type != "application/pdf" :
181+ raise ValueError (f"{ field } .mime_type must be application/pdf when provided" )
182+ return {
183+ "type" : "document" ,
184+ "source" : {
185+ "type" : "url" ,
186+ "url" : _require_remote_url (source .value , f"{ field } .value" ),
187+ },
188+ }
189+ raise ValueError (f"{ field } must be a data or URL source" )
190+
191+
192+ def _legacy_binary_block (block : BinaryInputContent , index : int ) -> Dict [str , Any ]:
193+ media_type = _normalized_media_type (block .mime_type )
194+ if block .data :
195+ source : Any = InputContentDataSource (
196+ value = block .data ,
197+ mime_type = media_type or "" ,
198+ )
199+ elif block .url :
200+ source = InputContentUrlSource (
201+ value = block .url ,
202+ mime_type = media_type ,
203+ )
204+ else :
205+ raise ValueError (
206+ f"content[{ index } ] uses an opaque file id, which the Claude Agent SDK adapter cannot resolve"
207+ )
208+
209+ if media_type in SUPPORTED_IMAGE_MEDIA_TYPES :
210+ return _image_block (source , f"content[{ index } ]" )
211+ if media_type == "application/pdf" :
212+ return _document_block (source , f"content[{ index } ]" )
213+ raise ValueError (f"content[{ index } ].mime_type is not supported" )
214+
215+
216+ def _convert_content_block (block : Any , index : int ) -> Optional [Dict [str , Any ]]:
217+ if isinstance (block , TextInputContent ):
218+ if not block .text .strip ():
219+ return None
220+ return {
221+ "type" : "text" ,
222+ "text" : block .text ,
223+ }
224+ if isinstance (block , ImageInputContent ):
225+ return _image_block (block .source , f"content[{ index } ].source" )
226+ if isinstance (block , DocumentInputContent ):
227+ return _document_block (block .source , f"content[{ index } ].source" )
228+ if isinstance (block , BinaryInputContent ):
229+ return _legacy_binary_block (block , index )
230+ if isinstance (block , (AudioInputContent , VideoInputContent )):
231+ raise ValueError (f"content[{ index } ] type { block .type } is not supported" )
232+ raise ValueError (f"content[{ index } ] has an unsupported type" )
233+
234+
235+ async def _structured_user_message (
236+ content : List [Dict [str , Any ]],
237+ session_id : str ,
238+ ) -> AsyncIterator [Dict [str , Any ]]:
239+ yield {
240+ "type" : "user" ,
241+ "message" : {"role" : "user" , "content" : content },
242+ "parent_tool_use_id" : None ,
243+ "session_id" : session_id ,
244+ }
245+
246+
247+ ClaudePrompt = str | AsyncIterable [Dict [str , Any ]]
248+
249+
250+ def process_messages (input_data : RunAgentInput ) -> Tuple [ClaudePrompt , bool ]:
93251 """
94252 Process and validate all messages from RunAgentInput.
95253
@@ -100,7 +258,9 @@ def process_messages(input_data: RunAgentInput) -> Tuple[str, bool]:
100258 input_data: RunAgentInput with messages array
101259
102260 Returns:
103- Tuple of (user_message: str, has_pending_tool_result: bool)
261+ Tuple of (user_message, has_pending_tool_result). ``user_message`` is
262+ a string for plain text or a one-message async iterable for structured
263+ content.
104264 """
105265 messages = input_data .messages or []
106266
@@ -133,7 +293,8 @@ def process_messages(input_data: RunAgentInput) -> Tuple[str, bool]:
133293
134294 # Extract content from the LAST message (any role - user, tool, or assistant)
135295 # Claude SDK manages conversation history via session_id, we just need the latest input
136- user_message = ""
296+ user_message : ClaudePrompt = ""
297+ has_user_content = False
137298 if messages :
138299 last_msg = messages [- 1 ]
139300
@@ -148,17 +309,21 @@ def process_messages(input_data: RunAgentInput) -> Tuple[str, bool]:
148309 # Handle different content formats
149310 if isinstance (content , str ):
150311 user_message = content
312+ has_user_content = bool (content )
151313 elif isinstance (content , list ):
152- # Content blocks format - extract text from first text block
153- for block in content :
154- if hasattr (block , 'text' ):
155- user_message = block .text
156- break
157- elif isinstance (block , dict ) and 'text' in block :
158- user_message = block ['text' ]
159- break
160-
161- if not user_message :
314+ blocks = []
315+ for index , block in enumerate (content ):
316+ converted = _convert_content_block (block , index )
317+ if converted is not None :
318+ blocks .append (converted )
319+ if blocks :
320+ user_message = _structured_user_message (
321+ blocks ,
322+ input_data .thread_id or "default" ,
323+ )
324+ has_user_content = True
325+
326+ if not has_user_content :
162327 logger .warning (f"No user message found in { len (messages )} messages" )
163328
164329 return user_message , has_pending_tool_result
0 commit comments