Skip to content

Commit 7627061

Browse files
authored
Merge pull request #2609 from ag-ui-protocol/codex/fac-144-171-claude-multimodal
fix(claude-agent-sdk): preserve multimodal input
2 parents 8e3e1ad + 88bf584 commit 7627061

9 files changed

Lines changed: 1259 additions & 83 deletions

File tree

integrations/claude-agent-sdk/python/ag_ui_claude_sdk/adapter.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,21 @@ async def run(self, input_data: RunAgentInput) -> AsyncIterator[BaseEvent]:
229229
run_id = input_data.run_id or str(uuid.uuid4())
230230
result_key = (thread_id, run_id)
231231

232+
# Validate and convert the prompt before touching the thread's live
233+
# SessionWorker. Unsupported content is an input error, not evidence
234+
# that the existing Claude CLI session is broken.
235+
try:
236+
prompt, _ = process_messages(input_data)
237+
except Exception as e:
238+
logger.error(f"Invalid input for thread={thread_id}: {e}")
239+
yield RunErrorEvent(
240+
type=EventType.RUN_ERROR,
241+
thread_id=thread_id,
242+
run_id=run_id,
243+
message=str(e),
244+
)
245+
return
246+
232247
# ── Run-admission serialization (Fix 1) ──
233248
# Acquire the per-thread RUN lock at admission — BEFORE worker.query() /
234249
# RUN_STARTED — and hold it across the WHOLE run, releasing in the
@@ -345,7 +360,6 @@ async def run(self, input_data: RunAgentInput) -> AsyncIterator[BaseEvent]:
345360
worker = entry["worker"]
346361
logger.debug(f"Reusing worker for thread={thread_id}")
347362

348-
prompt, _ = process_messages(input_data)
349363
message_stream = worker.query(prompt, session_id=thread_id)
350364

351365
# Log parent_run_id if provided (for branching/time travel tracking)
@@ -1158,4 +1172,3 @@ def flush_pending_msg():
11581172
type=EventType.MESSAGES_SNAPSHOT,
11591173
messages=all_messages,
11601174
)
1161-

integrations/claude-agent-sdk/python/ag_ui_claude_sdk/session.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import asyncio
99
import logging
1010
from contextlib import suppress
11+
from collections.abc import AsyncIterable
1112
from typing import Any, AsyncIterator, Optional
1213

1314
logger = logging.getLogger(__name__)
@@ -162,7 +163,11 @@ async def _graceful_disconnect(client: Any) -> None:
162163
except Exception as exc:
163164
logger.debug(f"[SessionWorker] Graceful disconnect error (ignored): {exc}")
164165

165-
async def query(self, prompt: str, session_id: str = "default") -> AsyncIterator[Any]:
166+
async def query(
167+
self,
168+
prompt: str | AsyncIterable[dict[str, Any]],
169+
session_id: str = "default",
170+
) -> AsyncIterator[Any]:
166171
"""Send prompt to the worker and yield SDK Message objects."""
167172
output_queue: asyncio.Queue = asyncio.Queue()
168173
# Register the output queue in the in-flight set BEFORE enqueuing the

integrations/claude-agent-sdk/python/ag_ui_claude_sdk/utils.py

Lines changed: 179 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,23 @@
66

77
import json
88
import logging
9+
from collections.abc import AsyncIterable, AsyncIterator
910
from 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

1227
from .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

Comments
 (0)