From 46031ed5738f962a2959b3a991855fc3920fe263 Mon Sep 17 00:00:00 2001 From: shivammittal274 Date: Sun, 15 Mar 2026 17:42:34 +0530 Subject: [PATCH] fix: filter empty messages from conversation history to prevent validation errors The AI SDK can produce assistant messages with empty parts (parts:[]) when a stream is aborted, and providers reject assistant messages with empty text content. This adds a validation utility that filters both cases before sending messages to createAgentUIStreamResponse and when persisting them. --- .../server/src/agent/message-validation.ts | 46 +++++++++++++++++++ .../server/src/api/services/chat-service.ts | 6 ++- 2 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 packages/browseros-agent/apps/server/src/agent/message-validation.ts diff --git a/packages/browseros-agent/apps/server/src/agent/message-validation.ts b/packages/browseros-agent/apps/server/src/agent/message-validation.ts new file mode 100644 index 000000000..13a0e84bf --- /dev/null +++ b/packages/browseros-agent/apps/server/src/agent/message-validation.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { UIMessage } from 'ai' + +/** + * Checks whether a UIMessage has meaningful content that can be sent + * to the AI provider without causing validation errors. + * + * Two layers of validation can reject messages: + * + * 1. **AI SDK** (`validate-ui-messages.ts`): + * - `parts` array must be `.nonempty()` — rejects `parts: []` + * + * 2. **Provider API** (e.g. Gemini `generateContent`, Anthropic, OpenAI): + * - Assistant messages with only empty-string text are rejected + * as semantically empty, even though the SDK schema allows it + * + * This function guards against both layers so callers can filter + * messages before passing them to `createAgentUIStreamResponse`. + */ +export function hasMessageContent(message: UIMessage): boolean { + if (message.parts.length === 0) return false + + // A message that contains any non-text part (tool invocation, reasoning, + // file, step-start, etc.) is always considered valid — those part types + // carry meaning regardless of text content. + const hasNonTextPart = message.parts.some((p) => p.type !== 'text') + if (hasNonTextPart) return true + + // All parts are text — at least one must have non-whitespace content. + return message.parts.some( + (p) => p.type === 'text' && p.text.trim().length > 0, + ) +} + +/** + * Filters a UIMessage array, removing messages that would fail + * SDK validation or provider-level content checks. + */ +export function filterValidMessages(messages: UIMessage[]): UIMessage[] { + return messages.filter(hasMessageContent) +} diff --git a/packages/browseros-agent/apps/server/src/api/services/chat-service.ts b/packages/browseros-agent/apps/server/src/api/services/chat-service.ts index c28ca6343..72bbe7029 100644 --- a/packages/browseros-agent/apps/server/src/api/services/chat-service.ts +++ b/packages/browseros-agent/apps/server/src/api/services/chat-service.ts @@ -8,6 +8,7 @@ import { mkdir, utimes } from 'node:fs/promises' import path from 'node:path' import { createAgentUIStreamResponse, type UIMessage } from 'ai' import { AiSdkAgent } from '../../agent/ai-sdk-agent' +import { filterValidMessages } from '../../agent/message-validation' import { formatUserMessage } from '../../agent/format-message' import type { SessionStore } from '../../agent/session-store' import type { ResolvedAgentConfig } from '../../agent/types' @@ -139,6 +140,7 @@ export class ChatService { if (isNewSession && request.previousConversation?.length) { for (const msg of request.previousConversation) { + if (!msg.content.trim()) continue session.agent.messages.push({ id: crypto.randomUUID(), role: msg.role === 'assistant' ? 'assistant' : 'user', @@ -168,10 +170,10 @@ export class ChatService { return createAgentUIStreamResponse({ agent: session.agent.toolLoopAgent, - uiMessages: session.agent.messages, + uiMessages: filterValidMessages(session.agent.messages), abortSignal, onFinish: async ({ messages }: { messages: UIMessage[] }) => { - session.agent.messages = messages + session.agent.messages = filterValidMessages(messages) logger.info('Agent execution complete', { conversationId: request.conversationId, totalMessages: messages.length,