fast-agent-mcp 0.1.7__py3-none-any.whl → 0.1.9__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fast_agent_mcp-0.1.7.dist-info → fast_agent_mcp-0.1.9.dist-info}/METADATA +37 -9
- {fast_agent_mcp-0.1.7.dist-info → fast_agent_mcp-0.1.9.dist-info}/RECORD +53 -31
- {fast_agent_mcp-0.1.7.dist-info → fast_agent_mcp-0.1.9.dist-info}/entry_points.txt +1 -0
- mcp_agent/agents/agent.py +5 -11
- mcp_agent/core/agent_app.py +125 -44
- mcp_agent/core/decorators.py +3 -2
- mcp_agent/core/enhanced_prompt.py +106 -20
- mcp_agent/core/factory.py +28 -66
- mcp_agent/core/fastagent.py +13 -3
- mcp_agent/core/mcp_content.py +222 -0
- mcp_agent/core/prompt.py +132 -0
- mcp_agent/core/proxies.py +41 -36
- mcp_agent/human_input/handler.py +4 -1
- mcp_agent/logging/transport.py +30 -3
- mcp_agent/mcp/mcp_aggregator.py +27 -22
- mcp_agent/mcp/mime_utils.py +69 -0
- mcp_agent/mcp/prompt_message_multipart.py +64 -0
- mcp_agent/mcp/prompt_serialization.py +447 -0
- mcp_agent/mcp/prompts/__init__.py +0 -0
- mcp_agent/mcp/prompts/__main__.py +10 -0
- mcp_agent/mcp/prompts/prompt_server.py +508 -0
- mcp_agent/mcp/prompts/prompt_template.py +469 -0
- mcp_agent/mcp/resource_utils.py +203 -0
- mcp_agent/resources/examples/internal/agent.py +1 -1
- mcp_agent/resources/examples/internal/fastagent.config.yaml +2 -2
- mcp_agent/resources/examples/internal/sizer.py +0 -5
- mcp_agent/resources/examples/prompting/__init__.py +3 -0
- mcp_agent/resources/examples/prompting/agent.py +23 -0
- mcp_agent/resources/examples/prompting/fastagent.config.yaml +44 -0
- mcp_agent/resources/examples/prompting/image_server.py +56 -0
- mcp_agent/resources/examples/researcher/researcher-eval.py +1 -1
- mcp_agent/resources/examples/workflows/orchestrator.py +5 -4
- mcp_agent/resources/examples/workflows/router.py +0 -2
- mcp_agent/workflows/evaluator_optimizer/evaluator_optimizer.py +57 -87
- mcp_agent/workflows/llm/anthropic_utils.py +101 -0
- mcp_agent/workflows/llm/augmented_llm.py +155 -141
- mcp_agent/workflows/llm/augmented_llm_anthropic.py +135 -281
- mcp_agent/workflows/llm/augmented_llm_openai.py +175 -337
- mcp_agent/workflows/llm/augmented_llm_passthrough.py +104 -0
- mcp_agent/workflows/llm/augmented_llm_playback.py +109 -0
- mcp_agent/workflows/llm/model_factory.py +25 -6
- mcp_agent/workflows/llm/openai_utils.py +65 -0
- mcp_agent/workflows/llm/providers/__init__.py +8 -0
- mcp_agent/workflows/llm/providers/multipart_converter_anthropic.py +348 -0
- mcp_agent/workflows/llm/providers/multipart_converter_openai.py +426 -0
- mcp_agent/workflows/llm/providers/openai_multipart.py +197 -0
- mcp_agent/workflows/llm/providers/sampling_converter_anthropic.py +258 -0
- mcp_agent/workflows/llm/providers/sampling_converter_openai.py +229 -0
- mcp_agent/workflows/llm/sampling_format_converter.py +39 -0
- mcp_agent/workflows/orchestrator/orchestrator.py +62 -153
- mcp_agent/workflows/router/router_llm.py +18 -24
- mcp_agent/core/server_validation.py +0 -44
- mcp_agent/core/simulator_registry.py +0 -22
- mcp_agent/workflows/llm/enhanced_passthrough.py +0 -70
- {fast_agent_mcp-0.1.7.dist-info → fast_agent_mcp-0.1.9.dist-info}/WHEEL +0 -0
- {fast_agent_mcp-0.1.7.dist-info → fast_agent_mcp-0.1.9.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,426 @@
|
|
1
|
+
from typing import List, Union, Optional, Dict, Any, Tuple
|
2
|
+
|
3
|
+
from mcp.types import (
|
4
|
+
TextContent,
|
5
|
+
ImageContent,
|
6
|
+
EmbeddedResource,
|
7
|
+
CallToolResult,
|
8
|
+
)
|
9
|
+
from mcp_agent.mcp.prompt_message_multipart import PromptMessageMultipart
|
10
|
+
from mcp_agent.mcp.mime_utils import (
|
11
|
+
guess_mime_type,
|
12
|
+
is_text_mime_type,
|
13
|
+
is_image_mime_type,
|
14
|
+
)
|
15
|
+
from mcp_agent.mcp.resource_utils import extract_title_from_uri
|
16
|
+
|
17
|
+
from mcp_agent.logging.logger import get_logger
|
18
|
+
|
19
|
+
_logger = get_logger("multipart_converter_openai")
|
20
|
+
|
21
|
+
# Define the types for OpenAI API
|
22
|
+
OpenAIContentBlock = Dict[str, Any]
|
23
|
+
OpenAIMessage = Dict[str, Any]
|
24
|
+
|
25
|
+
|
26
|
+
class OpenAIConverter:
|
27
|
+
"""Converts MCP message types to OpenAI API format."""
|
28
|
+
|
29
|
+
@staticmethod
|
30
|
+
def convert_to_openai(
|
31
|
+
multipart_msg: PromptMessageMultipart, concatenate_text_blocks: bool = False
|
32
|
+
) -> OpenAIMessage:
|
33
|
+
"""
|
34
|
+
Convert a PromptMessageMultipart message to OpenAI API format.
|
35
|
+
|
36
|
+
Args:
|
37
|
+
multipart_msg: The PromptMessageMultipart message to convert
|
38
|
+
concatenate_text_blocks: If True, adjacent text blocks will be combined
|
39
|
+
|
40
|
+
Returns:
|
41
|
+
An OpenAI API message object
|
42
|
+
"""
|
43
|
+
role = multipart_msg.role
|
44
|
+
|
45
|
+
# Handle empty content
|
46
|
+
if not multipart_msg.content:
|
47
|
+
return {"role": role, "content": ""}
|
48
|
+
|
49
|
+
# Assistant messages in OpenAI only support string content, not array of content blocks
|
50
|
+
if role == "assistant":
|
51
|
+
# Extract text from all text content blocks
|
52
|
+
content_text = ""
|
53
|
+
for item in multipart_msg.content:
|
54
|
+
if isinstance(item, TextContent):
|
55
|
+
content_text += item.text
|
56
|
+
# Other types are ignored for assistant messages in OpenAI
|
57
|
+
|
58
|
+
return {"role": role, "content": content_text}
|
59
|
+
|
60
|
+
# For user messages, convert each content block
|
61
|
+
content_blocks = []
|
62
|
+
|
63
|
+
for item in multipart_msg.content:
|
64
|
+
try:
|
65
|
+
if isinstance(item, TextContent):
|
66
|
+
content_blocks.append(OpenAIConverter._convert_text_content(item))
|
67
|
+
|
68
|
+
elif isinstance(item, ImageContent):
|
69
|
+
content_blocks.append(OpenAIConverter._convert_image_content(item))
|
70
|
+
|
71
|
+
elif isinstance(item, EmbeddedResource):
|
72
|
+
block = OpenAIConverter._convert_embedded_resource(item)
|
73
|
+
if block:
|
74
|
+
content_blocks.append(block)
|
75
|
+
|
76
|
+
# Handle input_audio if implemented
|
77
|
+
elif hasattr(item, "type") and getattr(item, "type") == "input_audio":
|
78
|
+
# This assumes an InputAudioContent class structure with input_audio attribute
|
79
|
+
if hasattr(item, "input_audio"):
|
80
|
+
content_blocks.append(
|
81
|
+
{
|
82
|
+
"type": "input_audio",
|
83
|
+
"input_audio": {
|
84
|
+
"data": item.input_audio.get("data", ""),
|
85
|
+
"format": item.input_audio.get("format", "wav"),
|
86
|
+
},
|
87
|
+
}
|
88
|
+
)
|
89
|
+
else:
|
90
|
+
_logger.warning(
|
91
|
+
"InputAudio content missing input_audio attribute"
|
92
|
+
)
|
93
|
+
fallback_text = "[Audio content missing data]"
|
94
|
+
content_blocks.append({"type": "text", "text": fallback_text})
|
95
|
+
|
96
|
+
else:
|
97
|
+
_logger.warning(f"Unsupported content type: {type(item)}")
|
98
|
+
# Create a text block with information about the skipped content
|
99
|
+
fallback_text = f"[Unsupported content type: {type(item).__name__}]"
|
100
|
+
content_blocks.append({"type": "text", "text": fallback_text})
|
101
|
+
|
102
|
+
except Exception as e:
|
103
|
+
_logger.warning(f"Error converting content item: {e}")
|
104
|
+
# Create a text block with information about the conversion error
|
105
|
+
fallback_text = f"[Content conversion error: {str(e)}]"
|
106
|
+
content_blocks.append({"type": "text", "text": fallback_text})
|
107
|
+
|
108
|
+
# Special case: empty content list or only empty text blocks
|
109
|
+
if not content_blocks:
|
110
|
+
return {"role": role, "content": ""}
|
111
|
+
|
112
|
+
# If we only have one text content and it's empty, return an empty string for content
|
113
|
+
if (
|
114
|
+
len(content_blocks) == 1
|
115
|
+
and content_blocks[0]["type"] == "text"
|
116
|
+
and not content_blocks[0]["text"]
|
117
|
+
):
|
118
|
+
return {"role": role, "content": ""}
|
119
|
+
|
120
|
+
# If concatenate_text_blocks is True, combine adjacent text blocks
|
121
|
+
if concatenate_text_blocks:
|
122
|
+
combined_blocks = []
|
123
|
+
current_text = ""
|
124
|
+
|
125
|
+
for block in content_blocks:
|
126
|
+
if block["type"] == "text":
|
127
|
+
# Add to current text accumulator
|
128
|
+
if current_text:
|
129
|
+
current_text += " " + block["text"]
|
130
|
+
else:
|
131
|
+
current_text = block["text"]
|
132
|
+
else:
|
133
|
+
# Non-text block found, flush accumulated text if any
|
134
|
+
if current_text:
|
135
|
+
combined_blocks.append({"type": "text", "text": current_text})
|
136
|
+
current_text = ""
|
137
|
+
# Add the non-text block
|
138
|
+
combined_blocks.append(block)
|
139
|
+
|
140
|
+
# Don't forget any remaining text
|
141
|
+
if current_text:
|
142
|
+
combined_blocks.append({"type": "text", "text": current_text})
|
143
|
+
|
144
|
+
content_blocks = combined_blocks
|
145
|
+
|
146
|
+
return {"role": role, "content": content_blocks}
|
147
|
+
|
148
|
+
@staticmethod
|
149
|
+
def _convert_text_content(content: TextContent) -> OpenAIContentBlock:
|
150
|
+
"""Convert TextContent to OpenAI text content block."""
|
151
|
+
return {"type": "text", "text": content.text}
|
152
|
+
|
153
|
+
@staticmethod
|
154
|
+
def _convert_image_content(content: ImageContent) -> OpenAIContentBlock:
|
155
|
+
"""Convert ImageContent to OpenAI image_url content block."""
|
156
|
+
# OpenAI requires image URLs or data URIs for images
|
157
|
+
image_url = {"url": f"data:{content.mimeType};base64,{content.data}"}
|
158
|
+
|
159
|
+
# Check if the image has annotations for detail level
|
160
|
+
# This would depend on your ImageContent implementation
|
161
|
+
# If annotations are available, use them for the detail parameter
|
162
|
+
if hasattr(content, "annotations") and content.annotations:
|
163
|
+
if hasattr(content.annotations, "detail"):
|
164
|
+
detail = content.annotations.detail
|
165
|
+
if detail in ("auto", "low", "high"):
|
166
|
+
image_url["detail"] = detail
|
167
|
+
|
168
|
+
return {"type": "image_url", "image_url": image_url}
|
169
|
+
|
170
|
+
@staticmethod
|
171
|
+
def _convert_embedded_resource(
|
172
|
+
resource: EmbeddedResource,
|
173
|
+
) -> Optional[OpenAIContentBlock]:
|
174
|
+
"""Convert EmbeddedResource to appropriate OpenAI content block."""
|
175
|
+
resource_content = resource.resource
|
176
|
+
uri = resource_content.uri
|
177
|
+
|
178
|
+
# Use mime_utils to guess MIME type if not provided
|
179
|
+
if resource_content.mimeType is None and uri:
|
180
|
+
mime_type = guess_mime_type(str(uri))
|
181
|
+
_logger.info(f"MIME type not provided, guessed {mime_type} for {uri}")
|
182
|
+
else:
|
183
|
+
mime_type = resource_content.mimeType or "application/octet-stream"
|
184
|
+
|
185
|
+
is_url: bool = str(uri).startswith(("http://", "https://"))
|
186
|
+
title = extract_title_from_uri(uri) if uri else "resource"
|
187
|
+
|
188
|
+
# Handle image resources
|
189
|
+
if is_image_mime_type(mime_type) and mime_type != "image/svg+xml":
|
190
|
+
image_url = {}
|
191
|
+
|
192
|
+
if is_url:
|
193
|
+
image_url["url"] = str(uri)
|
194
|
+
elif hasattr(resource_content, "blob"):
|
195
|
+
image_url["url"] = f"data:{mime_type};base64,{resource_content.blob}"
|
196
|
+
else:
|
197
|
+
_logger.warning(f"Image resource missing both URL and blob data: {uri}")
|
198
|
+
return {"type": "text", "text": f"[Image missing data: {title}]"}
|
199
|
+
|
200
|
+
# Check for detail level in annotations if available
|
201
|
+
if hasattr(resource, "annotations") and resource.annotations:
|
202
|
+
if hasattr(resource.annotations, "detail"):
|
203
|
+
detail = resource.annotations.detail
|
204
|
+
if detail in ("auto", "low", "high"):
|
205
|
+
image_url["detail"] = detail
|
206
|
+
|
207
|
+
return {"type": "image_url", "image_url": image_url}
|
208
|
+
|
209
|
+
# Handle PDF resources - OpenAI has specific file format for PDFs
|
210
|
+
elif mime_type == "application/pdf":
|
211
|
+
if is_url:
|
212
|
+
# OpenAI doesn't directly support PDF URLs, only file_id or base64
|
213
|
+
_logger.warning(f"PDF URL not directly supported in OpenAI API: {uri}")
|
214
|
+
fallback_text = f"[PDF URL: {uri}]\nOpenAI requires PDF files to be uploaded or provided as base64 data."
|
215
|
+
return {"type": "text", "text": fallback_text}
|
216
|
+
elif hasattr(resource_content, "blob"):
|
217
|
+
return {
|
218
|
+
"type": "file",
|
219
|
+
"file": {
|
220
|
+
"filename": title or "document.pdf",
|
221
|
+
"file_data": f"data:application/pdf;base64,{resource_content.blob}",
|
222
|
+
},
|
223
|
+
}
|
224
|
+
|
225
|
+
# Handle SVG as text with fastagent:file tags
|
226
|
+
elif mime_type == "image/svg+xml":
|
227
|
+
if hasattr(resource_content, "text"):
|
228
|
+
file_text = (
|
229
|
+
f'<fastagent:file title="{title}" mimetype="{mime_type}">\n'
|
230
|
+
f"{resource_content.text}\n"
|
231
|
+
f"</fastagent:file>"
|
232
|
+
)
|
233
|
+
return {"type": "text", "text": file_text}
|
234
|
+
|
235
|
+
# Handle text resources with fastagent:file tags
|
236
|
+
elif is_text_mime_type(mime_type):
|
237
|
+
if hasattr(resource_content, "text"):
|
238
|
+
# Wrap in fastagent:file tags for text resources
|
239
|
+
file_text = (
|
240
|
+
f'<fastagent:file title="{title}" mimetype="{mime_type}">\n'
|
241
|
+
f"{resource_content.text}\n"
|
242
|
+
f"</fastagent:file>"
|
243
|
+
)
|
244
|
+
return {"type": "text", "text": file_text}
|
245
|
+
|
246
|
+
# Handle other binary formats that OpenAI supports with file type
|
247
|
+
# Currently, OpenAI supports PDFs for comprehensive viewing, but we can try
|
248
|
+
# to use the file type for other binary formats as well for future compatibility
|
249
|
+
elif hasattr(resource_content, "blob"):
|
250
|
+
# For now, we'll use file type for PDFs only, and use fallback for others
|
251
|
+
if mime_type == "application/pdf":
|
252
|
+
return {
|
253
|
+
"type": "file",
|
254
|
+
"file": {"file_name": title, "file_data": resource_content.blob},
|
255
|
+
}
|
256
|
+
else:
|
257
|
+
# For other binary formats, create a text message mentioning the resource
|
258
|
+
return {
|
259
|
+
"type": "text",
|
260
|
+
"text": f"[Binary resource: {title} ({mime_type})]",
|
261
|
+
}
|
262
|
+
|
263
|
+
# Default fallback - convert to text if possible
|
264
|
+
if hasattr(resource_content, "text"):
|
265
|
+
# For anything with text content that isn't handled specially above,
|
266
|
+
# use the raw text without special formatting
|
267
|
+
return {"type": "text", "text": resource_content.text}
|
268
|
+
|
269
|
+
_logger.warning(f"Unable to convert resource with MIME type: {mime_type}")
|
270
|
+
return {
|
271
|
+
"type": "text",
|
272
|
+
"text": f"[Unsupported resource: {title} ({mime_type})]",
|
273
|
+
}
|
274
|
+
|
275
|
+
@staticmethod
|
276
|
+
def convert_tool_result_to_openai(
|
277
|
+
tool_result: CallToolResult,
|
278
|
+
tool_call_id: str,
|
279
|
+
concatenate_text_blocks: bool = False,
|
280
|
+
) -> Union[OpenAIMessage, Tuple[OpenAIMessage, List[OpenAIMessage]]]:
|
281
|
+
"""
|
282
|
+
Convert a CallToolResult to an OpenAI tool message.
|
283
|
+
If the result contains non-text elements, those are converted to separate messages
|
284
|
+
since OpenAI tool messages can only contain text.
|
285
|
+
|
286
|
+
Args:
|
287
|
+
tool_result: The tool result from a tool call
|
288
|
+
tool_call_id: The ID of the associated tool use
|
289
|
+
concatenate_text_blocks: If True, adjacent text blocks will be combined
|
290
|
+
|
291
|
+
Returns:
|
292
|
+
Either a single OpenAI message for the tool response (if text only),
|
293
|
+
or a tuple containing the tool message and a list of additional messages for non-text content
|
294
|
+
"""
|
295
|
+
# Handle empty content case
|
296
|
+
if not tool_result.content:
|
297
|
+
return {
|
298
|
+
"role": "tool",
|
299
|
+
"tool_call_id": tool_call_id,
|
300
|
+
"content": "[No content in tool result]",
|
301
|
+
}
|
302
|
+
|
303
|
+
# First, separate text and non-text content
|
304
|
+
text_content = []
|
305
|
+
non_text_content = []
|
306
|
+
|
307
|
+
for item in tool_result.content:
|
308
|
+
if isinstance(item, TextContent):
|
309
|
+
text_content.append(item)
|
310
|
+
else:
|
311
|
+
non_text_content.append(item)
|
312
|
+
|
313
|
+
# If we only have text content, process as before
|
314
|
+
if not non_text_content:
|
315
|
+
# Create a temporary PromptMessageMultipart to reuse the conversion logic
|
316
|
+
temp_multipart = PromptMessageMultipart(role="user", content=text_content)
|
317
|
+
|
318
|
+
# Convert using the same logic as user messages
|
319
|
+
converted = OpenAIConverter.convert_to_openai(
|
320
|
+
temp_multipart, concatenate_text_blocks=concatenate_text_blocks
|
321
|
+
)
|
322
|
+
|
323
|
+
# For tool messages, we need to extract and combine all text content
|
324
|
+
if isinstance(converted["content"], str):
|
325
|
+
content = converted["content"]
|
326
|
+
else:
|
327
|
+
# For compatibility with OpenAI's tool message format, combine all text blocks
|
328
|
+
all_text = all(
|
329
|
+
block.get("type") == "text" for block in converted["content"]
|
330
|
+
)
|
331
|
+
|
332
|
+
if all_text and len(converted["content"]) > 0:
|
333
|
+
# Combine all text blocks
|
334
|
+
content = " ".join(
|
335
|
+
block.get("text", "") for block in converted["content"]
|
336
|
+
)
|
337
|
+
else:
|
338
|
+
# Fallback for unexpected cases
|
339
|
+
content = "[Complex content converted to text]"
|
340
|
+
|
341
|
+
# Create a tool message with the converted content
|
342
|
+
return {"role": "tool", "tool_call_id": tool_call_id, "content": content}
|
343
|
+
|
344
|
+
# If we have mixed content or only non-text content
|
345
|
+
|
346
|
+
# Process text content for the tool message
|
347
|
+
tool_message_content = ""
|
348
|
+
if text_content:
|
349
|
+
temp_multipart = PromptMessageMultipart(role="user", content=text_content)
|
350
|
+
converted = OpenAIConverter.convert_to_openai(
|
351
|
+
temp_multipart, concatenate_text_blocks=True
|
352
|
+
)
|
353
|
+
|
354
|
+
if isinstance(converted["content"], str):
|
355
|
+
tool_message_content = converted["content"]
|
356
|
+
else:
|
357
|
+
# Combine all text blocks
|
358
|
+
all_text = [
|
359
|
+
block.get("text", "")
|
360
|
+
for block in converted["content"]
|
361
|
+
if block.get("type") == "text"
|
362
|
+
]
|
363
|
+
tool_message_content = " ".join(all_text)
|
364
|
+
|
365
|
+
if not tool_message_content:
|
366
|
+
tool_message_content = "[Tool returned non-text content]"
|
367
|
+
|
368
|
+
# Create the tool message with just the text
|
369
|
+
tool_message = {
|
370
|
+
"role": "tool",
|
371
|
+
"tool_call_id": tool_call_id,
|
372
|
+
"content": tool_message_content,
|
373
|
+
}
|
374
|
+
|
375
|
+
# Process non-text content as a separate user message
|
376
|
+
if non_text_content:
|
377
|
+
# Create a multipart message with the non-text content
|
378
|
+
non_text_multipart = PromptMessageMultipart(
|
379
|
+
role="user", content=non_text_content
|
380
|
+
)
|
381
|
+
|
382
|
+
# Convert to OpenAI format
|
383
|
+
user_message = OpenAIConverter.convert_to_openai(non_text_multipart)
|
384
|
+
# Add tool_call_id to associate with the tool call
|
385
|
+
user_message["tool_call_id"] = tool_call_id
|
386
|
+
|
387
|
+
return (tool_message, [user_message])
|
388
|
+
|
389
|
+
return tool_message
|
390
|
+
|
391
|
+
@staticmethod
|
392
|
+
def convert_function_results_to_openai(
|
393
|
+
results: List[Tuple[str, CallToolResult]],
|
394
|
+
concatenate_text_blocks: bool = False,
|
395
|
+
) -> List[OpenAIMessage]:
|
396
|
+
"""
|
397
|
+
Convert a list of function call results to OpenAI messages.
|
398
|
+
Handles cases where tool results contain non-text content by creating
|
399
|
+
additional user messages as needed.
|
400
|
+
|
401
|
+
Args:
|
402
|
+
results: List of (tool_call_id, result) tuples
|
403
|
+
concatenate_text_blocks: If True, adjacent text blocks will be combined
|
404
|
+
|
405
|
+
Returns:
|
406
|
+
List of OpenAI API messages for tool responses
|
407
|
+
"""
|
408
|
+
messages = []
|
409
|
+
|
410
|
+
for tool_call_id, result in results:
|
411
|
+
converted = OpenAIConverter.convert_tool_result_to_openai(
|
412
|
+
tool_result=result,
|
413
|
+
tool_call_id=tool_call_id,
|
414
|
+
concatenate_text_blocks=concatenate_text_blocks,
|
415
|
+
)
|
416
|
+
|
417
|
+
# Handle the case where we have mixed content and get back a tuple
|
418
|
+
if isinstance(converted, tuple):
|
419
|
+
tool_message, additional_messages = converted
|
420
|
+
messages.append(tool_message)
|
421
|
+
messages.extend(additional_messages)
|
422
|
+
else:
|
423
|
+
# Single message case (text-only)
|
424
|
+
messages.append(converted)
|
425
|
+
|
426
|
+
return messages
|
@@ -0,0 +1,197 @@
|
|
1
|
+
# openai_multipart.py
|
2
|
+
"""
|
3
|
+
Clean utilities for converting between PromptMessageMultipart and OpenAI message formats.
|
4
|
+
Each function handles all content types consistently and is designed for simple testing.
|
5
|
+
"""
|
6
|
+
|
7
|
+
from typing import Dict, Any, Union, List
|
8
|
+
|
9
|
+
from openai.types.chat import (
|
10
|
+
ChatCompletionMessage,
|
11
|
+
ChatCompletionMessageParam,
|
12
|
+
)
|
13
|
+
|
14
|
+
from mcp.types import (
|
15
|
+
TextContent,
|
16
|
+
ImageContent,
|
17
|
+
EmbeddedResource,
|
18
|
+
TextResourceContents,
|
19
|
+
BlobResourceContents,
|
20
|
+
)
|
21
|
+
|
22
|
+
from mcp_agent.mcp.prompt_message_multipart import PromptMessageMultipart
|
23
|
+
|
24
|
+
|
25
|
+
def openai_to_multipart(
|
26
|
+
message: Union[
|
27
|
+
ChatCompletionMessage,
|
28
|
+
ChatCompletionMessageParam,
|
29
|
+
List[Union[ChatCompletionMessage, ChatCompletionMessageParam]],
|
30
|
+
],
|
31
|
+
) -> Union[PromptMessageMultipart, List[PromptMessageMultipart]]:
|
32
|
+
"""
|
33
|
+
Convert OpenAI messages to PromptMessageMultipart format.
|
34
|
+
|
35
|
+
Args:
|
36
|
+
message: OpenAI Message, MessageParam, or list of them
|
37
|
+
|
38
|
+
Returns:
|
39
|
+
Equivalent message(s) in PromptMessageMultipart format
|
40
|
+
"""
|
41
|
+
if isinstance(message, list):
|
42
|
+
return [_openai_message_to_multipart(m) for m in message]
|
43
|
+
return _openai_message_to_multipart(message)
|
44
|
+
|
45
|
+
|
46
|
+
def _openai_message_to_multipart(
|
47
|
+
message: Union[ChatCompletionMessage, Dict[str, Any]],
|
48
|
+
) -> PromptMessageMultipart:
|
49
|
+
"""Convert a single OpenAI message to PromptMessageMultipart."""
|
50
|
+
# Get role and content from message
|
51
|
+
if isinstance(message, dict):
|
52
|
+
role = message.get("role", "assistant")
|
53
|
+
content = message.get("content", "")
|
54
|
+
else:
|
55
|
+
role = message.role
|
56
|
+
content = message.content
|
57
|
+
|
58
|
+
mcp_contents = []
|
59
|
+
|
60
|
+
# Handle string content (simple case)
|
61
|
+
if isinstance(content, str):
|
62
|
+
mcp_contents.append(TextContent(type="text", text=content))
|
63
|
+
|
64
|
+
# Handle list of content parts
|
65
|
+
elif isinstance(content, list):
|
66
|
+
for part in content:
|
67
|
+
part_type = (
|
68
|
+
part.get("type")
|
69
|
+
if isinstance(part, dict)
|
70
|
+
else getattr(part, "type", None)
|
71
|
+
)
|
72
|
+
|
73
|
+
# Handle text content
|
74
|
+
if part_type == "text":
|
75
|
+
text = (
|
76
|
+
part.get("text")
|
77
|
+
if isinstance(part, dict)
|
78
|
+
else getattr(part, "text", "")
|
79
|
+
)
|
80
|
+
|
81
|
+
# Check if this is a resource marker
|
82
|
+
if (
|
83
|
+
text
|
84
|
+
and (
|
85
|
+
text.startswith("[Resource:")
|
86
|
+
or text.startswith("[Binary Resource:")
|
87
|
+
)
|
88
|
+
and "\n" in text
|
89
|
+
):
|
90
|
+
header, content_text = text.split("\n", 1)
|
91
|
+
if "MIME:" in header:
|
92
|
+
mime_match = header.split("MIME:", 1)[1].split("]")[0].strip()
|
93
|
+
|
94
|
+
# If not text/plain, create an embedded resource
|
95
|
+
if mime_match != "text/plain":
|
96
|
+
if (
|
97
|
+
"Resource:" in header
|
98
|
+
and "Binary Resource:" not in header
|
99
|
+
):
|
100
|
+
uri = (
|
101
|
+
header.split("Resource:", 1)[1]
|
102
|
+
.split(",")[0]
|
103
|
+
.strip()
|
104
|
+
)
|
105
|
+
mcp_contents.append(
|
106
|
+
EmbeddedResource(
|
107
|
+
type="resource",
|
108
|
+
resource=TextResourceContents(
|
109
|
+
uri=uri,
|
110
|
+
mimeType=mime_match,
|
111
|
+
text=content_text,
|
112
|
+
),
|
113
|
+
)
|
114
|
+
)
|
115
|
+
continue
|
116
|
+
|
117
|
+
# Regular text content
|
118
|
+
mcp_contents.append(TextContent(type="text", text=text))
|
119
|
+
|
120
|
+
# Handle image content
|
121
|
+
elif part_type == "image_url":
|
122
|
+
image_url = (
|
123
|
+
part.get("image_url", {})
|
124
|
+
if isinstance(part, dict)
|
125
|
+
else getattr(part, "image_url", None)
|
126
|
+
)
|
127
|
+
if image_url:
|
128
|
+
url = (
|
129
|
+
image_url.get("url")
|
130
|
+
if isinstance(image_url, dict)
|
131
|
+
else getattr(image_url, "url", "")
|
132
|
+
)
|
133
|
+
if url and url.startswith("data:image/"):
|
134
|
+
# Handle base64 data URLs
|
135
|
+
mime_type = url.split(";")[0].replace("data:", "")
|
136
|
+
data = url.split(",")[1]
|
137
|
+
mcp_contents.append(
|
138
|
+
ImageContent(type="image", data=data, mimeType=mime_type)
|
139
|
+
)
|
140
|
+
|
141
|
+
# Handle explicit resource types
|
142
|
+
elif (
|
143
|
+
part_type == "resource"
|
144
|
+
and isinstance(part, dict)
|
145
|
+
and "resource" in part
|
146
|
+
):
|
147
|
+
resource = part["resource"]
|
148
|
+
if isinstance(resource, dict):
|
149
|
+
# Text resource
|
150
|
+
if "text" in resource and "mimeType" in resource:
|
151
|
+
mime_type = resource["mimeType"]
|
152
|
+
uri = resource.get("uri", "resource://unknown")
|
153
|
+
|
154
|
+
if mime_type == "text/plain":
|
155
|
+
mcp_contents.append(
|
156
|
+
TextContent(type="text", text=resource["text"])
|
157
|
+
)
|
158
|
+
else:
|
159
|
+
mcp_contents.append(
|
160
|
+
EmbeddedResource(
|
161
|
+
type="resource",
|
162
|
+
resource=TextResourceContents(
|
163
|
+
text=resource["text"],
|
164
|
+
mimeType=mime_type,
|
165
|
+
uri=uri,
|
166
|
+
),
|
167
|
+
)
|
168
|
+
)
|
169
|
+
# Binary resource
|
170
|
+
elif "blob" in resource and "mimeType" in resource:
|
171
|
+
mime_type = resource["mimeType"]
|
172
|
+
uri = resource.get("uri", "resource://unknown")
|
173
|
+
|
174
|
+
if (
|
175
|
+
mime_type.startswith("image/")
|
176
|
+
and mime_type != "image/svg+xml"
|
177
|
+
):
|
178
|
+
mcp_contents.append(
|
179
|
+
ImageContent(
|
180
|
+
type="image",
|
181
|
+
data=resource["blob"],
|
182
|
+
mimeType=mime_type,
|
183
|
+
)
|
184
|
+
)
|
185
|
+
else:
|
186
|
+
mcp_contents.append(
|
187
|
+
EmbeddedResource(
|
188
|
+
type="resource",
|
189
|
+
resource=BlobResourceContents(
|
190
|
+
blob=resource["blob"],
|
191
|
+
mimeType=mime_type,
|
192
|
+
uri=uri,
|
193
|
+
),
|
194
|
+
)
|
195
|
+
)
|
196
|
+
|
197
|
+
return PromptMessageMultipart(role=role, content=mcp_contents)
|