Compare commits

...

41 Commits

Author SHA1 Message Date
ehconitin 83e49a27a8 Merge remote-tracking branch 'upstream/ai-fast-follows-16' into worflow-agent-turn 2026-04-17 23:09:53 +05:30
ehconitin 15a962b196 Merge remote-tracking branch 'upstream/main' into ai-fast-follows-16 2026-04-17 23:09:01 +05:30
ehconitin d3c11f2949 claude 2026-04-17 22:25:18 +05:30
ehconitin 276e379f3c codegen 2026-04-17 21:56:51 +05:30
ehconitin ac762defab fixes 2026-04-17 21:26:32 +05:30
ehconitin 7ee8197967 clarify 2026-04-17 20:18:16 +05:30
ehconitin 86a97f84a2 more 2026-04-17 20:04:16 +05:30
ehconitin a7fb6476d2 more 2026-04-17 19:34:04 +05:30
ehconitin 154b868543 more 2026-04-17 18:12:17 +05:30
ehconitin 040b62e823 more 2026-04-17 18:08:55 +05:30
ehconitin cece97924c more 2026-04-17 17:43:23 +05:30
ehconitin d2104b2fae more 2026-04-17 17:43:17 +05:30
ehconitin 8eea5ea397 more 2026-04-17 16:58:39 +05:30
ehconitin f1de57b15c more 2026-04-17 14:57:27 +05:30
ehconitin d027021e39 more 2026-04-17 14:37:55 +05:30
ehconitin 6e37f8059d init 2026-04-17 14:13:14 +05:30
ehconitin cc64bdb920 more 2026-04-17 01:30:10 +05:30
ehconitin 62e6bddb64 more 2026-04-17 00:53:10 +05:30
ehconitin 8f7fdf4d7e more 2026-04-17 00:41:59 +05:30
ehconitin 0e3f502d4a Merge remote-tracking branch 'upstream/main' into ai-fast-follows-16 2026-04-16 23:42:44 +05:30
ehconitin 99c6e88226 more 2026-04-16 23:42:19 +05:30
ehconitin 1025e24a23 more 2026-04-16 23:20:19 +05:30
ehconitin d570020a9b more 2026-04-16 22:32:06 +05:30
ehconitin 06bdb65d37 more 2026-04-16 22:31:07 +05:30
ehconitin 42194646eb fix 2026-04-16 19:36:32 +05:30
ehconitin 32ac1a93fe Merge remote-tracking branch 'upstream/main' into ai-fast-follows-16 2026-04-16 19:07:03 +05:30
ehconitin ed9e187438 more 2026-04-16 18:57:14 +05:30
ehconitin 78fd9a5efe more 2026-04-16 18:11:19 +05:30
ehconitin a86cab7594 more 2026-04-16 17:18:56 +05:30
ehconitin cd914a358d more 2026-04-16 16:27:42 +05:30
ehconitin 49a7769836 codegen 2026-04-16 13:52:11 +05:30
ehconitin be5799212c Merge remote-tracking branch 'upstream/main' into ai-fast-follows-16 2026-04-16 13:43:29 +05:30
ehconitin a82b13df2d more 2026-04-16 13:42:37 +05:30
ehconitin 70112bf48f Merge remote-tracking branch 'upstream/main' into ai-fast-follows-16 2026-04-13 12:52:56 +05:30
ehconitin a7ff1cbb25 Merge remote-tracking branch 'upstream/main' into ai-fast-follows-16 2026-04-13 11:53:31 +05:30
ehconitin 3eb14f6861 more 2026-04-13 11:45:00 +05:30
ehconitin 7a31f2dad2 more 2026-04-10 20:34:28 +05:30
ehconitin eea99df480 Add per-agent code interpreter toggle 2026-04-10 16:14:07 +05:30
ehconitin 4fcc389c11 Rename nativeCapabilities to capabilities with AgentCapabilities type 2026-04-10 16:07:25 +05:30
ehconitin 5691c9808d Expose web search toggle for EXA-configured models and rename capabilities section 2026-04-10 15:55:37 +05:30
ehconitin db7ab32987 done 2026-04-10 14:24:33 +05:30
84 changed files with 3986 additions and 773 deletions
@@ -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>;
@@ -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>
@@ -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',
});
});
});
@@ -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,
@@ -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;
@@ -38,6 +38,7 @@ const mockClientConfig = {
mutationMaximumAffectedRecords: 100,
},
isAttachmentPreviewEnabled: true,
isCodeInterpreterEnabled: false,
analyticsEnabled: false,
canManageFeatureFlags: true,
publicFeatureFlags: [],
@@ -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>&middot; </StyledSummarySeparator>
)}
{summaryItem}
</span>
))}
</StyledSummaryRow>
);
};
@@ -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>
);
};
@@ -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>
);
};
@@ -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>
);
};
@@ -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[]) => {
@@ -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,
@@ -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"',
);
}
}
@@ -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,
@@ -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: [],
@@ -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;
@@ -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');
});
});
});
@@ -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') ===
@@ -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,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;
};
@@ -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),
);
@@ -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 },
);
});
});
@@ -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 },
);
}
}
@@ -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'],
);
});
});
@@ -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));
}
}
@@ -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,
};
}
}
@@ -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,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>,
) => ({
@@ -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:
@@ -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;
};
@@ -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']);
});
});
@@ -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])],
};
};
@@ -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,
@@ -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,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;
@@ -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']),
);
});
});
@@ -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,
@@ -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);
});
});
@@ -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;
}
}
@@ -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>[];
}
@@ -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;
@@ -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',
});
});
});
@@ -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;
}
@@ -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' },
]);
});
});
@@ -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;
};
@@ -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);
}
});
};
@@ -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,
);
};
@@ -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);
});
});
@@ -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;
};
@@ -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,
],
@@ -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);
}
}
@@ -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)
@@ -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;
};
@@ -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;
@@ -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:
@@ -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 {
@@ -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,
};
}
}
@@ -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>;
+4
View File
@@ -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>;
};
};
@@ -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';