Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 83e49a27a8 | |||
| 15a962b196 | |||
| d3c11f2949 | |||
| 276e379f3c | |||
| ac762defab | |||
| 7ee8197967 | |||
| 86a97f84a2 | |||
| a7fb6476d2 | |||
| 154b868543 | |||
| 040b62e823 | |||
| cece97924c | |||
| d2104b2fae | |||
| 8eea5ea397 | |||
| f1de57b15c | |||
| d027021e39 | |||
| 6e37f8059d | |||
| cc64bdb920 | |||
| 62e6bddb64 | |||
| 8f7fdf4d7e | |||
| 0e3f502d4a | |||
| 99c6e88226 | |||
| 1025e24a23 | |||
| d570020a9b | |||
| 06bdb65d37 | |||
| 42194646eb | |||
| 32ac1a93fe | |||
| ed9e187438 | |||
| 78fd9a5efe | |||
| a86cab7594 | |||
| cd914a358d | |||
| 49a7769836 | |||
| be5799212c | |||
| a82b13df2d | |||
| 70112bf48f | |||
| a7ff1cbb25 | |||
| 3eb14f6861 | |||
| 7a31f2dad2 | |||
| eea99df480 | |||
| 4fcc389c11 | |||
| 5691c9808d | |||
| db7ab32987 |
@@ -1738,9 +1738,9 @@ type PublicWorkspaceDataSummary {
|
||||
displayName: String
|
||||
}
|
||||
|
||||
type NativeModelCapabilities {
|
||||
webSearch: Boolean
|
||||
twitterSearch: Boolean
|
||||
type AgentCapabilities {
|
||||
webSearch: Boolean!
|
||||
twitterSearch: Boolean!
|
||||
}
|
||||
|
||||
type ClientAIModelConfig {
|
||||
@@ -1751,7 +1751,7 @@ type ClientAIModelConfig {
|
||||
sdkPackage: String
|
||||
inputCostPerMillionTokens: Float
|
||||
outputCostPerMillionTokens: Float
|
||||
nativeCapabilities: NativeModelCapabilities
|
||||
capabilities: AgentCapabilities!
|
||||
isDeprecated: Boolean
|
||||
isRecommended: Boolean
|
||||
providerName: String
|
||||
@@ -1861,6 +1861,7 @@ type ClientConfig {
|
||||
analyticsEnabled: Boolean!
|
||||
support: Support!
|
||||
isAttachmentPreviewEnabled: Boolean!
|
||||
isCodeInterpreterEnabled: Boolean!
|
||||
sentry: Sentry!
|
||||
captcha: Captcha!
|
||||
api: ApiConfig!
|
||||
@@ -3000,6 +3001,14 @@ type AgentChatThreadConnection {
|
||||
edges: [AgentChatThreadEdge!]!
|
||||
}
|
||||
|
||||
type AgentTurnThreadSummary {
|
||||
id: UUID!
|
||||
totalInputTokens: Int!
|
||||
totalOutputTokens: Int!
|
||||
totalInputCredits: Float!
|
||||
totalOutputCredits: Float!
|
||||
}
|
||||
|
||||
type AgentTurnEvaluation {
|
||||
id: UUID!
|
||||
turnId: UUID!
|
||||
@@ -3014,6 +3023,7 @@ type AgentTurn {
|
||||
agentId: UUID
|
||||
evaluations: [AgentTurnEvaluation!]!
|
||||
messages: [AgentMessage!]!
|
||||
thread: AgentTurnThreadSummary
|
||||
createdAt: DateTime!
|
||||
}
|
||||
|
||||
@@ -3333,6 +3343,7 @@ type Query {
|
||||
sorting: [AgentChatThreadSort!]! = [{field: updatedAt, direction: DESC}]
|
||||
): AgentChatThreadConnection!
|
||||
agentTurns(agentId: UUID!): [AgentTurn!]!
|
||||
workflowAgentTrace(workflowRunId: UUID!, workflowStepId: String!): AgentTurn
|
||||
eventLogs(input: EventLogQueryInput!): EventLogQueryResult!
|
||||
pieChartData(input: PieChartDataInput!): PieChartData!
|
||||
lineChartData(input: LineChartDataInput!): LineChartData!
|
||||
|
||||
@@ -1437,10 +1437,10 @@ export interface PublicWorkspaceDataSummary {
|
||||
__typename: 'PublicWorkspaceDataSummary'
|
||||
}
|
||||
|
||||
export interface NativeModelCapabilities {
|
||||
webSearch?: Scalars['Boolean']
|
||||
twitterSearch?: Scalars['Boolean']
|
||||
__typename: 'NativeModelCapabilities'
|
||||
export interface AgentCapabilities {
|
||||
webSearch: Scalars['Boolean']
|
||||
twitterSearch: Scalars['Boolean']
|
||||
__typename: 'AgentCapabilities'
|
||||
}
|
||||
|
||||
export interface ClientAIModelConfig {
|
||||
@@ -1451,7 +1451,7 @@ export interface ClientAIModelConfig {
|
||||
sdkPackage?: Scalars['String']
|
||||
inputCostPerMillionTokens?: Scalars['Float']
|
||||
outputCostPerMillionTokens?: Scalars['Float']
|
||||
nativeCapabilities?: NativeModelCapabilities
|
||||
capabilities: AgentCapabilities
|
||||
isDeprecated?: Scalars['Boolean']
|
||||
isRecommended?: Scalars['Boolean']
|
||||
providerName?: Scalars['String']
|
||||
@@ -1560,6 +1560,7 @@ export interface ClientConfig {
|
||||
analyticsEnabled: Scalars['Boolean']
|
||||
support: Support
|
||||
isAttachmentPreviewEnabled: Scalars['Boolean']
|
||||
isCodeInterpreterEnabled: Scalars['Boolean']
|
||||
sentry: Sentry
|
||||
captcha: Captcha
|
||||
api: ApiConfig
|
||||
@@ -2675,6 +2676,15 @@ export interface AgentChatThreadConnection {
|
||||
__typename: 'AgentChatThreadConnection'
|
||||
}
|
||||
|
||||
export interface AgentTurnThreadSummary {
|
||||
id: Scalars['UUID']
|
||||
totalInputTokens: Scalars['Int']
|
||||
totalOutputTokens: Scalars['Int']
|
||||
totalInputCredits: Scalars['Float']
|
||||
totalOutputCredits: Scalars['Float']
|
||||
__typename: 'AgentTurnThreadSummary'
|
||||
}
|
||||
|
||||
export interface AgentTurnEvaluation {
|
||||
id: Scalars['UUID']
|
||||
turnId: Scalars['UUID']
|
||||
@@ -2690,6 +2700,7 @@ export interface AgentTurn {
|
||||
agentId?: Scalars['UUID']
|
||||
evaluations: AgentTurnEvaluation[]
|
||||
messages: AgentMessage[]
|
||||
thread?: AgentTurnThreadSummary
|
||||
createdAt: Scalars['DateTime']
|
||||
__typename: 'AgentTurn'
|
||||
}
|
||||
@@ -2895,6 +2906,7 @@ export interface Query {
|
||||
skill?: Skill
|
||||
chatThreads: AgentChatThreadConnection
|
||||
agentTurns: AgentTurn[]
|
||||
workflowAgentTrace?: AgentTurn
|
||||
eventLogs: EventLogQueryResult
|
||||
pieChartData: PieChartData
|
||||
lineChartData: LineChartData
|
||||
@@ -4697,7 +4709,7 @@ export interface PublicWorkspaceDataSummaryGenqlSelection{
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface NativeModelCapabilitiesGenqlSelection{
|
||||
export interface AgentCapabilitiesGenqlSelection{
|
||||
webSearch?: boolean | number
|
||||
twitterSearch?: boolean | number
|
||||
__typename?: boolean | number
|
||||
@@ -4712,7 +4724,7 @@ export interface ClientAIModelConfigGenqlSelection{
|
||||
sdkPackage?: boolean | number
|
||||
inputCostPerMillionTokens?: boolean | number
|
||||
outputCostPerMillionTokens?: boolean | number
|
||||
nativeCapabilities?: NativeModelCapabilitiesGenqlSelection
|
||||
capabilities?: AgentCapabilitiesGenqlSelection
|
||||
isDeprecated?: boolean | number
|
||||
isRecommended?: boolean | number
|
||||
providerName?: boolean | number
|
||||
@@ -4826,6 +4838,7 @@ export interface ClientConfigGenqlSelection{
|
||||
analyticsEnabled?: boolean | number
|
||||
support?: SupportGenqlSelection
|
||||
isAttachmentPreviewEnabled?: boolean | number
|
||||
isCodeInterpreterEnabled?: boolean | number
|
||||
sentry?: SentryGenqlSelection
|
||||
captcha?: CaptchaGenqlSelection
|
||||
api?: ApiConfigGenqlSelection
|
||||
@@ -6046,6 +6059,16 @@ export interface AgentChatThreadConnectionGenqlSelection{
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface AgentTurnThreadSummaryGenqlSelection{
|
||||
id?: boolean | number
|
||||
totalInputTokens?: boolean | number
|
||||
totalOutputTokens?: boolean | number
|
||||
totalInputCredits?: boolean | number
|
||||
totalOutputCredits?: boolean | number
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface AgentTurnEvaluationGenqlSelection{
|
||||
id?: boolean | number
|
||||
turnId?: boolean | number
|
||||
@@ -6062,6 +6085,7 @@ export interface AgentTurnGenqlSelection{
|
||||
agentId?: boolean | number
|
||||
evaluations?: AgentTurnEvaluationGenqlSelection
|
||||
messages?: AgentMessageGenqlSelection
|
||||
thread?: AgentTurnThreadSummaryGenqlSelection
|
||||
createdAt?: boolean | number
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
@@ -6274,6 +6298,7 @@ export interface QueryGenqlSelection{
|
||||
/** Specify to sort results. */
|
||||
sorting: AgentChatThreadSort[]} })
|
||||
agentTurns?: (AgentTurnGenqlSelection & { __args: {agentId: Scalars['UUID']} })
|
||||
workflowAgentTrace?: (AgentTurnGenqlSelection & { __args: {workflowRunId: Scalars['UUID'], workflowStepId: Scalars['String']} })
|
||||
eventLogs?: (EventLogQueryResultGenqlSelection & { __args: {input: EventLogQueryInput} })
|
||||
pieChartData?: (PieChartDataGenqlSelection & { __args: {input: PieChartDataInput} })
|
||||
lineChartData?: (LineChartDataGenqlSelection & { __args: {input: LineChartDataInput} })
|
||||
@@ -7877,10 +7902,10 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
|
||||
|
||||
|
||||
|
||||
const NativeModelCapabilities_possibleTypes: string[] = ['NativeModelCapabilities']
|
||||
export const isNativeModelCapabilities = (obj?: { __typename?: any } | null): obj is NativeModelCapabilities => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isNativeModelCapabilities"')
|
||||
return NativeModelCapabilities_possibleTypes.includes(obj.__typename)
|
||||
const AgentCapabilities_possibleTypes: string[] = ['AgentCapabilities']
|
||||
export const isAgentCapabilities = (obj?: { __typename?: any } | null): obj is AgentCapabilities => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isAgentCapabilities"')
|
||||
return AgentCapabilities_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
@@ -9029,6 +9054,14 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
|
||||
|
||||
|
||||
|
||||
const AgentTurnThreadSummary_possibleTypes: string[] = ['AgentTurnThreadSummary']
|
||||
export const isAgentTurnThreadSummary = (obj?: { __typename?: any } | null): obj is AgentTurnThreadSummary => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isAgentTurnThreadSummary"')
|
||||
return AgentTurnThreadSummary_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const AgentTurnEvaluation_possibleTypes: string[] = ['AgentTurnEvaluation']
|
||||
export const isAgentTurnEvaluation = (obj?: { __typename?: any } | null): obj is AgentTurnEvaluation => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isAgentTurnEvaluation"')
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -163,6 +163,12 @@ export type Agent = {
|
||||
updatedAt: Scalars['DateTime'];
|
||||
};
|
||||
|
||||
export type AgentCapabilities = {
|
||||
__typename?: 'AgentCapabilities';
|
||||
twitterSearch: Scalars['Boolean'];
|
||||
webSearch: Scalars['Boolean'];
|
||||
};
|
||||
|
||||
export type AgentChatEvent = {
|
||||
__typename?: 'AgentChatEvent';
|
||||
event: Scalars['JSON'];
|
||||
@@ -279,6 +285,7 @@ export type AgentTurn = {
|
||||
evaluations: Array<AgentTurnEvaluation>;
|
||||
id: Scalars['UUID'];
|
||||
messages: Array<AgentMessage>;
|
||||
thread?: Maybe<AgentTurnThreadSummary>;
|
||||
threadId: Scalars['UUID'];
|
||||
};
|
||||
|
||||
@@ -291,6 +298,15 @@ export type AgentTurnEvaluation = {
|
||||
turnId: Scalars['UUID'];
|
||||
};
|
||||
|
||||
export type AgentTurnThreadSummary = {
|
||||
__typename?: 'AgentTurnThreadSummary';
|
||||
id: Scalars['UUID'];
|
||||
totalInputCredits: Scalars['Float'];
|
||||
totalInputTokens: Scalars['Int'];
|
||||
totalOutputCredits: Scalars['Float'];
|
||||
totalOutputTokens: Scalars['Int'];
|
||||
};
|
||||
|
||||
export type AggregateChartConfiguration = {
|
||||
__typename?: 'AggregateChartConfiguration';
|
||||
aggregateFieldMetadataId: Scalars['UUID'];
|
||||
@@ -925,6 +941,7 @@ export type CheckUserExist = {
|
||||
|
||||
export type ClientAiModelConfig = {
|
||||
__typename?: 'ClientAIModelConfig';
|
||||
capabilities: AgentCapabilities;
|
||||
contextWindowTokens?: Maybe<Scalars['Float']>;
|
||||
dataResidency?: Maybe<Scalars['String']>;
|
||||
inputCostPerMillionTokens?: Maybe<Scalars['Float']>;
|
||||
@@ -935,7 +952,6 @@ export type ClientAiModelConfig = {
|
||||
modelFamily?: Maybe<ModelFamily>;
|
||||
modelFamilyLabel?: Maybe<Scalars['String']>;
|
||||
modelId: Scalars['String'];
|
||||
nativeCapabilities?: Maybe<NativeModelCapabilities>;
|
||||
outputCostPerMillionTokens?: Maybe<Scalars['Float']>;
|
||||
providerLabel?: Maybe<Scalars['String']>;
|
||||
providerName?: Maybe<Scalars['String']>;
|
||||
@@ -959,6 +975,7 @@ export type ClientConfig = {
|
||||
isAttachmentPreviewEnabled: Scalars['Boolean'];
|
||||
isClickHouseConfigured: Scalars['Boolean'];
|
||||
isCloudflareIntegrationEnabled: Scalars['Boolean'];
|
||||
isCodeInterpreterEnabled: Scalars['Boolean'];
|
||||
isConfigVariablesInDbEnabled: Scalars['Boolean'];
|
||||
isEmailVerificationRequired: Scalars['Boolean'];
|
||||
isGoogleCalendarEnabled: Scalars['Boolean'];
|
||||
@@ -3907,12 +3924,6 @@ export type MutationVerifyTwoFactorAuthenticationMethodForAuthenticatedUserArgs
|
||||
otp: Scalars['String'];
|
||||
};
|
||||
|
||||
export type NativeModelCapabilities = {
|
||||
__typename?: 'NativeModelCapabilities';
|
||||
twitterSearch?: Maybe<Scalars['Boolean']>;
|
||||
webSearch?: Maybe<Scalars['Boolean']>;
|
||||
};
|
||||
|
||||
export type NavigationMenuItem = {
|
||||
__typename?: 'NavigationMenuItem';
|
||||
applicationId?: Maybe<Scalars['UUID']>;
|
||||
@@ -4514,6 +4525,7 @@ export type Query = {
|
||||
versionInfo: VersionInfo;
|
||||
webhook?: Maybe<Webhook>;
|
||||
webhooks: Array<Webhook>;
|
||||
workflowAgentTrace?: Maybe<AgentTurn>;
|
||||
workspaceLookupAdminPanel: UserLookup;
|
||||
};
|
||||
|
||||
@@ -4951,6 +4963,12 @@ export type QueryWebhookArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type QueryWorkflowAgentTraceArgs = {
|
||||
workflowRunId: Scalars['UUID'];
|
||||
workflowStepId: Scalars['String'];
|
||||
};
|
||||
|
||||
|
||||
export type QueryWorkspaceLookupAdminPanelArgs = {
|
||||
workspaceId: Scalars['UUID'];
|
||||
};
|
||||
@@ -6695,6 +6713,22 @@ export type GetToolInputSchemaQueryVariables = Exact<{
|
||||
|
||||
export type GetToolInputSchemaQuery = { __typename?: 'Query', getToolInputSchema?: any | null };
|
||||
|
||||
export type GetWorkflowAgentTraceQueryVariables = Exact<{
|
||||
workflowRunId: Scalars['UUID'];
|
||||
workflowStepId: Scalars['String'];
|
||||
}>;
|
||||
|
||||
|
||||
export type GetWorkflowAgentTraceQuery = { __typename?: 'Query', workflowAgentTrace?: { __typename?: 'AgentTurn', id: string, thread?: { __typename?: 'AgentTurnThreadSummary', id: string, totalInputTokens: number, totalOutputTokens: number, totalInputCredits: number, totalOutputCredits: number } | null, messages: Array<{ __typename?: 'AgentMessage', id: string, threadId: string, turnId?: string | null, agentId?: string | null, role: string, status: string, processedAt?: string | null, createdAt: string, parts: Array<{ __typename?: 'AgentMessagePart', id: string, messageId: string, orderIndex: number, type: string, textContent?: string | null, reasoningContent?: string | null, toolName?: string | null, toolCallId?: string | null, toolInput?: any | null, toolOutput?: any | null, errorMessage?: string | null, state?: string | null, errorDetails?: any | null, sourceUrlSourceId?: string | null, sourceUrlUrl?: string | null, sourceUrlTitle?: string | null, sourceDocumentSourceId?: string | null, sourceDocumentMediaType?: string | null, sourceDocumentTitle?: string | null, sourceDocumentFilename?: string | null, fileMediaType?: string | null, fileFilename?: string | null, fileUrl?: string | null, fileId?: string | null, providerMetadata?: any | null, createdAt: string }> }> } | null };
|
||||
|
||||
export type GetWorkflowAgentTraceSummaryQueryVariables = Exact<{
|
||||
workflowRunId: Scalars['UUID'];
|
||||
workflowStepId: Scalars['String'];
|
||||
}>;
|
||||
|
||||
|
||||
export type GetWorkflowAgentTraceSummaryQuery = { __typename?: 'Query', workflowAgentTrace?: { __typename?: 'AgentTurn', id: string, thread?: { __typename?: 'AgentTurnThreadSummary', id: string, totalInputTokens: number, totalOutputTokens: number, totalInputCredits: number, totalOutputCredits: number } | null } | null };
|
||||
|
||||
export type OnAgentChatEventSubscriptionVariables = Exact<{
|
||||
threadId: Scalars['UUID'];
|
||||
}>;
|
||||
@@ -8680,6 +8714,8 @@ export const GetChatMessagesDocument = {"kind":"Document","definitions":[{"kind"
|
||||
export const GetChatThreadsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetChatThreads"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"paging"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"CursorPaging"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"chatThreads"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"paging"},"value":{"kind":"Variable","name":{"kind":"Name","value":"paging"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"totalInputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"totalOutputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"contextWindowTokens"}},{"kind":"Field","name":{"kind":"Name","value":"conversationSize"}},{"kind":"Field","name":{"kind":"Name","value":"totalInputCredits"}},{"kind":"Field","name":{"kind":"Name","value":"totalOutputCredits"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}},{"kind":"Field","name":{"kind":"Name","value":"cursor"}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"endCursor"}},{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}}]}}]}}]}}]} as unknown as DocumentNode<GetChatThreadsQuery, GetChatThreadsQueryVariables>;
|
||||
export const GetToolIndexDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetToolIndex"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getToolIndex"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"category"}},{"kind":"Field","name":{"kind":"Name","value":"objectName"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}}]}}]}}]} as unknown as DocumentNode<GetToolIndexQuery, GetToolIndexQueryVariables>;
|
||||
export const GetToolInputSchemaDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetToolInputSchema"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"toolName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getToolInputSchema"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"toolName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"toolName"}}}]}]}}]} as unknown as DocumentNode<GetToolInputSchemaQuery, GetToolInputSchemaQueryVariables>;
|
||||
export const GetWorkflowAgentTraceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetWorkflowAgentTrace"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workflowRunId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workflowStepId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workflowAgentTrace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workflowRunId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workflowRunId"}}},{"kind":"Argument","name":{"kind":"Name","value":"workflowStepId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workflowStepId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"thread"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"totalInputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"totalOutputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"totalInputCredits"}},{"kind":"Field","name":{"kind":"Name","value":"totalOutputCredits"}}]}},{"kind":"Field","name":{"kind":"Name","value":"messages"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"threadId"}},{"kind":"Field","name":{"kind":"Name","value":"turnId"}},{"kind":"Field","name":{"kind":"Name","value":"agentId"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"processedAt"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"parts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"messageId"}},{"kind":"Field","name":{"kind":"Name","value":"orderIndex"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"textContent"}},{"kind":"Field","name":{"kind":"Name","value":"reasoningContent"}},{"kind":"Field","name":{"kind":"Name","value":"toolName"}},{"kind":"Field","name":{"kind":"Name","value":"toolCallId"}},{"kind":"Field","name":{"kind":"Name","value":"toolInput"}},{"kind":"Field","name":{"kind":"Name","value":"toolOutput"}},{"kind":"Field","name":{"kind":"Name","value":"errorMessage"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"errorDetails"}},{"kind":"Field","name":{"kind":"Name","value":"sourceUrlSourceId"}},{"kind":"Field","name":{"kind":"Name","value":"sourceUrlUrl"}},{"kind":"Field","name":{"kind":"Name","value":"sourceUrlTitle"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentSourceId"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentMediaType"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentTitle"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentFilename"}},{"kind":"Field","name":{"kind":"Name","value":"fileMediaType"}},{"kind":"Field","name":{"kind":"Name","value":"fileFilename"}},{"kind":"Field","name":{"kind":"Name","value":"fileUrl"}},{"kind":"Field","name":{"kind":"Name","value":"fileId"}},{"kind":"Field","name":{"kind":"Name","value":"providerMetadata"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]}}]}}]} as unknown as DocumentNode<GetWorkflowAgentTraceQuery, GetWorkflowAgentTraceQueryVariables>;
|
||||
export const GetWorkflowAgentTraceSummaryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetWorkflowAgentTraceSummary"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workflowRunId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workflowStepId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workflowAgentTrace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workflowRunId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workflowRunId"}}},{"kind":"Argument","name":{"kind":"Name","value":"workflowStepId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workflowStepId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"thread"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"totalInputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"totalOutputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"totalInputCredits"}},{"kind":"Field","name":{"kind":"Name","value":"totalOutputCredits"}}]}}]}}]}}]} as unknown as DocumentNode<GetWorkflowAgentTraceSummaryQuery, GetWorkflowAgentTraceSummaryQueryVariables>;
|
||||
export const OnAgentChatEventDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"OnAgentChatEvent"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"onAgentChatEvent"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"threadId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"threadId"}},{"kind":"Field","name":{"kind":"Name","value":"event"}}]}}]}}]} as unknown as DocumentNode<OnAgentChatEventSubscription, OnAgentChatEventSubscriptionVariables>;
|
||||
export const TrackAnalyticsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"TrackAnalytics"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"type"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"AnalyticsType"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"event"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"name"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"properties"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"trackAnalytics"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"type"},"value":{"kind":"Variable","name":{"kind":"Name","value":"type"}}},{"kind":"Argument","name":{"kind":"Name","value":"event"},"value":{"kind":"Variable","name":{"kind":"Name","value":"event"}}},{"kind":"Argument","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"name"}}},{"kind":"Argument","name":{"kind":"Name","value":"properties"},"value":{"kind":"Variable","name":{"kind":"Name","value":"properties"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}}]}}]}}]} as unknown as DocumentNode<TrackAnalyticsMutation, TrackAnalyticsMutationVariables>;
|
||||
export const FindManyApplicationsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindManyApplications"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findManyApplications"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"version"}},{"kind":"Field","name":{"kind":"Name","value":"universalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"applicationRegistrationId"}},{"kind":"Field","name":{"kind":"Name","value":"applicationRegistration"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"latestAvailableVersion"}},{"kind":"Field","name":{"kind":"Name","value":"sourceType"}}]}}]}}]}}]} as unknown as DocumentNode<FindManyApplicationsQuery, FindManyApplicationsQueryVariables>;
|
||||
|
||||
+54
-72
@@ -1,51 +1,25 @@
|
||||
import { aiModelsState } from '@/client-config/states/aiModelsState';
|
||||
import { isCodeInterpreterEnabledState } from '@/client-config/states/isCodeInterpreterEnabledState';
|
||||
import { InputLabel } from '@/ui/input/components/InputLabel';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { styled } from '@linaria/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useContext } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IconBrandX, IconWorld } from 'twenty-ui/display';
|
||||
import { Checkbox } from 'twenty-ui/input';
|
||||
import {
|
||||
type AgentCapability,
|
||||
isAgentCapabilityEnabled,
|
||||
type ModelConfiguration,
|
||||
} from 'twenty-shared/ai';
|
||||
import { IconBrandX, IconCode, IconWorld } from 'twenty-ui/display';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { MenuItemToggle } from 'twenty-ui/navigation';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
const StyledCheckboxContainer = styled.div<{ disabled: boolean }>`
|
||||
align-items: center;
|
||||
border-radius: ${themeCssVariables.border.radius.sm};
|
||||
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
|
||||
display: flex;
|
||||
height: ${themeCssVariables.spacing[8]};
|
||||
justify-content: space-between;
|
||||
padding-inline: ${themeCssVariables.spacing[2]};
|
||||
transition: background-color
|
||||
calc(${themeCssVariables.animation.duration.normal} * 1s) ease;
|
||||
|
||||
&:hover {
|
||||
background-color: ${({ disabled }) =>
|
||||
disabled
|
||||
? 'transparent'
|
||||
: themeCssVariables.background.transparent.light};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledCheckboxLabel = styled.div`
|
||||
align-items: center;
|
||||
const StyledCapabilitiesContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[1]};
|
||||
`;
|
||||
|
||||
type ModelConfiguration = {
|
||||
webSearch?: {
|
||||
enabled: boolean;
|
||||
configuration?: Record<string, unknown>;
|
||||
};
|
||||
twitterSearch?: {
|
||||
enabled: boolean;
|
||||
configuration?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
type SettingsAgentModelCapabilitiesProps = {
|
||||
selectedModelId: string;
|
||||
modelConfiguration: ModelConfiguration;
|
||||
@@ -59,22 +33,24 @@ export const SettingsAgentModelCapabilities = ({
|
||||
onConfigurationChange,
|
||||
disabled = false,
|
||||
}: SettingsAgentModelCapabilitiesProps) => {
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const aiModels = useAtomStateValue(aiModelsState);
|
||||
const isCodeInterpreterEnabled = useAtomStateValue(
|
||||
isCodeInterpreterEnabledState,
|
||||
);
|
||||
|
||||
const selectedModel = aiModels.find((m) => m.modelId === selectedModelId);
|
||||
const nativeCapabilities = selectedModel?.nativeCapabilities;
|
||||
const modelCapabilities = selectedModel?.capabilities;
|
||||
|
||||
if (!isDefined(nativeCapabilities)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!nativeCapabilities.webSearch && !nativeCapabilities.twitterSearch) {
|
||||
if (
|
||||
!modelCapabilities?.webSearch &&
|
||||
!modelCapabilities?.twitterSearch &&
|
||||
!isCodeInterpreterEnabled
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleCapabilityToggle = (
|
||||
capability: 'webSearch' | 'twitterSearch',
|
||||
capability: AgentCapability,
|
||||
enabled: boolean,
|
||||
) => {
|
||||
if (disabled) {
|
||||
@@ -90,24 +66,40 @@ export const SettingsAgentModelCapabilities = ({
|
||||
});
|
||||
};
|
||||
|
||||
const capabilities = [
|
||||
...(nativeCapabilities.webSearch
|
||||
const capabilityItems = [
|
||||
...(modelCapabilities?.webSearch
|
||||
? [
|
||||
{
|
||||
key: 'webSearch' as const,
|
||||
label: t`Web Search`,
|
||||
Icon: IconWorld,
|
||||
enabled: modelConfiguration.webSearch?.enabled || false,
|
||||
enabled: isAgentCapabilityEnabled(modelConfiguration, 'webSearch'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(nativeCapabilities.twitterSearch
|
||||
...(modelCapabilities?.twitterSearch
|
||||
? [
|
||||
{
|
||||
key: 'twitterSearch' as const,
|
||||
label: t`Twitter/X Search`,
|
||||
Icon: IconBrandX,
|
||||
enabled: modelConfiguration.twitterSearch?.enabled || false,
|
||||
enabled: isAgentCapabilityEnabled(
|
||||
modelConfiguration,
|
||||
'twitterSearch',
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(isCodeInterpreterEnabled
|
||||
? [
|
||||
{
|
||||
key: 'codeInterpreter' as const,
|
||||
label: t`Code Interpreter`,
|
||||
Icon: IconCode,
|
||||
enabled: isAgentCapabilityEnabled(
|
||||
modelConfiguration,
|
||||
'codeInterpreter',
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -115,31 +107,21 @@ export const SettingsAgentModelCapabilities = ({
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<InputLabel>{t`Enable model-specific features`}</InputLabel>
|
||||
<div>
|
||||
{capabilities.map((capability) => (
|
||||
<StyledCheckboxContainer
|
||||
disabled={disabled}
|
||||
<InputLabel>{t`Capabilities`}</InputLabel>
|
||||
<StyledCapabilitiesContainer>
|
||||
{capabilityItems.map((capability) => (
|
||||
<MenuItemToggle
|
||||
key={capability.key}
|
||||
onClick={() =>
|
||||
handleCapabilityToggle(capability.key, !capability.enabled)
|
||||
LeftIcon={capability.Icon}
|
||||
text={capability.label}
|
||||
toggled={capability.enabled}
|
||||
onToggleChange={(toggled) =>
|
||||
handleCapabilityToggle(capability.key, toggled)
|
||||
}
|
||||
>
|
||||
<StyledCheckboxLabel>
|
||||
<capability.Icon size={theme.icon.size.sm} />
|
||||
<span>{capability.label}</span>
|
||||
</StyledCheckboxLabel>
|
||||
<Checkbox
|
||||
checked={capability.enabled}
|
||||
onChange={(event) => {
|
||||
event.stopPropagation();
|
||||
handleCapabilityToggle(capability.key, event.target.checked);
|
||||
}}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</StyledCheckboxContainer>
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</StyledCapabilitiesContainer>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import { styled } from '@linaria/react';
|
||||
import { plural, t } from '@lingui/core/macro';
|
||||
import { useState } from 'react';
|
||||
import { type ToolUIPart } from 'ai';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { isToolPartErrored } from 'twenty-shared/ai';
|
||||
import {
|
||||
IconChevronRight,
|
||||
IconCpu,
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
getToolDisplayMessage,
|
||||
resolveToolInput,
|
||||
} from '@/ai/utils/getToolDisplayMessage';
|
||||
import { isToolOutputInspectable } from '@/ai/utils/isToolOutputInspectable';
|
||||
import { getActiveReasoningContent } from '@/ai/utils/getActiveReasoningContent';
|
||||
import { getLastReasoningContent } from '@/ai/utils/getLastReasoningContent';
|
||||
import { isThinkingStepPartActive } from '@/ai/utils/isThinkingStepPartActive';
|
||||
@@ -271,8 +272,8 @@ const ThinkingToolStepRow = ({
|
||||
|
||||
const ToolIcon = getToolIcon(resolvedToolName);
|
||||
const label = getToolDisplayMessage(part.input, rawToolName, !isActive);
|
||||
const hasError = isDefined(part.errorText);
|
||||
const isExpandable = isDefined(part.output) || hasError;
|
||||
const hasError = isToolPartErrored(part.state);
|
||||
const isExpandable = isToolOutputInspectable(part.output) || hasError;
|
||||
|
||||
const outputObj =
|
||||
typeof part.output === 'object' && part.output !== null
|
||||
@@ -329,7 +330,9 @@ const ThinkingToolStepRow = ({
|
||||
<AnimatedExpandableContainer isExpanded={isExpanded} mode="fit-content">
|
||||
<StyledToolDetailsContainer>
|
||||
{hasError ? (
|
||||
<StyledToolErrorText>{part.errorText}</StyledToolErrorText>
|
||||
<StyledToolErrorText>
|
||||
{part.errorText?.trim() || t`Unknown error`}
|
||||
</StyledToolErrorText>
|
||||
) : (
|
||||
<StyledToolDetailsContent>
|
||||
<StyledToolTabListContainer>
|
||||
|
||||
@@ -13,9 +13,11 @@ import {
|
||||
resolveToolInput,
|
||||
} from '@/ai/utils/getToolDisplayMessage';
|
||||
import { getToolIcon } from '@/ai/utils/getToolIcon';
|
||||
import { isToolOutputInspectable } from '@/ai/utils/isToolOutputInspectable';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { type ToolUIPart } from 'ai';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { isToolPartErrored } from 'twenty-shared/ai';
|
||||
import { isPlainObject } from 'twenty-shared/utils';
|
||||
import { type JsonValue } from 'type-fest';
|
||||
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
|
||||
|
||||
@@ -146,8 +148,8 @@ export const ToolStepRenderer = ({
|
||||
const { resolvedInput: toolInput, resolvedToolName: toolName } =
|
||||
resolveToolInput(input, rawToolName);
|
||||
|
||||
const hasError = isDefined(errorText);
|
||||
const isExpandable = isDefined(output) || hasError;
|
||||
const hasError = isToolPartErrored(toolPart.state);
|
||||
const isExpandable = isToolOutputInspectable(output) || hasError;
|
||||
const ToolIcon = getToolIcon(toolName);
|
||||
|
||||
const outputObj =
|
||||
@@ -161,17 +163,19 @@ export const ToolStepRenderer = ({
|
||||
|
||||
if (toolName === 'code_interpreter') {
|
||||
const codeInput = toolInput as { code?: string } | undefined;
|
||||
const codeOutput = outputObj as {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
exitCode?: number;
|
||||
files?: Array<{
|
||||
fileId: string;
|
||||
filename: string;
|
||||
url: string;
|
||||
mimeType?: string;
|
||||
}>;
|
||||
} | null;
|
||||
const codeOutput = isPlainObject(outputObj?.result)
|
||||
? (outputObj.result as {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
exitCode?: number;
|
||||
files?: Array<{
|
||||
fileId: string;
|
||||
filename: string;
|
||||
url: string;
|
||||
mimeType?: string;
|
||||
}>;
|
||||
})
|
||||
: null;
|
||||
|
||||
const isRunning = !outputObj && !hasError && isStreaming;
|
||||
|
||||
@@ -228,7 +232,13 @@ export const ToolStepRenderer = ({
|
||||
return (
|
||||
<StyledContainer>
|
||||
<StyledToggleButton
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
onClick={() => {
|
||||
if (!isExpandable) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsExpanded(!isExpanded);
|
||||
}}
|
||||
isExpandable={isExpandable}
|
||||
>
|
||||
<StyledLeftContent>
|
||||
@@ -252,7 +262,7 @@ export const ToolStepRenderer = ({
|
||||
<AnimatedExpandableContainer isExpanded={isExpanded} mode="fit-content">
|
||||
<StyledContentContainer>
|
||||
{hasError ? (
|
||||
errorText
|
||||
errorText?.trim() || t`Unknown error`
|
||||
) : (
|
||||
<>
|
||||
<StyledTabContainer>
|
||||
|
||||
+4
-13
@@ -14,6 +14,7 @@ import {
|
||||
type AgentChatLastMessageUsage,
|
||||
} from '@/ai/states/agentChatUsageComponentFamilyState';
|
||||
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
|
||||
import { formatAiCost } from '@/ai/utils/formatAiCost';
|
||||
import { SettingsBillingLabelValueItem } from '@/settings/billing/components/internal/SettingsBillingLabelValueItem';
|
||||
import { billingState } from '@/client-config/states/billingState';
|
||||
import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue';
|
||||
@@ -104,17 +105,6 @@ export const AIChatContextUsageButton = () => {
|
||||
const billing = useAtomStateValue(billingState);
|
||||
const isBillingEnabled = billing?.isBillingEnabled ?? false;
|
||||
|
||||
// Values from the streaming API arrive as display credits (micro-credits / 1000).
|
||||
// 1000 display credits = $1. Convert accordingly.
|
||||
const formatChatCost = (displayCredits: number): string => {
|
||||
if (isBillingEnabled) {
|
||||
return `${formatNumber(displayCredits, { decimals: 1 })} credits`;
|
||||
}
|
||||
const dollars = displayCredits / 1000;
|
||||
|
||||
return `$${formatNumber(dollars, { decimals: 2 })}`;
|
||||
};
|
||||
|
||||
const hasMessages = useAtomComponentSelectorValue(
|
||||
agentChatHasMessageComponentSelector,
|
||||
);
|
||||
@@ -211,8 +201,9 @@ export const AIChatContextUsageButton = () => {
|
||||
/>
|
||||
<SettingsBillingLabelValueItem
|
||||
label={t`Cost`}
|
||||
value={formatChatCost(
|
||||
value={formatAiCost(
|
||||
lastMessage.inputCredits + lastMessage.outputCredits,
|
||||
{ isBillingEnabled },
|
||||
)}
|
||||
/>
|
||||
</StyledSection>
|
||||
@@ -241,7 +232,7 @@ export const AIChatContextUsageButton = () => {
|
||||
/>
|
||||
<SettingsBillingLabelValueItem
|
||||
label={t`Total cost`}
|
||||
value={formatChatCost(totalCredits)}
|
||||
value={formatAiCost(totalCredits, { isBillingEnabled })}
|
||||
/>
|
||||
</StyledSection>
|
||||
</StyledHoverCard>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const GET_WORKFLOW_AGENT_TRACE = gql`
|
||||
query GetWorkflowAgentTrace($workflowRunId: UUID!, $workflowStepId: String!) {
|
||||
workflowAgentTrace(
|
||||
workflowRunId: $workflowRunId
|
||||
workflowStepId: $workflowStepId
|
||||
) {
|
||||
id
|
||||
thread {
|
||||
id
|
||||
totalInputTokens
|
||||
totalOutputTokens
|
||||
totalInputCredits
|
||||
totalOutputCredits
|
||||
}
|
||||
messages {
|
||||
id
|
||||
threadId
|
||||
turnId
|
||||
agentId
|
||||
role
|
||||
status
|
||||
processedAt
|
||||
createdAt
|
||||
parts {
|
||||
id
|
||||
messageId
|
||||
orderIndex
|
||||
type
|
||||
textContent
|
||||
reasoningContent
|
||||
toolName
|
||||
toolCallId
|
||||
toolInput
|
||||
toolOutput
|
||||
errorMessage
|
||||
state
|
||||
errorDetails
|
||||
sourceUrlSourceId
|
||||
sourceUrlUrl
|
||||
sourceUrlTitle
|
||||
sourceDocumentSourceId
|
||||
sourceDocumentMediaType
|
||||
sourceDocumentTitle
|
||||
sourceDocumentFilename
|
||||
fileMediaType
|
||||
fileFilename
|
||||
fileUrl
|
||||
fileId
|
||||
providerMetadata
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,22 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const GET_WORKFLOW_AGENT_TRACE_SUMMARY = gql`
|
||||
query GetWorkflowAgentTraceSummary(
|
||||
$workflowRunId: UUID!
|
||||
$workflowStepId: String!
|
||||
) {
|
||||
workflowAgentTrace(
|
||||
workflowRunId: $workflowRunId
|
||||
workflowStepId: $workflowStepId
|
||||
) {
|
||||
id
|
||||
thread {
|
||||
id
|
||||
totalInputTokens
|
||||
totalOutputTokens
|
||||
totalInputCredits
|
||||
totalOutputCredits
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,23 @@
|
||||
import { isToolOutputInspectable } from '@/ai/utils/isToolOutputInspectable';
|
||||
|
||||
describe('isToolOutputInspectable', () => {
|
||||
it('should return false for missing output', () => {
|
||||
expect(isToolOutputInspectable(null)).toBe(false);
|
||||
expect(isToolOutputInspectable(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for empty output containers', () => {
|
||||
expect(isToolOutputInspectable({})).toBe(false);
|
||||
expect(isToolOutputInspectable([])).toBe(false);
|
||||
expect(isToolOutputInspectable('')).toBe(false);
|
||||
expect(isToolOutputInspectable(' ')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for inspectable output', () => {
|
||||
expect(isToolOutputInspectable({ result: 'ok' })).toBe(true);
|
||||
expect(isToolOutputInspectable(['ok'])).toBe(true);
|
||||
expect(isToolOutputInspectable('ok')).toBe(true);
|
||||
expect(isToolOutputInspectable(0)).toBe(true);
|
||||
expect(isToolOutputInspectable(false)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { mapDBPartToUIMessagePart } from '@/ai/utils/mapDBPartToUIMessagePart';
|
||||
import { type AgentMessagePart } from '~/generated-metadata/graphql';
|
||||
|
||||
const createToolMessagePart = (
|
||||
overrides: Partial<AgentMessagePart> = {},
|
||||
): AgentMessagePart =>
|
||||
({
|
||||
id: 'part-id',
|
||||
messageId: 'message-id',
|
||||
orderIndex: 0,
|
||||
type: 'tool-learn_tools',
|
||||
textContent: null,
|
||||
reasoningContent: null,
|
||||
toolName: 'learn_tools',
|
||||
toolCallId: 'tool-call-id',
|
||||
toolInput: { toolNames: ['findCompanies'] },
|
||||
toolOutput: {
|
||||
tools: [{ name: 'findCompanies', description: 'Find companies' }],
|
||||
notFound: [],
|
||||
message: 'Learned 1 tool(s): findCompanies.',
|
||||
},
|
||||
state: 'output-available',
|
||||
errorMessage: null,
|
||||
errorDetails: null,
|
||||
sourceUrlSourceId: null,
|
||||
sourceUrlUrl: null,
|
||||
sourceUrlTitle: null,
|
||||
sourceDocumentSourceId: null,
|
||||
sourceDocumentMediaType: null,
|
||||
sourceDocumentTitle: null,
|
||||
sourceDocumentFilename: null,
|
||||
fileMediaType: null,
|
||||
fileFilename: null,
|
||||
fileId: null,
|
||||
fileUrl: null,
|
||||
providerMetadata: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
}) as AgentMessagePart;
|
||||
|
||||
describe('mapDBPartToUIMessagePart', () => {
|
||||
it('should omit errorText for successful persisted tool parts', () => {
|
||||
const uiMessagePart = mapDBPartToUIMessagePart(createToolMessagePart());
|
||||
|
||||
expect(uiMessagePart).toMatchObject({
|
||||
type: 'tool-learn_tools',
|
||||
toolCallId: 'tool-call-id',
|
||||
input: { toolNames: ['findCompanies'] },
|
||||
output: {
|
||||
tools: [{ name: 'findCompanies', description: 'Find companies' }],
|
||||
notFound: [],
|
||||
message: 'Learned 1 tool(s): findCompanies.',
|
||||
},
|
||||
state: 'output-available',
|
||||
});
|
||||
expect(uiMessagePart).not.toHaveProperty('errorText');
|
||||
});
|
||||
|
||||
it('should include errorText for failed persisted tool parts', () => {
|
||||
const uiMessagePart = mapDBPartToUIMessagePart(
|
||||
createToolMessagePart({
|
||||
toolOutput: null,
|
||||
state: 'output-error',
|
||||
errorMessage: 'Tool failed',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(uiMessagePart).toMatchObject({
|
||||
type: 'tool-learn_tools',
|
||||
errorText: 'Tool failed',
|
||||
state: 'output-error',
|
||||
});
|
||||
});
|
||||
});
|
||||
+21
-2
@@ -23,12 +23,14 @@ const createToolPart = ({
|
||||
errorText,
|
||||
input = {},
|
||||
output,
|
||||
state = 'output-available',
|
||||
type = 'tool-web_search',
|
||||
}: {
|
||||
type?: `tool-${string}`;
|
||||
input?: Record<string, unknown>;
|
||||
output?: unknown;
|
||||
errorText?: string;
|
||||
state?: string;
|
||||
} = {}): ThinkingStepPart =>
|
||||
({
|
||||
type,
|
||||
@@ -36,7 +38,7 @@ const createToolPart = ({
|
||||
input,
|
||||
output,
|
||||
errorText,
|
||||
state: 'output-available',
|
||||
state,
|
||||
}) as ThinkingStepPart;
|
||||
|
||||
describe('thinkingStepsDisplayState', () => {
|
||||
@@ -102,17 +104,34 @@ describe('thinkingStepsDisplayState', () => {
|
||||
expect(isThinkingStepPartActive(toolPart, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('should mark tool parts with output or error as inactive', () => {
|
||||
it('should treat non-error states as active regardless of errorText', () => {
|
||||
const toolPart = createToolPart({
|
||||
state: 'input-available',
|
||||
output: undefined,
|
||||
errorText: '',
|
||||
});
|
||||
|
||||
expect(isThinkingStepPartActive(toolPart, true)).toBe(true);
|
||||
});
|
||||
|
||||
it('should mark tool parts with output or error state as inactive', () => {
|
||||
const completedToolPart = createToolPart({
|
||||
state: 'output-available',
|
||||
output: { result: { ok: true } },
|
||||
});
|
||||
const failedToolPart = createToolPart({
|
||||
state: 'output-error',
|
||||
output: undefined,
|
||||
errorText: 'Tool failed',
|
||||
});
|
||||
const deniedToolPart = createToolPart({
|
||||
state: 'output-denied',
|
||||
output: undefined,
|
||||
});
|
||||
|
||||
expect(isThinkingStepPartActive(completedToolPart, true)).toBe(false);
|
||||
expect(isThinkingStepPartActive(failedToolPart, true)).toBe(false);
|
||||
expect(isThinkingStepPartActive(deniedToolPart, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { formatNumber } from '~/utils/format/formatNumber';
|
||||
|
||||
// 1000 display credits = $1. Show credits when billing is on, dollars otherwise.
|
||||
type FormatAiCostOptions = { isBillingEnabled: boolean };
|
||||
|
||||
export const formatAiCost = (
|
||||
displayCredits: number,
|
||||
{ isBillingEnabled }: FormatAiCostOptions,
|
||||
): string => {
|
||||
if (isBillingEnabled) {
|
||||
return `${formatNumber(displayCredits, { decimals: 1 })} credits`;
|
||||
}
|
||||
|
||||
const dollars = displayCredits / 1000;
|
||||
|
||||
return `$${formatNumber(dollars, { decimals: 2 })}`;
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isToolPartErrored } from 'twenty-shared/ai';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type ThinkingStepPart } from '@/ai/utils/thinkingStepPart';
|
||||
@@ -13,6 +14,6 @@ export const isThinkingStepPartActive = (
|
||||
return (
|
||||
isLastMessageStreaming &&
|
||||
!isDefined(part.output) &&
|
||||
!isDefined(part.errorText)
|
||||
!isToolPartErrored(part.state)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const isToolOutputInspectable = (output: unknown): boolean => {
|
||||
if (!isDefined(output)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Array.isArray(output)) {
|
||||
return output.length > 0;
|
||||
}
|
||||
|
||||
if (typeof output === 'object') {
|
||||
return Object.keys(output).length > 0;
|
||||
}
|
||||
|
||||
if (typeof output === 'string') {
|
||||
return output.trim().length > 0;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
@@ -16,37 +16,37 @@ export const mapDBPartToUIMessagePart = (
|
||||
case 'text':
|
||||
return {
|
||||
type: 'text',
|
||||
text: part.textContent!,
|
||||
text: part.textContent ?? '',
|
||||
};
|
||||
case 'reasoning':
|
||||
return {
|
||||
type: 'reasoning',
|
||||
text: part.reasoningContent!,
|
||||
text: part.reasoningContent ?? '',
|
||||
state: part.state as ReasoningUIPart['state'],
|
||||
};
|
||||
case 'file':
|
||||
return {
|
||||
type: 'file',
|
||||
mediaType: part.fileMediaType!,
|
||||
filename: part.fileFilename!,
|
||||
url: part.fileUrl!,
|
||||
fileId: part.fileId!,
|
||||
mediaType: part.fileMediaType ?? 'application/octet-stream',
|
||||
filename: part.fileFilename ?? '',
|
||||
url: part.fileUrl ?? '',
|
||||
fileId: part.fileId ?? '',
|
||||
} as ExtendedFileUIPart;
|
||||
case 'source-url':
|
||||
return {
|
||||
type: 'source-url',
|
||||
sourceId: part.sourceUrlSourceId!,
|
||||
url: part.sourceUrlUrl!,
|
||||
title: part.sourceUrlTitle!,
|
||||
sourceId: part.sourceUrlSourceId ?? '',
|
||||
url: part.sourceUrlUrl ?? '',
|
||||
title: part.sourceUrlTitle ?? '',
|
||||
providerMetadata: part.providerMetadata ?? undefined,
|
||||
};
|
||||
case 'source-document':
|
||||
return {
|
||||
type: 'source-document',
|
||||
sourceId: part.sourceDocumentSourceId!,
|
||||
mediaType: part.sourceDocumentMediaType!,
|
||||
title: part.sourceDocumentTitle!,
|
||||
filename: part.sourceDocumentFilename!,
|
||||
sourceId: part.sourceDocumentSourceId ?? '',
|
||||
mediaType: part.sourceDocumentMediaType ?? '',
|
||||
title: part.sourceDocumentTitle ?? '',
|
||||
filename: part.sourceDocumentFilename ?? '',
|
||||
providerMetadata: part.providerMetadata ?? undefined,
|
||||
};
|
||||
case 'step-start':
|
||||
@@ -57,8 +57,8 @@ export const mapDBPartToUIMessagePart = (
|
||||
return {
|
||||
type: part.type,
|
||||
data: {
|
||||
text: part.textContent!,
|
||||
state: part.state!,
|
||||
text: part.textContent ?? '',
|
||||
state: part.state ?? '',
|
||||
},
|
||||
};
|
||||
default:
|
||||
@@ -66,11 +66,11 @@ export const mapDBPartToUIMessagePart = (
|
||||
if (part.type.includes('tool-') === true) {
|
||||
return {
|
||||
type: part.type as `tool-${string}`,
|
||||
toolCallId: part.toolCallId!,
|
||||
toolCallId: part.toolCallId ?? '',
|
||||
input: part.toolInput ?? {},
|
||||
output: part.toolOutput,
|
||||
errorText: part.errorMessage!,
|
||||
state: part.state,
|
||||
...(part.errorMessage ? { errorText: part.errorMessage } : {}),
|
||||
} as ToolUIPart;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { canManageFeatureFlagsState } from '@/client-config/states/canManageFeat
|
||||
import { captchaState } from '@/client-config/states/captchaState';
|
||||
import { isAnalyticsEnabledState } from '@/client-config/states/isAnalyticsEnabledState';
|
||||
import { isAttachmentPreviewEnabledState } from '@/client-config/states/isAttachmentPreviewEnabledState';
|
||||
import { isCodeInterpreterEnabledState } from '@/client-config/states/isCodeInterpreterEnabledState';
|
||||
import { isConfigVariablesInDbEnabledState } from '@/client-config/states/isConfigVariablesInDbEnabledState';
|
||||
import { isDeveloperDefaultSignInPrefilledState } from '@/client-config/states/isDeveloperDefaultSignInPrefilledState';
|
||||
import { isClickHouseConfiguredState } from '@/client-config/states/isClickHouseConfiguredState';
|
||||
@@ -93,6 +94,9 @@ export const useClientConfig = (): UseClientConfigResult => {
|
||||
const setIsAttachmentPreviewEnabled = useSetAtomState(
|
||||
isAttachmentPreviewEnabledState,
|
||||
);
|
||||
const setIsCodeInterpreterEnabled = useSetAtomState(
|
||||
isCodeInterpreterEnabledState,
|
||||
);
|
||||
|
||||
const setIsConfigVariablesInDbEnabled = useSetAtomState(
|
||||
isConfigVariablesInDbEnabledState,
|
||||
@@ -185,6 +189,7 @@ export const useClientConfig = (): UseClientConfigResult => {
|
||||
setIsGoogleMessagingEnabled(clientConfig?.isGoogleMessagingEnabled);
|
||||
setIsGoogleCalendarEnabled(clientConfig?.isGoogleCalendarEnabled);
|
||||
setIsAttachmentPreviewEnabled(clientConfig?.isAttachmentPreviewEnabled);
|
||||
setIsCodeInterpreterEnabled(clientConfig?.isCodeInterpreterEnabled);
|
||||
setIsConfigVariablesInDbEnabled(
|
||||
clientConfig?.isConfigVariablesInDbEnabled,
|
||||
);
|
||||
@@ -229,6 +234,7 @@ export const useClientConfig = (): UseClientConfigResult => {
|
||||
setIsGoogleMessagingEnabled,
|
||||
setIsAnalyticsEnabled,
|
||||
setIsAttachmentPreviewEnabled,
|
||||
setIsCodeInterpreterEnabled,
|
||||
setIsConfigVariablesInDbEnabled,
|
||||
setIsDeveloperDefaultSignInPrefilled,
|
||||
setIsEmailVerificationRequired,
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
|
||||
export const isCodeInterpreterEnabledState = createAtomState<boolean>({
|
||||
key: 'isCodeInterpreterEnabledState',
|
||||
defaultValue: false,
|
||||
});
|
||||
@@ -23,6 +23,7 @@ export type ClientConfig = {
|
||||
defaultSubdomain?: string;
|
||||
frontDomain: string;
|
||||
isAttachmentPreviewEnabled: boolean;
|
||||
isCodeInterpreterEnabled: boolean;
|
||||
isConfigVariablesInDbEnabled: boolean;
|
||||
isEmailVerificationRequired: boolean;
|
||||
isGoogleCalendarEnabled: boolean;
|
||||
|
||||
+1
@@ -38,6 +38,7 @@ const mockClientConfig = {
|
||||
mutationMaximumAffectedRecords: 100,
|
||||
},
|
||||
isAttachmentPreviewEnabled: true,
|
||||
isCodeInterpreterEnabled: false,
|
||||
analyticsEnabled: false,
|
||||
canManageFeatureFlags: true,
|
||||
publicFeatureFlags: [],
|
||||
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
import { GET_WORKFLOW_AGENT_TRACE_SUMMARY } from '@/ai/graphql/queries/getWorkflowAgentTraceSummary';
|
||||
import { formatAiCost } from '@/ai/utils/formatAiCost';
|
||||
import { billingState } from '@/client-config/states/billingState';
|
||||
import { usePermissionFlagMap } from '@/settings/roles/hooks/usePermissionFlagMap';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useQuery } from '@apollo/client/react';
|
||||
import { styled } from '@linaria/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { StepStatus } from 'twenty-shared/workflow';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { PermissionFlagType } from '~/generated-metadata/graphql';
|
||||
import { formatNumber } from '~/utils/format/formatNumber';
|
||||
|
||||
type WorkflowRunAiAgentExecutionSummaryProps = {
|
||||
workflowRunId: string;
|
||||
workflowStepId: string;
|
||||
status: StepStatus;
|
||||
};
|
||||
|
||||
type WorkflowAgentTraceSummaryResult = {
|
||||
workflowAgentTrace: {
|
||||
id: string;
|
||||
thread: {
|
||||
id: string;
|
||||
totalInputTokens: number;
|
||||
totalOutputTokens: number;
|
||||
totalInputCredits: number;
|
||||
totalOutputCredits: number;
|
||||
} | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
const StyledSummaryRow = styled.div`
|
||||
align-items: center;
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
display: flex;
|
||||
font-size: ${themeCssVariables.font.size.sm};
|
||||
gap: ${themeCssVariables.spacing[1]};
|
||||
line-height: ${themeCssVariables.text.lineHeight.md};
|
||||
max-width: 360px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
padding-right: ${themeCssVariables.spacing[3]};
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const StyledSummarySeparator = styled.span`
|
||||
color: ${themeCssVariables.font.color.light};
|
||||
`;
|
||||
|
||||
const getStatusLabel = (status: StepStatus) => {
|
||||
switch (status) {
|
||||
case StepStatus.SUCCESS:
|
||||
return t`Completed`;
|
||||
case StepStatus.FAILED:
|
||||
case StepStatus.FAILED_SAFELY:
|
||||
return t`Failed`;
|
||||
case StepStatus.RUNNING:
|
||||
return t`Running`;
|
||||
case StepStatus.PENDING:
|
||||
return t`Pending`;
|
||||
case StepStatus.STOPPED:
|
||||
return t`Stopped`;
|
||||
case StepStatus.SKIPPED:
|
||||
return t`Skipped`;
|
||||
default:
|
||||
return String(status);
|
||||
}
|
||||
};
|
||||
|
||||
const getTokenCountLabel = (tokenCount: number) => {
|
||||
const formattedTokenCount = formatNumber(tokenCount, {
|
||||
abbreviate: true,
|
||||
decimals: 1,
|
||||
});
|
||||
|
||||
return tokenCount === 1
|
||||
? t`${formattedTokenCount} token`
|
||||
: t`${formattedTokenCount} tokens`;
|
||||
};
|
||||
|
||||
const shouldQueryTraceForStatus = (status: StepStatus) =>
|
||||
status !== StepStatus.NOT_STARTED &&
|
||||
status !== StepStatus.PENDING &&
|
||||
status !== StepStatus.RUNNING;
|
||||
|
||||
export const WorkflowRunAiAgentExecutionSummary = ({
|
||||
workflowRunId,
|
||||
workflowStepId,
|
||||
status,
|
||||
}: WorkflowRunAiAgentExecutionSummaryProps) => {
|
||||
const permissionMap = usePermissionFlagMap();
|
||||
const hasAiPermission = permissionMap[PermissionFlagType.AI];
|
||||
|
||||
const { data } = useQuery<WorkflowAgentTraceSummaryResult>(
|
||||
GET_WORKFLOW_AGENT_TRACE_SUMMARY,
|
||||
{
|
||||
variables: { workflowRunId, workflowStepId },
|
||||
fetchPolicy: 'cache-and-network',
|
||||
skip: !hasAiPermission || !shouldQueryTraceForStatus(status),
|
||||
},
|
||||
);
|
||||
|
||||
const billing = useAtomStateValue(billingState);
|
||||
const isBillingEnabled = billing?.isBillingEnabled ?? false;
|
||||
|
||||
const thread = data?.workflowAgentTrace?.thread ?? null;
|
||||
const totalTokens = isDefined(thread)
|
||||
? thread.totalInputTokens + thread.totalOutputTokens
|
||||
: 0;
|
||||
const totalCredits = isDefined(thread)
|
||||
? thread.totalInputCredits + thread.totalOutputCredits
|
||||
: 0;
|
||||
|
||||
const summaryItems = [
|
||||
getStatusLabel(status),
|
||||
...(totalTokens > 0 ? [getTokenCountLabel(totalTokens)] : []),
|
||||
...(totalCredits > 0
|
||||
? [formatAiCost(totalCredits, { isBillingEnabled })]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<StyledSummaryRow>
|
||||
{summaryItems.map((summaryItem, index) => (
|
||||
<span key={summaryItem}>
|
||||
{index > 0 && (
|
||||
<StyledSummarySeparator>· </StyledSummarySeparator>
|
||||
)}
|
||||
{summaryItem}
|
||||
</span>
|
||||
))}
|
||||
</StyledSummaryRow>
|
||||
);
|
||||
};
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
|
||||
import { AIChatAssistantMessageRenderer } from '@/ai/components/AIChatAssistantMessageRenderer';
|
||||
import { AgentChatFilePreview } from '@/ai/components/internal/AgentChatFilePreview';
|
||||
import { GET_WORKFLOW_AGENT_TRACE } from '@/ai/graphql/queries/getWorkflowAgentTrace';
|
||||
import { mapDBMessagesToUIMessages } from '@/ai/utils/mapDBMessagesToUIMessages';
|
||||
import { usePermissionFlagMap } from '@/settings/roles/hooks/usePermissionFlagMap';
|
||||
import { WorkflowRunAiAgentTracePrompt } from '@/workflow/workflow-steps/components/WorkflowRunAiAgentTracePrompt';
|
||||
import { useQuery } from '@apollo/client/react';
|
||||
import { styled } from '@linaria/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import Skeleton from 'react-loading-skeleton';
|
||||
import {
|
||||
type ExtendedUIMessagePart,
|
||||
isExtendedFileUIPart,
|
||||
} from 'twenty-shared/ai';
|
||||
import { getSafeUrl } from 'twenty-shared/utils';
|
||||
import { AvatarOrIcon, Chip, ChipVariant } from 'twenty-ui/components';
|
||||
import { IconExternalLink, IconFileText, IconWorld } from 'twenty-ui/display';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { StepStatus } from 'twenty-shared/workflow';
|
||||
import {
|
||||
PermissionFlagType,
|
||||
type AgentMessage,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
type GetWorkflowAgentTraceResult = {
|
||||
workflowAgentTrace: {
|
||||
id: string;
|
||||
messages: AgentMessage[];
|
||||
} | null;
|
||||
};
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
const StyledMessagesList = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
const StyledAssistantMessage = styled.div`
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
`;
|
||||
|
||||
const StyledAssistantMessageContent = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
const StyledSupplementaryPartsContainer = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
const StyledExternalSourceLink = styled.a`
|
||||
color: inherit;
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
text-decoration: none;
|
||||
`;
|
||||
|
||||
const StyledTraceUnavailable = styled.div`
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
font-size: ${themeCssVariables.font.size.md};
|
||||
`;
|
||||
|
||||
type WorkflowTraceSourcePart =
|
||||
| Extract<ExtendedUIMessagePart, { type: 'source-url' }>
|
||||
| Extract<ExtendedUIMessagePart, { type: 'source-document' }>;
|
||||
|
||||
const extractPromptText = (userMessage: AgentMessage | undefined): string => {
|
||||
if (!userMessage) return '';
|
||||
|
||||
return userMessage.parts
|
||||
.filter((part) => part.type === 'text')
|
||||
.map((part) => part.textContent ?? '')
|
||||
.join('\n')
|
||||
.trim();
|
||||
};
|
||||
|
||||
const isWorkflowTraceSourcePart = (
|
||||
part: ExtendedUIMessagePart,
|
||||
): part is WorkflowTraceSourcePart =>
|
||||
part.type === 'source-url' || part.type === 'source-document';
|
||||
|
||||
const getWorkflowTraceSourceLabel = (sourcePart: WorkflowTraceSourcePart) => {
|
||||
if (sourcePart.type === 'source-url') {
|
||||
return sourcePart.title || sourcePart.url;
|
||||
}
|
||||
|
||||
return (
|
||||
sourcePart.title || sourcePart.filename || sourcePart.mediaType || t`Source`
|
||||
);
|
||||
};
|
||||
|
||||
const WorkflowTraceSourceChip = ({
|
||||
sourcePart,
|
||||
}: {
|
||||
sourcePart: WorkflowTraceSourcePart;
|
||||
}) => {
|
||||
const label = getWorkflowTraceSourceLabel(sourcePart);
|
||||
const sourceChip = (
|
||||
<Chip
|
||||
label={label}
|
||||
emptyLabel={t`Untitled`}
|
||||
variant={ChipVariant.Static}
|
||||
leftComponent={
|
||||
<AvatarOrIcon
|
||||
Icon={sourcePart.type === 'source-url' ? IconWorld : IconFileText}
|
||||
/>
|
||||
}
|
||||
rightComponent={
|
||||
sourcePart.type === 'source-url' ? (
|
||||
<AvatarOrIcon Icon={IconExternalLink} />
|
||||
) : undefined
|
||||
}
|
||||
rightComponentDivider={sourcePart.type === 'source-url'}
|
||||
/>
|
||||
);
|
||||
|
||||
const safeHref =
|
||||
sourcePart.type === 'source-url' ? getSafeUrl(sourcePart.url) : undefined;
|
||||
|
||||
if (safeHref) {
|
||||
return (
|
||||
<StyledExternalSourceLink
|
||||
href={safeHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{sourceChip}
|
||||
</StyledExternalSourceLink>
|
||||
);
|
||||
}
|
||||
|
||||
return sourceChip;
|
||||
};
|
||||
|
||||
type WorkflowRunAiAgentTraceDetailProps = {
|
||||
workflowRunId: string;
|
||||
workflowStepId: string;
|
||||
status: StepStatus;
|
||||
};
|
||||
|
||||
const shouldQueryTraceForStatus = (status: StepStatus) =>
|
||||
status !== StepStatus.NOT_STARTED &&
|
||||
status !== StepStatus.PENDING &&
|
||||
status !== StepStatus.RUNNING;
|
||||
|
||||
export const WorkflowRunAiAgentTraceDetail = ({
|
||||
workflowRunId,
|
||||
workflowStepId,
|
||||
status,
|
||||
}: WorkflowRunAiAgentTraceDetailProps) => {
|
||||
const permissionMap = usePermissionFlagMap();
|
||||
const hasAiPermission = permissionMap[PermissionFlagType.AI];
|
||||
|
||||
const { data, loading } = useQuery<GetWorkflowAgentTraceResult>(
|
||||
GET_WORKFLOW_AGENT_TRACE,
|
||||
{
|
||||
variables: { workflowRunId, workflowStepId },
|
||||
fetchPolicy: 'cache-and-network',
|
||||
skip: !hasAiPermission || !shouldQueryTraceForStatus(status),
|
||||
},
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return <Skeleton height={SKELETON_LOADER_HEIGHT_SIZES.columns.m} />;
|
||||
}
|
||||
|
||||
const turn = data?.workflowAgentTrace;
|
||||
|
||||
if (!turn || turn.messages.length === 0) {
|
||||
return (
|
||||
<StyledTraceUnavailable>{t`Trace unavailable`}</StyledTraceUnavailable>
|
||||
);
|
||||
}
|
||||
|
||||
const userMessage = turn.messages.find((message) => message.role === 'user');
|
||||
const assistantMessages = turn.messages.filter(
|
||||
(message) => message.role === 'assistant' && message.parts.length > 0,
|
||||
);
|
||||
const promptText = extractPromptText(userMessage);
|
||||
const uiMessages = mapDBMessagesToUIMessages(assistantMessages);
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<WorkflowRunAiAgentTracePrompt promptText={promptText} />
|
||||
<StyledMessagesList>
|
||||
{uiMessages.map((message) => {
|
||||
const renderableMessageParts = message.parts.filter(
|
||||
(part) =>
|
||||
part.type !== 'file' &&
|
||||
part.type !== 'source-url' &&
|
||||
part.type !== 'source-document',
|
||||
);
|
||||
const fileParts = message.parts.filter(isExtendedFileUIPart);
|
||||
const sourceParts = message.parts.filter(isWorkflowTraceSourcePart);
|
||||
|
||||
return (
|
||||
<StyledAssistantMessage key={message.id}>
|
||||
<StyledAssistantMessageContent>
|
||||
{renderableMessageParts.length > 0 && (
|
||||
<AIChatAssistantMessageRenderer
|
||||
messageParts={renderableMessageParts}
|
||||
isLastMessageStreaming={false}
|
||||
/>
|
||||
)}
|
||||
{(fileParts.length > 0 || sourceParts.length > 0) && (
|
||||
<StyledSupplementaryPartsContainer>
|
||||
{fileParts.map((filePart) => (
|
||||
<AgentChatFilePreview
|
||||
key={filePart.fileId}
|
||||
file={filePart}
|
||||
/>
|
||||
))}
|
||||
{sourceParts.map((sourcePart) => (
|
||||
<WorkflowTraceSourceChip
|
||||
key={`${sourcePart.type}-${sourcePart.sourceId}`}
|
||||
sourcePart={sourcePart}
|
||||
/>
|
||||
))}
|
||||
</StyledSupplementaryPartsContainer>
|
||||
)}
|
||||
</StyledAssistantMessageContent>
|
||||
</StyledAssistantMessage>
|
||||
);
|
||||
})}
|
||||
</StyledMessagesList>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useState } from 'react';
|
||||
import { IconChevronRight } from 'twenty-ui/display';
|
||||
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
const StyledPromptSection = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[1]};
|
||||
`;
|
||||
|
||||
const StyledPromptToggle = styled.button`
|
||||
align-items: center;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: ${themeCssVariables.border.radius.sm};
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
font-family: inherit;
|
||||
font-size: ${themeCssVariables.font.size.md};
|
||||
font-weight: ${themeCssVariables.font.weight.regular};
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
line-height: ${themeCssVariables.text.lineHeight.md};
|
||||
min-height: 24px;
|
||||
padding: 0;
|
||||
transition: color calc(${themeCssVariables.animation.duration.fast} * 1s)
|
||||
ease-in-out;
|
||||
width: fit-content;
|
||||
|
||||
&:hover {
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid ${themeCssVariables.color.blue};
|
||||
outline-offset: 2px;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledChevron = styled.span<{ isExpanded: boolean }>`
|
||||
align-items: center;
|
||||
color: ${themeCssVariables.font.color.light};
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
transform: rotate(${({ isExpanded }) => (isExpanded ? '90deg' : '0deg')});
|
||||
transition: transform calc(${themeCssVariables.animation.duration.fast} * 1s)
|
||||
ease-in-out;
|
||||
`;
|
||||
|
||||
const StyledPromptBody = styled.div`
|
||||
background: ${themeCssVariables.background.transparent.lighter};
|
||||
border: 1px solid ${themeCssVariables.border.color.light};
|
||||
border-radius: ${themeCssVariables.border.radius.sm};
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
font-size: ${themeCssVariables.font.size.md};
|
||||
line-height: ${themeCssVariables.text.lineHeight.lg};
|
||||
margin-top: ${themeCssVariables.spacing[1]};
|
||||
padding: ${themeCssVariables.spacing[3]};
|
||||
white-space: pre-wrap;
|
||||
`;
|
||||
|
||||
export const WorkflowRunAiAgentTracePrompt = ({
|
||||
promptText,
|
||||
}: {
|
||||
promptText: string;
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
if (promptText.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledPromptSection>
|
||||
<StyledPromptToggle
|
||||
type="button"
|
||||
aria-expanded={isExpanded}
|
||||
onClick={() => setIsExpanded((previous) => !previous)}
|
||||
>
|
||||
<StyledChevron isExpanded={isExpanded}>
|
||||
<IconChevronRight size={14} />
|
||||
</StyledChevron>
|
||||
{t`Prompt`}
|
||||
</StyledPromptToggle>
|
||||
<AnimatedExpandableContainer isExpanded={isExpanded} mode="fit-content">
|
||||
<StyledPromptBody>{promptText}</StyledPromptBody>
|
||||
</AnimatedExpandableContainer>
|
||||
</StyledPromptSection>
|
||||
);
|
||||
};
|
||||
+151
-19
@@ -1,18 +1,134 @@
|
||||
import { TabList } from '@/ui/layout/tab-list/components/TabList';
|
||||
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
|
||||
import { type SingleTabProps } from '@/ui/layout/tab-list/types/SingleTabProps';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { usePermissionFlagMap } from '@/settings/roles/hooks/usePermissionFlagMap';
|
||||
import { useWorkflowRun } from '@/workflow/hooks/useWorkflowRun';
|
||||
import { useWorkflowRunIdOrThrow } from '@/workflow/hooks/useWorkflowRunIdOrThrow';
|
||||
import { getStepDefinitionOrThrow } from '@/workflow/utils/getStepDefinitionOrThrow';
|
||||
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
|
||||
import { WorkflowRunAiAgentExecutionSummary } from '@/workflow/workflow-steps/components/WorkflowRunAiAgentExecutionSummary';
|
||||
import { WorkflowRunAiAgentTraceDetail } from '@/workflow/workflow-steps/components/WorkflowRunAiAgentTraceDetail';
|
||||
import { WorkflowRunStepJsonContainer } from '@/workflow/workflow-steps/components/WorkflowRunStepJsonContainer';
|
||||
import { useWorkflowRunStepInfo } from '@/workflow/workflow-steps/hooks/useWorkflowRunStepInfo';
|
||||
import { getWorkflowRunStepInfoToDisplayAsOutput } from '@/workflow/workflow-steps/utils/getWorkflowRunStepInfoToDisplayAsOutput';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { type ReactNode } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type StepStatus } from 'twenty-shared/workflow';
|
||||
import {
|
||||
type GetJsonNodeHighlighting,
|
||||
isTwoFirstDepths,
|
||||
JsonTree,
|
||||
} from 'twenty-ui/json-visualizer';
|
||||
import { IconJson, IconTimelineEvent } from 'twenty-ui/display';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { PermissionFlagType } from '~/generated-metadata/graphql';
|
||||
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
|
||||
|
||||
const WORKFLOW_RUN_AI_AGENT_OUTPUT_TABS = {
|
||||
RESULT: 'result',
|
||||
TRACE: 'trace',
|
||||
} as const;
|
||||
|
||||
type WorkflowRunAiAgentOutputTabId =
|
||||
(typeof WORKFLOW_RUN_AI_AGENT_OUTPUT_TABS)[keyof typeof WORKFLOW_RUN_AI_AGENT_OUTPUT_TABS];
|
||||
|
||||
const StyledTabListContainer = styled.div`
|
||||
background-color: ${themeCssVariables.background.secondary};
|
||||
padding-left: ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
const getAiAgentOutputTabListComponentInstanceId = ({
|
||||
workflowRunId,
|
||||
workflowStepId,
|
||||
}: {
|
||||
workflowRunId: string;
|
||||
workflowStepId: string;
|
||||
}) => `workflow-run-ai-agent-output-tabs-${workflowRunId}-${workflowStepId}`;
|
||||
|
||||
const WorkflowRunAiAgentOutputDetail = ({
|
||||
workflowRunId,
|
||||
workflowStepId,
|
||||
hasError,
|
||||
status,
|
||||
resultTree,
|
||||
}: {
|
||||
workflowRunId: string;
|
||||
workflowStepId: string;
|
||||
hasError: boolean;
|
||||
status: StepStatus;
|
||||
resultTree: ReactNode;
|
||||
}) => {
|
||||
const { t } = useLingui();
|
||||
const aiAgentOutputTabListComponentInstanceId =
|
||||
getAiAgentOutputTabListComponentInstanceId({
|
||||
workflowRunId,
|
||||
workflowStepId,
|
||||
});
|
||||
const permissionMap = usePermissionFlagMap();
|
||||
const hasAiPermission = permissionMap[PermissionFlagType.AI];
|
||||
|
||||
const activeTabId = useAtomComponentStateValue(
|
||||
activeTabIdComponentState,
|
||||
aiAgentOutputTabListComponentInstanceId,
|
||||
);
|
||||
const currentAiAgentOutputTabId =
|
||||
activeTabId === WORKFLOW_RUN_AI_AGENT_OUTPUT_TABS.TRACE && hasAiPermission
|
||||
? WORKFLOW_RUN_AI_AGENT_OUTPUT_TABS.TRACE
|
||||
: WORKFLOW_RUN_AI_AGENT_OUTPUT_TABS.RESULT;
|
||||
const aiAgentOutputTabs: SingleTabProps<WorkflowRunAiAgentOutputTabId>[] = [
|
||||
{
|
||||
id: WORKFLOW_RUN_AI_AGENT_OUTPUT_TABS.RESULT,
|
||||
title: hasError ? t`Error` : t`Result`,
|
||||
Icon: IconJson,
|
||||
},
|
||||
...(hasAiPermission
|
||||
? [
|
||||
{
|
||||
id: WORKFLOW_RUN_AI_AGENT_OUTPUT_TABS.TRACE,
|
||||
title: t`Trace`,
|
||||
Icon: IconTimelineEvent,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledTabListContainer>
|
||||
<TabList
|
||||
tabs={aiAgentOutputTabs}
|
||||
behaveAsLinks={false}
|
||||
componentInstanceId={aiAgentOutputTabListComponentInstanceId}
|
||||
rightComponent={
|
||||
<WorkflowRunAiAgentExecutionSummary
|
||||
workflowRunId={workflowRunId}
|
||||
workflowStepId={workflowStepId}
|
||||
status={status}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</StyledTabListContainer>
|
||||
{currentAiAgentOutputTabId ===
|
||||
WORKFLOW_RUN_AI_AGENT_OUTPUT_TABS.RESULT ? (
|
||||
<WorkflowStepBody overflow="auto">{resultTree}</WorkflowStepBody>
|
||||
) : null}
|
||||
|
||||
{currentAiAgentOutputTabId === WORKFLOW_RUN_AI_AGENT_OUTPUT_TABS.TRACE ? (
|
||||
<WorkflowStepBody overflow="auto">
|
||||
<WorkflowRunAiAgentTraceDetail
|
||||
workflowRunId={workflowRunId}
|
||||
workflowStepId={workflowStepId}
|
||||
status={status}
|
||||
/>
|
||||
</WorkflowStepBody>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const WorkflowRunStepOutputDetail = ({ stepId }: { stepId: string }) => {
|
||||
const { t } = useLingui();
|
||||
const { copyToClipboard } = useCopyToClipboard();
|
||||
@@ -39,6 +155,10 @@ export const WorkflowRunStepOutputDetail = ({ stepId }: { stepId: string }) => {
|
||||
throw new Error('The step is expected to be properly shaped.');
|
||||
}
|
||||
|
||||
const isAiAgentStep =
|
||||
stepDefinition.type === 'action' &&
|
||||
stepDefinition.definition.type === 'AI_AGENT';
|
||||
|
||||
const setRedHighlightingForEveryNode: GetJsonNodeHighlighting = (keyPath) => {
|
||||
if (keyPath.startsWith('error')) {
|
||||
return 'red';
|
||||
@@ -47,25 +167,37 @@ export const WorkflowRunStepOutputDetail = ({ stepId }: { stepId: string }) => {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const resultTree = (
|
||||
<JsonTree
|
||||
value={stepInfoToDisplay ?? t`No output available`}
|
||||
shouldExpandNodeInitially={isTwoFirstDepths}
|
||||
emptyArrayLabel={t`Empty Array`}
|
||||
emptyObjectLabel={t`Empty Object`}
|
||||
emptyStringLabel={t`[empty string]`}
|
||||
arrowButtonCollapsedLabel={t`Expand`}
|
||||
arrowButtonExpandedLabel={t`Collapse`}
|
||||
getNodeHighlighting={
|
||||
isDefined(stepInfo?.error) ? setRedHighlightingForEveryNode : undefined
|
||||
}
|
||||
onNodeValueClick={copyToClipboard}
|
||||
/>
|
||||
);
|
||||
|
||||
if (isAiAgentStep) {
|
||||
const hasError = isDefined(stepInfo.error);
|
||||
|
||||
return (
|
||||
<WorkflowRunAiAgentOutputDetail
|
||||
workflowRunId={workflowRunId}
|
||||
workflowStepId={stepId}
|
||||
hasError={hasError}
|
||||
status={stepInfo.status}
|
||||
resultTree={resultTree}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<WorkflowRunStepJsonContainer>
|
||||
<JsonTree
|
||||
value={stepInfoToDisplay ?? t`No output available`}
|
||||
shouldExpandNodeInitially={isTwoFirstDepths}
|
||||
emptyArrayLabel={t`Empty Array`}
|
||||
emptyObjectLabel={t`Empty Object`}
|
||||
emptyStringLabel={t`[empty string]`}
|
||||
arrowButtonCollapsedLabel={t`Expand`}
|
||||
arrowButtonExpandedLabel={t`Collapse`}
|
||||
getNodeHighlighting={
|
||||
isDefined(stepInfo?.error)
|
||||
? setRedHighlightingForEveryNode
|
||||
: undefined
|
||||
}
|
||||
onNodeValueClick={copyToClipboard}
|
||||
/>
|
||||
</WorkflowRunStepJsonContainer>
|
||||
</>
|
||||
<WorkflowRunStepJsonContainer>{resultTree}</WorkflowRunStepJsonContainer>
|
||||
);
|
||||
};
|
||||
|
||||
+4
@@ -119,6 +119,8 @@ export const WorkflowAiAgentPromptTab = ({
|
||||
await updateAgentField({
|
||||
modelId,
|
||||
});
|
||||
|
||||
onActionUpdate?.({ ...action });
|
||||
};
|
||||
|
||||
const handleModelConfigurationChange = async (
|
||||
@@ -127,6 +129,8 @@ export const WorkflowAiAgentPromptTab = ({
|
||||
await updateAgentField({
|
||||
modelConfiguration: configuration,
|
||||
});
|
||||
|
||||
onActionUpdate?.({ ...action });
|
||||
};
|
||||
|
||||
const handleOutputSchemaChange = (updatedFields: OutputSchemaField[]) => {
|
||||
|
||||
+6
@@ -25,6 +25,12 @@ export const settingsAIAgentFormSchema = z.object({
|
||||
configuration: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
codeInterpreter: z
|
||||
.object({
|
||||
enabled: z.boolean(),
|
||||
configuration: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
responseFormat: z
|
||||
|
||||
@@ -51,6 +51,7 @@ export const mockedClientConfig: ClientConfig = {
|
||||
isGoogleMessagingEnabled: true,
|
||||
isGoogleCalendarEnabled: true,
|
||||
isAttachmentPreviewEnabled: true,
|
||||
isCodeInterpreterEnabled: false,
|
||||
isConfigVariablesInDbEnabled: false,
|
||||
isImapSmtpCaldavEnabled: false,
|
||||
isTwoFactorAuthenticationEnabled: false,
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import { QueryRunner } from 'typeorm';
|
||||
|
||||
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
|
||||
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
|
||||
|
||||
@RegisteredInstanceCommand('1.23.0', 1786375159878)
|
||||
export class MakeAgentChatThreadSourcePolymorphicFastInstanceCommand
|
||||
implements FastInstanceCommand
|
||||
{
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "core"."agentChatThread" ADD "workflowRunId" uuid',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "core"."agentChatThread" ADD "workflowStepId" varchar',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "core"."agentChatThread" ALTER COLUMN "userWorkspaceId" DROP NOT NULL',
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."agentChatThread" ADD CONSTRAINT "CHK_agent_chat_thread_source" CHECK (("userWorkspaceId" IS NOT NULL AND "workflowRunId" IS NULL AND "workflowStepId" IS NULL) OR ("userWorkspaceId" IS NULL AND "workflowRunId" IS NOT NULL AND "workflowStepId" IS NOT NULL))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "IDX_AGENT_CHAT_THREAD_WORKFLOW" ON "core"."agentChatThread" ("workspaceId", "workflowRunId", "workflowStepId") WHERE "workflowRunId" IS NOT NULL`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
'DROP INDEX "core"."IDX_AGENT_CHAT_THREAD_WORKFLOW"',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "core"."agentChatThread" DROP CONSTRAINT IF EXISTS "CHK_agent_chat_thread_source"',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'DELETE FROM "core"."agentChatThread" WHERE "workflowRunId" IS NOT NULL',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "core"."agentChatThread" ALTER COLUMN "userWorkspaceId" SET NOT NULL',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "core"."agentChatThread" DROP COLUMN "workflowStepId"',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "core"."agentChatThread" DROP COLUMN "workflowRunId"',
|
||||
);
|
||||
}
|
||||
}
|
||||
+6
-4
@@ -3,18 +3,19 @@
|
||||
import { AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-instance-command-fast-1775129420309-add-view-field-group-id-index-on-view-field';
|
||||
import { MigrateMessagingCalendarToCoreFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-instance-command-fast-1775165049548-migrate-messaging-calendar-to-core';
|
||||
import { AddEmailThreadWidgetTypeFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-instance-command-fast-1775200000000-add-email-thread-widget-type';
|
||||
import { AddStandalonePageFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-23/1-23-instance-command-fast-1775752781995-add-standalone-page';
|
||||
import { AddPermissionFlagRoleIdIndexFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-22/1-22-instance-command-fast-1775749486425-add-permission-flag-role-id-index';
|
||||
import { AddTableWidgetViewTypeFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-23/1-23-instance-command-fast-1775752190522-add-table-widget-view-type';
|
||||
import { AddWorkspaceIdToIndirectEntitiesFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-22/1-22-instance-command-fast-1775758621017-add-workspace-id-to-indirect-entities';
|
||||
import { AddWorkspaceIdIndexesAndFksFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-22/1-22-instance-command-fast-1775761294897-add-workspace-id-indexes-and-fks-to-indirect-entities';
|
||||
import { DropObjectMetadataDataSourceFkFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-22/1-22-instance-command-fast-1775804361516-drop-object-metadata-data-source-fk';
|
||||
import { AddCreditBalanceToBillingCustomerFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-22/1-22-instance-command-fast-1776078919203-add-credit-balance-to-billing-customer';
|
||||
import { BackfillWorkspaceIdOnIndirectEntitiesSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/1-22/1-22-instance-command-slow-1775758621018-backfill-workspace-id-on-indirect-entities';
|
||||
import { DropWorkspaceVersionColumnFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-23/1-23-instance-command-fast-1785000000000-drop-workspace-version-column';
|
||||
import { AddConditionalAvailabilityExpressionToPageLayoutWidgetFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-23/1-23-instance-command-fast-1775654781000-add-conditional-availability-expression-to-page-layout-widget';
|
||||
import { AddTableWidgetViewTypeFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-23/1-23-instance-command-fast-1775752190522-add-table-widget-view-type';
|
||||
import { AddStandalonePageFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-23/1-23-instance-command-fast-1775752781995-add-standalone-page';
|
||||
import { AddGlobalObjectContextToCommandMenuItemAvailabilityTypeFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-23/1-23-instance-command-fast-1776090711153-add-global-object-context-to-command-menu-item-availability-type';
|
||||
import { AddPageLayoutIdToCommandMenuItemFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-23/1-23-instance-command-fast-1776168404836-add-page-layout-id-to-command-menu-item';
|
||||
import { AddConditionalAvailabilityExpressionToPageLayoutWidgetFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-23/1-23-instance-command-fast-1775654781000-add-conditional-availability-expression-to-page-layout-widget';
|
||||
import { DropWorkspaceVersionColumnFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-23/1-23-instance-command-fast-1785000000000-drop-workspace-version-column';
|
||||
import { MakeAgentChatThreadSourcePolymorphicFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-23/1-23-instance-command-fast-1786375159878-make-agent-chat-thread-source-polymorphic';
|
||||
|
||||
export const INSTANCE_COMMANDS = [
|
||||
AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand,
|
||||
@@ -31,5 +32,6 @@ export const INSTANCE_COMMANDS = [
|
||||
DropWorkspaceVersionColumnFastInstanceCommand,
|
||||
AddGlobalObjectContextToCommandMenuItemAvailabilityTypeFastInstanceCommand,
|
||||
AddPageLayoutIdToCommandMenuItemFastInstanceCommand,
|
||||
MakeAgentChatThreadSourcePolymorphicFastInstanceCommand,
|
||||
AddConditionalAvailabilityExpressionToPageLayoutWidgetFastInstanceCommand,
|
||||
];
|
||||
|
||||
@@ -15,6 +15,7 @@ import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-
|
||||
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
|
||||
import { buildApiKeyAuthContext } from 'src/engine/core-modules/auth/utils/build-api-key-auth-context.util';
|
||||
import { COMMON_PRELOAD_TOOLS } from 'src/engine/core-modules/tool-provider/constants/common-preload-tools.const';
|
||||
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
|
||||
import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
|
||||
import {
|
||||
createExecuteToolTool,
|
||||
@@ -107,9 +108,10 @@ export class McpProtocolService {
|
||||
userWorkspaceId?: string;
|
||||
},
|
||||
): Promise<ToolSet> {
|
||||
const toolContext = {
|
||||
const toolContext: ToolProviderContext = {
|
||||
workspaceId: workspace.id,
|
||||
roleId,
|
||||
rolePermissionConfig: { unionOf: [roleId] },
|
||||
authContext: options?.authContext,
|
||||
userId: options?.userId,
|
||||
userWorkspaceId: options?.userWorkspaceId,
|
||||
|
||||
+5
@@ -52,6 +52,10 @@ describe('ClientConfigController', () => {
|
||||
label: 'GPT-4o',
|
||||
modelFamily: ModelFamily.GPT,
|
||||
sdkPackage: '@ai-sdk/openai' as const,
|
||||
capabilities: {
|
||||
webSearch: true,
|
||||
twitterSearch: false,
|
||||
},
|
||||
inputCostPerMillionTokensInCredits: 2500000,
|
||||
outputCostPerMillionTokensInCredits: 10000000,
|
||||
},
|
||||
@@ -85,6 +89,7 @@ describe('ClientConfigController', () => {
|
||||
mutationMaximumAffectedRecords: 100,
|
||||
},
|
||||
isAttachmentPreviewEnabled: true,
|
||||
isCodeInterpreterEnabled: false,
|
||||
analyticsEnabled: false,
|
||||
canManageFeatureFlags: true,
|
||||
publicFeatureFlags: [],
|
||||
|
||||
+10
-7
@@ -30,12 +30,12 @@ registerEnumType(AiModelRole, {
|
||||
});
|
||||
|
||||
@ObjectType()
|
||||
export class NativeModelCapabilities {
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
webSearch?: boolean;
|
||||
export class AgentCapabilities {
|
||||
@Field(() => Boolean)
|
||||
webSearch: boolean;
|
||||
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
twitterSearch?: boolean;
|
||||
@Field(() => Boolean)
|
||||
twitterSearch: boolean;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
@@ -62,8 +62,8 @@ export class ClientAIModelConfig {
|
||||
@Field(() => Number, { nullable: true })
|
||||
outputCostPerMillionTokens?: number;
|
||||
|
||||
@Field(() => NativeModelCapabilities, { nullable: true })
|
||||
nativeCapabilities?: NativeModelCapabilities;
|
||||
@Field(() => AgentCapabilities)
|
||||
capabilities: AgentCapabilities;
|
||||
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
isDeprecated?: boolean;
|
||||
@@ -276,6 +276,9 @@ export class ClientConfig {
|
||||
@Field(() => Boolean)
|
||||
isAttachmentPreviewEnabled: boolean;
|
||||
|
||||
@Field(() => Boolean)
|
||||
isCodeInterpreterEnabled: boolean;
|
||||
|
||||
@Field(() => Sentry)
|
||||
sentry: Sentry;
|
||||
|
||||
|
||||
+90
-1
@@ -3,18 +3,25 @@ import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
|
||||
import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/support.interface';
|
||||
|
||||
import { MaintenanceModeService } from 'src/engine/core-modules/admin-panel/maintenance-mode.service';
|
||||
import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces';
|
||||
import { ClientConfigService } from 'src/engine/core-modules/client-config/services/client-config.service';
|
||||
import { CodeInterpreterDriverType } from 'src/engine/core-modules/code-interpreter/code-interpreter.interface';
|
||||
import { DomainServerConfigService } from 'src/engine/core-modules/domain/domain-server-config/services/domain-server-config.service';
|
||||
import { PUBLIC_FEATURE_FLAGS } from 'src/engine/core-modules/feature-flag/constants/public-feature-flag.const';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { WebSearchDriverType } from 'src/engine/core-modules/web-search/web-search.interface';
|
||||
import {
|
||||
AI_SDK_OPENAI,
|
||||
AI_SDK_XAI,
|
||||
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-sdk-package.const';
|
||||
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
import { MaintenanceModeService } from 'src/engine/core-modules/admin-panel/maintenance-mode.service';
|
||||
|
||||
describe('ClientConfigService', () => {
|
||||
let service: ClientConfigService;
|
||||
let twentyConfigService: TwentyConfigService;
|
||||
let domainServerConfigService: DomainServerConfigService;
|
||||
let aiModelRegistryService: AiModelRegistryService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
@@ -57,6 +64,9 @@ describe('ClientConfigService', () => {
|
||||
domainServerConfigService = module.get<DomainServerConfigService>(
|
||||
DomainServerConfigService,
|
||||
);
|
||||
aiModelRegistryService = module.get<AiModelRegistryService>(
|
||||
AiModelRegistryService,
|
||||
);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
@@ -103,6 +113,9 @@ describe('ClientConfigService', () => {
|
||||
CLOUDFLARE_ZONE_ID: undefined,
|
||||
ALLOW_REQUESTS_TO_TWENTY_ICONS: false,
|
||||
CLICKHOUSE_URL: undefined,
|
||||
WEB_SEARCH_DRIVER: WebSearchDriverType.DISABLED,
|
||||
WEB_SEARCH_PREFER_NATIVE: false,
|
||||
CODE_INTERPRETER_TYPE: CodeInterpreterDriverType.DISABLED,
|
||||
};
|
||||
|
||||
return mockValues[key];
|
||||
@@ -162,6 +175,7 @@ describe('ClientConfigService', () => {
|
||||
mutationMaximumAffectedRecords: 1000,
|
||||
},
|
||||
isAttachmentPreviewEnabled: true,
|
||||
isCodeInterpreterEnabled: false,
|
||||
analyticsEnabled: true,
|
||||
canManageFeatureFlags: true,
|
||||
publicFeatureFlags: PUBLIC_FEATURE_FLAGS,
|
||||
@@ -240,5 +254,80 @@ describe('ClientConfigService', () => {
|
||||
|
||||
expect(result.canManageFeatureFlags).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps x search available when native web search is preferred', async () => {
|
||||
jest
|
||||
.spyOn(aiModelRegistryService, 'getAdminFilteredModels')
|
||||
.mockReturnValue([
|
||||
{
|
||||
modelId: 'xai-model',
|
||||
sdkPackage: AI_SDK_XAI,
|
||||
model: {} as never,
|
||||
providerName: 'xai',
|
||||
},
|
||||
]);
|
||||
|
||||
jest
|
||||
.spyOn(twentyConfigService, 'get')
|
||||
.mockImplementation((key: string) => {
|
||||
if (key === 'WEB_SEARCH_DRIVER') return WebSearchDriverType.EXA;
|
||||
if (key === 'WEB_SEARCH_PREFER_NATIVE') return true;
|
||||
if (key === 'CODE_INTERPRETER_TYPE')
|
||||
return CodeInterpreterDriverType.DISABLED;
|
||||
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const result = await service.getClientConfig();
|
||||
const xaiModel = result.aiModels.find(
|
||||
(model) => model.modelId === 'xai-model',
|
||||
);
|
||||
|
||||
expect(xaiModel?.capabilities).toEqual({
|
||||
webSearch: true,
|
||||
twitterSearch: true,
|
||||
});
|
||||
expect(xaiModel?.capabilities).not.toHaveProperty('codeInterpreter');
|
||||
expect(result.isCodeInterpreterEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('surfaces code interpreter availability at the client-config level', async () => {
|
||||
jest
|
||||
.spyOn(aiModelRegistryService, 'getAdminFilteredModels')
|
||||
.mockReturnValue([
|
||||
{
|
||||
modelId: 'openai-model',
|
||||
sdkPackage: AI_SDK_OPENAI,
|
||||
model: {} as never,
|
||||
providerName: 'openai',
|
||||
},
|
||||
]);
|
||||
|
||||
jest
|
||||
.spyOn(twentyConfigService, 'get')
|
||||
.mockImplementation((key: string) => {
|
||||
if (key === 'WEB_SEARCH_DRIVER') {
|
||||
return WebSearchDriverType.DISABLED;
|
||||
}
|
||||
|
||||
if (key === 'CODE_INTERPRETER_TYPE') {
|
||||
return CodeInterpreterDriverType.LOCAL;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const result = await service.getClientConfig();
|
||||
const openAiModel = result.aiModels.find(
|
||||
(model) => model.modelId === 'openai-model',
|
||||
);
|
||||
|
||||
expect(result.isCodeInterpreterEnabled).toBe(true);
|
||||
expect(openAiModel?.capabilities).toEqual({
|
||||
webSearch: true,
|
||||
twitterSearch: false,
|
||||
});
|
||||
expect(openAiModel?.capabilities).not.toHaveProperty('codeInterpreter');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+35
-17
@@ -1,32 +1,35 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type AiSdkPackage } from 'twenty-shared/ai';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { CodeInterpreterDriverType } from 'src/engine/core-modules/code-interpreter/code-interpreter.interface';
|
||||
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
|
||||
import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/support.interface';
|
||||
import { WebSearchDriverType } from 'src/engine/core-modules/web-search/web-search.interface';
|
||||
import {
|
||||
AI_SDK_ANTHROPIC,
|
||||
AI_SDK_BEDROCK,
|
||||
AI_SDK_OPENAI,
|
||||
AI_SDK_XAI,
|
||||
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-sdk-package.const';
|
||||
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
|
||||
import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/support.interface';
|
||||
|
||||
import { MaintenanceModeService } from 'src/engine/core-modules/admin-panel/maintenance-mode.service';
|
||||
import {
|
||||
type AgentCapabilities,
|
||||
type ClientAIModelConfig,
|
||||
type ClientConfig,
|
||||
type NativeModelCapabilities,
|
||||
} from 'src/engine/core-modules/client-config/client-config.entity';
|
||||
import { DomainServerConfigService } from 'src/engine/core-modules/domain/domain-server-config/services/domain-server-config.service';
|
||||
import { PUBLIC_FEATURE_FLAGS } from 'src/engine/core-modules/feature-flag/constants/public-feature-flag.const';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { MODEL_FAMILY_LABELS } from 'src/engine/metadata-modules/ai/ai-models/constants/model-family-labels.const';
|
||||
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
import {
|
||||
AUTO_SELECT_FAST_MODEL_ID,
|
||||
AUTO_SELECT_SMART_MODEL_ID,
|
||||
} from 'twenty-shared/constants';
|
||||
import { MODEL_FAMILY_LABELS } from 'src/engine/metadata-modules/ai/ai-models/constants/model-family-labels.const';
|
||||
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
|
||||
@Injectable()
|
||||
export class ClientConfigService {
|
||||
@@ -37,17 +40,23 @@ export class ClientConfigService {
|
||||
private maintenanceModeService: MaintenanceModeService,
|
||||
) {}
|
||||
|
||||
private deriveNativeCapabilities(
|
||||
private deriveAvailableModelCapabilities(
|
||||
sdkPackage?: AiSdkPackage,
|
||||
): NativeModelCapabilities | undefined {
|
||||
switch (sdkPackage) {
|
||||
case AI_SDK_OPENAI:
|
||||
case AI_SDK_ANTHROPIC:
|
||||
case AI_SDK_BEDROCK:
|
||||
return { webSearch: true };
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
): AgentCapabilities {
|
||||
const supportsProviderNativeWebSearch =
|
||||
sdkPackage === AI_SDK_OPENAI ||
|
||||
sdkPackage === AI_SDK_ANTHROPIC ||
|
||||
sdkPackage === AI_SDK_BEDROCK ||
|
||||
sdkPackage === AI_SDK_XAI;
|
||||
|
||||
const isExternalWebSearchEnabled =
|
||||
this.twentyConfigService.get('WEB_SEARCH_DRIVER') !==
|
||||
WebSearchDriverType.DISABLED;
|
||||
|
||||
return {
|
||||
webSearch: supportsProviderNativeWebSearch || isExternalWebSearchEnabled,
|
||||
twitterSearch: sdkPackage === AI_SDK_XAI,
|
||||
};
|
||||
}
|
||||
|
||||
private isCloudflareIntegrationEnabled(): boolean {
|
||||
@@ -95,7 +104,7 @@ export class ClientConfigService {
|
||||
sdkPackage: registeredModel.sdkPackage,
|
||||
providerName,
|
||||
providerLabel: getProviderLabel(providerName),
|
||||
nativeCapabilities: this.deriveNativeCapabilities(
|
||||
capabilities: this.deriveAvailableModelCapabilities(
|
||||
registeredModel.sdkPackage,
|
||||
),
|
||||
inputCostPerMillionTokens: modelConfig?.inputCostPerMillionTokens,
|
||||
@@ -135,6 +144,9 @@ export class ClientConfigService {
|
||||
defaultPerformanceModel?.providerName,
|
||||
),
|
||||
sdkPackage: defaultPerformanceModel?.sdkPackage ?? null,
|
||||
capabilities: this.deriveAvailableModelCapabilities(
|
||||
defaultPerformanceModel?.sdkPackage,
|
||||
),
|
||||
inputCostPerMillionTokens:
|
||||
defaultPerformanceModelConfig?.inputCostPerMillionTokens,
|
||||
outputCostPerMillionTokens:
|
||||
@@ -153,6 +165,9 @@ export class ClientConfigService {
|
||||
providerName: defaultSpeedModel?.providerName,
|
||||
providerLabel: getProviderLabel(defaultSpeedModel?.providerName),
|
||||
sdkPackage: defaultSpeedModel?.sdkPackage ?? null,
|
||||
capabilities: this.deriveAvailableModelCapabilities(
|
||||
defaultSpeedModel?.sdkPackage,
|
||||
),
|
||||
inputCostPerMillionTokens:
|
||||
defaultSpeedModelConfig?.inputCostPerMillionTokens,
|
||||
outputCostPerMillionTokens:
|
||||
@@ -223,6 +238,9 @@ export class ClientConfigService {
|
||||
isAttachmentPreviewEnabled: this.twentyConfigService.get(
|
||||
'IS_ATTACHMENT_PREVIEW_ENABLED',
|
||||
),
|
||||
isCodeInterpreterEnabled:
|
||||
this.twentyConfigService.get('CODE_INTERPRETER_TYPE') !==
|
||||
CodeInterpreterDriverType.DISABLED,
|
||||
analyticsEnabled: this.twentyConfigService.get('ANALYTICS_ENABLED'),
|
||||
canManageFeatureFlags:
|
||||
this.twentyConfigService.get('NODE_ENV') ===
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { type FlatAgentWithRoleId } from 'src/engine/metadata-modules/flat-agent/types/flat-agent.type';
|
||||
|
||||
export type ToolProviderAgent = Pick<
|
||||
FlatAgentWithRoleId,
|
||||
'modelId' | 'modelConfiguration'
|
||||
>;
|
||||
+2
-2
@@ -2,7 +2,7 @@ import { type ActorMetadata } from 'twenty-shared/types';
|
||||
|
||||
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
|
||||
import { type CodeExecutionStreamEmitter } from 'src/engine/core-modules/tool-provider/interfaces/code-execution-stream-emitter.type';
|
||||
import { type FlatAgentWithRoleId } from 'src/engine/metadata-modules/flat-agent/types/flat-agent.type';
|
||||
import { type ToolProviderAgent } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-agent.type';
|
||||
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
|
||||
|
||||
export type ToolProviderContext = {
|
||||
@@ -13,6 +13,6 @@ export type ToolProviderContext = {
|
||||
actorContext?: ActorMetadata;
|
||||
userId?: string;
|
||||
userWorkspaceId?: string;
|
||||
agent?: FlatAgentWithRoleId | null;
|
||||
agent?: ToolProviderAgent | null;
|
||||
onCodeExecutionUpdate?: CodeExecutionStreamEmitter;
|
||||
};
|
||||
|
||||
+13
-2
@@ -1,13 +1,14 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { isAgentCapabilityEnabled, ToolCategory } from 'twenty-shared/ai';
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type GenerateDescriptorOptions } from 'src/engine/core-modules/tool-provider/interfaces/generate-descriptor-options.type';
|
||||
import { type ToolProvider } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
|
||||
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
|
||||
|
||||
import { ToolCategory } from 'twenty-shared/ai';
|
||||
import { type StaticToolHandler } from 'src/engine/core-modules/tool-provider/interfaces/static-tool-handler.interface';
|
||||
import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
|
||||
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
|
||||
@@ -128,8 +129,14 @@ export class ActionToolProvider implements ToolProvider {
|
||||
),
|
||||
);
|
||||
|
||||
const agent = context.agent;
|
||||
const isCodeInterpreterEnabledForContext = !isDefined(agent)
|
||||
? true
|
||||
: isAgentCapabilityEnabled(agent.modelConfiguration, 'codeInterpreter');
|
||||
|
||||
const hasCodeInterpreterPermission =
|
||||
this.codeInterpreterService.isEnabled() &&
|
||||
isCodeInterpreterEnabledForContext &&
|
||||
(await this.permissionsService.hasToolPermission(
|
||||
context.rolePermissionConfig,
|
||||
context.workspaceId,
|
||||
@@ -146,7 +153,11 @@ export class ActionToolProvider implements ToolProvider {
|
||||
);
|
||||
}
|
||||
|
||||
if (this.webSearchService.isEnabled()) {
|
||||
const isWebSearchEnabledForContext = !isDefined(agent)
|
||||
? true
|
||||
: isAgentCapabilityEnabled(agent.modelConfiguration, 'webSearch');
|
||||
|
||||
if (this.webSearchService.isEnabled() && isWebSearchEnabledForContext) {
|
||||
descriptors.push(
|
||||
this.buildDescriptor('web_search', this.webSearchTool, includeSchemas),
|
||||
);
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import { NativeModelToolProvider } from './native-model-tool.provider';
|
||||
|
||||
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
|
||||
import { type WebSearchService } from 'src/engine/core-modules/web-search/web-search.service';
|
||||
import { type AgentModelConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/agent-model-config.service';
|
||||
import {
|
||||
type AiModelRegistryService,
|
||||
type RegisteredAIModel,
|
||||
} from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
import { type FlatAgentWithRoleId } from 'src/engine/metadata-modules/flat-agent/types/flat-agent.type';
|
||||
|
||||
describe('NativeModelToolProvider', () => {
|
||||
const agent = {
|
||||
id: 'agent-id',
|
||||
modelId: 'xai-model',
|
||||
modelConfiguration: {
|
||||
webSearch: { enabled: true },
|
||||
twitterSearch: { enabled: true },
|
||||
},
|
||||
} as FlatAgentWithRoleId;
|
||||
|
||||
const context = {
|
||||
workspaceId: 'workspace-id',
|
||||
roleId: 'role-id',
|
||||
rolePermissionConfig: { unionOf: ['role-id'] },
|
||||
agent,
|
||||
} as ToolProviderContext;
|
||||
|
||||
it('asks for native model tools even when external web search is preferred', async () => {
|
||||
const registeredModel = {
|
||||
modelId: 'xai-model',
|
||||
} as RegisteredAIModel;
|
||||
const agentModelConfigService = {
|
||||
getNativeModelTools: jest.fn().mockReturnValue({
|
||||
x_search: { type: 'provider', id: 'xai.x_search', args: {} },
|
||||
}),
|
||||
} as Pick<AgentModelConfigService, 'getNativeModelTools'>;
|
||||
const aiModelRegistryService = {
|
||||
resolveModelForAgent: jest.fn().mockResolvedValue(registeredModel),
|
||||
} as Pick<AiModelRegistryService, 'resolveModelForAgent'>;
|
||||
const webSearchService = {
|
||||
shouldUseNativeSearch: jest.fn().mockReturnValue(false),
|
||||
} as Pick<WebSearchService, 'shouldUseNativeSearch'>;
|
||||
|
||||
const provider = new NativeModelToolProvider(
|
||||
agentModelConfigService as AgentModelConfigService,
|
||||
aiModelRegistryService as AiModelRegistryService,
|
||||
webSearchService as WebSearchService,
|
||||
);
|
||||
|
||||
const tools = await provider.generateTools(context);
|
||||
|
||||
expect(tools).toEqual({
|
||||
x_search: { type: 'provider', id: 'xai.x_search', args: {} },
|
||||
});
|
||||
expect(agentModelConfigService.getNativeModelTools).toHaveBeenCalledWith(
|
||||
registeredModel,
|
||||
agent,
|
||||
{ useProviderNativeWebSearch: false },
|
||||
);
|
||||
});
|
||||
});
|
||||
+11
-5
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { type ToolSet } from 'ai';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
@@ -6,15 +6,17 @@ import { isDefined } from 'twenty-shared/utils';
|
||||
import { type NativeToolProvider } from 'src/engine/core-modules/tool-provider/interfaces/native-tool-provider.interface';
|
||||
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
|
||||
|
||||
import { ToolCategory } from 'twenty-shared/ai';
|
||||
import { WebSearchService } from 'src/engine/core-modules/web-search/web-search.service';
|
||||
import { AgentModelConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/agent-model-config.service';
|
||||
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
import { ToolCategory } from 'twenty-shared/ai';
|
||||
|
||||
// SDK-native tools (anthropic webSearch, etc.) are opaque and not serializable.
|
||||
// This provider keeps generateTools() and is excluded from the descriptor system.
|
||||
@Injectable()
|
||||
export class NativeModelToolProvider implements NativeToolProvider {
|
||||
private readonly logger = new Logger(NativeModelToolProvider.name);
|
||||
|
||||
readonly category = ToolCategory.NATIVE_MODEL;
|
||||
|
||||
constructor(
|
||||
@@ -32,9 +34,12 @@ export class NativeModelToolProvider implements NativeToolProvider {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (!this.webSearchService.shouldUseNativeSearch()) {
|
||||
return {};
|
||||
}
|
||||
const useProviderNativeWebSearch =
|
||||
this.webSearchService.shouldUseNativeSearch();
|
||||
|
||||
this.logger.log(
|
||||
`Web search strategy: ${useProviderNativeWebSearch ? 'native (provider SDK)' : 'external (EXA)'}`,
|
||||
);
|
||||
|
||||
const registeredModel =
|
||||
await this.aiModelRegistryService.resolveModelForAgent(context.agent);
|
||||
@@ -42,6 +47,7 @@ export class NativeModelToolProvider implements NativeToolProvider {
|
||||
return this.agentModelConfigService.getNativeModelTools(
|
||||
registeredModel,
|
||||
context.agent,
|
||||
{ useProviderNativeWebSearch },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
import { type ToolSet } from 'ai';
|
||||
|
||||
import { ToolCategory } from 'twenty-shared/ai';
|
||||
|
||||
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
|
||||
import { LazyToolRuntimeService } from 'src/engine/core-modules/tool-provider/services/lazy-tool-runtime.service';
|
||||
import { type ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
|
||||
import {
|
||||
EXECUTE_TOOL_TOOL_NAME,
|
||||
LEARN_TOOLS_TOOL_NAME,
|
||||
} from 'src/engine/core-modules/tool-provider/tools';
|
||||
import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type';
|
||||
|
||||
const createTool = (name: string): ToolSet[string] =>
|
||||
({
|
||||
description: name,
|
||||
inputSchema: {},
|
||||
execute: jest.fn(),
|
||||
}) as unknown as ToolSet[string];
|
||||
|
||||
const createToolIndexEntry = (
|
||||
name: string,
|
||||
category: ToolCategory,
|
||||
): ToolIndexEntry => ({
|
||||
name,
|
||||
category,
|
||||
description: name,
|
||||
executionRef: { kind: 'static', toolId: name },
|
||||
});
|
||||
|
||||
describe('LazyToolRuntimeService', () => {
|
||||
const context: ToolProviderContext = {
|
||||
workspaceId: 'workspace-id',
|
||||
roleId: 'role-id',
|
||||
rolePermissionConfig: { unionOf: ['role-id'] },
|
||||
};
|
||||
|
||||
const setup = () => {
|
||||
const toolRegistry = {
|
||||
getCatalog: jest.fn(),
|
||||
getToolsByName: jest.fn(),
|
||||
getToolInfo: jest.fn(),
|
||||
resolveAndExecute: jest.fn(),
|
||||
} as unknown as jest.Mocked<ToolRegistryService>;
|
||||
|
||||
const service = new LazyToolRuntimeService(toolRegistry);
|
||||
|
||||
return { service, toolRegistry };
|
||||
};
|
||||
|
||||
it('builds runtime tools from direct tools', async () => {
|
||||
const { service, toolRegistry } = setup();
|
||||
|
||||
toolRegistry.getCatalog.mockResolvedValue([
|
||||
createToolIndexEntry('search_help_center', ToolCategory.ACTION),
|
||||
]);
|
||||
|
||||
const runtime = await service.buildToolRuntime({
|
||||
context,
|
||||
directTools: {
|
||||
search_help_center: createTool('search_help_center'),
|
||||
x_search: createTool('x_search'),
|
||||
},
|
||||
});
|
||||
|
||||
expect(runtime.directToolNames).toEqual(['search_help_center', 'x_search']);
|
||||
expect(Object.keys(runtime.runtimeTools)).toEqual([
|
||||
'search_help_center',
|
||||
'x_search',
|
||||
LEARN_TOOLS_TOOL_NAME,
|
||||
EXECUTE_TOOL_TOOL_NAME,
|
||||
]);
|
||||
});
|
||||
|
||||
it('filters lazy tools by category while keeping direct tools callable', async () => {
|
||||
const { service, toolRegistry } = setup();
|
||||
|
||||
toolRegistry.getCatalog.mockResolvedValue([
|
||||
createToolIndexEntry('find_companies', ToolCategory.DATABASE_CRUD),
|
||||
createToolIndexEntry('create_workflow', ToolCategory.WORKFLOW),
|
||||
]);
|
||||
toolRegistry.getToolInfo.mockImplementation(async (toolNames: string[]) =>
|
||||
toolNames.map((toolName) => ({
|
||||
name: toolName,
|
||||
description: toolName,
|
||||
})),
|
||||
);
|
||||
|
||||
const runtime = await service.buildToolRuntime({
|
||||
context,
|
||||
directTools: {
|
||||
x_search: createTool('x_search'),
|
||||
},
|
||||
lazyToolCategories: [ToolCategory.DATABASE_CRUD],
|
||||
});
|
||||
|
||||
expect(runtime.lazyToolCatalog.map((tool) => tool.name)).toEqual([
|
||||
'find_companies',
|
||||
]);
|
||||
expect(runtime.runtimeTools.x_search).toBeDefined();
|
||||
|
||||
const learnTools = runtime.runtimeTools[
|
||||
LEARN_TOOLS_TOOL_NAME
|
||||
] as unknown as {
|
||||
execute: (parameters: {
|
||||
toolNames: string[];
|
||||
aspects: ['description'];
|
||||
}) => Promise<unknown>;
|
||||
};
|
||||
|
||||
await learnTools.execute({
|
||||
toolNames: ['find_companies', 'create_workflow'],
|
||||
aspects: ['description'],
|
||||
});
|
||||
|
||||
expect(toolRegistry.getToolInfo).toHaveBeenCalledWith(
|
||||
['find_companies'],
|
||||
context,
|
||||
['description'],
|
||||
);
|
||||
});
|
||||
});
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { type ToolSet } from 'ai';
|
||||
|
||||
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
|
||||
import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
|
||||
import {
|
||||
createExecuteToolTool,
|
||||
createLearnToolsTool,
|
||||
EXECUTE_TOOL_TOOL_NAME,
|
||||
LEARN_TOOLS_TOOL_NAME,
|
||||
} from 'src/engine/core-modules/tool-provider/tools';
|
||||
import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type';
|
||||
import { ToolCategory } from 'twenty-shared/ai';
|
||||
|
||||
export type LazyToolRuntime = {
|
||||
toolCatalog: ToolIndexEntry[];
|
||||
lazyToolCatalog: ToolIndexEntry[];
|
||||
directTools: ToolSet;
|
||||
directToolNames: string[];
|
||||
runtimeTools: ToolSet;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class LazyToolRuntimeService {
|
||||
constructor(private readonly toolRegistry: ToolRegistryService) {}
|
||||
|
||||
async buildToolRuntime({
|
||||
context,
|
||||
directTools = {},
|
||||
lazyToolCategories,
|
||||
}: {
|
||||
context: ToolProviderContext;
|
||||
directTools?: ToolSet;
|
||||
lazyToolCategories?: readonly ToolCategory[];
|
||||
}): Promise<LazyToolRuntime> {
|
||||
const toolCatalog = await this.toolRegistry.getCatalog(context);
|
||||
const lazyToolCatalog = this.filterLazyToolCatalog(
|
||||
toolCatalog,
|
||||
lazyToolCategories,
|
||||
);
|
||||
|
||||
const lazyToolNames = new Set(lazyToolCatalog.map((tool) => tool.name));
|
||||
const excludedToolNames = new Set([
|
||||
...toolCatalog
|
||||
.filter((tool) => !lazyToolNames.has(tool.name))
|
||||
.map((tool) => tool.name),
|
||||
...Object.keys(directTools),
|
||||
]);
|
||||
|
||||
return {
|
||||
toolCatalog,
|
||||
lazyToolCatalog,
|
||||
directTools,
|
||||
directToolNames: Object.keys(directTools),
|
||||
runtimeTools: {
|
||||
...directTools,
|
||||
[LEARN_TOOLS_TOOL_NAME]: createLearnToolsTool(
|
||||
this.toolRegistry,
|
||||
context,
|
||||
excludedToolNames,
|
||||
),
|
||||
[EXECUTE_TOOL_TOOL_NAME]: createExecuteToolTool(
|
||||
this.toolRegistry,
|
||||
context,
|
||||
directTools,
|
||||
excludedToolNames,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private filterLazyToolCatalog(
|
||||
toolCatalog: ToolIndexEntry[],
|
||||
lazyToolCategories?: readonly ToolCategory[],
|
||||
): ToolIndexEntry[] {
|
||||
if (!lazyToolCategories) {
|
||||
return toolCatalog;
|
||||
}
|
||||
|
||||
const categorySet = new Set(lazyToolCategories);
|
||||
|
||||
return toolCatalog.filter((tool) => categorySet.has(tool.category));
|
||||
}
|
||||
}
|
||||
+14
-41
@@ -3,22 +3,20 @@ import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { type ToolExecutionOptions, type ToolSet, jsonSchema } from 'ai';
|
||||
|
||||
import { type NativeToolProvider } from 'src/engine/core-modules/tool-provider/interfaces/native-tool-provider.interface';
|
||||
import { type ToolProvider } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
|
||||
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
|
||||
import { type ToolProvider } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
|
||||
import { type ToolRetrievalOptions } from 'src/engine/core-modules/tool-provider/interfaces/tool-retrieval-options.type';
|
||||
|
||||
import { TOOL_PROVIDERS } from 'src/engine/core-modules/tool-provider/constants/tool-providers.token';
|
||||
import { ToolCategory } from 'twenty-shared/ai';
|
||||
import { NativeModelToolProvider } from 'src/engine/core-modules/tool-provider/providers/native-model-tool.provider';
|
||||
import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
|
||||
import { type LearnToolsAspect } from 'src/engine/core-modules/tool-provider/tools/learn-tools.tool';
|
||||
import { type ToolContext } from 'src/engine/core-modules/tool-provider/types/tool-context.type';
|
||||
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
|
||||
import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type';
|
||||
import { wrapWithErrorHandler } from 'src/engine/core-modules/tool-provider/utils/tool-error.util';
|
||||
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
|
||||
import { wrapJsonSchemaForExecution } from 'src/engine/core-modules/tool/utils/wrap-tool-for-execution.util';
|
||||
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
|
||||
import { ToolCategory } from 'twenty-shared/ai';
|
||||
|
||||
@Injectable()
|
||||
export class ToolRegistryService {
|
||||
@@ -135,27 +133,24 @@ export class ToolRegistryService {
|
||||
roleId: string,
|
||||
options?: { userId?: string; userWorkspaceId?: string },
|
||||
): Promise<ToolIndexEntry[]> {
|
||||
const context = this.buildContextFromToolContext({
|
||||
return this.getCatalog({
|
||||
workspaceId,
|
||||
roleId,
|
||||
rolePermissionConfig: { unionOf: [roleId] },
|
||||
userId: options?.userId,
|
||||
userWorkspaceId: options?.userWorkspaceId,
|
||||
});
|
||||
|
||||
return this.getCatalog(context);
|
||||
}
|
||||
|
||||
async getToolsByName(
|
||||
names: string[],
|
||||
context: ToolContext,
|
||||
context: ToolProviderContext,
|
||||
): Promise<ToolSet> {
|
||||
const fullContext = this.buildContextFromToolContext(context);
|
||||
|
||||
const index = await this.getCatalog(fullContext);
|
||||
const index = await this.getCatalog(context);
|
||||
const nameSet = new Set(names);
|
||||
const matchingEntries = index.filter((entry) => nameSet.has(entry.name));
|
||||
|
||||
const schemas = await this.resolveSchemas(names, fullContext);
|
||||
const schemas = await this.resolveSchemas(names, context);
|
||||
|
||||
const descriptors: ToolDescriptor[] = matchingEntries
|
||||
.filter((entry) => schemas.has(entry.name))
|
||||
@@ -164,26 +159,24 @@ export class ToolRegistryService {
|
||||
inputSchema: schemas.get(entry.name)!,
|
||||
}));
|
||||
|
||||
return this.hydrateToolSet(descriptors, fullContext);
|
||||
return this.hydrateToolSet(descriptors, context);
|
||||
}
|
||||
|
||||
async getToolInfo(
|
||||
names: string[],
|
||||
context: ToolContext,
|
||||
context: ToolProviderContext,
|
||||
aspects: LearnToolsAspect[] = ['description', 'schema'],
|
||||
): Promise<
|
||||
Array<{ name: string; description?: string; inputSchema?: object }>
|
||||
> {
|
||||
const fullContext = this.buildContextFromToolContext(context);
|
||||
|
||||
const index = await this.getCatalog(fullContext);
|
||||
const index = await this.getCatalog(context);
|
||||
const nameSet = new Set(names);
|
||||
const matchingEntries = index.filter((entry) => nameSet.has(entry.name));
|
||||
|
||||
let schemas: Map<string, object> | undefined;
|
||||
|
||||
if (aspects.includes('schema')) {
|
||||
schemas = await this.resolveSchemas(names, fullContext);
|
||||
schemas = await this.resolveSchemas(names, context);
|
||||
}
|
||||
|
||||
return matchingEntries.map((entry) => {
|
||||
@@ -208,13 +201,11 @@ export class ToolRegistryService {
|
||||
async resolveAndExecute(
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
context: ToolContext,
|
||||
context: ToolProviderContext,
|
||||
_options: ToolExecutionOptions,
|
||||
): Promise<ToolOutput> {
|
||||
try {
|
||||
const fullContext = this.buildContextFromToolContext(context);
|
||||
|
||||
const index = await this.getCatalog(fullContext);
|
||||
const index = await this.getCatalog(context);
|
||||
const entry = index.find((indexEntry) => indexEntry.name === toolName);
|
||||
|
||||
if (!entry) {
|
||||
@@ -225,7 +216,7 @@ export class ToolRegistryService {
|
||||
};
|
||||
}
|
||||
|
||||
return await this.toolExecutorService.dispatch(entry, args, fullContext);
|
||||
return await this.toolExecutorService.dispatch(entry, args, context);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
@@ -297,22 +288,4 @@ export class ToolRegistryService {
|
||||
|
||||
return toolSet;
|
||||
}
|
||||
|
||||
private buildContextFromToolContext(
|
||||
context: ToolContext,
|
||||
): ToolProviderContext {
|
||||
const rolePermissionConfig: RolePermissionConfig = {
|
||||
unionOf: [context.roleId],
|
||||
};
|
||||
|
||||
return {
|
||||
workspaceId: context.workspaceId,
|
||||
roleId: context.roleId,
|
||||
rolePermissionConfig,
|
||||
authContext: context.authContext,
|
||||
userId: context.userId,
|
||||
userWorkspaceId: context.userWorkspaceId,
|
||||
onCodeExecutionUpdate: context.onCodeExecutionUpdate,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -13,6 +13,7 @@ import { NativeModelToolProvider } from 'src/engine/core-modules/tool-provider/p
|
||||
import { ViewFieldToolProvider } from 'src/engine/core-modules/tool-provider/providers/view-field-tool.provider';
|
||||
import { ViewToolProvider } from 'src/engine/core-modules/tool-provider/providers/view-tool.provider';
|
||||
import { WorkflowToolProvider } from 'src/engine/core-modules/tool-provider/providers/workflow-tool.provider';
|
||||
import { LazyToolRuntimeService } from 'src/engine/core-modules/tool-provider/services/lazy-tool-runtime.service';
|
||||
import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
|
||||
import { ToolModule } from 'src/engine/core-modules/tool/tool.module';
|
||||
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
@@ -104,8 +105,9 @@ import { ToolRegistryService } from './services/tool-registry.service';
|
||||
WorkflowToolProvider,
|
||||
],
|
||||
},
|
||||
LazyToolRuntimeService,
|
||||
ToolRegistryService,
|
||||
],
|
||||
exports: [ToolRegistryService],
|
||||
exports: [LazyToolRuntimeService, ToolRegistryService],
|
||||
})
|
||||
export class ToolProviderModule {}
|
||||
|
||||
+2
-2
@@ -2,8 +2,8 @@ import { jsonSchema, type ToolExecutionOptions, type ToolSet } from 'ai';
|
||||
import { type JSONSchema7 } from 'json-schema';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
|
||||
import { type ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
|
||||
import { type ToolContext } from 'src/engine/core-modules/tool-provider/types/tool-context.type';
|
||||
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
|
||||
|
||||
export const EXECUTE_TOOL_TOOL_NAME = 'execute_tool';
|
||||
@@ -43,7 +43,7 @@ export const executeToolInputSchema = jsonSchema<ExecuteToolInput>(
|
||||
|
||||
export const createExecuteToolTool = (
|
||||
toolRegistry: ToolRegistryService,
|
||||
context: ToolContext,
|
||||
context: ToolProviderContext,
|
||||
directTools?: ToolSet,
|
||||
excludeTools?: Set<string>,
|
||||
) => ({
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
|
||||
import { type ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
|
||||
import { type ToolContext } from 'src/engine/core-modules/tool-provider/types/tool-context.type';
|
||||
|
||||
export const LEARN_TOOLS_TOOL_NAME = 'learn_tools';
|
||||
|
||||
@@ -38,7 +38,7 @@ export type LearnToolsResult = {
|
||||
|
||||
export const createLearnToolsTool = (
|
||||
toolRegistry: ToolRegistryService,
|
||||
context: ToolContext,
|
||||
context: ToolProviderContext,
|
||||
excludeTools?: Set<string>,
|
||||
) => ({
|
||||
description:
|
||||
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
import { type ActorMetadata } from 'twenty-shared/types';
|
||||
|
||||
import { type CodeExecutionStreamEmitter } from 'src/engine/core-modules/tool-provider/interfaces/code-execution-stream-emitter.type';
|
||||
|
||||
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
|
||||
|
||||
export type ToolContext = {
|
||||
workspaceId: string;
|
||||
roleId: string;
|
||||
authContext?: WorkspaceAuthContext;
|
||||
actorContext?: ActorMetadata;
|
||||
userId?: string;
|
||||
userWorkspaceId?: string;
|
||||
onCodeExecutionUpdate?: CodeExecutionStreamEmitter;
|
||||
};
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
import { wrapJsonSchemaForExecution } from '../wrap-tool-for-execution.util';
|
||||
|
||||
describe('wrapJsonSchemaForExecution', () => {
|
||||
it('preserves schema metadata such as $defs and additionalProperties', () => {
|
||||
const filterSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
and: {
|
||||
type: 'array',
|
||||
items: {
|
||||
$ref: '#/$defs/condition',
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['and'],
|
||||
};
|
||||
|
||||
const inputSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
filter: {
|
||||
$ref: '#/$defs/filter',
|
||||
},
|
||||
},
|
||||
required: ['filter'],
|
||||
additionalProperties: false,
|
||||
$defs: {
|
||||
filter: filterSchema,
|
||||
condition: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
eq: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const wrappedSchema = wrapJsonSchemaForExecution(inputSchema);
|
||||
|
||||
expect(wrappedSchema.$defs).toEqual(inputSchema.$defs);
|
||||
expect(wrappedSchema.additionalProperties).toBe(false);
|
||||
expect(wrappedSchema.properties).toMatchObject({
|
||||
filter: { $ref: '#/$defs/filter' },
|
||||
loadingMessage: {
|
||||
type: 'string',
|
||||
description: 'A brief status message for the user.',
|
||||
},
|
||||
});
|
||||
expect(wrappedSchema.required).toEqual(
|
||||
expect.arrayContaining(['loadingMessage', 'filter']),
|
||||
);
|
||||
});
|
||||
|
||||
it('deduplicates loadingMessage in required fields', () => {
|
||||
const wrappedSchema = wrapJsonSchemaForExecution({
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string' },
|
||||
},
|
||||
required: ['loadingMessage', 'query'],
|
||||
});
|
||||
|
||||
expect(wrappedSchema.required).toEqual(['loadingMessage', 'query']);
|
||||
});
|
||||
|
||||
it('builds a valid object schema when optional keys are missing', () => {
|
||||
const wrappedSchema = wrapJsonSchemaForExecution({});
|
||||
|
||||
expect(wrappedSchema.type).toBe('object');
|
||||
expect(wrappedSchema.properties).toEqual({
|
||||
loadingMessage: {
|
||||
type: 'string',
|
||||
description: 'A brief status message for the user.',
|
||||
},
|
||||
});
|
||||
expect(wrappedSchema.required).toEqual(['loadingMessage']);
|
||||
});
|
||||
});
|
||||
+10
-3
@@ -1,3 +1,4 @@
|
||||
import { isArray, isObject } from '@sniptt/guards';
|
||||
import { z } from 'zod';
|
||||
|
||||
const DEFAULT_LOADING_MESSAGE_SCHEMA = z
|
||||
@@ -22,10 +23,16 @@ export const wrapSchemaForExecution = <T extends z.ZodRawShape>(
|
||||
export const wrapJsonSchemaForExecution = (
|
||||
schema: Record<string, unknown>,
|
||||
): Record<string, unknown> => {
|
||||
const properties = (schema.properties as Record<string, unknown>) ?? {};
|
||||
const required = (schema.required as string[]) ?? [];
|
||||
const properties =
|
||||
isObject(schema.properties) && !isArray(schema.properties)
|
||||
? (schema.properties as Record<string, unknown>)
|
||||
: {};
|
||||
const required = isArray(schema.required)
|
||||
? schema.required.filter((item): item is string => typeof item === 'string')
|
||||
: [];
|
||||
|
||||
return {
|
||||
...schema,
|
||||
type: 'object',
|
||||
properties: {
|
||||
loadingMessage: {
|
||||
@@ -34,7 +41,7 @@ export const wrapJsonSchemaForExecution = (
|
||||
},
|
||||
...properties,
|
||||
},
|
||||
required: ['loadingMessage', ...required],
|
||||
required: [...new Set(['loadingMessage', ...required])],
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+8
@@ -2,6 +2,7 @@ import { forwardRef, Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
|
||||
import { FileAIChatModule } from 'src/engine/core-modules/file/file-ai-chat/file-ai-chat.module';
|
||||
import { FileUrlModule } from 'src/engine/core-modules/file/file-url/file-url.module';
|
||||
import { ToolProviderModule } from 'src/engine/core-modules/tool-provider/tool-provider.module';
|
||||
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
|
||||
@@ -15,18 +16,22 @@ import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-t
|
||||
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
|
||||
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
|
||||
|
||||
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
|
||||
|
||||
import { AgentMessagePartEntity } from './entities/agent-message-part.entity';
|
||||
import { AgentMessageEntity } from './entities/agent-message.entity';
|
||||
import { AgentTurnEntity } from './entities/agent-turn.entity';
|
||||
import { AgentMessagePartResolver } from './resolvers/agent-message-part.resolver';
|
||||
import { AgentActorContextService } from './services/agent-actor-context.service';
|
||||
import { AgentAsyncExecutorService } from './services/agent-async-executor.service';
|
||||
import { WorkflowAgentTracePersistenceService } from './services/workflow-agent-trace-persistence.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AiBillingModule,
|
||||
AiModelsModule,
|
||||
AiAgentModule,
|
||||
FileAIChatModule,
|
||||
FileUrlModule,
|
||||
WorkspaceDomainsModule,
|
||||
UserWorkspaceModule,
|
||||
@@ -35,6 +40,7 @@ import { AgentAsyncExecutorService } from './services/agent-async-executor.servi
|
||||
WorkspaceCacheModule,
|
||||
forwardRef(() => ToolProviderModule),
|
||||
TypeOrmModule.forFeature([
|
||||
AgentChatThreadEntity,
|
||||
AgentEntity,
|
||||
AgentMessageEntity,
|
||||
AgentMessagePartEntity,
|
||||
@@ -47,10 +53,12 @@ import { AgentAsyncExecutorService } from './services/agent-async-executor.servi
|
||||
AgentAsyncExecutorService,
|
||||
AgentActorContextService,
|
||||
AgentMessagePartResolver,
|
||||
WorkflowAgentTracePersistenceService,
|
||||
],
|
||||
exports: [
|
||||
AgentAsyncExecutorService,
|
||||
AgentActorContextService,
|
||||
WorkflowAgentTracePersistenceService,
|
||||
TypeOrmModule.forFeature([
|
||||
AgentMessageEntity,
|
||||
AgentMessagePartEntity,
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { Field, Float, Int, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
@ObjectType('AgentTurnThreadSummary')
|
||||
export class AgentTurnThreadSummaryDTO {
|
||||
@Field(() => UUIDScalarType)
|
||||
id: string;
|
||||
|
||||
@Field(() => Int)
|
||||
totalInputTokens: number;
|
||||
|
||||
@Field(() => Int)
|
||||
totalOutputTokens: number;
|
||||
|
||||
@Field(() => Float)
|
||||
totalInputCredits: number;
|
||||
|
||||
@Field(() => Float)
|
||||
totalOutputCredits: number;
|
||||
}
|
||||
+4
@@ -4,6 +4,7 @@ import { IsDateString } from 'class-validator';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { AgentMessageDTO } from 'src/engine/metadata-modules/ai/ai-agent-execution/dtos/agent-message.dto';
|
||||
import { AgentTurnThreadSummaryDTO } from 'src/engine/metadata-modules/ai/ai-agent-execution/dtos/agent-turn-thread-summary.dto';
|
||||
import { AgentTurnEvaluationDTO } from 'src/engine/metadata-modules/ai/ai-agent-monitor/dtos/agent-turn-evaluation.dto';
|
||||
|
||||
@ObjectType('AgentTurn')
|
||||
@@ -23,6 +24,9 @@ export class AgentTurnDTO {
|
||||
@Field(() => [AgentMessageDTO])
|
||||
messages: AgentMessageDTO[];
|
||||
|
||||
@Field(() => AgentTurnThreadSummaryDTO, { nullable: true })
|
||||
thread: AgentTurnThreadSummaryDTO | null;
|
||||
|
||||
@IsDateString()
|
||||
@Field()
|
||||
createdAt: Date;
|
||||
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
jest.mock('ai', () => {
|
||||
const actual = jest.requireActual('ai');
|
||||
|
||||
return {
|
||||
...actual,
|
||||
generateText: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { generateText, type ToolSet } from 'ai';
|
||||
|
||||
import { type ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
||||
import { type LazyToolRuntimeService } from 'src/engine/core-modules/tool-provider/services/lazy-tool-runtime.service';
|
||||
import { type ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
|
||||
import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AgentAsyncExecutorService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service';
|
||||
import { type AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
|
||||
import { type AgentModelConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/agent-model-config.service';
|
||||
import {
|
||||
type AiModelRegistryService,
|
||||
type RegisteredAIModel,
|
||||
} from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
import { ToolCategory } from 'twenty-shared/ai';
|
||||
|
||||
const createTool = (name: string): ToolSet[string] =>
|
||||
({
|
||||
description: name,
|
||||
inputSchema: {},
|
||||
execute: jest.fn(),
|
||||
}) as unknown as ToolSet[string];
|
||||
|
||||
const createToolIndexEntry = (
|
||||
name: string,
|
||||
category: ToolCategory,
|
||||
): ToolIndexEntry => ({
|
||||
name,
|
||||
category,
|
||||
description: name,
|
||||
executionRef: { kind: 'static', toolId: name },
|
||||
});
|
||||
|
||||
describe('AgentAsyncExecutorService', () => {
|
||||
const mockedGenerateText = jest.mocked(generateText);
|
||||
|
||||
beforeEach(() => {
|
||||
mockedGenerateText.mockResolvedValue({
|
||||
text: 'Done',
|
||||
steps: [],
|
||||
usage: {} as never,
|
||||
} as never);
|
||||
});
|
||||
|
||||
it('builds workflow execution with native tools eager and database/action tools lazy', async () => {
|
||||
const registeredModel = {
|
||||
modelId: 'openai/gpt-4o',
|
||||
sdkPackage: '@ai-sdk/openai',
|
||||
model: {} as never,
|
||||
} as RegisteredAIModel;
|
||||
|
||||
const nativeModelTools = {
|
||||
web_search: createTool('web_search'),
|
||||
} as ToolSet;
|
||||
|
||||
const runtimeTools = {
|
||||
web_search: createTool('web_search'),
|
||||
learn_tools: createTool('learn_tools'),
|
||||
execute_tool: createTool('execute_tool'),
|
||||
} as ToolSet;
|
||||
|
||||
const lazyToolCatalog = [
|
||||
createToolIndexEntry('find_people', ToolCategory.DATABASE_CRUD),
|
||||
createToolIndexEntry('send_email', ToolCategory.ACTION),
|
||||
];
|
||||
|
||||
const aiModelRegistryService = {
|
||||
validateModelAvailability: jest.fn(),
|
||||
resolveModelForAgent: jest.fn().mockReturnValue(registeredModel),
|
||||
} as unknown as jest.Mocked<AiModelRegistryService>;
|
||||
|
||||
const agentModelConfigService = {
|
||||
getProviderOptions: jest.fn().mockReturnValue({}),
|
||||
} as unknown as jest.Mocked<AgentModelConfigService>;
|
||||
|
||||
const lazyToolRuntimeService = {
|
||||
buildToolRuntime: jest.fn().mockResolvedValue({
|
||||
toolCatalog: lazyToolCatalog,
|
||||
lazyToolCatalog,
|
||||
directTools: nativeModelTools,
|
||||
directToolNames: ['web_search'],
|
||||
runtimeTools,
|
||||
}),
|
||||
} as unknown as jest.Mocked<LazyToolRuntimeService>;
|
||||
|
||||
const toolRegistry = {
|
||||
getToolsByCategories: jest.fn().mockResolvedValue(nativeModelTools),
|
||||
} as unknown as jest.Mocked<ToolRegistryService>;
|
||||
|
||||
const exceptionHandlerService = {
|
||||
captureExceptions: jest.fn(),
|
||||
} as unknown as jest.Mocked<ExceptionHandlerService>;
|
||||
|
||||
const roleTargetRepository = {
|
||||
findOne: jest.fn().mockResolvedValue({ roleId: 'agent-role-id' }),
|
||||
};
|
||||
|
||||
const workspace = { id: 'workspace-id' } as WorkspaceEntity;
|
||||
const workspaceRepository = {
|
||||
findOneBy: jest.fn().mockResolvedValue(workspace),
|
||||
};
|
||||
|
||||
const service = new AgentAsyncExecutorService(
|
||||
aiModelRegistryService,
|
||||
agentModelConfigService,
|
||||
lazyToolRuntimeService,
|
||||
toolRegistry,
|
||||
exceptionHandlerService,
|
||||
roleTargetRepository as never,
|
||||
workspaceRepository as never,
|
||||
);
|
||||
|
||||
const agent = {
|
||||
id: 'agent-id',
|
||||
workspaceId: 'workspace-id',
|
||||
modelId: 'openai/gpt-4o',
|
||||
prompt: 'Use tools carefully.',
|
||||
modelConfiguration: {
|
||||
webSearch: { enabled: true },
|
||||
codeInterpreter: { enabled: false },
|
||||
},
|
||||
responseFormat: { type: 'text' },
|
||||
} as unknown as AgentEntity;
|
||||
|
||||
await service.executeAgent({
|
||||
agent,
|
||||
userPrompt: 'Find the matching person.',
|
||||
});
|
||||
|
||||
expect(toolRegistry.getToolsByCategories).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspaceId: 'workspace-id',
|
||||
roleId: 'agent-role-id',
|
||||
rolePermissionConfig: { intersectionOf: ['agent-role-id'] },
|
||||
agent: {
|
||||
modelId: 'openai/gpt-4o',
|
||||
modelConfiguration: agent.modelConfiguration,
|
||||
},
|
||||
}),
|
||||
{
|
||||
categories: [ToolCategory.NATIVE_MODEL],
|
||||
wrapWithErrorContext: false,
|
||||
},
|
||||
);
|
||||
|
||||
expect(lazyToolRuntimeService.buildToolRuntime).toHaveBeenCalledWith({
|
||||
context: expect.objectContaining({
|
||||
workspaceId: 'workspace-id',
|
||||
roleId: 'agent-role-id',
|
||||
agent: {
|
||||
modelId: 'openai/gpt-4o',
|
||||
modelConfiguration: agent.modelConfiguration,
|
||||
},
|
||||
}),
|
||||
directTools: nativeModelTools,
|
||||
lazyToolCategories: [ToolCategory.DATABASE_CRUD, ToolCategory.ACTION],
|
||||
});
|
||||
|
||||
expect(agentModelConfigService.getProviderOptions).toHaveBeenCalledWith(
|
||||
registeredModel,
|
||||
);
|
||||
expect(mockedGenerateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tools: runtimeTools,
|
||||
system: expect.stringContaining('`find_people`'),
|
||||
}),
|
||||
);
|
||||
|
||||
const firstGenerateTextCall = mockedGenerateText.mock.calls[0]?.[0];
|
||||
|
||||
expect(firstGenerateTextCall).toBeDefined();
|
||||
expect(Object.keys(firstGenerateTextCall?.tools ?? {})).not.toEqual(
|
||||
expect.arrayContaining(['find_people', 'send_email']),
|
||||
);
|
||||
});
|
||||
});
|
||||
+147
-38
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import {
|
||||
APICallError,
|
||||
generateText,
|
||||
jsonSchema,
|
||||
Output,
|
||||
@@ -13,15 +14,20 @@ import { isDefined } from 'twenty-shared/utils';
|
||||
import { type Repository } from 'typeorm';
|
||||
|
||||
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
|
||||
import { type ToolProviderAgent } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-agent.type';
|
||||
|
||||
import { isUserAuthContext } from 'src/engine/core-modules/auth/guards/is-user-auth-context.guard';
|
||||
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
|
||||
import { ToolCategory } from 'twenty-shared/ai';
|
||||
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
||||
import { LazyToolRuntimeService } from 'src/engine/core-modules/tool-provider/services/lazy-tool-runtime.service';
|
||||
import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
|
||||
import {
|
||||
EXECUTE_TOOL_TOOL_NAME,
|
||||
LEARN_TOOLS_TOOL_NAME,
|
||||
} from 'src/engine/core-modules/tool-provider/tools';
|
||||
import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { type AgentExecutionResult } from 'src/engine/metadata-modules/ai/ai-agent-execution/types/agent-execution-result.type';
|
||||
import { countNativeWebSearchCallsFromSteps } from 'src/engine/metadata-modules/ai/ai-billing/utils/count-native-web-search-calls-from-steps.util';
|
||||
import { extractCacheCreationTokensFromSteps } from 'src/engine/metadata-modules/ai/ai-billing/utils/extract-cache-creation-tokens.util';
|
||||
import { mergeLanguageModelUsage } from 'src/engine/metadata-modules/ai/ai-billing/utils/merge-language-model-usage.util';
|
||||
import {
|
||||
AgentException,
|
||||
AgentExceptionCode,
|
||||
@@ -30,12 +36,28 @@ import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai/ai-agent/constants/
|
||||
import { WORKFLOW_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-system-prompts.const';
|
||||
import { type AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
|
||||
import { repairToolCall } from 'src/engine/metadata-modules/ai/ai-agent/utils/repair-tool-call.util';
|
||||
import { countNativeWebSearchCallsFromSteps } from 'src/engine/metadata-modules/ai/ai-billing/utils/count-native-web-search-calls-from-steps.util';
|
||||
import { extractCacheCreationTokensFromSteps } from 'src/engine/metadata-modules/ai/ai-billing/utils/extract-cache-creation-tokens.util';
|
||||
import { mergeLanguageModelUsage } from 'src/engine/metadata-modules/ai/ai-billing/utils/merge-language-model-usage.util';
|
||||
import { AI_TELEMETRY_CONFIG } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-telemetry.const';
|
||||
import { AgentModelConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/agent-model-config.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
import {
|
||||
AiModelRegistryService,
|
||||
type RegisteredAIModel,
|
||||
} from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity';
|
||||
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
|
||||
import { ToolCategory } from 'twenty-shared/ai';
|
||||
|
||||
const WORKFLOW_AGENT_LAZY_TOOL_CATEGORIES = [
|
||||
ToolCategory.DATABASE_CRUD,
|
||||
ToolCategory.ACTION,
|
||||
] as const;
|
||||
|
||||
const toToolProviderAgent = (agent: AgentEntity): ToolProviderAgent => ({
|
||||
modelId: agent.modelId,
|
||||
modelConfiguration: agent.modelConfiguration,
|
||||
});
|
||||
|
||||
// Agent execution within workflows uses database and action tools only.
|
||||
// Workflow tools are intentionally excluded to avoid circular dependencies
|
||||
@@ -47,7 +69,9 @@ export class AgentAsyncExecutorService {
|
||||
constructor(
|
||||
private readonly aiModelRegistryService: AiModelRegistryService,
|
||||
private readonly agentModelConfigService: AgentModelConfigService,
|
||||
private readonly lazyToolRuntimeService: LazyToolRuntimeService,
|
||||
private readonly toolRegistry: ToolRegistryService,
|
||||
private readonly exceptionHandlerService: ExceptionHandlerService,
|
||||
@InjectRepository(RoleTargetEntity)
|
||||
private readonly roleTargetRepository: Repository<RoleTargetEntity>,
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
@@ -99,6 +123,49 @@ export class AgentAsyncExecutorService {
|
||||
return { intersectionOf: allRoleIds };
|
||||
}
|
||||
|
||||
private buildWorkflowToolCatalogPrompt({
|
||||
toolCatalog,
|
||||
directToolNames,
|
||||
}: {
|
||||
toolCatalog: ToolIndexEntry[];
|
||||
directToolNames: string[];
|
||||
}): string {
|
||||
const toolsByCategory = new Map<ToolCategory, ToolIndexEntry[]>();
|
||||
|
||||
for (const tool of toolCatalog) {
|
||||
const existing = toolsByCategory.get(tool.category) ?? [];
|
||||
|
||||
existing.push(tool);
|
||||
toolsByCategory.set(tool.category, existing);
|
||||
}
|
||||
|
||||
const directToolsSection =
|
||||
directToolNames.length > 0
|
||||
? `Direct native model tools available now: ${directToolNames.map((toolName) => `\`${toolName}\``).join(', ')}.`
|
||||
: 'No direct native model tools are available.';
|
||||
|
||||
const sections = [
|
||||
`## Available Workflow Tools
|
||||
|
||||
${directToolsSection}
|
||||
|
||||
For database and action tools, first call \`${LEARN_TOOLS_TOOL_NAME}\` with the exact tool name to learn its schema, then call \`${EXECUTE_TOOL_TOOL_NAME}\` with matching arguments. Do not call tools that are not listed below.`,
|
||||
];
|
||||
|
||||
for (const category of WORKFLOW_AGENT_LAZY_TOOL_CATEGORIES) {
|
||||
const tools = toolsByCategory.get(category);
|
||||
|
||||
if (!tools || tools.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
sections.push(`### ${category}
|
||||
${tools.map((tool) => `- \`${tool.name}\``).join('\n')}`);
|
||||
}
|
||||
|
||||
return sections.join('\n\n');
|
||||
}
|
||||
|
||||
async executeAgent({
|
||||
agent,
|
||||
userPrompt,
|
||||
@@ -112,6 +179,9 @@ export class AgentAsyncExecutorService {
|
||||
rolePermissionConfig?: RolePermissionConfig;
|
||||
authContext?: WorkspaceAuthContext;
|
||||
}): Promise<AgentExecutionResult> {
|
||||
let resolvedModelForLog: RegisteredAIModel | undefined;
|
||||
let lazyWorkflowToolCount = 0;
|
||||
|
||||
try {
|
||||
if (agent) {
|
||||
const workspace = await this.workspaceRepository.findOneBy({
|
||||
@@ -129,8 +199,11 @@ export class AgentAsyncExecutorService {
|
||||
const registeredModel =
|
||||
await this.aiModelRegistryService.resolveModelForAgent(agent);
|
||||
|
||||
resolvedModelForLog = registeredModel;
|
||||
|
||||
let tools: ToolSet = {};
|
||||
let providerOptions = {};
|
||||
let workflowToolCatalogPrompt = '';
|
||||
|
||||
if (agent) {
|
||||
const effectiveRoleConfig = await this.getEffectiveRolePermissionConfig(
|
||||
@@ -139,49 +212,57 @@ export class AgentAsyncExecutorService {
|
||||
rolePermissionConfig,
|
||||
);
|
||||
|
||||
// Workflow context: DATABASE_CRUD, ACTION, and NATIVE_MODEL tools only
|
||||
// Workflow tools are excluded to prevent circular dependencies
|
||||
const roleId = this.extractRoleIds(effectiveRoleConfig)[0] ?? '';
|
||||
const toolProviderContext: ToolProviderContext = {
|
||||
workspaceId: agent.workspaceId,
|
||||
roleId,
|
||||
rolePermissionConfig: effectiveRoleConfig ?? { unionOf: [] },
|
||||
authContext,
|
||||
actorContext,
|
||||
agent: toToolProviderAgent(agent),
|
||||
userId:
|
||||
isDefined(authContext) && isUserAuthContext(authContext)
|
||||
? authContext.user.id
|
||||
: undefined,
|
||||
userWorkspaceId:
|
||||
isDefined(authContext) && isUserAuthContext(authContext)
|
||||
? authContext.userWorkspaceId
|
||||
: undefined,
|
||||
};
|
||||
|
||||
tools = await this.toolRegistry.getToolsByCategories(
|
||||
const nativeModelTools = await this.toolRegistry.getToolsByCategories(
|
||||
toolProviderContext,
|
||||
{
|
||||
workspaceId: agent.workspaceId,
|
||||
roleId,
|
||||
rolePermissionConfig: effectiveRoleConfig ?? { unionOf: [] },
|
||||
authContext,
|
||||
actorContext,
|
||||
agent: agent as unknown as ToolProviderContext['agent'],
|
||||
userId:
|
||||
isDefined(authContext) && isUserAuthContext(authContext)
|
||||
? authContext.user.id
|
||||
: undefined,
|
||||
userWorkspaceId:
|
||||
isDefined(authContext) && isUserAuthContext(authContext)
|
||||
? authContext.userWorkspaceId
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
categories: [
|
||||
ToolCategory.DATABASE_CRUD,
|
||||
ToolCategory.ACTION,
|
||||
ToolCategory.NATIVE_MODEL,
|
||||
],
|
||||
categories: [ToolCategory.NATIVE_MODEL],
|
||||
wrapWithErrorContext: false,
|
||||
},
|
||||
);
|
||||
|
||||
providerOptions = this.agentModelConfigService.getProviderOptions(
|
||||
registeredModel,
|
||||
agent as unknown as Parameters<
|
||||
typeof this.agentModelConfigService.getProviderOptions
|
||||
>[1],
|
||||
);
|
||||
const toolRuntime = await this.lazyToolRuntimeService.buildToolRuntime({
|
||||
context: toolProviderContext,
|
||||
directTools: nativeModelTools,
|
||||
lazyToolCategories: WORKFLOW_AGENT_LAZY_TOOL_CATEGORIES,
|
||||
});
|
||||
|
||||
tools = toolRuntime.runtimeTools;
|
||||
lazyWorkflowToolCount = toolRuntime.lazyToolCatalog.length;
|
||||
workflowToolCatalogPrompt = this.buildWorkflowToolCatalogPrompt({
|
||||
toolCatalog: toolRuntime.lazyToolCatalog,
|
||||
directToolNames: toolRuntime.directToolNames,
|
||||
});
|
||||
|
||||
providerOptions =
|
||||
this.agentModelConfigService.getProviderOptions(registeredModel);
|
||||
}
|
||||
|
||||
this.logger.log(`Generated ${Object.keys(tools).length} tools for agent`);
|
||||
const runtimeToolCount = Object.keys(tools).length;
|
||||
|
||||
this.logger.log(
|
||||
`Generated ${runtimeToolCount} runtime tools and ${lazyWorkflowToolCount} lazy workflow tools for agent`,
|
||||
);
|
||||
|
||||
const textResponse = await generateText({
|
||||
system: `${WORKFLOW_SYSTEM_PROMPTS.BASE}\n\n${agent ? agent.prompt : ''}`,
|
||||
system: `${WORKFLOW_SYSTEM_PROMPTS.BASE}\n\n${workflowToolCatalogPrompt}\n\n${agent ? agent.prompt : ''}`,
|
||||
tools,
|
||||
model: registeredModel.model,
|
||||
prompt: userPrompt,
|
||||
@@ -223,6 +304,7 @@ export class AgentAsyncExecutorService {
|
||||
usage: textResponse.usage,
|
||||
cacheCreationTokens,
|
||||
nativeWebSearchCallCount,
|
||||
steps: textResponse.steps,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -253,11 +335,38 @@ export class AgentAsyncExecutorService {
|
||||
),
|
||||
cacheCreationTokens,
|
||||
nativeWebSearchCallCount,
|
||||
steps: textResponse.steps,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AgentException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const cause =
|
||||
error instanceof Error
|
||||
? (error as Error & { cause?: unknown }).cause
|
||||
: undefined;
|
||||
const apiCallError = APICallError.isInstance(error)
|
||||
? error
|
||||
: APICallError.isInstance(cause)
|
||||
? cause
|
||||
: undefined;
|
||||
const statusSuffix = apiCallError?.statusCode
|
||||
? ` status=${apiCallError.statusCode}`
|
||||
: '';
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
this.logger.error(
|
||||
`Workflow agent execution failed [workspace=${agent?.workspaceId} agent=${agent?.id} model=${resolvedModelForLog?.modelId}${statusSuffix}]: ${errorMessage}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
|
||||
this.exceptionHandlerService.captureExceptions(
|
||||
[error],
|
||||
agent ? { workspace: { id: agent.workspaceId } } : undefined,
|
||||
);
|
||||
|
||||
throw new AgentException(
|
||||
error instanceof Error ? error.message : 'Agent execution failed',
|
||||
AgentExceptionCode.AGENT_EXECUTION_FAILED,
|
||||
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
import { type StepResult, type ToolSet } from 'ai';
|
||||
|
||||
import { FileAIChatService } from 'src/engine/core-modules/file/file-ai-chat/services/file-ai-chat.service';
|
||||
import { AgentMessagePartEntity } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message-part.entity';
|
||||
import { AgentMessageEntity } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message.entity';
|
||||
import { AgentTurnEntity } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-turn.entity';
|
||||
import { WorkflowAgentTracePersistenceService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/workflow-agent-trace-persistence.service';
|
||||
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
|
||||
|
||||
const createInsertResult = (id: string) =>
|
||||
({
|
||||
identifiers: [{ id }],
|
||||
}) as never;
|
||||
|
||||
type TraceTestHarness = {
|
||||
service: WorkflowAgentTracePersistenceService;
|
||||
threadRepository: {
|
||||
findOne: jest.Mock;
|
||||
insert: jest.Mock;
|
||||
manager: { transaction: jest.Mock };
|
||||
};
|
||||
threadRepositoryInTx: { update: jest.Mock };
|
||||
turnRepository: { insert: jest.Mock };
|
||||
messageRepository: { insert: jest.Mock };
|
||||
messagePartRepository: { insert: jest.Mock };
|
||||
fileAIChatService: jest.Mocked<FileAIChatService>;
|
||||
};
|
||||
|
||||
const createHarness = (): TraceTestHarness => {
|
||||
const threadRepositoryInTx = {
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const turnRepository = {
|
||||
insert: jest.fn().mockResolvedValue(createInsertResult('turn-1')),
|
||||
};
|
||||
const messageRepository = {
|
||||
insert: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(createInsertResult('user-message-1'))
|
||||
.mockResolvedValueOnce(createInsertResult('assistant-message-1'))
|
||||
.mockResolvedValueOnce(createInsertResult('user-message-2'))
|
||||
.mockResolvedValueOnce(createInsertResult('assistant-message-2')),
|
||||
};
|
||||
const messagePartRepository = {
|
||||
insert: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const entityManager = {
|
||||
getRepository: jest.fn((entity) => {
|
||||
if (entity === AgentChatThreadEntity) {
|
||||
return threadRepositoryInTx;
|
||||
}
|
||||
if (entity === AgentTurnEntity) {
|
||||
return turnRepository;
|
||||
}
|
||||
if (entity === AgentMessageEntity) {
|
||||
return messageRepository;
|
||||
}
|
||||
if (entity === AgentMessagePartEntity) {
|
||||
return messagePartRepository;
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected entity: ${String(entity)}`);
|
||||
}),
|
||||
};
|
||||
const threadRepository = {
|
||||
findOne: jest.fn(),
|
||||
insert: jest.fn(),
|
||||
manager: {
|
||||
transaction: jest.fn(async (callback) => callback(entityManager)),
|
||||
},
|
||||
};
|
||||
const fileAIChatService = {
|
||||
uploadFile: jest.fn(),
|
||||
} as unknown as jest.Mocked<FileAIChatService>;
|
||||
|
||||
const service = new WorkflowAgentTracePersistenceService(
|
||||
threadRepository as never,
|
||||
fileAIChatService,
|
||||
);
|
||||
|
||||
return {
|
||||
service,
|
||||
threadRepository,
|
||||
threadRepositoryInTx,
|
||||
turnRepository,
|
||||
messageRepository,
|
||||
messagePartRepository,
|
||||
fileAIChatService,
|
||||
};
|
||||
};
|
||||
|
||||
const basePersistParams = {
|
||||
userPrompt: 'Do the thing',
|
||||
agentId: 'agent-1',
|
||||
workspaceId: 'workspace-1',
|
||||
workflowRunId: 'workflow-run-1',
|
||||
workflowStepId: 'workflow-step-1',
|
||||
totalInputTokens: 10,
|
||||
totalOutputTokens: 20,
|
||||
totalInputCredits: 30,
|
||||
totalOutputCredits: 40,
|
||||
contextWindowTokens: 50,
|
||||
conversationSize: 60,
|
||||
};
|
||||
|
||||
const oneTextStep = [
|
||||
{ content: [{ type: 'text', text: 'Answer' }] },
|
||||
] as StepResult<ToolSet>[];
|
||||
|
||||
describe('WorkflowAgentTracePersistenceService', () => {
|
||||
it('creates a new thread with zero-initialized stats and applies the run stats inside the transaction', async () => {
|
||||
const harness = createHarness();
|
||||
|
||||
harness.threadRepository.findOne.mockResolvedValueOnce(null);
|
||||
harness.threadRepository.insert.mockResolvedValueOnce(
|
||||
createInsertResult('thread-1'),
|
||||
);
|
||||
|
||||
const persistedTrace = await harness.service.persistTrace({
|
||||
...basePersistParams,
|
||||
steps: oneTextStep,
|
||||
});
|
||||
|
||||
expect(persistedTrace).toEqual({
|
||||
turnId: 'turn-1',
|
||||
threadId: 'thread-1',
|
||||
});
|
||||
|
||||
expect(harness.threadRepository.insert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspaceId: 'workspace-1',
|
||||
userWorkspaceId: null,
|
||||
workflowRunId: 'workflow-run-1',
|
||||
workflowStepId: 'workflow-step-1',
|
||||
title: 'Do the thing',
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
totalInputCredits: 0,
|
||||
totalOutputCredits: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(harness.threadRepositoryInTx.update).toHaveBeenCalledWith(
|
||||
'thread-1',
|
||||
expect.objectContaining({
|
||||
title: 'Do the thing',
|
||||
totalInputTokens: expect.any(Function),
|
||||
totalOutputTokens: expect.any(Function),
|
||||
totalInputCredits: expect.any(Function),
|
||||
totalOutputCredits: expect.any(Function),
|
||||
contextWindowTokens: 50,
|
||||
conversationSize: 60,
|
||||
}),
|
||||
);
|
||||
|
||||
const updatePayload = harness.threadRepositoryInTx.update.mock.calls[0][1];
|
||||
|
||||
expect(updatePayload.totalInputTokens()).toBe('"totalInputTokens" + 10');
|
||||
expect(updatePayload.totalOutputTokens()).toBe('"totalOutputTokens" + 20');
|
||||
expect(updatePayload.totalInputCredits()).toBe('"totalInputCredits" + 30');
|
||||
expect(updatePayload.totalOutputCredits()).toBe(
|
||||
'"totalOutputCredits" + 40',
|
||||
);
|
||||
|
||||
expect(harness.messageRepository.insert).toHaveBeenCalledTimes(2);
|
||||
expect(harness.messagePartRepository.insert).toHaveBeenCalledTimes(2);
|
||||
expect(harness.fileAIChatService.uploadFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reuses the existing thread when the same workflow step executes again', async () => {
|
||||
const harness = createHarness();
|
||||
|
||||
harness.threadRepository.findOne.mockResolvedValueOnce({ id: 'thread-1' });
|
||||
|
||||
const persistedTrace = await harness.service.persistTrace({
|
||||
...basePersistParams,
|
||||
userPrompt: 'Second prompt',
|
||||
totalInputTokens: 11,
|
||||
steps: oneTextStep,
|
||||
});
|
||||
|
||||
expect(persistedTrace.threadId).toBe('thread-1');
|
||||
expect(harness.threadRepository.insert).not.toHaveBeenCalled();
|
||||
|
||||
const updatePayload = harness.threadRepositoryInTx.update.mock.calls[0][1];
|
||||
|
||||
expect(updatePayload.title).toBe('Second prompt');
|
||||
expect(updatePayload.totalInputTokens()).toBe('"totalInputTokens" + 11');
|
||||
});
|
||||
|
||||
it('recovers when a concurrent persist wins the unique-index race', async () => {
|
||||
const harness = createHarness();
|
||||
const uniqueViolation = Object.assign(new Error('duplicate'), {
|
||||
code: '23505',
|
||||
});
|
||||
|
||||
harness.threadRepository.findOne
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({ id: 'thread-winner' });
|
||||
harness.threadRepository.insert.mockRejectedValueOnce(uniqueViolation);
|
||||
|
||||
const persistedTrace = await harness.service.persistTrace({
|
||||
...basePersistParams,
|
||||
steps: oneTextStep,
|
||||
});
|
||||
|
||||
expect(persistedTrace.threadId).toBe('thread-winner');
|
||||
expect(harness.threadRepository.findOne).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('skips assistant part inserts when the step produced no persistable parts', async () => {
|
||||
const harness = createHarness();
|
||||
|
||||
harness.threadRepository.findOne.mockResolvedValueOnce(null);
|
||||
harness.threadRepository.insert.mockResolvedValueOnce(
|
||||
createInsertResult('thread-1'),
|
||||
);
|
||||
|
||||
await harness.service.persistTrace({
|
||||
...basePersistParams,
|
||||
steps: [] as StepResult<ToolSet>[],
|
||||
});
|
||||
|
||||
// Only the user prompt part is inserted; no assistant parts.
|
||||
expect(harness.messagePartRepository.insert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
+282
@@ -0,0 +1,282 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { type StepResult, type ToolSet } from 'ai';
|
||||
import { EntityManager, Repository } from 'typeorm';
|
||||
import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
|
||||
|
||||
import {
|
||||
AgentMessageEntity,
|
||||
AgentMessageRole,
|
||||
} from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message.entity';
|
||||
import { AgentMessagePartEntity } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message-part.entity';
|
||||
import { AgentTurnEntity } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-turn.entity';
|
||||
import { mapGenerateTextStepsToPersistableParts } from 'src/engine/metadata-modules/ai/ai-agent-execution/utils/mapGenerateTextStepsToPersistableParts';
|
||||
import { mapPersistablePartsToDatabaseParts } from 'src/engine/metadata-modules/ai/ai-agent-execution/utils/mapPersistablePartsToDatabaseParts';
|
||||
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
|
||||
import { FileAIChatService } from 'src/engine/core-modules/file/file-ai-chat/services/file-ai-chat.service';
|
||||
|
||||
const MAX_THREAD_TITLE_LENGTH = 100;
|
||||
const POSTGRES_UNIQUE_VIOLATION_CODE = '23505';
|
||||
|
||||
type WorkflowTraceThreadKey = {
|
||||
workspaceId: string;
|
||||
workflowRunId: string;
|
||||
workflowStepId: string;
|
||||
};
|
||||
|
||||
type WorkflowTraceThreadStats = {
|
||||
totalInputTokens: number;
|
||||
totalOutputTokens: number;
|
||||
totalInputCredits: number;
|
||||
totalOutputCredits: number;
|
||||
contextWindowTokens: number;
|
||||
conversationSize: number;
|
||||
};
|
||||
|
||||
type PersistTraceParams = WorkflowTraceThreadKey &
|
||||
WorkflowTraceThreadStats & {
|
||||
steps: StepResult<ToolSet>[];
|
||||
userPrompt: string;
|
||||
agentId: string | null;
|
||||
};
|
||||
|
||||
const isUniqueViolationError = (error: unknown): error is { code: string } =>
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
(error as { code: unknown }).code === POSTGRES_UNIQUE_VIOLATION_CODE;
|
||||
|
||||
const buildColumnIncrementExpression =
|
||||
(columnName: string, delta: number) => () =>
|
||||
`"${columnName}" + ${delta}`;
|
||||
|
||||
@Injectable()
|
||||
export class WorkflowAgentTracePersistenceService {
|
||||
private readonly logger = new Logger(
|
||||
WorkflowAgentTracePersistenceService.name,
|
||||
);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(AgentChatThreadEntity)
|
||||
private readonly threadRepository: Repository<AgentChatThreadEntity>,
|
||||
private readonly fileAIChatService: FileAIChatService,
|
||||
) {}
|
||||
|
||||
async persistTrace(
|
||||
params: PersistTraceParams,
|
||||
): Promise<{ turnId: string; threadId: string }> {
|
||||
const persistableParts = await mapGenerateTextStepsToPersistableParts({
|
||||
steps: params.steps,
|
||||
uploadGeneratedFile: async ({ file, filename }) => {
|
||||
// Upload outside the DB transaction; orphan files are acceptable since regeneration is cheap.
|
||||
const uploadedFile = await this.fileAIChatService.uploadFile({
|
||||
file,
|
||||
filename,
|
||||
workspaceId: params.workspaceId,
|
||||
});
|
||||
|
||||
return {
|
||||
fileId: uploadedFile.id,
|
||||
filename,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const threadId = await this.ensureWorkflowTraceThread(params);
|
||||
|
||||
const turnId = await this.threadRepository.manager.transaction(
|
||||
async (entityManager) => {
|
||||
await this.applyThreadAggregates(entityManager, threadId, params);
|
||||
|
||||
return this.insertTurnWithMessages(entityManager, {
|
||||
threadId,
|
||||
persistableParts,
|
||||
userPrompt: params.userPrompt,
|
||||
agentId: params.agentId,
|
||||
workspaceId: params.workspaceId,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Persisted workflow agent trace: turnId=${turnId} threadId=${threadId} steps=${params.steps.length} parts=${persistableParts.length}`,
|
||||
);
|
||||
|
||||
return { turnId, threadId };
|
||||
}
|
||||
|
||||
private async ensureWorkflowTraceThread(
|
||||
params: WorkflowTraceThreadKey & { userPrompt: string },
|
||||
): Promise<string> {
|
||||
const { workspaceId, workflowRunId, workflowStepId } = params;
|
||||
const existingThread = await this.findWorkflowTraceThread({
|
||||
workspaceId,
|
||||
workflowRunId,
|
||||
workflowStepId,
|
||||
});
|
||||
|
||||
if (existingThread) {
|
||||
return existingThread.id;
|
||||
}
|
||||
|
||||
try {
|
||||
const insertResult = await this.threadRepository.insert({
|
||||
workspaceId,
|
||||
userWorkspaceId: null,
|
||||
workflowRunId,
|
||||
workflowStepId,
|
||||
title: params.userPrompt.substring(0, MAX_THREAD_TITLE_LENGTH),
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
totalInputCredits: 0,
|
||||
totalOutputCredits: 0,
|
||||
});
|
||||
|
||||
return insertResult.identifiers[0].id as string;
|
||||
} catch (error) {
|
||||
// Re-read on unique-violation: a concurrent insert may have raced past findOne.
|
||||
if (!isUniqueViolationError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const winningThread = await this.findWorkflowTraceThread({
|
||||
workspaceId,
|
||||
workflowRunId,
|
||||
workflowStepId,
|
||||
});
|
||||
|
||||
if (!winningThread) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return winningThread.id;
|
||||
}
|
||||
}
|
||||
|
||||
private findWorkflowTraceThread(key: WorkflowTraceThreadKey) {
|
||||
return this.threadRepository.findOne({
|
||||
where: key,
|
||||
select: ['id'],
|
||||
});
|
||||
}
|
||||
|
||||
private async applyThreadAggregates(
|
||||
entityManager: EntityManager,
|
||||
threadId: string,
|
||||
params: WorkflowTraceThreadStats & { userPrompt: string },
|
||||
): Promise<void> {
|
||||
await entityManager.getRepository(AgentChatThreadEntity).update(threadId, {
|
||||
title: params.userPrompt.substring(0, MAX_THREAD_TITLE_LENGTH),
|
||||
totalInputTokens: buildColumnIncrementExpression(
|
||||
'totalInputTokens',
|
||||
params.totalInputTokens,
|
||||
),
|
||||
totalOutputTokens: buildColumnIncrementExpression(
|
||||
'totalOutputTokens',
|
||||
params.totalOutputTokens,
|
||||
),
|
||||
totalInputCredits: buildColumnIncrementExpression(
|
||||
'totalInputCredits',
|
||||
params.totalInputCredits,
|
||||
),
|
||||
totalOutputCredits: buildColumnIncrementExpression(
|
||||
'totalOutputCredits',
|
||||
params.totalOutputCredits,
|
||||
),
|
||||
contextWindowTokens: params.contextWindowTokens,
|
||||
conversationSize: params.conversationSize,
|
||||
});
|
||||
}
|
||||
|
||||
private async insertTurnWithMessages(
|
||||
entityManager: EntityManager,
|
||||
params: {
|
||||
threadId: string;
|
||||
persistableParts: Awaited<
|
||||
ReturnType<typeof mapGenerateTextStepsToPersistableParts>
|
||||
>;
|
||||
userPrompt: string;
|
||||
agentId: string | null;
|
||||
workspaceId: string;
|
||||
},
|
||||
): Promise<string> {
|
||||
const { threadId, persistableParts, userPrompt, agentId, workspaceId } =
|
||||
params;
|
||||
const turnId = await this.insertAndGetId(
|
||||
entityManager.getRepository(AgentTurnEntity),
|
||||
{ threadId, agentId, workspaceId },
|
||||
);
|
||||
const userMessageId = await this.insertMessage(entityManager, {
|
||||
threadId,
|
||||
turnId,
|
||||
role: AgentMessageRole.USER,
|
||||
agentId: null,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
await entityManager.getRepository(AgentMessagePartEntity).insert({
|
||||
messageId: userMessageId,
|
||||
orderIndex: 0,
|
||||
type: 'text',
|
||||
textContent: userPrompt,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
const assistantMessageId = await this.insertMessage(entityManager, {
|
||||
threadId,
|
||||
turnId,
|
||||
role: AgentMessageRole.ASSISTANT,
|
||||
agentId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
if (persistableParts.length > 0) {
|
||||
const databaseParts = mapPersistablePartsToDatabaseParts(
|
||||
persistableParts,
|
||||
assistantMessageId,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
await entityManager
|
||||
.getRepository(AgentMessagePartEntity)
|
||||
.insert(
|
||||
databaseParts as QueryDeepPartialEntity<AgentMessagePartEntity>[],
|
||||
);
|
||||
}
|
||||
|
||||
return turnId;
|
||||
}
|
||||
|
||||
private insertMessage(
|
||||
entityManager: EntityManager,
|
||||
params: {
|
||||
threadId: string;
|
||||
turnId: string;
|
||||
role: AgentMessageRole;
|
||||
agentId: string | null;
|
||||
workspaceId: string;
|
||||
},
|
||||
): Promise<string> {
|
||||
return this.insertAndGetId(
|
||||
entityManager.getRepository(AgentMessageEntity),
|
||||
{
|
||||
threadId: params.threadId,
|
||||
turnId: params.turnId,
|
||||
role: params.role,
|
||||
agentId: params.agentId,
|
||||
processedAt: new Date(),
|
||||
workspaceId: params.workspaceId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async insertAndGetId<T extends { id: string }>(
|
||||
repository: Repository<T>,
|
||||
payload: QueryDeepPartialEntity<T>,
|
||||
): Promise<string> {
|
||||
const insertResult = await repository.insert(payload);
|
||||
|
||||
return insertResult.identifiers[0].id as string;
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -1,8 +1,9 @@
|
||||
import { type LanguageModelUsage } from 'ai';
|
||||
import { type LanguageModelUsage, type StepResult, type ToolSet } from 'ai';
|
||||
|
||||
export interface AgentExecutionResult {
|
||||
result: object;
|
||||
usage: LanguageModelUsage;
|
||||
cacheCreationTokens: number;
|
||||
nativeWebSearchCallCount: number;
|
||||
steps?: StepResult<ToolSet>[];
|
||||
}
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
import { type JSONValue } from 'ai';
|
||||
|
||||
type PersistableProviderMetadata = Record<
|
||||
string,
|
||||
Record<string, JSONValue | undefined>
|
||||
>;
|
||||
|
||||
type PersistableTextPart = {
|
||||
type: 'text';
|
||||
text: string;
|
||||
};
|
||||
|
||||
type PersistableReasoningPart = {
|
||||
type: 'reasoning';
|
||||
text: string;
|
||||
state?: 'streaming' | 'done';
|
||||
};
|
||||
|
||||
type PersistableFilePart = {
|
||||
type: 'file';
|
||||
fileId: string;
|
||||
filename: string | null;
|
||||
};
|
||||
|
||||
type PersistableSourceUrlPart = {
|
||||
type: 'source-url';
|
||||
sourceId: string;
|
||||
url: string;
|
||||
title: string;
|
||||
providerMetadata?: PersistableProviderMetadata;
|
||||
};
|
||||
|
||||
type PersistableSourceDocumentPart = {
|
||||
type: 'source-document';
|
||||
sourceId: string;
|
||||
mediaType: string;
|
||||
title: string;
|
||||
filename?: string;
|
||||
providerMetadata?: PersistableProviderMetadata;
|
||||
};
|
||||
|
||||
type PersistableStepStartPart = {
|
||||
type: 'step-start';
|
||||
};
|
||||
|
||||
type PersistableRoutingStatusPart = {
|
||||
type: 'data-routing-status';
|
||||
text: string;
|
||||
state: string;
|
||||
};
|
||||
|
||||
type PersistableToolPart = {
|
||||
type: `tool-${string}`;
|
||||
toolName: string;
|
||||
toolCallId: string;
|
||||
input: unknown;
|
||||
state:
|
||||
| 'input-streaming'
|
||||
| 'input-available'
|
||||
| 'output-available'
|
||||
| 'output-error'
|
||||
| 'output-denied';
|
||||
output?: unknown;
|
||||
errorText?: string;
|
||||
};
|
||||
|
||||
export type PersistableAgentMessagePart =
|
||||
| PersistableFilePart
|
||||
| PersistableReasoningPart
|
||||
| PersistableRoutingStatusPart
|
||||
| PersistableSourceDocumentPart
|
||||
| PersistableSourceUrlPart
|
||||
| PersistableStepStartPart
|
||||
| PersistableTextPart
|
||||
| PersistableToolPart;
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
import { mapDBPartToUIMessagePart } from 'src/engine/metadata-modules/ai/ai-agent-execution/utils/mapDBPartToUIMessagePart';
|
||||
import { type AgentMessagePartEntity } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message-part.entity';
|
||||
|
||||
const createToolMessagePartEntity = (
|
||||
overrides: Partial<AgentMessagePartEntity> = {},
|
||||
): AgentMessagePartEntity =>
|
||||
({
|
||||
id: 'part-id',
|
||||
workspaceId: 'workspace-id',
|
||||
messageId: 'message-id',
|
||||
orderIndex: 0,
|
||||
type: 'tool-learn_tools',
|
||||
textContent: null,
|
||||
reasoningContent: null,
|
||||
toolName: 'learn_tools',
|
||||
toolCallId: 'tool-call-id',
|
||||
toolInput: { toolNames: ['findCompanies'] },
|
||||
toolOutput: {
|
||||
tools: [{ name: 'findCompanies', description: 'Find companies' }],
|
||||
notFound: [],
|
||||
message: 'Learned 1 tool(s): findCompanies.',
|
||||
},
|
||||
state: 'output-available',
|
||||
errorMessage: null,
|
||||
errorDetails: null,
|
||||
sourceUrlSourceId: null,
|
||||
sourceUrlUrl: null,
|
||||
sourceUrlTitle: null,
|
||||
sourceDocumentSourceId: null,
|
||||
sourceDocumentMediaType: null,
|
||||
sourceDocumentTitle: null,
|
||||
sourceDocumentFilename: null,
|
||||
fileFilename: null,
|
||||
fileId: null,
|
||||
file: null,
|
||||
providerMetadata: null,
|
||||
createdAt: new Date(),
|
||||
...overrides,
|
||||
}) as AgentMessagePartEntity;
|
||||
|
||||
describe('mapDBPartToUIMessagePart', () => {
|
||||
it('should omit errorText for successful persisted tool parts', () => {
|
||||
const uiMessagePart = mapDBPartToUIMessagePart(
|
||||
createToolMessagePartEntity(),
|
||||
);
|
||||
|
||||
expect(uiMessagePart).toMatchObject({
|
||||
type: 'tool-learn_tools',
|
||||
toolCallId: 'tool-call-id',
|
||||
input: { toolNames: ['findCompanies'] },
|
||||
output: {
|
||||
tools: [{ name: 'findCompanies', description: 'Find companies' }],
|
||||
notFound: [],
|
||||
message: 'Learned 1 tool(s): findCompanies.',
|
||||
},
|
||||
state: 'output-available',
|
||||
});
|
||||
expect(uiMessagePart).not.toHaveProperty('errorText');
|
||||
});
|
||||
|
||||
it('should include errorText for failed persisted tool parts', () => {
|
||||
const uiMessagePart = mapDBPartToUIMessagePart(
|
||||
createToolMessagePartEntity({
|
||||
toolOutput: null,
|
||||
state: 'output-error',
|
||||
errorMessage: 'Tool failed',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(uiMessagePart).toMatchObject({
|
||||
type: 'tool-learn_tools',
|
||||
errorText: 'Tool failed',
|
||||
state: 'output-error',
|
||||
});
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -62,8 +62,8 @@ export const mapDBPartToUIMessagePart = (
|
||||
toolCallId: part.toolCallId,
|
||||
input: part.toolInput ?? {},
|
||||
output: part.toolOutput,
|
||||
errorText: part.errorMessage ?? '',
|
||||
state: part.state,
|
||||
...(part.errorMessage ? { errorText: part.errorMessage } : {}),
|
||||
} as ExtendedUIMessagePart;
|
||||
}
|
||||
|
||||
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
import { type StepResult, type ToolSet } from 'ai';
|
||||
|
||||
import { mapGenerateTextStepsToPersistableParts } from 'src/engine/metadata-modules/ai/ai-agent-execution/utils/mapGenerateTextStepsToPersistableParts';
|
||||
|
||||
describe('mapGenerateTextStepsToPersistableParts', () => {
|
||||
it('maps supported step content parts without dropping workflow trace data', async () => {
|
||||
const uploadGeneratedFile = jest.fn().mockResolvedValue({
|
||||
fileId: 'file-1',
|
||||
filename: 'workflow-trace-step-1-part-6.pdf',
|
||||
});
|
||||
|
||||
const steps = [
|
||||
{
|
||||
content: [
|
||||
{ type: 'text', text: '' },
|
||||
{ type: 'text', text: 'Answer' },
|
||||
{ type: 'reasoning', text: 'Plan' },
|
||||
{
|
||||
type: 'source',
|
||||
sourceType: 'url',
|
||||
id: 'source-url-1',
|
||||
url: 'https://example.com',
|
||||
title: 'Example',
|
||||
},
|
||||
{
|
||||
type: 'source',
|
||||
sourceType: 'document',
|
||||
id: 'source-document-1',
|
||||
mediaType: 'application/pdf',
|
||||
title: 'Spec',
|
||||
filename: 'spec.pdf',
|
||||
},
|
||||
{
|
||||
type: 'file',
|
||||
file: {
|
||||
base64: 'SGVsbG8=',
|
||||
uint8Array: new Uint8Array([72, 101, 108, 108, 111]),
|
||||
mediaType: 'application/pdf',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolName: 'search',
|
||||
toolCallId: 'tool-call-1',
|
||||
input: { query: 'hello' },
|
||||
},
|
||||
{
|
||||
type: 'tool-result',
|
||||
toolName: 'search',
|
||||
toolCallId: 'tool-call-1',
|
||||
input: { query: 'hello' },
|
||||
output: { answer: 'world' },
|
||||
},
|
||||
{
|
||||
type: 'tool-error',
|
||||
toolName: 'search',
|
||||
toolCallId: 'tool-call-2',
|
||||
input: { query: 'oops' },
|
||||
error: new Error('boom'),
|
||||
},
|
||||
{
|
||||
type: 'tool-approval-request',
|
||||
approvalId: 'approval-1',
|
||||
toolCall: {
|
||||
toolName: 'approve_search',
|
||||
toolCallId: 'tool-call-3',
|
||||
input: { query: 'hold' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
content: [{ type: 'text', text: 'Second step' }],
|
||||
},
|
||||
] as StepResult<ToolSet>[];
|
||||
|
||||
const parts = await mapGenerateTextStepsToPersistableParts({
|
||||
steps,
|
||||
uploadGeneratedFile,
|
||||
});
|
||||
|
||||
expect(uploadGeneratedFile).toHaveBeenCalledWith({
|
||||
file: Buffer.from([72, 101, 108, 108, 111]),
|
||||
filename: 'workflow-trace-step-1-part-6.pdf',
|
||||
});
|
||||
|
||||
expect(parts).toEqual([
|
||||
{ type: 'text', text: 'Answer' },
|
||||
{ type: 'reasoning', text: 'Plan', state: 'done' },
|
||||
{
|
||||
type: 'source-url',
|
||||
sourceId: 'source-url-1',
|
||||
url: 'https://example.com',
|
||||
title: 'Example',
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{
|
||||
type: 'source-document',
|
||||
sourceId: 'source-document-1',
|
||||
mediaType: 'application/pdf',
|
||||
title: 'Spec',
|
||||
filename: 'spec.pdf',
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{
|
||||
type: 'file',
|
||||
fileId: 'file-1',
|
||||
filename: 'workflow-trace-step-1-part-6.pdf',
|
||||
},
|
||||
{
|
||||
type: 'tool-search',
|
||||
toolName: 'search',
|
||||
toolCallId: 'tool-call-1',
|
||||
input: { query: 'hello' },
|
||||
state: 'input-available',
|
||||
},
|
||||
{
|
||||
type: 'tool-search',
|
||||
toolName: 'search',
|
||||
toolCallId: 'tool-call-1',
|
||||
input: { query: 'hello' },
|
||||
output: { answer: 'world' },
|
||||
state: 'output-available',
|
||||
},
|
||||
{
|
||||
type: 'tool-search',
|
||||
toolName: 'search',
|
||||
toolCallId: 'tool-call-2',
|
||||
input: { query: 'oops' },
|
||||
errorText: 'Error: boom',
|
||||
state: 'output-error',
|
||||
},
|
||||
{
|
||||
type: 'tool-approve_search',
|
||||
toolName: 'approve_search',
|
||||
toolCallId: 'tool-call-3',
|
||||
input: { query: 'hold' },
|
||||
state: 'input-available',
|
||||
},
|
||||
{ type: 'step-start' },
|
||||
{ type: 'text', text: 'Second step' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
import { type StepResult, type ToolSet } from 'ai';
|
||||
|
||||
import { type PersistableAgentMessagePart } from 'src/engine/metadata-modules/ai/ai-agent-execution/types/persistable-agent-message-part.type';
|
||||
|
||||
const GENERATED_FILE_EXTENSION_OVERRIDES: Record<string, string> = {
|
||||
'application/json': 'json',
|
||||
'application/pdf': 'pdf',
|
||||
'application/xml': 'xml',
|
||||
'image/jpeg': 'jpg',
|
||||
'image/svg+xml': 'svg',
|
||||
'text/csv': 'csv',
|
||||
'text/html': 'html',
|
||||
'text/markdown': 'md',
|
||||
'text/plain': 'txt',
|
||||
'text/xml': 'xml',
|
||||
};
|
||||
|
||||
const getGeneratedFileExtension = (mediaType: string) => {
|
||||
const overriddenExtension = GENERATED_FILE_EXTENSION_OVERRIDES[mediaType];
|
||||
|
||||
if (overriddenExtension) {
|
||||
return overriddenExtension;
|
||||
}
|
||||
|
||||
const rawSubtype = mediaType.split('/')[1]?.split(';')[0];
|
||||
const sanitizedSubtype = rawSubtype?.replace(/[^a-z0-9.+-]/gi, '-');
|
||||
|
||||
return sanitizedSubtype || 'bin';
|
||||
};
|
||||
|
||||
const buildGeneratedFileFilename = ({
|
||||
mediaType,
|
||||
partIndex,
|
||||
stepIndex,
|
||||
}: {
|
||||
mediaType: string;
|
||||
partIndex: number;
|
||||
stepIndex: number;
|
||||
}) => {
|
||||
const extension = getGeneratedFileExtension(mediaType);
|
||||
|
||||
return `workflow-trace-step-${stepIndex + 1}-part-${partIndex + 1}.${extension}`;
|
||||
};
|
||||
|
||||
const assertUnreachable = (value: never): never => {
|
||||
throw new Error(`Unsupported content part: ${JSON.stringify(value)}`);
|
||||
};
|
||||
|
||||
export const mapGenerateTextStepsToPersistableParts = async ({
|
||||
steps,
|
||||
uploadGeneratedFile,
|
||||
}: {
|
||||
steps: StepResult<ToolSet>[];
|
||||
uploadGeneratedFile: (args: {
|
||||
file: Buffer;
|
||||
filename: string;
|
||||
}) => Promise<{ fileId: string; filename: string | null }>;
|
||||
}): Promise<PersistableAgentMessagePart[]> => {
|
||||
const parts: PersistableAgentMessagePart[] = [];
|
||||
|
||||
for (const [stepIndex, step] of steps.entries()) {
|
||||
if (stepIndex > 0) {
|
||||
parts.push({ type: 'step-start' });
|
||||
}
|
||||
|
||||
for (const [partIndex, contentPart] of step.content.entries()) {
|
||||
switch (contentPart.type) {
|
||||
case 'text':
|
||||
if (contentPart.text.length > 0) {
|
||||
parts.push({ type: 'text', text: contentPart.text });
|
||||
}
|
||||
break;
|
||||
case 'reasoning':
|
||||
parts.push({
|
||||
type: 'reasoning',
|
||||
text: contentPart.text,
|
||||
state: 'done',
|
||||
});
|
||||
break;
|
||||
case 'source':
|
||||
if (contentPart.sourceType === 'url') {
|
||||
parts.push({
|
||||
type: 'source-url',
|
||||
sourceId: contentPart.id,
|
||||
url: contentPart.url,
|
||||
title: contentPart.title ?? '',
|
||||
providerMetadata: contentPart.providerMetadata,
|
||||
});
|
||||
} else {
|
||||
parts.push({
|
||||
type: 'source-document',
|
||||
sourceId: contentPart.id,
|
||||
mediaType: contentPart.mediaType,
|
||||
title: contentPart.title,
|
||||
filename: contentPart.filename,
|
||||
providerMetadata: contentPart.providerMetadata,
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'file': {
|
||||
// Workflow traces keep generated files as inspectable artifacts.
|
||||
// They are not promoted into the workflow step output schema.
|
||||
const filename = buildGeneratedFileFilename({
|
||||
mediaType: contentPart.file.mediaType,
|
||||
partIndex,
|
||||
stepIndex,
|
||||
});
|
||||
const uploadedFile = await uploadGeneratedFile({
|
||||
file: Buffer.from(contentPart.file.uint8Array),
|
||||
filename,
|
||||
});
|
||||
|
||||
parts.push({
|
||||
type: 'file',
|
||||
fileId: uploadedFile.fileId,
|
||||
filename: uploadedFile.filename,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'tool-call':
|
||||
parts.push({
|
||||
type: `tool-${contentPart.toolName}`,
|
||||
toolName: contentPart.toolName,
|
||||
toolCallId: contentPart.toolCallId,
|
||||
input: contentPart.input,
|
||||
state: 'input-available',
|
||||
});
|
||||
break;
|
||||
case 'tool-result':
|
||||
parts.push({
|
||||
type: `tool-${contentPart.toolName}`,
|
||||
toolName: contentPart.toolName,
|
||||
toolCallId: contentPart.toolCallId,
|
||||
input: contentPart.input,
|
||||
output: contentPart.output,
|
||||
state: 'output-available',
|
||||
});
|
||||
break;
|
||||
case 'tool-error':
|
||||
parts.push({
|
||||
type: `tool-${contentPart.toolName}`,
|
||||
toolName: contentPart.toolName,
|
||||
toolCallId: contentPart.toolCallId,
|
||||
input: contentPart.input,
|
||||
errorText: String(contentPart.error),
|
||||
state: 'output-error',
|
||||
});
|
||||
break;
|
||||
case 'tool-approval-request':
|
||||
parts.push({
|
||||
type: `tool-${contentPart.toolCall.toolName}`,
|
||||
toolName: contentPart.toolCall.toolName,
|
||||
toolCallId: contentPart.toolCall.toolCallId,
|
||||
input: contentPart.toolCall.input,
|
||||
state: 'input-available',
|
||||
});
|
||||
break;
|
||||
default:
|
||||
assertUnreachable(contentPart);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parts;
|
||||
};
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
import { type AgentMessagePartEntity } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message-part.entity';
|
||||
import { type PersistableAgentMessagePart } from 'src/engine/metadata-modules/ai/ai-agent-execution/types/persistable-agent-message-part.type';
|
||||
|
||||
const isPersistableToolPart = (
|
||||
part: PersistableAgentMessagePart,
|
||||
): part is Extract<PersistableAgentMessagePart, { type: `tool-${string}` }> => {
|
||||
return part.type.startsWith('tool-');
|
||||
};
|
||||
|
||||
const assertUnreachable = (value: never): never => {
|
||||
throw new Error(`Unsupported persistable part: ${JSON.stringify(value)}`);
|
||||
};
|
||||
|
||||
export const mapPersistablePartsToDatabaseParts = (
|
||||
parts: PersistableAgentMessagePart[],
|
||||
messageId: string,
|
||||
workspaceId: string,
|
||||
): Partial<AgentMessagePartEntity>[] => {
|
||||
return parts.map((part, index) => {
|
||||
const basePart: Partial<AgentMessagePartEntity> = {
|
||||
messageId,
|
||||
orderIndex: index,
|
||||
type: part.type,
|
||||
workspaceId,
|
||||
};
|
||||
|
||||
if (isPersistableToolPart(part)) {
|
||||
return {
|
||||
...basePart,
|
||||
toolName: part.toolName,
|
||||
toolCallId: part.toolCallId,
|
||||
toolInput: part.input,
|
||||
toolOutput: part.output,
|
||||
errorMessage: part.errorText,
|
||||
state: part.state,
|
||||
};
|
||||
}
|
||||
|
||||
switch (part.type) {
|
||||
case 'text':
|
||||
return {
|
||||
...basePart,
|
||||
textContent: part.text,
|
||||
};
|
||||
case 'reasoning':
|
||||
return {
|
||||
...basePart,
|
||||
reasoningContent: part.text,
|
||||
state: part.state ?? null,
|
||||
};
|
||||
case 'file':
|
||||
return {
|
||||
...basePart,
|
||||
fileFilename: part.filename,
|
||||
fileId: part.fileId,
|
||||
};
|
||||
case 'source-url':
|
||||
return {
|
||||
...basePart,
|
||||
sourceUrlSourceId: part.sourceId,
|
||||
sourceUrlUrl: part.url,
|
||||
sourceUrlTitle: part.title,
|
||||
providerMetadata:
|
||||
(part.providerMetadata as AgentMessagePartEntity['providerMetadata']) ??
|
||||
null,
|
||||
};
|
||||
case 'source-document':
|
||||
return {
|
||||
...basePart,
|
||||
sourceDocumentSourceId: part.sourceId,
|
||||
sourceDocumentMediaType: part.mediaType,
|
||||
sourceDocumentTitle: part.title,
|
||||
sourceDocumentFilename: part.filename ?? null,
|
||||
providerMetadata:
|
||||
(part.providerMetadata as AgentMessagePartEntity['providerMetadata']) ??
|
||||
null,
|
||||
};
|
||||
case 'step-start':
|
||||
return basePart;
|
||||
case 'data-routing-status':
|
||||
return {
|
||||
...basePart,
|
||||
textContent: part.text,
|
||||
state: part.state,
|
||||
};
|
||||
default:
|
||||
return assertUnreachable(part);
|
||||
}
|
||||
});
|
||||
};
|
||||
+8
-93
@@ -1,102 +1,17 @@
|
||||
import { type ToolUIPart } from 'ai';
|
||||
import {
|
||||
isExtendedFileUIPart,
|
||||
type ExtendedUIMessagePart,
|
||||
} from 'twenty-shared/ai';
|
||||
import { type ExtendedUIMessagePart } from 'twenty-shared/ai';
|
||||
|
||||
import { type AgentMessagePartEntity } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message-part.entity';
|
||||
|
||||
const isToolPart = (part: ExtendedUIMessagePart): part is ToolUIPart => {
|
||||
return part.type.includes('tool-') && 'toolCallId' in part;
|
||||
};
|
||||
import { mapPersistablePartsToDatabaseParts } from 'src/engine/metadata-modules/ai/ai-agent-execution/utils/mapPersistablePartsToDatabaseParts';
|
||||
import { mapUIMessagePartsToPersistableParts } from 'src/engine/metadata-modules/ai/ai-agent-execution/utils/mapUIMessagePartsToPersistableParts';
|
||||
|
||||
export const mapUIMessagePartsToDBParts = (
|
||||
uiMessageParts: ExtendedUIMessagePart[],
|
||||
messageId: string,
|
||||
workspaceId: string,
|
||||
): Partial<AgentMessagePartEntity>[] => {
|
||||
return uiMessageParts
|
||||
.map((part, index) => {
|
||||
const basePart: Partial<AgentMessagePartEntity> = {
|
||||
messageId,
|
||||
orderIndex: index,
|
||||
type: part.type,
|
||||
workspaceId,
|
||||
};
|
||||
|
||||
switch (part.type) {
|
||||
case 'text':
|
||||
return {
|
||||
...basePart,
|
||||
textContent: part.text,
|
||||
};
|
||||
case 'reasoning':
|
||||
return {
|
||||
...basePart,
|
||||
reasoningContent: part.text,
|
||||
};
|
||||
case 'file': {
|
||||
if (!isExtendedFileUIPart(part)) {
|
||||
throw new Error('Expected file part');
|
||||
}
|
||||
|
||||
return {
|
||||
...basePart,
|
||||
fileFilename: part.filename,
|
||||
fileId: part.fileId,
|
||||
};
|
||||
}
|
||||
case 'source-url':
|
||||
return {
|
||||
...basePart,
|
||||
sourceUrlSourceId: part.sourceId,
|
||||
sourceUrlUrl: part.url,
|
||||
sourceUrlTitle: part.title,
|
||||
providerMetadata: part.providerMetadata ?? null,
|
||||
};
|
||||
case 'source-document':
|
||||
return {
|
||||
...basePart,
|
||||
sourceDocumentSourceId: part.sourceId,
|
||||
sourceDocumentMediaType: part.mediaType,
|
||||
sourceDocumentTitle: part.title,
|
||||
sourceDocumentFilename: part.filename,
|
||||
providerMetadata: part.providerMetadata ?? null,
|
||||
};
|
||||
case 'step-start':
|
||||
return basePart;
|
||||
case 'data-compaction':
|
||||
return null;
|
||||
case 'data-routing-status':
|
||||
return {
|
||||
...basePart,
|
||||
textContent: part.data.text,
|
||||
state: part.data.state,
|
||||
};
|
||||
case 'data-code-execution':
|
||||
// Code execution parts are streamed during execution but don't need
|
||||
// to be persisted - the final result is captured in the tool part
|
||||
return null;
|
||||
case 'data-thread-title':
|
||||
// Thread title is a transient notification for the client
|
||||
return null;
|
||||
default:
|
||||
{
|
||||
if (isToolPart(part)) {
|
||||
const { toolCallId, input, output, errorText, state } = part;
|
||||
|
||||
return {
|
||||
...basePart,
|
||||
toolCallId: toolCallId,
|
||||
toolInput: input,
|
||||
toolOutput: output,
|
||||
errorMessage: errorText,
|
||||
state,
|
||||
};
|
||||
}
|
||||
}
|
||||
throw new Error(`Unsupported part type: ${part.type}`);
|
||||
}
|
||||
})
|
||||
.filter((part): part is Partial<AgentMessagePartEntity> => part !== null);
|
||||
return mapPersistablePartsToDatabaseParts(
|
||||
mapUIMessagePartsToPersistableParts(uiMessageParts),
|
||||
messageId,
|
||||
workspaceId,
|
||||
);
|
||||
};
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
type ExtendedFileUIPart,
|
||||
type ExtendedUIMessagePart,
|
||||
} from 'twenty-shared/ai';
|
||||
|
||||
import { mapUIMessagePartsToPersistableParts } from 'src/engine/metadata-modules/ai/ai-agent-execution/utils/mapUIMessagePartsToPersistableParts';
|
||||
|
||||
describe('mapUIMessagePartsToPersistableParts', () => {
|
||||
const sourceDocumentPart: ExtendedUIMessagePart = {
|
||||
type: 'source-document',
|
||||
sourceId: 'source-document-1',
|
||||
mediaType: 'application/pdf',
|
||||
title: 'Spec',
|
||||
filename: 'spec.pdf',
|
||||
};
|
||||
|
||||
const filePart: ExtendedFileUIPart = {
|
||||
type: 'file',
|
||||
mediaType: 'text/plain',
|
||||
filename: 'notes.txt',
|
||||
url: 'https://example.com/notes.txt',
|
||||
fileId: 'file-1',
|
||||
};
|
||||
|
||||
const dynamicToolPart = {
|
||||
type: 'dynamic-tool',
|
||||
toolName: 'search',
|
||||
toolCallId: 'tool-call-1',
|
||||
input: { query: 'hello' },
|
||||
state: 'approval-requested',
|
||||
} as ExtendedUIMessagePart;
|
||||
|
||||
const threadTitlePart = {
|
||||
type: 'data-thread-title',
|
||||
data: { title: 'Ignore me' },
|
||||
} as ExtendedUIMessagePart;
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'source document parts',
|
||||
parts: [sourceDocumentPart],
|
||||
expected: [
|
||||
{
|
||||
type: 'source-document',
|
||||
sourceId: 'source-document-1',
|
||||
mediaType: 'application/pdf',
|
||||
title: 'Spec',
|
||||
filename: 'spec.pdf',
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'file parts',
|
||||
parts: [filePart],
|
||||
expected: [{ type: 'file', fileId: 'file-1', filename: 'notes.txt' }],
|
||||
},
|
||||
{
|
||||
name: 'dynamic tool parts with approval states',
|
||||
parts: [dynamicToolPart],
|
||||
expected: [
|
||||
{
|
||||
type: 'tool-search',
|
||||
toolName: 'search',
|
||||
toolCallId: 'tool-call-1',
|
||||
input: { query: 'hello' },
|
||||
state: 'input-available',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'transient title parts',
|
||||
parts: [threadTitlePart],
|
||||
expected: [],
|
||||
},
|
||||
])('maps $name', ({ parts, expected }) => {
|
||||
expect(mapUIMessagePartsToPersistableParts(parts)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
import { type DynamicToolUIPart, type ToolUIPart } from 'ai';
|
||||
import {
|
||||
isExtendedFileUIPart,
|
||||
type ExtendedUIMessagePart,
|
||||
} from 'twenty-shared/ai';
|
||||
|
||||
import { type PersistableAgentMessagePart } from 'src/engine/metadata-modules/ai/ai-agent-execution/types/persistable-agent-message-part.type';
|
||||
|
||||
type PersistableToolUIPart = DynamicToolUIPart | ToolUIPart;
|
||||
type PersistableToolPartState = Extract<
|
||||
PersistableAgentMessagePart,
|
||||
{ type: `tool-${string}` }
|
||||
>['state'];
|
||||
|
||||
const isPersistableToolUIPart = (
|
||||
part: ExtendedUIMessagePart,
|
||||
): part is PersistableToolUIPart => {
|
||||
return (
|
||||
'toolCallId' in part &&
|
||||
(part.type === 'dynamic-tool' || part.type.startsWith('tool-'))
|
||||
);
|
||||
};
|
||||
|
||||
const normalizePersistableToolState = (
|
||||
state: PersistableToolUIPart['state'],
|
||||
): PersistableToolPartState => {
|
||||
if (state === 'approval-requested' || state === 'approval-responded') {
|
||||
return 'input-available';
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
const assertUnreachable = (value: never): never => {
|
||||
throw new Error(`Unsupported UI message part: ${JSON.stringify(value)}`);
|
||||
};
|
||||
|
||||
export const mapUIMessagePartsToPersistableParts = (
|
||||
uiMessageParts: ExtendedUIMessagePart[],
|
||||
): PersistableAgentMessagePart[] => {
|
||||
const persistableParts: PersistableAgentMessagePart[] = [];
|
||||
|
||||
for (const part of uiMessageParts) {
|
||||
if (isPersistableToolUIPart(part)) {
|
||||
const toolName =
|
||||
part.type === 'dynamic-tool'
|
||||
? part.toolName
|
||||
: part.type.slice('tool-'.length);
|
||||
|
||||
persistableParts.push({
|
||||
type: `tool-${toolName}` as const,
|
||||
toolName,
|
||||
toolCallId: part.toolCallId,
|
||||
input: part.input,
|
||||
output: 'output' in part ? part.output : undefined,
|
||||
errorText: 'errorText' in part ? part.errorText : undefined,
|
||||
state: normalizePersistableToolState(part.state),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (part.type) {
|
||||
case 'text':
|
||||
persistableParts.push({
|
||||
type: 'text',
|
||||
text: part.text,
|
||||
});
|
||||
break;
|
||||
case 'reasoning':
|
||||
persistableParts.push({
|
||||
type: 'reasoning',
|
||||
text: part.text,
|
||||
state: part.state,
|
||||
});
|
||||
break;
|
||||
case 'file':
|
||||
if (!isExtendedFileUIPart(part)) {
|
||||
throw new Error('Expected file part');
|
||||
}
|
||||
|
||||
persistableParts.push({
|
||||
type: 'file',
|
||||
filename: part.filename ?? null,
|
||||
fileId: part.fileId,
|
||||
});
|
||||
break;
|
||||
case 'source-url':
|
||||
persistableParts.push({
|
||||
type: 'source-url',
|
||||
sourceId: part.sourceId,
|
||||
url: part.url,
|
||||
title: part.title ?? '',
|
||||
providerMetadata: part.providerMetadata,
|
||||
});
|
||||
break;
|
||||
case 'source-document':
|
||||
persistableParts.push({
|
||||
type: 'source-document',
|
||||
sourceId: part.sourceId,
|
||||
mediaType: part.mediaType,
|
||||
title: part.title,
|
||||
filename: part.filename,
|
||||
providerMetadata: part.providerMetadata,
|
||||
});
|
||||
break;
|
||||
case 'step-start':
|
||||
persistableParts.push({
|
||||
type: 'step-start',
|
||||
});
|
||||
break;
|
||||
case 'data-compaction':
|
||||
break;
|
||||
case 'data-routing-status':
|
||||
persistableParts.push({
|
||||
type: 'data-routing-status',
|
||||
text: part.data.text,
|
||||
state: part.data.state,
|
||||
});
|
||||
break;
|
||||
case 'data-code-execution':
|
||||
// Code execution parts are streamed during execution but don't need
|
||||
// to be persisted - the final result is captured in the tool part.
|
||||
break;
|
||||
case 'data-thread-title':
|
||||
// Thread title is a transient notification for the client.
|
||||
break;
|
||||
default:
|
||||
assertUnreachable(part);
|
||||
}
|
||||
}
|
||||
|
||||
return persistableParts;
|
||||
};
|
||||
+2
@@ -14,6 +14,7 @@ import { AgentTurnEvaluationEntity } from './entities/agent-turn-evaluation.enti
|
||||
import { EvaluateAgentTurnJob } from './jobs/evaluate-agent-turn.job';
|
||||
import { RunEvaluationInputJob } from './jobs/run-evaluation-input.job';
|
||||
import { AgentTurnResolver } from './resolvers/agent-turn.resolver';
|
||||
import { AgentTurnThreadSummaryResolver } from './resolvers/agent-turn-thread-summary.resolver';
|
||||
import { AgentTurnGraderService } from './services/agent-turn-grader.service';
|
||||
|
||||
@Module({
|
||||
@@ -33,6 +34,7 @@ import { AgentTurnGraderService } from './services/agent-turn-grader.service';
|
||||
providers: [
|
||||
AgentTurnGraderService,
|
||||
AgentTurnResolver,
|
||||
AgentTurnThreadSummaryResolver,
|
||||
EvaluateAgentTurnJob,
|
||||
RunEvaluationInputJob,
|
||||
],
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { Float, Parent, ResolveField } from '@nestjs/graphql';
|
||||
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { toDisplayCredits } from 'src/engine/core-modules/usage/utils/to-display-credits.util';
|
||||
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { AgentTurnThreadSummaryDTO } from 'src/engine/metadata-modules/ai/ai-agent-execution/dtos/agent-turn-thread-summary.dto';
|
||||
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
|
||||
|
||||
@UseGuards(WorkspaceAuthGuard, SettingsPermissionGuard(PermissionFlagType.AI))
|
||||
@MetadataResolver(() => AgentTurnThreadSummaryDTO)
|
||||
export class AgentTurnThreadSummaryResolver {
|
||||
@ResolveField(() => Float)
|
||||
totalInputCredits(@Parent() thread: AgentChatThreadEntity): number {
|
||||
return toDisplayCredits(thread.totalInputCredits);
|
||||
}
|
||||
|
||||
@ResolveField(() => Float)
|
||||
totalOutputCredits(@Parent() thread: AgentChatThreadEntity): number {
|
||||
return toDisplayCredits(thread.totalOutputCredits);
|
||||
}
|
||||
}
|
||||
+46
-8
@@ -1,4 +1,4 @@
|
||||
import { Logger, UseGuards } from '@nestjs/common';
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { Args, Mutation, Query } from '@nestjs/graphql';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
@@ -27,8 +27,6 @@ import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/en
|
||||
@UseGuards(WorkspaceAuthGuard, SettingsPermissionGuard(PermissionFlagType.AI))
|
||||
@MetadataResolver()
|
||||
export class AgentTurnResolver {
|
||||
private readonly logger = new Logger(AgentTurnResolver.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(AgentTurnEntity)
|
||||
private readonly turnRepository: Repository<AgentTurnEntity>,
|
||||
@@ -42,12 +40,52 @@ export class AgentTurnResolver {
|
||||
@Query(() => [AgentTurnDTO])
|
||||
async agentTurns(
|
||||
@Args('agentId', { type: () => UUIDScalarType }) agentId: string,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
): Promise<AgentTurnEntity[]> {
|
||||
return this.turnRepository.find({
|
||||
where: { agentId },
|
||||
relations: ['evaluations', 'messages', 'messages.parts'],
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
return this.turnRepository
|
||||
.createQueryBuilder('turn')
|
||||
.innerJoin('turn.thread', 'thread')
|
||||
.leftJoinAndSelect('turn.evaluations', 'evaluation')
|
||||
.leftJoinAndSelect('turn.messages', 'message')
|
||||
.leftJoinAndSelect('message.parts', 'part')
|
||||
.where('turn.agentId = :agentId', { agentId })
|
||||
.andWhere('turn.workspaceId = :workspaceId', {
|
||||
workspaceId: workspace.id,
|
||||
})
|
||||
.andWhere('thread.workflowRunId IS NULL')
|
||||
.orderBy('turn.createdAt', 'DESC')
|
||||
.addOrderBy('message.processedAt', 'ASC', 'NULLS LAST')
|
||||
.addOrderBy('part.orderIndex', 'ASC', 'NULLS LAST')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
@Query(() => AgentTurnDTO, { nullable: true })
|
||||
async workflowAgentTrace(
|
||||
@Args('workflowRunId', { type: () => UUIDScalarType })
|
||||
workflowRunId: string,
|
||||
@Args('workflowStepId') workflowStepId: string,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
): Promise<AgentTurnEntity | null> {
|
||||
// Workflow traces are latest-only until turns can be addressed by execution.
|
||||
return this.turnRepository
|
||||
.createQueryBuilder('turn')
|
||||
.innerJoinAndSelect('turn.thread', 'thread')
|
||||
.leftJoinAndSelect('turn.evaluations', 'evaluation')
|
||||
.leftJoinAndSelect('turn.messages', 'message')
|
||||
.leftJoinAndSelect('message.parts', 'part')
|
||||
.where('thread.workspaceId = :workspaceId', {
|
||||
workspaceId: workspace.id,
|
||||
})
|
||||
.andWhere('thread.workflowRunId = :workflowRunId', {
|
||||
workflowRunId,
|
||||
})
|
||||
.andWhere('thread.workflowStepId = :workflowStepId', {
|
||||
workflowStepId,
|
||||
})
|
||||
.orderBy('turn.createdAt', 'DESC')
|
||||
.addOrderBy('message.processedAt', 'ASC', 'NULLS LAST')
|
||||
.addOrderBy('part.orderIndex', 'ASC', 'NULLS LAST')
|
||||
.getOne();
|
||||
}
|
||||
|
||||
@Mutation(() => AgentTurnEvaluationDTO)
|
||||
|
||||
+16
-10
@@ -1,15 +1,21 @@
|
||||
import { type StepResult, type ToolSet } from 'ai';
|
||||
|
||||
const WEB_SEARCH_TOOL_NAME = 'web_search';
|
||||
// Shared by billing and workflow execution logging because both treat these
|
||||
// as provider-native search tool calls.
|
||||
export const NATIVE_SEARCH_TOOL_NAMES = new Set(['web_search', 'x_search']);
|
||||
|
||||
export const countNativeWebSearchCallsFromSteps = (
|
||||
steps: StepResult<ToolSet>[],
|
||||
): number =>
|
||||
steps.reduce(
|
||||
(count, step) =>
|
||||
count +
|
||||
step.toolCalls.filter(
|
||||
(toolCall) => toolCall.toolName === WEB_SEARCH_TOOL_NAME,
|
||||
).length,
|
||||
0,
|
||||
);
|
||||
): number => {
|
||||
let searchCallCount = 0;
|
||||
|
||||
for (const step of steps) {
|
||||
for (const toolCall of step.toolCalls) {
|
||||
if (NATIVE_SEARCH_TOOL_NAMES.has(toolCall.toolName)) {
|
||||
searchCallCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return searchCallCount;
|
||||
};
|
||||
|
||||
+21
-2
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
Check,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
@@ -17,6 +18,18 @@ import type { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspac
|
||||
import { EntityRelation } from 'src/engine/workspace-manager/workspace-migration/types/entity-relation.interface';
|
||||
|
||||
@Entity({ name: 'agentChatThread', schema: 'core' })
|
||||
@Check(
|
||||
'CHK_agent_chat_thread_source',
|
||||
'("userWorkspaceId" IS NOT NULL AND "workflowRunId" IS NULL AND "workflowStepId" IS NULL) OR ("userWorkspaceId" IS NULL AND "workflowRunId" IS NOT NULL AND "workflowStepId" IS NOT NULL)',
|
||||
)
|
||||
@Index(
|
||||
'IDX_AGENT_CHAT_THREAD_WORKFLOW',
|
||||
['workspaceId', 'workflowRunId', 'workflowStepId'],
|
||||
{
|
||||
unique: true,
|
||||
where: '"workflowRunId" IS NOT NULL',
|
||||
},
|
||||
)
|
||||
export class AgentChatThreadEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
@@ -29,9 +42,9 @@ export class AgentChatThreadEntity {
|
||||
@JoinColumn({ name: 'workspaceId' })
|
||||
workspace: EntityRelation<WorkspaceEntity>;
|
||||
|
||||
@Column({ nullable: false, type: 'uuid' })
|
||||
@Column({ nullable: true, type: 'uuid' })
|
||||
@Index()
|
||||
userWorkspaceId: string;
|
||||
userWorkspaceId: string | null;
|
||||
|
||||
@ManyToOne(() => UserWorkspaceEntity, {
|
||||
onDelete: 'CASCADE',
|
||||
@@ -39,6 +52,12 @@ export class AgentChatThreadEntity {
|
||||
@JoinColumn({ name: 'userWorkspaceId' })
|
||||
userWorkspace: EntityRelation<UserWorkspaceEntity>;
|
||||
|
||||
@Column({ nullable: true, type: 'uuid' })
|
||||
workflowRunId: string | null;
|
||||
|
||||
@Column({ nullable: true, type: 'varchar' })
|
||||
workflowStepId: string | null;
|
||||
|
||||
@Column({ nullable: true, type: 'varchar' })
|
||||
title: string;
|
||||
|
||||
|
||||
+37
-51
@@ -23,16 +23,15 @@ import { CodeInterpreterService } from 'src/engine/core-modules/code-interpreter
|
||||
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
|
||||
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
||||
import { COMMON_PRELOAD_TOOLS } from 'src/engine/core-modules/tool-provider/constants/common-preload-tools.const';
|
||||
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
|
||||
import { wrapToolsWithOutputSerialization } from 'src/engine/core-modules/tool-provider/output-serialization/wrap-tools-with-output-serialization.util';
|
||||
import { LazyToolRuntimeService } from 'src/engine/core-modules/tool-provider/services/lazy-tool-runtime.service';
|
||||
import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
|
||||
import {
|
||||
createExecuteToolTool,
|
||||
createLearnToolsTool,
|
||||
createLoadSkillTool,
|
||||
EXECUTE_TOOL_TOOL_NAME,
|
||||
LEARN_TOOLS_TOOL_NAME,
|
||||
LOAD_SKILL_TOOL_NAME,
|
||||
} from 'src/engine/core-modules/tool-provider/tools';
|
||||
import { WebSearchService } from 'src/engine/core-modules/web-search/web-search.service';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AgentActorContextService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-actor-context.service';
|
||||
import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-config.const';
|
||||
@@ -59,7 +58,6 @@ import {
|
||||
} from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
import { SdkProviderFactoryService } from 'src/engine/metadata-modules/ai/ai-models/services/sdk-provider-factory.service';
|
||||
import { type AIModelConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-config.type';
|
||||
import { WebSearchService } from 'src/engine/core-modules/web-search/web-search.service';
|
||||
import { SkillService } from 'src/engine/metadata-modules/skill/skill.service';
|
||||
|
||||
export type ChatExecutionOptions = {
|
||||
@@ -84,6 +82,7 @@ export class ChatExecutionService {
|
||||
private readonly logger = new Logger(ChatExecutionService.name);
|
||||
|
||||
constructor(
|
||||
private readonly lazyToolRuntimeService: LazyToolRuntimeService,
|
||||
private readonly toolRegistry: ToolRegistryService,
|
||||
private readonly skillService: SkillService,
|
||||
private readonly aiModelRegistryService: AiModelRegistryService,
|
||||
@@ -115,9 +114,12 @@ export class ChatExecutionService {
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
const toolContext = {
|
||||
// Regular AI chat is not executing a saved agent, so agent-specific
|
||||
// capability toggles should only apply in AgentAsyncExecutorService.
|
||||
const toolProviderContext: ToolProviderContext = {
|
||||
workspaceId: workspace.id,
|
||||
roleId,
|
||||
rolePermissionConfig: { unionOf: [roleId] },
|
||||
actorContext,
|
||||
userId,
|
||||
userWorkspaceId,
|
||||
@@ -128,32 +130,17 @@ export class ChatExecutionService {
|
||||
? this.buildContextFromBrowsingContext(workspace, browsingContext)
|
||||
: undefined;
|
||||
|
||||
const toolCatalog = await this.toolRegistry.buildToolIndex(
|
||||
workspace.id,
|
||||
roleId,
|
||||
{ userId, userWorkspaceId },
|
||||
);
|
||||
|
||||
const skillCatalog = await this.skillService.findAllFlatSkills(
|
||||
workspace.id,
|
||||
);
|
||||
const useNativeSearch = this.webSearchService.shouldUseNativeSearch();
|
||||
|
||||
this.logger.log(
|
||||
`Built tool catalog with ${toolCatalog.length} tools, ${skillCatalog.length} skills available`,
|
||||
`Web search strategy: ${useNativeSearch ? 'native (provider SDK)' : 'external (EXA)'}`,
|
||||
);
|
||||
|
||||
const useNativeSearch = this.webSearchService.shouldUseNativeSearch();
|
||||
|
||||
const toolNamesToPreload = [
|
||||
...COMMON_PRELOAD_TOOLS,
|
||||
...(useNativeSearch ? [] : ['web_search']),
|
||||
];
|
||||
|
||||
const preloadedTools = await this.toolRegistry.getToolsByName(
|
||||
toolNamesToPreload,
|
||||
toolContext,
|
||||
);
|
||||
|
||||
const resolvedModelId = modelId ?? workspace.smartModel;
|
||||
|
||||
this.aiModelRegistryService.validateModelAvailability(
|
||||
@@ -170,36 +157,38 @@ export class ChatExecutionService {
|
||||
registeredModel.modelId,
|
||||
);
|
||||
|
||||
const { tools: nativeSearchTools, callableToolNames: searchToolNames } =
|
||||
useNativeSearch
|
||||
? this.getNativeWebSearchTools(registeredModel)
|
||||
: { tools: {}, callableToolNames: [] };
|
||||
const { tools: nativeSearchTools } = useNativeSearch
|
||||
? this.getNativeWebSearchTools(registeredModel)
|
||||
: { tools: {} };
|
||||
|
||||
// Direct tools: native provider tools + preloaded tools.
|
||||
// These are callable directly AND as fallback through execute_tool.
|
||||
const directTools: ToolSet = {
|
||||
...wrapToolsWithOutputSerialization(preloadedTools),
|
||||
...nativeSearchTools,
|
||||
};
|
||||
const preloadedTools = await this.toolRegistry.getToolsByName(
|
||||
toolNamesToPreload,
|
||||
toolProviderContext,
|
||||
);
|
||||
|
||||
const preloadedToolNames = [
|
||||
...Object.keys(preloadedTools),
|
||||
...searchToolNames,
|
||||
];
|
||||
const toolRuntime = await this.lazyToolRuntimeService.buildToolRuntime({
|
||||
context: toolProviderContext,
|
||||
directTools: {
|
||||
...wrapToolsWithOutputSerialization(preloadedTools),
|
||||
...nativeSearchTools,
|
||||
},
|
||||
});
|
||||
|
||||
const toolCatalog = toolRuntime.toolCatalog;
|
||||
const skillCatalog = await this.skillService.findAllFlatSkills(
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Built tool catalog with ${toolCatalog.length} tools, ${skillCatalog.length} skills available`,
|
||||
);
|
||||
|
||||
const preloadedToolNames = toolRuntime.directToolNames;
|
||||
|
||||
// ToolSet is constant for the entire conversation — no mutation.
|
||||
// learn_tools returns schemas as text; execute_tool dispatches to cached tools.
|
||||
const activeTools: ToolSet = {
|
||||
...directTools,
|
||||
[LEARN_TOOLS_TOOL_NAME]: createLearnToolsTool(
|
||||
this.toolRegistry,
|
||||
toolContext,
|
||||
),
|
||||
[EXECUTE_TOOL_TOOL_NAME]: createExecuteToolTool(
|
||||
this.toolRegistry,
|
||||
toolContext,
|
||||
directTools,
|
||||
),
|
||||
...toolRuntime.runtimeTools,
|
||||
[LOAD_SKILL_TOOL_NAME]: createLoadSkillTool(
|
||||
(skillNames) =>
|
||||
this.skillService.findFlatSkillsByNames(skillNames, workspace.id),
|
||||
@@ -457,9 +446,8 @@ export class ChatExecutionService {
|
||||
|
||||
private getNativeWebSearchTools(model: RegisteredAIModel): {
|
||||
tools: ToolSet;
|
||||
callableToolNames: string[];
|
||||
} {
|
||||
const empty = { tools: {}, callableToolNames: [] };
|
||||
const empty = { tools: {} };
|
||||
const providerName = model.providerName;
|
||||
|
||||
if (!providerName) {
|
||||
@@ -477,7 +465,6 @@ export class ChatExecutionService {
|
||||
|
||||
return {
|
||||
tools: { web_search: provider.tools.webSearch_20250305() },
|
||||
callableToolNames: ['web_search'],
|
||||
};
|
||||
}
|
||||
case AI_SDK_BEDROCK:
|
||||
@@ -492,7 +479,6 @@ export class ChatExecutionService {
|
||||
|
||||
return {
|
||||
tools: { web_search: provider.tools.webSearch() },
|
||||
callableToolNames: ['web_search'],
|
||||
};
|
||||
}
|
||||
default:
|
||||
|
||||
+44
-51
@@ -2,7 +2,9 @@ import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { ProviderOptions } from '@ai-sdk/provider-utils';
|
||||
import { ToolSet } from 'ai';
|
||||
import { isAgentCapabilityEnabled } from 'twenty-shared/ai';
|
||||
|
||||
import { type ToolProviderAgent } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-agent.type';
|
||||
import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-config.const';
|
||||
import {
|
||||
AI_SDK_ANTHROPIC,
|
||||
@@ -10,27 +12,17 @@ import {
|
||||
AI_SDK_OPENAI,
|
||||
AI_SDK_XAI,
|
||||
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-sdk-package.const';
|
||||
import {
|
||||
AiModelRegistryService,
|
||||
RegisteredAIModel,
|
||||
} from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
import { RegisteredAIModel } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
import { SdkProviderFactoryService } from 'src/engine/metadata-modules/ai/ai-models/services/sdk-provider-factory.service';
|
||||
import { FlatAgentWithRoleId } from 'src/engine/metadata-modules/flat-agent/types/flat-agent.type';
|
||||
|
||||
@Injectable()
|
||||
export class AgentModelConfigService {
|
||||
constructor(
|
||||
private readonly aiModelRegistryService: AiModelRegistryService,
|
||||
private readonly sdkProviderFactory: SdkProviderFactoryService,
|
||||
) {}
|
||||
constructor(private readonly sdkProviderFactory: SdkProviderFactoryService) {}
|
||||
|
||||
getProviderOptions(
|
||||
model: RegisteredAIModel,
|
||||
agent: FlatAgentWithRoleId,
|
||||
): ProviderOptions {
|
||||
getProviderOptions(model: RegisteredAIModel): ProviderOptions {
|
||||
switch (model.sdkPackage) {
|
||||
case AI_SDK_XAI:
|
||||
return this.getXaiProviderOptions(agent);
|
||||
return {};
|
||||
case AI_SDK_ANTHROPIC:
|
||||
return this.getAnthropicProviderOptions(model);
|
||||
case AI_SDK_BEDROCK:
|
||||
@@ -42,17 +34,25 @@ export class AgentModelConfigService {
|
||||
|
||||
getNativeModelTools(
|
||||
model: RegisteredAIModel,
|
||||
agent: FlatAgentWithRoleId,
|
||||
agent: ToolProviderAgent,
|
||||
options: { useProviderNativeWebSearch: boolean },
|
||||
): ToolSet {
|
||||
const tools: ToolSet = {};
|
||||
|
||||
if (!agent.modelConfiguration) {
|
||||
return tools;
|
||||
}
|
||||
const modelConfiguration = agent.modelConfiguration ?? {};
|
||||
const isWebSearchEnabledForAgent = isAgentCapabilityEnabled(
|
||||
modelConfiguration,
|
||||
'webSearch',
|
||||
);
|
||||
const isTwitterSearchEnabledForAgent = isAgentCapabilityEnabled(
|
||||
modelConfiguration,
|
||||
'twitterSearch',
|
||||
);
|
||||
const shouldExposeProviderNativeWebSearch =
|
||||
options.useProviderNativeWebSearch && isWebSearchEnabledForAgent;
|
||||
|
||||
switch (model.sdkPackage) {
|
||||
case AI_SDK_ANTHROPIC:
|
||||
if (agent.modelConfiguration.webSearch?.enabled) {
|
||||
if (shouldExposeProviderNativeWebSearch) {
|
||||
const anthropicProvider = model.providerName
|
||||
? this.sdkProviderFactory.getRawAnthropicProvider(
|
||||
model.providerName,
|
||||
@@ -65,7 +65,7 @@ export class AgentModelConfigService {
|
||||
}
|
||||
break;
|
||||
case AI_SDK_BEDROCK: {
|
||||
if (agent.modelConfiguration.webSearch?.enabled) {
|
||||
if (shouldExposeProviderNativeWebSearch) {
|
||||
const bedrockProvider = model.providerName
|
||||
? this.sdkProviderFactory.getRawBedrockProvider(model.providerName)
|
||||
: undefined;
|
||||
@@ -78,7 +78,7 @@ export class AgentModelConfigService {
|
||||
break;
|
||||
}
|
||||
case AI_SDK_OPENAI:
|
||||
if (agent.modelConfiguration.webSearch?.enabled) {
|
||||
if (shouldExposeProviderNativeWebSearch) {
|
||||
const openaiProvider = model.providerName
|
||||
? this.sdkProviderFactory.getRawOpenAIProvider(model.providerName)
|
||||
: undefined;
|
||||
@@ -87,41 +87,34 @@ export class AgentModelConfigService {
|
||||
tools.web_search = openaiProvider.tools.webSearch();
|
||||
}
|
||||
}
|
||||
break;
|
||||
case AI_SDK_XAI:
|
||||
if (!model.providerName) {
|
||||
break;
|
||||
}
|
||||
|
||||
const xaiProvider = this.sdkProviderFactory.getRawXaiProvider(
|
||||
model.providerName,
|
||||
);
|
||||
|
||||
if (!xaiProvider) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (shouldExposeProviderNativeWebSearch) {
|
||||
tools.web_search = xaiProvider.tools.webSearch() as ToolSet[string];
|
||||
}
|
||||
|
||||
if (isTwitterSearchEnabledForAgent) {
|
||||
tools.x_search = xaiProvider.tools.xSearch() as ToolSet[string];
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
private getXaiProviderOptions(agent: FlatAgentWithRoleId): ProviderOptions {
|
||||
if (
|
||||
!agent.modelConfiguration ||
|
||||
(!agent.modelConfiguration.webSearch?.enabled &&
|
||||
!agent.modelConfiguration.twitterSearch?.enabled)
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const sources: Array<{ type: string }> = [];
|
||||
|
||||
if (agent.modelConfiguration.webSearch?.enabled) {
|
||||
sources.push({ type: 'web' });
|
||||
}
|
||||
|
||||
if (agent.modelConfiguration.twitterSearch?.enabled) {
|
||||
sources.push({ type: 'x' });
|
||||
}
|
||||
|
||||
return {
|
||||
xai: {
|
||||
searchParameters: {
|
||||
mode: 'auto',
|
||||
...(sources.length > 0 && { sources }),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private getAnthropicProviderOptions(
|
||||
model: RegisteredAIModel,
|
||||
): ProviderOptions {
|
||||
|
||||
+19
-2
@@ -9,7 +9,7 @@ import { createGoogleGenerativeAI } from '@ai-sdk/google';
|
||||
import { createMistral } from '@ai-sdk/mistral';
|
||||
import { createOpenAI, type OpenAIProvider } from '@ai-sdk/openai';
|
||||
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
||||
import { createXai } from '@ai-sdk/xai';
|
||||
import { createXai, type XaiProvider } from '@ai-sdk/xai';
|
||||
import { fromNodeProviderChain } from '@aws-sdk/credential-providers';
|
||||
import { type LanguageModel } from 'ai';
|
||||
import { type AiSdkPackage } from 'twenty-shared/ai';
|
||||
@@ -85,6 +85,10 @@ export class SdkProviderFactoryService {
|
||||
return this.getRawProvider<OpenAIProvider>(providerName, AI_SDK_OPENAI);
|
||||
}
|
||||
|
||||
getRawXaiProvider(providerName: string): XaiProvider | undefined {
|
||||
return this.getRawProvider<XaiProvider>(providerName, AI_SDK_XAI);
|
||||
}
|
||||
|
||||
clearCache(): void {
|
||||
this.providerInstances.clear();
|
||||
}
|
||||
@@ -102,7 +106,7 @@ export class SdkProviderFactoryService {
|
||||
case AI_SDK_MISTRAL:
|
||||
return this.buildStandardProvider(config, createMistral);
|
||||
case AI_SDK_XAI:
|
||||
return this.buildStandardProvider(config, createXai);
|
||||
return this.buildXaiProvider(config);
|
||||
case AI_SDK_BEDROCK:
|
||||
return this.buildBedrockProvider(config);
|
||||
case AI_SDK_OPENAI_COMPATIBLE:
|
||||
@@ -186,4 +190,17 @@ export class SdkProviderFactoryService {
|
||||
sdkPackage: AI_SDK_OPENAI_COMPATIBLE,
|
||||
};
|
||||
}
|
||||
|
||||
private buildXaiProvider(config: AiProviderConfig): AiSdkProviderInstance {
|
||||
const provider = createXai({
|
||||
...(config.apiKey && { apiKey: config.apiKey }),
|
||||
...(config.baseUrl && { baseURL: config.baseUrl }),
|
||||
});
|
||||
|
||||
return {
|
||||
createModel: (modelId: string) => provider.responses(modelId),
|
||||
rawProvider: provider,
|
||||
sdkPackage: AI_SDK_XAI,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+70
-13
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { resolveInput } from 'twenty-shared/utils';
|
||||
@@ -7,8 +7,12 @@ import { type Repository } from 'typeorm';
|
||||
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
|
||||
|
||||
import { AgentAsyncExecutorService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service';
|
||||
import { WorkflowAgentTracePersistenceService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/workflow-agent-trace-persistence.service';
|
||||
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
|
||||
import { AiBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service';
|
||||
import { computeCostBreakdown } from 'src/engine/metadata-modules/ai/ai-billing/utils/compute-cost-breakdown.util';
|
||||
import { convertDollarsToBillingCredits } from 'src/engine/metadata-modules/ai/ai-billing/utils/convert-dollars-to-billing-credits.util';
|
||||
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
import { UsageOperationType } from 'src/engine/core-modules/usage/enums/usage-operation-type.enum';
|
||||
import { WebSearchService } from 'src/engine/core-modules/web-search/web-search.service';
|
||||
import { AUTO_SELECT_SMART_MODEL_ID } from 'twenty-shared/constants';
|
||||
@@ -25,11 +29,15 @@ import { isWorkflowAiAgentAction } from './guards/is-workflow-ai-agent-action.gu
|
||||
|
||||
@Injectable()
|
||||
export class AiAgentWorkflowAction implements WorkflowAction {
|
||||
private readonly logger = new Logger(AiAgentWorkflowAction.name);
|
||||
|
||||
constructor(
|
||||
private readonly aiAgentExecutionService: AgentAsyncExecutorService,
|
||||
private readonly aiBillingService: AiBillingService,
|
||||
private readonly aiModelRegistryService: AiModelRegistryService,
|
||||
private readonly webSearchService: WebSearchService,
|
||||
private readonly workflowExecutionContextService: WorkflowExecutionContextService,
|
||||
private readonly tracePersistenceService: WorkflowAgentTracePersistenceService,
|
||||
@InjectRepository(AgentEntity)
|
||||
private readonly agentRepository: Repository<AgentEntity>,
|
||||
) {}
|
||||
@@ -53,7 +61,7 @@ export class AiAgentWorkflowAction implements WorkflowAction {
|
||||
}
|
||||
|
||||
const { agentId, prompt } = step.settings.input;
|
||||
const workspaceId = context.workspaceId as string;
|
||||
const workspaceId = runInfo.workspaceId;
|
||||
|
||||
let agent: AgentEntity | null = null;
|
||||
|
||||
@@ -73,6 +81,8 @@ export class AiAgentWorkflowAction implements WorkflowAction {
|
||||
);
|
||||
}
|
||||
|
||||
const modelId = agent?.modelId ?? AUTO_SELECT_SMART_MODEL_ID;
|
||||
|
||||
const executionContext =
|
||||
await this.workflowExecutionContextService.getExecutionContext(runInfo);
|
||||
|
||||
@@ -81,19 +91,26 @@ export class AiAgentWorkflowAction implements WorkflowAction {
|
||||
? executionContext.authContext.userWorkspaceId
|
||||
: null;
|
||||
|
||||
const { result, usage, cacheCreationTokens, nativeWebSearchCallCount } =
|
||||
await this.aiAgentExecutionService.executeAgent({
|
||||
agent,
|
||||
userPrompt: resolveInput(prompt, context) as string,
|
||||
actorContext: executionContext.isActingOnBehalfOfUser
|
||||
? executionContext.initiator
|
||||
: undefined,
|
||||
rolePermissionConfig: executionContext.rolePermissionConfig,
|
||||
authContext: executionContext.authContext,
|
||||
});
|
||||
const resolvedPrompt = resolveInput(prompt, context) as string;
|
||||
|
||||
const {
|
||||
result,
|
||||
usage,
|
||||
cacheCreationTokens,
|
||||
nativeWebSearchCallCount,
|
||||
steps: executionSteps,
|
||||
} = await this.aiAgentExecutionService.executeAgent({
|
||||
agent,
|
||||
userPrompt: resolvedPrompt,
|
||||
actorContext: executionContext.isActingOnBehalfOfUser
|
||||
? executionContext.initiator
|
||||
: undefined,
|
||||
rolePermissionConfig: executionContext.rolePermissionConfig,
|
||||
authContext: executionContext.authContext,
|
||||
});
|
||||
|
||||
await this.aiBillingService.calculateAndBillUsage(
|
||||
agent?.modelId ?? AUTO_SELECT_SMART_MODEL_ID,
|
||||
modelId,
|
||||
{ usage, cacheCreationTokens },
|
||||
workspaceId,
|
||||
UsageOperationType.AI_WORKFLOW_TOKEN,
|
||||
@@ -109,6 +126,46 @@ export class AiAgentWorkflowAction implements WorkflowAction {
|
||||
);
|
||||
}
|
||||
|
||||
if (executionSteps) {
|
||||
try {
|
||||
const modelConfiguration =
|
||||
this.aiModelRegistryService.getEffectiveModelConfig(modelId);
|
||||
const usageBreakdown = computeCostBreakdown(modelConfiguration, {
|
||||
inputTokens: usage.inputTokens,
|
||||
outputTokens: usage.outputTokens,
|
||||
cachedInputTokens: usage.inputTokenDetails?.cacheReadTokens,
|
||||
reasoningTokens: usage.outputTokenDetails?.reasoningTokens,
|
||||
cacheCreationTokens,
|
||||
});
|
||||
|
||||
// Trace persistence is observability-only. Artifacts such as generated
|
||||
// files stay attached to the trace UI and do not change the step output.
|
||||
await this.tracePersistenceService.persistTrace({
|
||||
steps: executionSteps,
|
||||
userPrompt: resolvedPrompt,
|
||||
agentId: agent?.id ?? null,
|
||||
workspaceId,
|
||||
workflowRunId: runInfo.workflowRunId,
|
||||
workflowStepId: currentStepId,
|
||||
totalInputTokens: usageBreakdown.tokenCounts.totalInputTokens,
|
||||
totalOutputTokens: usage.outputTokens ?? 0,
|
||||
totalInputCredits: Math.round(
|
||||
convertDollarsToBillingCredits(usageBreakdown.inputCostInDollars),
|
||||
),
|
||||
totalOutputCredits: Math.round(
|
||||
convertDollarsToBillingCredits(usageBreakdown.outputCostInDollars),
|
||||
),
|
||||
contextWindowTokens: modelConfiguration.contextWindowTokens,
|
||||
conversationSize: usageBreakdown.tokenCounts.totalInputTokens,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to persist workflow agent trace for run ${runInfo.workflowRunId} step ${currentStepId}`,
|
||||
error instanceof Error ? error.stack : String(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
result,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { type AgentCapability } from '../types/agent-capability.type';
|
||||
|
||||
export const AGENT_CAPABILITY_DEFAULTS = {
|
||||
webSearch: true,
|
||||
twitterSearch: false,
|
||||
// Default-off: unlike webSearch (free via model-native capability), codeInterpreter
|
||||
// has no free path — E2B bills per execution. Per-agent opt-in prevents silent
|
||||
// spend. Breaking for self-hosters on CODE_INTERPRETER_TYPE=E2B: existing agents
|
||||
// must flip the per-agent toggle to restore prior workspace-wide behavior.
|
||||
codeInterpreter: false,
|
||||
} satisfies Record<AgentCapability, boolean>;
|
||||
@@ -7,6 +7,7 @@
|
||||
* |___/
|
||||
*/
|
||||
|
||||
export { AGENT_CAPABILITY_DEFAULTS } from './constants/agent-capability-defaults.const';
|
||||
export { AI_SDK_PACKAGE_LABELS } from './constants/ai-sdk-package-labels.const';
|
||||
export type { AiSdkPackage } from './constants/ai-sdk-packages.const';
|
||||
export { AI_SDK_PACKAGES } from './constants/ai-sdk-packages.const';
|
||||
@@ -15,6 +16,7 @@ export { DATA_RESIDENCY_KEYS } from './constants/data-residency.const';
|
||||
export type { NativeAiSdkProviderId } from './constants/native-ai-sdk-provider-ids.const';
|
||||
export { NATIVE_AI_SDK_PROVIDER_IDS } from './constants/native-ai-sdk-provider-ids.const';
|
||||
export { ToolCategory } from './constants/tool-category.const';
|
||||
export type { AgentCapability } from './types/agent-capability.type';
|
||||
export type {
|
||||
AgentResponseFieldType,
|
||||
AgentResponseSchema,
|
||||
@@ -37,5 +39,7 @@ export type { ExtendedUIMessagePart } from './types/ExtendedUIMessagePart';
|
||||
export type { ModelConfiguration } from './types/model-configuration.type';
|
||||
export type { NavigateAppToolOutput } from './types/NavigateAppToolOutput';
|
||||
export { inferAiSdkPackage } from './utils/infer-ai-sdk-package.util';
|
||||
export { isAgentCapabilityEnabled } from './utils/is-agent-capability-enabled.util';
|
||||
export { isAiSdkPackage } from './utils/is-ai-sdk-package.util';
|
||||
export { isDataResidency } from './utils/is-data-residency.util';
|
||||
export { isToolPartErrored } from './utils/is-tool-part-errored.util';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isDefined } from '@/utils/validation/isDefined';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
|
||||
export type CodeExecutionFile = {
|
||||
fileId: string;
|
||||
@@ -20,9 +20,9 @@ export const isExtendedFileUIPart = (
|
||||
): part is ExtendedFileUIPart => {
|
||||
return (
|
||||
part.type === 'file' &&
|
||||
isDefined(part.fileId) &&
|
||||
isDefined(part.url) &&
|
||||
isDefined(part.mediaType)
|
||||
isNonEmptyString(part.fileId) &&
|
||||
isNonEmptyString(part.url) &&
|
||||
isNonEmptyString(part.mediaType)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { type ModelConfiguration } from './model-configuration.type';
|
||||
|
||||
export type AgentCapability = keyof ModelConfiguration;
|
||||
@@ -7,4 +7,8 @@ export type ModelConfiguration = {
|
||||
enabled: boolean;
|
||||
configuration?: Record<string, unknown>;
|
||||
};
|
||||
codeInterpreter?: {
|
||||
enabled: boolean;
|
||||
configuration?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { AGENT_CAPABILITY_DEFAULTS } from '../../constants/agent-capability-defaults.const';
|
||||
import { isAgentCapabilityEnabled } from '../is-agent-capability-enabled.util';
|
||||
|
||||
describe('isAgentCapabilityEnabled', () => {
|
||||
it('returns the shared defaults when an agent does not override a capability', () => {
|
||||
expect(isAgentCapabilityEnabled(undefined, 'webSearch')).toBe(
|
||||
AGENT_CAPABILITY_DEFAULTS.webSearch,
|
||||
);
|
||||
expect(isAgentCapabilityEnabled(undefined, 'twitterSearch')).toBe(
|
||||
AGENT_CAPABILITY_DEFAULTS.twitterSearch,
|
||||
);
|
||||
expect(isAgentCapabilityEnabled(undefined, 'codeInterpreter')).toBe(
|
||||
AGENT_CAPABILITY_DEFAULTS.codeInterpreter,
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers explicit agent configuration over the shared defaults', () => {
|
||||
const modelConfiguration = {
|
||||
webSearch: { enabled: false },
|
||||
twitterSearch: { enabled: true },
|
||||
codeInterpreter: { enabled: false },
|
||||
};
|
||||
|
||||
expect(isAgentCapabilityEnabled(modelConfiguration, 'webSearch')).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isAgentCapabilityEnabled(modelConfiguration, 'twitterSearch')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isAgentCapabilityEnabled(modelConfiguration, 'codeInterpreter'),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { isToolPartErrored } from '../is-tool-part-errored.util';
|
||||
|
||||
describe('isToolPartErrored', () => {
|
||||
it('returns true for error states', () => {
|
||||
expect(isToolPartErrored('output-error')).toBe(true);
|
||||
expect(isToolPartErrored('output-denied')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-error states', () => {
|
||||
expect(isToolPartErrored('input-streaming')).toBe(false);
|
||||
expect(isToolPartErrored('input-available')).toBe(false);
|
||||
expect(isToolPartErrored('approval-requested')).toBe(false);
|
||||
expect(isToolPartErrored('approval-responded')).toBe(false);
|
||||
expect(isToolPartErrored('output-available')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { AGENT_CAPABILITY_DEFAULTS } from '../constants/agent-capability-defaults.const';
|
||||
import { type AgentCapability } from '../types/agent-capability.type';
|
||||
import { type ModelConfiguration } from '../types/model-configuration.type';
|
||||
|
||||
export const isAgentCapabilityEnabled = (
|
||||
modelConfiguration: ModelConfiguration | null | undefined,
|
||||
capability: AgentCapability,
|
||||
): boolean => {
|
||||
return (
|
||||
modelConfiguration?.[capability]?.enabled ??
|
||||
AGENT_CAPABILITY_DEFAULTS[capability]
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
import { type ToolUIPart } from 'ai';
|
||||
|
||||
export const isToolPartErrored = (state: ToolUIPart['state']): boolean =>
|
||||
state === 'output-error' || state === 'output-denied';
|
||||
Reference in New Issue
Block a user