Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/blue-times-shine.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@mastra/core': patch
---

Fixed an issue where generating a response in an empty thread (system-only messages) would throw an error. Providers that support system-only prompts like Anthropic and OpenAI now work as expected. A warning is logged for providers that require at least one user message (e.g. Gemini). Fixes #13045.
19 changes: 14 additions & 5 deletions packages/core/src/agent/message-list/message-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type * as AIV4Type from '@internal/ai-sdk-v4';
import { v4 as randomUUID } from '@lukeed/uuid';

import { MastraError, ErrorDomain, ErrorCategory } from '../../error';
import type { IMastraLogger } from '../../logger';
import type { IdGeneratorContext } from '../../types';
import { AIV4Adapter, AIV5Adapter } from './adapters';
import { CacheKeyGenerator } from './cache/CacheKeyGenerator';
Expand Down Expand Up @@ -77,6 +78,7 @@ export class MessageList {

private generateMessageId?: (context?: IdGeneratorContext) => string;
private _agentNetworkAppend = false;
private logger?: IMastraLogger;

// Event recording for observability
private isRecording = false;
Expand All @@ -94,13 +96,20 @@ export class MessageList {
threadId,
resourceId,
generateMessageId,
logger,
// @ts-expect-error Flag for agent network messages
_agentNetworkAppend,
}: { threadId?: string; resourceId?: string; generateMessageId?: (context?: IdGeneratorContext) => string } = {}) {
}: {
threadId?: string;
resourceId?: string;
generateMessageId?: (context?: IdGeneratorContext) => string;
logger?: IMastraLogger;
} = {}) {
if (threadId) {
this.memoryInfo = { threadId, resourceId };
}
this.generateMessageId = generateMessageId;
this.logger = logger;
this._agentNetworkAppend = _agentNetworkAppend || false;
}

Expand Down Expand Up @@ -356,7 +365,7 @@ export class MessageList {

const messages = [...systemMessages, ...modelMessages];

return ensureGeminiCompatibleMessages(messages);
return ensureGeminiCompatibleMessages(messages, this.logger);
},

// Used for creating LLM prompt messages without AI SDK streamText/generateText
Expand Down Expand Up @@ -427,7 +436,7 @@ export class MessageList {
});
}

messages = ensureGeminiCompatibleMessages(messages);
messages = ensureGeminiCompatibleMessages(messages, this.logger);

return messages.map(aiV5ModelMessageToV2PromptMessage);
},
Expand All @@ -448,7 +457,7 @@ export class MessageList {
const coreMessages = this.all.aiV4.core();
const messages = [...this.systemMessages, ...Object.values(this.taggedSystemMessages).flat(), ...coreMessages];

return ensureGeminiCompatibleMessages(messages);
return ensureGeminiCompatibleMessages(messages, this.logger);
},

// Used for creating LLM prompt messages without AI SDK streamText/generateText
Expand All @@ -458,7 +467,7 @@ export class MessageList {
const systemMessages = [...this.systemMessages, ...Object.values(this.taggedSystemMessages).flat()];
let messages = [...systemMessages, ...coreMessages];

messages = ensureGeminiCompatibleMessages(messages);
messages = ensureGeminiCompatibleMessages(messages, this.logger);

return messages.map(aiV4CoreMessageToV1PromptMessage);
},
Expand Down
104 changes: 85 additions & 19 deletions packages/core/src/agent/message-list/tests/message-list-gemini.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,22 @@
import { convertToModelMessages } from '@internal/ai-sdk-v5';
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import type { IMastraLogger } from '../../../logger';
import type { MastraDBMessage } from '../index';
import { MessageList } from '../index';

function createMockLogger(): IMastraLogger & { warn: ReturnType<typeof vi.fn> } {
return {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
trackException: vi.fn(),
getTransports: vi.fn().mockReturnValue(new Map()),
listLogs: vi.fn().mockResolvedValue({ logs: [], total: 0, page: 1, perPage: 10, hasMore: false }),
listLogsByRunId: vi.fn().mockResolvedValue({ logs: [], total: 0, page: 1, perPage: 10, hasMore: false }),
};
}

describe('MessageList - Gemini Compatibility', () => {
describe('aiV5.prompt() - Gemini message ordering requirements', () => {
it('should ensure first non-system message is user when starting with assistant', () => {
Expand Down Expand Up @@ -96,21 +110,22 @@ describe('MessageList - Gemini Compatibility', () => {
expect(prompt.filter(m => m.content === '.').length).toBe(0);
});

it('should throw error for empty message list', () => {
it('should pass through empty message list unchanged', () => {
const list = new MessageList();

expect(() => list.get.all.aiV5.prompt()).toThrow(
'This request does not contain any user or assistant messages. At least one user or assistant message is required to generate a response.',
);
const prompt = list.get.all.aiV5.prompt();
expect(prompt).toHaveLength(0);
});

it('should throw error for system-only message list', () => {
const list = new MessageList();
it('should pass through system-only message list unchanged with warning', () => {
const logger = createMockLogger();
const list = new MessageList({ logger });
list.addSystem('You are a helpful assistant');

expect(() => list.get.all.aiV5.prompt()).toThrow(
'This request does not contain any user or assistant messages. At least one user or assistant message is required to generate a response.',
);
const prompt = list.get.all.aiV5.prompt();
expect(prompt).toHaveLength(1);
expect(prompt[0].role).toBe('system');
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('No user or assistant messages'));
});
});

Expand Down Expand Up @@ -146,21 +161,22 @@ describe('MessageList - Gemini Compatibility', () => {
expect(llmPrompt[2].role).toBe('assistant');
});

it('should throw error for empty message list in llmPrompt', async () => {
it('should pass through empty message list unchanged in llmPrompt', async () => {
const list = new MessageList();

await expect(list.get.all.aiV5.llmPrompt()).rejects.toThrow(
'This request does not contain any user or assistant messages. At least one user or assistant message is required to generate a response.',
);
const llmPrompt = await list.get.all.aiV5.llmPrompt();
expect(llmPrompt).toHaveLength(0);
});

it('should throw error for system-only message list in llmPrompt', async () => {
const list = new MessageList();
it('should pass through system-only message list unchanged in llmPrompt', async () => {
const logger = createMockLogger();
const list = new MessageList({ logger });
list.addSystem('You are a helpful assistant');

await expect(list.get.all.aiV5.llmPrompt()).rejects.toThrow(
'This request does not contain any user or assistant messages. At least one user or assistant message is required to generate a response.',
);
const llmPrompt = await list.get.all.aiV5.llmPrompt();
expect(llmPrompt).toHaveLength(1);
expect(llmPrompt[0].role).toBe('system');
expect(logger.warn).toHaveBeenCalled();
});
});

Expand Down Expand Up @@ -514,6 +530,56 @@ describe('MessageList - Gemini Compatibility', () => {
});
});

describe('Issue #13045 - Generate response in empty thread', () => {
it('should not throw when generating with system-only messages and warn about provider compatibility', () => {
const logger = createMockLogger();
const list = new MessageList({ logger });
list.addSystem('You are a helpful assistant.');

expect(() => list.get.all.aiV5.prompt()).not.toThrow();

const prompt = list.get.all.aiV5.prompt();
expect(prompt).toHaveLength(1);
expect(prompt[0].role).toBe('system');
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('No user or assistant messages'));
});

it('should not throw when generating with system-only messages via llmPrompt', async () => {
const logger = createMockLogger();
const list = new MessageList({ logger });
list.addSystem('You are a helpful assistant.');

const llmPrompt = await list.get.all.aiV5.llmPrompt();
expect(llmPrompt).toHaveLength(1);
expect(llmPrompt[0].role).toBe('system');
expect(logger.warn).toHaveBeenCalled();
});

it('should not throw when generating with system-only messages via aiV4.prompt', () => {
const logger = createMockLogger();
const list = new MessageList({ logger });
list.addSystem('You are a helpful assistant.');

expect(() => list.get.all.aiV4.prompt()).not.toThrow();

const prompt = list.get.all.aiV4.prompt();
expect(prompt).toHaveLength(1);
expect(prompt[0].role).toBe('system');
expect(logger.warn).toHaveBeenCalled();
});

it('should not warn for completely empty message list', () => {
const logger = createMockLogger();
const list = new MessageList({ logger });

expect(() => list.get.all.aiV5.prompt()).not.toThrow();

const prompt = list.get.all.aiV5.prompt();
expect(prompt).toHaveLength(0);
expect(logger.warn).not.toHaveBeenCalled();
});
});

describe('Agent Network scenarios', () => {
it('should handle agent network memory pattern correctly', () => {
const list = new MessageList();
Expand Down
22 changes: 10 additions & 12 deletions packages/core/src/agent/message-list/tests/message-list-v5.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -534,12 +534,11 @@ describe('MessageList V5 Support', () => {
});
});

it('prompt() should throw error for empty message list', () => {
it('prompt() should pass through empty message list unchanged', () => {
const list = new MessageList({ threadId, resourceId });

expect(() => list.get.all.aiV5.prompt()).toThrow(
'This request does not contain any user or assistant messages. At least one user or assistant message is required to generate a response.',
);
const prompt = list.get.all.aiV5.prompt();
expect(prompt).toHaveLength(0);
});

it('prompt() should ensure proper message ordering for Gemini compatibility', () => {
Expand Down Expand Up @@ -1128,16 +1127,15 @@ describe('MessageList V5 Support', () => {
});

describe('Edge Cases', () => {
it('should throw error for empty message list with prompt methods', () => {
it('should pass through empty message list unchanged with prompt methods', () => {
const list = new MessageList({ threadId, resourceId });

// Both v4 and v5 should throw error for empty list
expect(() => list.get.all.aiV4.prompt()).toThrow(
'This request does not contain any user or assistant messages. At least one user or assistant message is required to generate a response.',
);
expect(() => list.get.all.aiV5.prompt()).toThrow(
'This request does not contain any user or assistant messages. At least one user or assistant message is required to generate a response.',
);
// Both v4 and v5 should pass through empty list
const v4Prompt = list.get.all.aiV4.prompt();
expect(v4Prompt).toHaveLength(0);

const v5Prompt = list.get.all.aiV5.prompt();
expect(v5Prompt).toHaveLength(0);
});

it('should throw error for system messages with wrong role', () => {
Expand Down
33 changes: 16 additions & 17 deletions packages/core/src/agent/message-list/tests/message-list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3868,40 +3868,39 @@ describe('MessageList', () => {
});
});

describe('Empty message list validation', () => {
it('should throw error when calling prompt() with empty message list', () => {
describe('Empty message list handling', () => {
it('should pass through empty message list unchanged when calling prompt()', () => {
const list = new MessageList();

expect(() => list.get.all.aiV5.prompt()).toThrow(
'This request does not contain any user or assistant messages. At least one user or assistant message is required to generate a response.',
);
const prompt = list.get.all.aiV5.prompt();
expect(prompt).toHaveLength(0);
});

it('should throw error when calling prompt() with only system messages', () => {
it('should pass through system-only message list unchanged when calling prompt()', () => {
const list = new MessageList();
list.addSystem('You are a helpful assistant');
list.addSystem('Follow these rules');

expect(() => list.get.all.aiV5.prompt()).toThrow(
'This request does not contain any user or assistant messages. At least one user or assistant message is required to generate a response.',
);
const prompt = list.get.all.aiV5.prompt();
expect(prompt).toHaveLength(2);
expect(prompt[0].role).toBe('system');
expect(prompt[1].role).toBe('system');
});

it('should throw error when calling llmPrompt() with empty message list', async () => {
it('should pass through empty message list unchanged when calling llmPrompt()', async () => {
const list = new MessageList();

await expect(list.get.all.aiV5.llmPrompt()).rejects.toThrow(
'This request does not contain any user or assistant messages. At least one user or assistant message is required to generate a response.',
);
const llmPrompt = await list.get.all.aiV5.llmPrompt();
expect(llmPrompt).toHaveLength(0);
});

it('should throw error when calling llmPrompt() with only system messages', async () => {
it('should pass through system-only message list unchanged when calling llmPrompt()', async () => {
const list = new MessageList();
list.addSystem('You are a helpful assistant');

await expect(list.get.all.aiV5.llmPrompt()).rejects.toThrow(
'This request does not contain any user or assistant messages. At least one user or assistant message is required to generate a response.',
);
const llmPrompt = await list.get.all.aiV5.llmPrompt();
expect(llmPrompt).toHaveLength(1);
expect(llmPrompt[0].role).toBe('system');
});
});

Expand Down
25 changes: 15 additions & 10 deletions packages/core/src/agent/message-list/utils/provider-compat.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { CoreMessage as CoreMessageV4 } from '@internal/ai-sdk-v4';
import type { ModelMessage, ToolResultPart } from '@internal/ai-sdk-v5';

import { MastraError, ErrorDomain, ErrorCategory } from '../../../error';
import type { IMastraLogger } from '../../../logger';
import type { MastraDBMessage } from '../state/types';

/**
Expand All @@ -23,26 +23,31 @@ export type ToolResultWithInput = ToolResultPart & {
* 2. Cannot have only system messages - at least one user/assistant is required
*
* @param messages - Array of model messages to validate and fix
* @param logger - Optional logger for warnings
* @returns Modified messages array that satisfies Gemini requirements
* @throws MastraError if no user or assistant messages are present
*
* @see https://github.com/mastra-ai/mastra/issues/7287 - Tool call ordering
* @see https://github.com/mastra-ai/mastra/issues/8053 - Single turn validation
* @see https://github.com/mastra-ai/mastra/issues/13045 - Empty thread support
*/
export function ensureGeminiCompatibleMessages<T extends ModelMessage | CoreMessageV4>(messages: T[]): T[] {
export function ensureGeminiCompatibleMessages<T extends ModelMessage | CoreMessageV4>(
messages: T[],
logger?: IMastraLogger,
): T[] {
const result = [...messages];

// Ensure first non-system message is user
const firstNonSystemIndex = result.findIndex(m => m.role !== 'system');

if (firstNonSystemIndex === -1) {
// Only system messages or empty - this is an error condition
throw new MastraError({
id: 'NO_USER_OR_ASSISTANT_MESSAGES',
domain: ErrorDomain.AGENT,
category: ErrorCategory.USER,
text: 'This request does not contain any user or assistant messages. At least one user or assistant message is required to generate a response.',
});
// Only system messages or empty — warn and pass through unchanged.
// Providers that support system-only prompts (Anthropic, OpenAI) will work natively.
// Providers that don't (Gemini) will return their own error.
if (result.length > 0) {
logger?.warn(
'No user or assistant messages in the request. Some providers (e.g. Gemini) require at least one user message to generate a response.',
);
}
} else if (result[firstNonSystemIndex]?.role === 'assistant') {
// First non-system is assistant, insert user message before it
result.splice(firstNonSystemIndex, 0, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export function createPrepareMemoryStep<OUTPUT = undefined>({
threadId: thread?.id,
resourceId,
generateMessageId: capabilities.generateMessageId,
logger: capabilities.logger,
// @ts-expect-error Flag for agent network messages
_agentNetworkAppend: capabilities._agentNetworkAppend,
});
Expand Down