Feat: Add reasoning summary and tool details display for AI responses (#14414)

Closes [#1405](https://github.com/twentyhq/core-team-issues/issues/1405)

### Implemented Features
- Reasoning Summary: Collapsible display of AI thinking process with
real-time streaming
- Tool Execution: Detailed tool call rendering with expandable results
and loading states
- Error Handling: Clean error stream event display with proper
formatting


### Demo



https://github.com/user-attachments/assets/76ad29b7-d831-4bc8-a115-ad25f7ace6e2

<img width="504" height="819" alt="Screenshot 2025-09-12 at 3 23 39 AM"
src="https://github.com/user-attachments/assets/82dc177d-f009-4e8a-8901-ee1a49201984"
/>

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
Abdul Rahman
2025-09-17 15:00:24 +05:30
committed by GitHub
parent 1bf6821b07
commit 8d9617131c
45 changed files with 1500 additions and 540 deletions
@@ -25,6 +25,7 @@ export interface AIModelConfig {
provider: ModelProvider;
inputCostPer1kTokensInCents: number;
outputCostPer1kTokensInCents: number;
doesSupportThinking?: boolean;
}
export const AI_MODELS: AIModelConfig[] = [
@@ -55,6 +56,7 @@ export const AI_MODELS: AIModelConfig[] = [
provider: ModelProvider.ANTHROPIC,
inputCostPer1kTokensInCents: 1.5,
outputCostPer1kTokensInCents: 7.5,
doesSupportThinking: true,
},
{
modelId: 'claude-sonnet-4-20250514',
@@ -62,6 +64,7 @@ export const AI_MODELS: AIModelConfig[] = [
provider: ModelProvider.ANTHROPIC,
inputCostPer1kTokensInCents: 0.3,
outputCostPer1kTokensInCents: 1.5,
doesSupportThinking: true,
},
{
modelId: 'claude-3-5-haiku-20241022',
@@ -69,6 +72,7 @@ export const AI_MODELS: AIModelConfig[] = [
provider: ModelProvider.ANTHROPIC,
inputCostPer1kTokensInCents: 0.08,
outputCostPer1kTokensInCents: 0.4,
doesSupportThinking: true,
},
{
modelId: 'grok-3',
@@ -1,12 +1,12 @@
import { Test } from '@nestjs/testing';
import { ToolAdapterService } from 'src/engine/core-modules/ai/services/tool-adapter.service';
import { ToolRegistryService } from 'src/engine/core-modules/tool/services/tool-registry.service';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { ToolType } from 'src/engine/core-modules/tool/enums/tool-type.enum';
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
import { ToolRegistryService } from 'src/engine/core-modules/tool/services/tool-registry.service';
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
const createMockToolRegistry = () => ({
getAllToolTypes: jest.fn(),
@@ -27,6 +27,8 @@ describe('ToolAdapterService', () => {
// Shared tools
const unflaggedToolExecute = jest.fn(async (input: ToolInput) => ({
success: true,
message: 'Tool executed successfully',
result: { echoed: input },
}));
const unflaggedTool: Tool = {
@@ -36,6 +38,8 @@ describe('ToolAdapterService', () => {
};
const flaggedToolExecute = jest.fn(async (input: ToolInput) => ({
success: true,
message: 'Tool executed successfully',
result: { sent: input },
}));
const flaggedTool: Tool = {
@@ -152,6 +156,10 @@ describe('ToolAdapterService', () => {
// Ensure wrapper forwards only parameters.input
expect(unflaggedToolExecute).toHaveBeenCalledWith(input);
expect(result).toEqual({ result: { echoed: input } });
expect(result).toEqual({
success: true,
message: 'Tool executed successfully',
result: { echoed: input },
});
});
});
@@ -1,10 +1,10 @@
import { Test } from '@nestjs/testing';
import { ToolService } from 'src/engine/core-modules/ai/services/tool.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { getMockObjectMetadataEntity } from 'src/utils/__test__/get-object-metadata-entity.mock';
@@ -147,7 +147,7 @@ describe('ToolService', () => {
);
expect(result.success).toBe(true);
expect(result.record).toEqual(record);
expect(result.result).toEqual(record);
expect(ormManager.getRepositoryForWorkspace).toHaveBeenCalledWith(
workspaceId,
'testObject',
@@ -189,8 +189,8 @@ describe('ToolService', () => {
);
expect(result.success).toBe(true);
expect(result.records).toEqual(records);
expect(result.count).toBe(2);
expect(result.result.records).toEqual(records);
expect(result.result.count).toBe(2);
expect(mockRepo.find).toHaveBeenCalled();
});
});
@@ -16,6 +16,7 @@ export interface RegisteredAIModel {
modelId: string;
provider: ModelProvider;
model: LanguageModel;
doesSupportThinking?: boolean;
}
@Injectable()
@@ -86,6 +87,7 @@ export class AiModelRegistryService {
modelId: modelConfig.modelId,
provider: ModelProvider.ANTHROPIC,
model: anthropic(modelConfig.modelId),
doesSupportThinking: modelConfig.doesSupportThinking,
});
});
}
@@ -173,15 +173,17 @@ export class ToolService {
return {
success: true,
records,
count: records.length,
message: `Found ${records.length} ${objectName} records`,
result: {
records,
count: records.length,
},
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
message: `Failed to find ${objectName} records`,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
@@ -205,8 +207,8 @@ export class ToolService {
if (!id || typeof id !== 'string') {
return {
success: false,
error: 'Record ID is required',
message: `Failed to find ${objectName}: Record ID is required`,
error: 'Record ID is required',
};
}
@@ -217,21 +219,21 @@ export class ToolService {
if (!record) {
return {
success: false,
error: 'Record not found',
message: `Failed to find ${objectName}: Record with ID ${id} not found`,
error: 'Record not found',
};
}
return {
success: true,
record,
message: `Found ${objectName} record`,
result: record,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
message: `Failed to find ${objectName} record`,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
@@ -261,8 +263,8 @@ export class ToolService {
if (!objectMetadataItemWithFieldsMaps) {
return {
success: false,
error: 'Object metadata not found',
message: `Failed to create ${objectName}: Object metadata not found`,
error: 'Object metadata not found',
};
}
@@ -276,14 +278,14 @@ export class ToolService {
return {
success: true,
record: createdRecord,
message: `Successfully created ${objectName}`,
result: createdRecord,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
message: `Failed to create ${objectName}`,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
@@ -307,8 +309,8 @@ export class ToolService {
if (!id || typeof id !== 'string') {
return {
success: false,
error: 'Record ID is required for update',
message: `Failed to update ${objectName}: Record ID is required`,
error: 'Record ID is required for update',
};
}
@@ -319,8 +321,8 @@ export class ToolService {
if (!existingRecord) {
return {
success: false,
error: 'Record not found',
message: `Failed to update ${objectName}: Record with ID ${id} not found`,
error: 'Record not found',
};
}
@@ -335,8 +337,8 @@ export class ToolService {
if (!objectMetadataItemWithFieldsMaps) {
return {
success: false,
error: 'Object metadata not found',
message: `Failed to update ${objectName}: Object metadata not found`,
error: 'Object metadata not found',
};
}
@@ -355,21 +357,21 @@ export class ToolService {
if (!updatedRecord) {
return {
success: false,
error: 'Failed to retrieve updated record',
message: `Failed to update ${objectName}: Could not retrieve updated record`,
error: 'Failed to retrieve updated record',
};
}
return {
success: true,
record: updatedRecord,
message: `Successfully updated ${objectName}`,
result: updatedRecord,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
message: `Failed to update ${objectName}`,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
@@ -393,8 +395,8 @@ export class ToolService {
if (!id || typeof id !== 'string') {
return {
success: false,
error: 'Record ID is required for soft delete',
message: `Failed to soft delete ${objectName}: Record ID is required`,
error: 'Record ID is required for soft delete',
};
}
@@ -405,8 +407,8 @@ export class ToolService {
if (!existingRecord) {
return {
success: false,
error: 'Record not found',
message: `Failed to soft delete ${objectName}: Record with ID ${id} not found`,
error: 'Record not found',
};
}
@@ -415,12 +417,13 @@ export class ToolService {
return {
success: true,
message: `Successfully soft deleted ${objectName}`,
result: { id },
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
message: `Failed to soft delete ${objectName}`,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
@@ -444,8 +447,8 @@ export class ToolService {
if (!id || typeof id !== 'string') {
return {
success: false,
error: 'Record ID is required for destroy',
message: `Failed to destroy ${objectName}: Record ID is required`,
error: 'Record ID is required for destroy',
};
}
@@ -456,8 +459,8 @@ export class ToolService {
if (!existingRecord) {
return {
success: false,
error: 'Record not found',
message: `Failed to destroy ${objectName}: Record with ID ${id} not found`,
error: 'Record not found',
};
}
@@ -466,12 +469,13 @@ export class ToolService {
return {
success: true,
message: `Successfully destroyed ${objectName}`,
result: { id },
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
message: `Failed to destroy ${objectName}`,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
@@ -495,8 +499,8 @@ export class ToolService {
if (!filter || typeof filter !== 'object' || !('id' in filter)) {
return {
success: false,
error: 'Filter with record IDs is required for bulk soft delete',
message: `Failed to soft delete many ${objectName}: Filter with record IDs is required`,
error: 'Filter with record IDs is required for bulk soft delete',
};
}
@@ -506,8 +510,8 @@ export class ToolService {
if (!Array.isArray(recordIds) || recordIds.length === 0) {
return {
success: false,
error: 'At least one record ID is required for bulk soft delete',
message: `Failed to soft delete many ${objectName}: At least one record ID is required`,
error: 'At least one record ID is required for bulk soft delete',
};
}
@@ -518,8 +522,8 @@ export class ToolService {
if (existingRecords.length === 0) {
return {
success: false,
error: 'No records found to soft delete',
message: `Failed to soft delete many ${objectName}: No records found with the provided IDs`,
error: 'No records found to soft delete',
};
}
@@ -527,14 +531,17 @@ export class ToolService {
return {
success: true,
count: existingRecords.length,
message: `Successfully soft deleted ${existingRecords.length} ${objectName} records`,
result: {
count: existingRecords.length,
deletedIds: recordIds,
},
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
message: `Failed to soft delete many ${objectName}`,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
@@ -558,8 +565,8 @@ export class ToolService {
if (!filter || typeof filter !== 'object' || !('id' in filter)) {
return {
success: false,
error: 'Filter with record IDs is required for bulk destroy',
message: `Failed to destroy many ${objectName}: Filter with record IDs is required`,
error: 'Filter with record IDs is required for bulk destroy',
};
}
@@ -569,8 +576,8 @@ export class ToolService {
if (!Array.isArray(recordIds) || recordIds.length === 0) {
return {
success: false,
error: 'At least one record ID is required for bulk destroy',
message: `Failed to destroy many ${objectName}: At least one record ID is required`,
error: 'At least one record ID is required for bulk destroy',
};
}
@@ -581,8 +588,8 @@ export class ToolService {
if (existingRecords.length === 0) {
return {
success: false,
error: 'No records found to destroy',
message: `Failed to destroy many ${objectName}: No records found with the provided IDs`,
error: 'No records found to destroy',
};
}
@@ -590,14 +597,17 @@ export class ToolService {
return {
success: true,
count: existingRecords.length,
message: `Successfully destroyed ${existingRecords.length} ${objectName} records`,
result: {
count: existingRecords.length,
destroyedIds: recordIds,
},
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
message: `Failed to destroy many ${objectName}`,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { basename, dirname, extname } from 'path';
import { type Stream } from 'stream';
import { type Readable } from 'stream';
import { isNonEmptyString } from '@sniptt/guards';
import { buildSignedPath } from 'twenty-shared/utils';
@@ -28,7 +28,7 @@ export class FileService {
folderPath: string,
filename: string,
workspaceId: string,
): Promise<Stream> {
): Promise<Readable> {
const workspaceFolderPath = `workspace-${workspaceId}/${folderPath}`;
return await this.fileStorageService.read({
@@ -16,7 +16,7 @@ export const HttpRequestInputZodSchema = z.object({
});
export const HttpToolParametersZodSchema = z.object({
toolDescription: z
loadingMessage: z
.string()
.describe(
"A clear, human-readable status message describing the HTTP request being made. This will be shown to the user while the tool is being called, so phrase it as a present-tense status update (e.g., 'Making a GET request to ...'). Explain what endpoint you are calling and with what parameters in natural language.",
@@ -38,15 +38,23 @@ export class HttpTool implements Tool {
const response = await axios(axiosConfig);
return { result: response.data };
return {
success: true,
message: `HTTP ${method} request to ${url} completed successfully`,
result: response.data,
};
} catch (error) {
if (axios.isAxiosError(error)) {
return {
success: false,
message: `HTTP ${method} request to ${url} failed`,
error: error.response?.data || error.message || 'HTTP request failed',
};
}
return {
success: false,
message: `HTTP ${method} request to ${url} failed`,
error: error instanceof Error ? error.message : 'HTTP request failed',
};
}
@@ -14,7 +14,7 @@ export const SendEmailInputZodSchema = z.object({
});
export const SendEmailToolParametersZodSchema = z.object({
toolDescription: z
loadingMessage: z
.string()
.describe(
"A clear, human-readable status message describing the email being sent. This will be shown to the user while the tool is being called, so phrase it as a present-tense status update (e.g., 'Sending email to customer about order status'). Explain what email you are sending and to whom in natural language.",
@@ -133,14 +133,19 @@ export class SendEmailTool implements Tool {
this.logger.log(`Email sent successfully to ${email}`);
return {
success: true,
message: `Email sent successfully to ${email}`,
result: {
success: true,
message: `Email sent successfully to ${email}`,
recipient: email,
subject: safeSubject,
connectedAccountId,
},
};
} catch (error) {
if (error instanceof SendEmailToolException) {
return {
success: false,
message: `Failed to send email to ${email}`,
error: error.message,
};
}
@@ -148,6 +153,8 @@ export class SendEmailTool implements Tool {
this.logger.error(`Failed to send email: ${error}`);
return {
success: false,
message: `Failed to send email to ${email}`,
error: error instanceof Error ? error.message : 'Failed to send email',
};
}
@@ -1,4 +1,6 @@
export type ToolOutput = {
result?: unknown;
success: boolean;
message: string;
error?: string;
result?: unknown;
};