AI SDK v5 migration (#14549)

Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
Abdul Rahman
2025-09-23 01:43:43 +05:30
committed by GitHub
parent e8121919bd
commit 216d72b5d7
75 changed files with 1347 additions and 841 deletions
@@ -2,13 +2,14 @@ import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import {
type CoreMessage,
type CoreUserMessage,
type FilePart,
type ImagePart,
LanguageModelUsage,
type ModelMessage,
streamText,
ToolSet,
type UserContent,
UserModelMessage,
} from 'ai';
import { AppPath } from 'twenty-shared/types';
import { getAppPath } from 'twenty-shared/utils';
@@ -40,11 +41,7 @@ import { AgentException, AgentExceptionCode } from './agent.exception';
export interface AgentExecutionResult {
result: object;
usage: {
promptTokens: number;
completionTokens: number;
totalTokens: number;
};
usage: LanguageModelUsage;
}
@Injectable()
@@ -68,14 +65,12 @@ export class AgentExecutionService {
async prepareAIRequestConfig({
messages,
prompt,
system,
agent,
}: {
system: string;
agent: AgentEntity | null;
prompt?: string;
messages?: CoreMessage[];
messages: ModelMessage[];
}) {
try {
if (agent) {
@@ -111,8 +106,7 @@ export class AgentExecutionService {
system,
tools,
model: registeredModel.model,
...(messages && { messages }),
...(prompt && { prompt }),
messages,
maxSteps: AGENT_CONFIG.MAX_STEPS,
...(registeredModel.doesSupportThinking && {
providerOptions: {
@@ -149,7 +143,7 @@ export class AgentExecutionService {
private async buildUserMessage(
userMessage: string,
fileIds: string[],
): Promise<CoreUserMessage> {
): Promise<UserModelMessage> {
const content: Exclude<UserContent, string> = [
{
type: 'text',
@@ -248,22 +242,22 @@ export class AgentExecutionService {
return {
type: 'image',
image: fileBuffer,
mimeType: file.type,
mediaType: file.type,
};
}
return {
type: 'file',
data: fileBuffer,
mimeType: file.type,
mediaType: file.type,
};
}
private mapMessagesToCoreMessages(
messages: AgentChatMessageEntity[],
): CoreMessage[] {
): ModelMessage[] {
return messages
.map(({ role, rawContent }): CoreMessage => {
.map(({ role, rawContent }): ModelMessage => {
if (role === AgentChatMessageRole.USER) {
return {
role: 'user',
@@ -300,7 +294,8 @@ export class AgentExecutionService {
where: { id: agentId },
});
const llmMessages: CoreMessage[] = this.mapMessagesToCoreMessages(messages);
const llmMessages: ModelMessage[] =
this.mapMessagesToCoreMessages(messages);
let contextString = '';
@@ -1,7 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { CoreMessage, generateText } from 'ai';
import { ModelMessage, generateText } from 'ai';
import { Repository } from 'typeorm';
import { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-model-registry.service';
@@ -15,7 +15,7 @@ export type HandoffRequest = {
fromAgentId: string;
toAgentId: string;
workspaceId: string;
messages?: CoreMessage[];
messages: ModelMessage[];
};
@Injectable()
@@ -35,7 +35,7 @@ export class AgentHandoffToolService {
'{agentName}',
handoff.toAgent.name,
),
parameters: AGENT_HANDOFF_SCHEMA,
inputSchema: AGENT_HANDOFF_SCHEMA,
execute: async ({ input }) => {
const result = await this.agentHandoffExecutorService.executeHandoff({
fromAgentId: agentId,
@@ -25,6 +25,16 @@ export type StreamAgentChatOptions = {
res: Response;
};
const CLIENT_FORWARDED_EVENT_TYPES = [
'text-delta',
'reasoning',
'reasoning-delta',
'tool-call',
'tool-input-delta',
'tool-result',
'error',
];
@Injectable()
export class AgentStreamingService {
private readonly logger = new Logger(AgentStreamingService.name);
@@ -81,14 +91,7 @@ export class AgentStreamingService {
this.sendStreamEvent(
res,
[
'text-delta',
'reasoning',
'reasoning-signature',
'tool-call',
'tool-result',
'error',
].includes(chunk.type)
CLIENT_FORWARDED_EVENT_TYPES.includes(chunk.type)
? chunk
: { type: chunk.type },
);
@@ -26,24 +26,16 @@ export const AGENT_HANDOFF_SCHEMA = z.object({
}),
z.object({
type: z.literal('image'),
image: z.union([
z.string(),
z.instanceof(Uint8Array),
z.instanceof(Buffer),
z.instanceof(ArrayBuffer),
z.string().url(),
]),
image: z
.string()
.describe('Base64 encoded image data or URL'),
mediaType: z.string().optional(),
}),
z.object({
type: z.literal('file'),
data: z.union([
z.string(),
z.instanceof(Uint8Array),
z.instanceof(Buffer),
z.instanceof(ArrayBuffer),
z.string().url(),
]),
data: z
.string()
.describe('Base64 encoded file data or URL'),
mediaType: z.string(),
}),
]),
@@ -62,13 +54,9 @@ export const AGENT_HANDOFF_SCHEMA = z.object({
}),
z.object({
type: z.literal('file'),
data: z.union([
z.string(),
z.instanceof(Uint8Array),
z.instanceof(Buffer),
z.instanceof(ArrayBuffer),
z.string().url(),
]),
data: z
.string()
.describe('Base64 encoded file data or URL'),
mediaType: z.string(),
filename: z.string().optional(),
}),
@@ -80,7 +68,7 @@ export const AGENT_HANDOFF_SCHEMA = z.object({
type: z.literal('tool-call'),
toolCallId: z.string(),
toolName: z.string(),
input: z.record(z.any()),
input: z.record(z.string(), z.any()),
}),
]),
),
@@ -1,60 +1,96 @@
import { type ReasoningPart } from '@ai-sdk/provider-utils';
import { type TextPart } from 'ai';
type ReasoningPart = {
type: 'reasoning';
text: string;
signature: string;
};
import {
parseStreamLine,
splitStreamIntoLines,
type TextBlock,
} from 'twenty-shared/ai';
export const constructAssistantMessageContentFromStream = (
rawContent: string,
) => {
const lines = rawContent.trim().split('\n');
const lines = splitStreamIntoLines(rawContent);
const output: Array<TextPart | ReasoningPart> = [];
let reasoningText = '';
let textContent = '';
let currentTextBlock: TextBlock = null;
const flushTextBlock = () => {
if (currentTextBlock) {
if (currentTextBlock.type === 'reasoning') {
output.push({
type: 'reasoning',
text: currentTextBlock.content,
});
} else {
output.push({
type: 'text',
text: currentTextBlock.content,
});
}
currentTextBlock = null;
}
};
for (const line of lines) {
let event;
const event = parseStreamLine(line);
try {
event = JSON.parse(line);
} catch {
if (!event) {
continue;
}
switch (event.type) {
case 'reasoning':
reasoningText += event.textDelta || '';
case 'reasoning-start':
flushTextBlock();
currentTextBlock = {
type: 'reasoning',
content: '',
isThinking: true,
};
break;
case 'reasoning-signature':
if (reasoningText) {
output.push({
case 'reasoning-delta':
if (!currentTextBlock || currentTextBlock.type !== 'reasoning') {
flushTextBlock();
currentTextBlock = {
type: 'reasoning',
text: reasoningText,
signature: event.signature,
});
reasoningText = '';
content: '',
isThinking: true,
};
}
currentTextBlock.content += event.text || '';
break;
case 'reasoning-end':
if (currentTextBlock?.type === 'reasoning') {
currentTextBlock.isThinking = false;
}
break;
case 'text-delta':
textContent += event.textDelta || '';
if (!currentTextBlock || currentTextBlock.type !== 'text') {
flushTextBlock();
currentTextBlock = { type: 'text', content: '' };
}
currentTextBlock.content += event.text || '';
break;
case 'step-finish':
if (currentTextBlock?.type === 'reasoning') {
currentTextBlock.isThinking = false;
}
flushTextBlock();
break;
case 'error':
flushTextBlock();
break;
default:
if (textContent) {
output.push({
type: 'text',
text: textContent,
});
textContent = '';
}
break;
}
}
flushTextBlock();
return output;
};