Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/file-attachment-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
'@mastra/core': patch
'mastracode': patch
---

**`sendMessage` now accepts `files` instead of `images`**, supporting any file type with optional `filename`.

**Breaking change:** Rename `images` to `files` when calling `harness.sendMessage()`:

```ts
// Before
await harness.sendMessage({
content: "Analyze this",
images: [{ data: base64Data, mimeType: "image/png" }],
});

// After
await harness.sendMessage({
content: "Analyze this",
files: [{ data: base64Data, mediaType: "image/png", filename: "screenshot.png" }],
});
```

- `files` accepts `{ data, mediaType, filename? }` — filenames are now preserved through storage and message history
- Text-based files (`text/*`, `application/json`) are automatically decoded to readable text content instead of being sent as binary, which models could not process
- `HarnessMessageContent` now includes a `file` type, so file parts round-trip correctly through message history
3 changes: 2 additions & 1 deletion mastracode/src/tui/mastra-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,8 @@ export class MastraTUI {
* Errors are handled via harness events.
*/
private fireMessage(content: string, images?: Array<{ data: string; mimeType: string }>): void {
this.state.harness.sendMessage({ content, images: images ? images : undefined }).catch(error => {
const files = images?.map(img => ({ data: img.data, mediaType: img.mimeType }));
this.state.harness.sendMessage({ content, files }).catch(error => {
showError(this.state, error instanceof Error ? error.message : 'Unknown error');
});
}
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/agent/message-list/adapters/AIV4Adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,9 @@ export class AIV4Adapter {
if (aiV4Part.providerOptions) {
part.providerMetadata = aiV4Part.providerOptions;
}
if (aiV4Part.filename) {
(part as Record<string, unknown>).filename = aiV4Part.filename;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
parts.push(part);
} else if (typeof aiV4Part.data === 'string') {
const categorized = categorizeFileData(aiV4Part.data, aiV4Part.mimeType);
Expand All @@ -442,6 +445,9 @@ export class AIV4Adapter {
if (aiV4Part.providerOptions) {
part.providerMetadata = aiV4Part.providerOptions;
}
if (aiV4Part.filename) {
(part as Record<string, unknown>).filename = aiV4Part.filename;
}
parts.push(part);
} else {
try {
Expand All @@ -453,6 +459,9 @@ export class AIV4Adapter {
if (aiV4Part.providerOptions) {
part.providerMetadata = aiV4Part.providerOptions;
}
if (aiV4Part.filename) {
(part as Record<string, unknown>).filename = aiV4Part.filename;
}
parts.push(part);
} catch (error) {
console.error(`Failed to convert binary data to base64 in CoreMessage file part: ${error}`, error);
Expand All @@ -468,6 +477,9 @@ export class AIV4Adapter {
if (aiV4Part.providerOptions) {
part.providerMetadata = aiV4Part.providerOptions;
}
if (aiV4Part.filename) {
(part as Record<string, unknown>).filename = aiV4Part.filename;
}
parts.push(part);
} catch (error) {
console.error(`Failed to convert binary data to base64 in CoreMessage file part: ${error}`, error);
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/agent/message-list/adapters/AIV5Adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,7 @@ export class AIV5Adapter {
mimeType: p.mediaType,
data: p.url || '',
providerMetadata: p.providerMetadata,
...((p as { filename?: string }).filename ? { filename: (p as { filename?: string }).filename } : {}),
};
}

Expand Down Expand Up @@ -671,6 +672,9 @@ export class AIV5Adapter {
if (part.providerOptions) {
v2FilePart.providerMetadata = part.providerOptions;
}
if ((filePart as { filename?: string }).filename) {
(v2FilePart as Record<string, unknown>).filename = (filePart as { filename?: string }).filename;
}
mastraDBParts.push(v2FilePart);
experimental_attachments.push({
url: fileData,
Expand Down
64 changes: 53 additions & 11 deletions packages/core/src/harness/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1059,12 +1059,12 @@ export class Harness<TState extends HarnessStateSchema = HarnessStateSchema> {
*/
async sendMessage({
content,
images,
files,
tracingContext,
tracingOptions,
}: {
content: string;
images?: Array<{ data: string; mimeType: string }>;
files?: Array<{ data: string; mediaType: string; filename?: string }>;
tracingContext?: TracingContext;
tracingOptions?: TracingOptions;
}): Promise<void> {
Expand Down Expand Up @@ -1098,17 +1098,33 @@ export class Harness<TState extends HarnessStateSchema = HarnessStateSchema> {
streamOptions.toolsets = await this.buildToolsets(requestContext);

let messageInput: string | Record<string, unknown> = content;
if (images?.length) {
if (files?.length) {
const fileParts = files.map(f => {
const isText = f.mediaType.startsWith('text/') || f.mediaType === 'application/json';
if (isText) {
let textContent = f.data;
// Decode data URI to plain text
const base64Match = f.data.match(/^data:[^;]*;base64,(.*)$/);
if (base64Match) {
try {
textContent = Buffer.from(base64Match[1]!, 'base64').toString('utf-8');
} catch {
// Fall through with raw data
}
}
Comment on lines +1106 to +1114
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Decode non-base64 data URIs for text attachments.

Line 1100 only decodes ;base64 URIs. For data:text/plain,... payloads, the raw URI string is forwarded as content instead of decoded text.

💡 Proposed fix
-            // Decode data URI to plain text
-            const base64Match = f.data.match(/^data:[^;]*;base64,(.*)$/);
-            if (base64Match) {
-              try {
-                textContent = Buffer.from(base64Match[1]!, 'base64').toString('utf-8');
-              } catch {
-                // Fall through with raw data
-              }
-            }
+            // Decode data URI to plain text (supports base64 and url-encoded forms)
+            const dataUriMatch = f.data.match(/^data:([^,]*),(.*)$/s);
+            if (dataUriMatch) {
+              const meta = dataUriMatch[1] ?? '';
+              const payload = dataUriMatch[2] ?? '';
+              try {
+                textContent = /;base64/i.test(meta)
+                  ? Buffer.from(payload, 'base64').toString('utf-8')
+                  : decodeURIComponent(payload);
+              } catch {
+                // Fall through with raw data
+              }
+            }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Decode data URI to plain text
const base64Match = f.data.match(/^data:[^;]*;base64,(.*)$/);
if (base64Match) {
try {
textContent = Buffer.from(base64Match[1]!, 'base64').toString('utf-8');
} catch {
// Fall through with raw data
}
}
// Decode data URI to plain text (supports base64 and url-encoded forms)
const dataUriMatch = f.data.match(/^data:([^,]*),(.*)$/s);
if (dataUriMatch) {
const meta = dataUriMatch[1] ?? '';
const payload = dataUriMatch[2] ?? '';
try {
textContent = /;base64/i.test(meta)
? Buffer.from(payload, 'base64').toString('utf-8')
: decodeURIComponent(payload);
} catch {
// Fall through with raw data
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/harness/harness.ts` around lines 1099 - 1107, The current
logic only handles ;base64 data URIs and leaves plain data:text/... URIs as raw
strings; update the parsing for f.data so it matches a full data URI (e.g.
/^data:([^;]+)(?:;charset=[^;]+)?(?:;base64)?,(.*)$/i) and capture the payload;
if the captured group indicates ;base64 then decode with
Buffer.from(...,'base64'), otherwise URL-decode the payload (decodeURIComponent,
handling plus-to-space if needed) and set textContent accordingly; adjust the
existing base64Match usage and textContent assignment in the same block to use
this new match and branching logic.

const label = f.filename ? `[File: ${f.filename}]` : '[Attached file]';
return { type: 'text' as const, text: `${label}\n\`\`\`\n${textContent}\n\`\`\`` };
}
return {
type: 'file' as const,
data: f.data,
mimeType: f.mediaType,
filename: f.filename,
};
});
messageInput = {
role: 'user',
content: [
{ type: 'text', text: content },
...images.map((img: { data: string; mimeType: string }) => ({
type: 'file',
data: img.data,
mediaType: img.mimeType,
})),
],
content: [{ type: 'text', text: content }, ...fileParts],
};
}

Expand Down Expand Up @@ -1297,6 +1313,32 @@ export class Harness<TState extends HarnessStateSchema = HarnessStateSchema> {
});
break;
}
case 'file':
content.push({
type: 'file',
data: typeof part.data === 'string' ? part.data : '',
mediaType:
(part as { mediaType?: string }).mediaType ??
(part as { mimeType?: string }).mimeType ??
'application/octet-stream',
...((part as { filename?: string }).filename ? { filename: (part as { filename?: string }).filename } : {}),
});
break;
case 'image': {
const imgData =
typeof part.data === 'string'
? part.data
: typeof (part as { image?: string }).image === 'string'
? (part as { image?: string }).image!
: '';
content.push({
type: 'file',
data: imgData,
mediaType:
(part as { mimeType?: string }).mimeType ?? (part as { mediaType?: string }).mediaType ?? 'image/png',
});
break;
Comment on lines +1334 to +1340
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Preserve filename when normalizing image parts to file.

This branch converts image to file but drops filename if it exists on the source part, which can still lose metadata in history normalization paths.

💡 Suggested fix
           content.push({
             type: 'file',
             data: imgData,
             mediaType:
               (part as { mimeType?: string }).mimeType ?? (part as { mediaType?: string }).mediaType ?? 'image/png',
+            ...((part as { filename?: string }).filename ? { filename: (part as { filename?: string }).filename } : {}),
           });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/harness/harness.ts` around lines 1327 - 1333, The
normalization drops the source part's filename when converting an image to a
file: in the branch that calls content.push({ type: 'file', data: imgData,
mediaType: ... }) ensure the original part.filename (if present) is preserved by
copying it into the resulting file object (either as data.filename or a
top-level filename property on the pushed object). Update the content.push call
that constructs the file entry (and the imgData object if that's where filenames
belong) to set filename = (part as any).filename when available, keeping
existing mediaType handling intact.

}
// Skip other part types (step-start, data-om-status, etc.)
}
}
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/harness/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -764,6 +764,7 @@ export type HarnessMessageContent =
| { type: 'tool_call'; id: string; name: string; args: unknown }
| { type: 'tool_result'; id: string; name: string; result: unknown; isError: boolean }
| { type: 'image'; data: string; mimeType: string }
| { type: 'file'; data: string; mediaType: string; filename?: string }
| {
type: 'om_observation_start';
tokensToObserve: number;
Expand Down