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
87 changes: 73 additions & 14 deletions src/messages/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,6 @@ import type {
} from '@langchain/core/messages';
import type { RunnableConfig } from '@langchain/core/runnables';
import type { ToolCall } from '@langchain/core/messages/tool';
import type {
OrderedToolHistoryProjection,
ToolHistoryPreparation,
ToolHistoryCallMirrors,
} from './toolHistoryProjection';
import type {
BedrockReasoningContentText,
ExtendedMessageContent,
Expand All @@ -37,6 +32,11 @@ import type {
CompactionSemanticIndexEntry,
CompactionSemanticIndexSnapshot,
} from '@/types';
import type {
OrderedToolHistoryProjection,
ToolHistoryPreparation,
ToolHistoryCallMirrors,
} from './toolHistoryProjection';
import type {
ProviderMessageAttribution,
ProviderMessageProvenancePart,
Expand Down Expand Up @@ -70,6 +70,11 @@ import {
isAtomicToolContentBlock,
serializeStructuredValueBounded,
} from '@/utils/toolContent';
import {
createToolHistoryPreparation,
isToolHistoryCallMirror,
recordToolHistoryCallMirror,
} from './toolHistoryProjection';
import {
Providers,
ContentTypes,
Expand All @@ -78,15 +83,10 @@ import {
} from '@/common';
import { normalizeAnthropicToolCallId } from '@/llm/anthropic/utils/message_inputs';
import { toLangChainContent, toLangChainMessageFields } from './langchain';
import { isReasoningContentBlock } from './reasoningTypes';
import { flattenLegacyContent, isLegacyConvertible } from './content';
import { HARD_MAX_TOOL_RESULT_CHARS } from '@/utils/truncation';
import { isReasoningContentBlock } from './reasoningTypes';
import { emitAgentLog } from '@/utils/events';
import {
createToolHistoryPreparation,
isToolHistoryCallMirror,
recordToolHistoryCallMirror,
} from './toolHistoryProjection';

interface MediaMessageParams {
message: {
Expand Down Expand Up @@ -1333,6 +1333,43 @@ function formatToolCallOutput(
*/
const STEER_ANCHOR_PLACEHOLDER = '_';

/**
* Stand-in text for a user message whose content reduces to nothing visible
* (`userContentHasNoVisibleText` below): providers with strict payload
* validation (Anthropic, Bedrock, Maritaca) reject such a message — and with
* it the entire request — while lenient ones (OpenAI, Google, xAI) accept it
* silently, which hides the issue until the model list changes.
*/
const EMPTY_USER_MESSAGE_PLACEHOLDER = '(no text)';

/**
* True when a message's content would serialize to something strict providers
* reject for the whole request: `null`/`undefined`, an empty string, an empty
* array, or an array whose parts are all text parts with no visible text.
* Non-text parts (images, files) make a turn usable by definition, so a
* vision turn is never "empty". This is the shape a promptless send takes
* when its attachments are no longer re-sent during history replay.
*/
function userContentHasNoVisibleText(
content: string | MessageContentComplex[] | undefined | null
): boolean {
if (content == null || content === '') {
return true;
}
if (!Array.isArray(content)) {
return false;
}
if (content.length === 0) {
return true;
}
return content.every(
(part) =>
part != null &&
part.type === ContentTypes.TEXT &&
!getTextContent(part).trim()
);
}

/**
* True when an assistant message replayed as a steer and nothing followed it,
* so the emitted run ends on the steer's `HumanMessage`.
Expand Down Expand Up @@ -2934,6 +2971,30 @@ export const formatAgentMessages = (
}

if (message.role !== 'assistant') {
/**
* Strict providers (Anthropic, Bedrock, Maritaca) reject a user message
* whose effective content is empty — and with it the entire request.
* History replay of a promptless send reaches here in exactly that
* shape once its attachments are no longer re-sent. Dropping the turn
* is not safe either: adjacent messages would collapse into consecutive
* same-role turns those providers also reject. Keep the turn and give it
* the smallest honest stand-in. Vision turns keep their image parts —
* `userContentHasNoVisibleText` only matches text-only-empty content.
*/
if (
message.role === 'user' &&
userContentHasNoVisibleText(message.content)
) {
message = {
...message,
content: [
{
type: ContentTypes.TEXT,
[ContentTypes.TEXT]: EMPTY_USER_MESSAGE_PLACEHOLDER,
},
],
};
}
const formattedMessage = formatMessage({
message: message as MessageInput,
langChain: true,
Expand Down Expand Up @@ -3675,9 +3736,7 @@ function getSyntheticProviderContextProvenanceParts(
}
for (const attribution of source.additionalAttributions ?? []) {
const sourceMessageId =
sourceMessageIds.length === 1
? sourceMessageIds[0]
: undefined;
sourceMessageIds.length === 1 ? sourceMessageIds[0] : undefined;
const retainedSourceId =
retainedSourceIds.size === 1
? retainedSourceIds.values().next().value
Expand Down
56 changes: 54 additions & 2 deletions src/messages/formatAgentMessages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,58 @@ const getAnthropicPayloadBlocks = (
};

describe('formatAgentMessages', () => {
describe('empty user content stand-in', () => {
it('substitutes a stand-in for a user message with empty string content', () => {
const payload: TPayload = [
{ role: 'user', content: 'Hello' },
{ role: 'assistant', content: 'Hi!' },
{ role: 'user', content: '' },
];
const result = formatAgentMessages(payload);
const emptyTurn = result.messages[result.messages.length - 1];
expect(emptyTurn).toBeInstanceOf(HumanMessage);
expect(JSON.stringify(emptyTurn.content)).toContain('(no text)');
});

it('substitutes a stand-in when empty text parts are all a user turn has', () => {
const payload: TPayload = [
{
role: 'user',
content: [
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: '' },
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: ' ' },
],
},
];
const result = formatAgentMessages(payload);
expect(result.messages).toHaveLength(1);
expect(JSON.stringify(result.messages[0].content)).toContain('(no text)');
});

it('leaves a vision turn whose only non-empty part is an image alone', () => {
const content: MessageContentComplex[] = [
{ type: ContentTypes.TEXT, text: '' },
{
type: 'image_url',
image_url: { url: 'https://example.com/cat.png' },
},
];
const result = formatAgentMessages([{ role: 'user', content }]);
expect(result.messages).toHaveLength(1);
expect(result.messages[0].content).toEqual(content);
});

it('does not touch assistant messages with empty content', () => {
const result = formatAgentMessages([
{ role: 'user', content: 'Hello' },
{ role: 'assistant', content: '' },
]);
expect(
JSON.stringify(result.messages.map((message) => message.role))
).not.toContain('(no text)');
});
});

it('should format simple user and AI messages', () => {
const payload: TPayload = [
{ role: 'user', content: 'Hello' },
Expand Down Expand Up @@ -1098,8 +1150,8 @@ describe('formatAgentMessages', () => {
expect(
messages.flatMap(
(message) =>
getProviderMessageProvenance(message)?.parts.filter((part) =>
part.sourceContentPartIndices?.includes(1) === true
getProviderMessageProvenance(message)?.parts.filter(
(part) => part.sourceContentPartIndices?.includes(1) === true
) ?? []
)
).toEqual([
Expand Down