Clean up tool output architecture: remove wrappers, enforce ToolOutput everywhere (#19321)

## Summary

- **Remove `ExecuteToolResult` wrapper** — `execute_tool` is now a
transparent dispatcher that returns the raw `ToolOutput` from underlying
tools. No more `{ toolName, result }` envelope.
- **Type the entire execution chain as `Promise<ToolOutput>`** — from
`ToolExecutorService.dispatch()` through `resolveAndExecute()` to
`execute_tool.execute()`. Zero `Promise<unknown>` remaining in the tool
layer.
- **Use `Extract<ToolExecutionRef, ...>`** for dispatch methods,
enabling exhaustive switch checking and removing `as never` casts.
- **Relax `ToolOutput.result` to accept `null`** — removes `??
undefined` hacks at the boundary with logic function results.
- **Enforce 1-export-per-file** across tool type/interface files (split
`tool-descriptor.type.ts`, `tool-provider.interface.ts`, `tool.type.ts`,
`tool-output.type.ts`, `tool-executor.service.ts`).
- **Simplify error handling** — `wrapWithErrorHandler` and all
meta-errors (tool not found, tool excluded) now return consistent
`ToolOutput` shape with `error` as a plain string.
- **Frontend reads output directly** — removed `unwrapToolOutput`
utility; `ToolStepRenderer` and `ThinkingStepsDisplay` extract
`message`/`error` from the raw output with simple type guards.
- **Add permission error detection** for email tools via
`isInsufficientPermissionsError`, guiding the AI model to suggest
account reconnection instead of hallucinating about visibility settings.

## Test plan

- [ ] AI chat tool calls return visible output (not "null") in the UI
- [ ] Tool errors display correctly in the JSON tree
- [ ] Email draft/send tools return actionable permission errors
- [ ] Code interpreter output renders correctly
- [ ] Thinking steps display tool outputs properly


Made with [Cursor](https://cursor.com)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Félix Malfait
2026-04-04 14:05:42 +02:00
committed by GitHub
parent 9cc794ddb6
commit 563e27831b
48 changed files with 297 additions and 393 deletions
@@ -0,0 +1,3 @@
import { type CodeExecutionData } from 'twenty-shared/ai';
export type CodeExecutionStreamEmitter = (data: CodeExecutionData) => void;
@@ -0,0 +1,3 @@
export type GenerateDescriptorOptions = {
includeSchemas?: boolean;
};
@@ -0,0 +1,12 @@
import { type ToolSet } from 'ai';
import { type ToolCategory } from 'twenty-shared/ai';
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
export interface NativeToolProvider {
readonly category: ToolCategory;
isAvailable(context: ToolProviderContext): Promise<boolean>;
generateTools(context: ToolProviderContext): Promise<ToolSet>;
}
@@ -0,0 +1,7 @@
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
export interface StaticToolHandler {
execute(args: ToolInput, context: ToolProviderContext): Promise<ToolOutput>;
}
@@ -0,0 +1,18 @@
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 RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
export type ToolProviderContext = {
workspaceId: string;
roleId: string;
rolePermissionConfig: RolePermissionConfig;
authContext?: WorkspaceAuthContext;
actorContext?: ActorMetadata;
userId?: string;
userWorkspaceId?: string;
agent?: FlatAgentWithRoleId | null;
onCodeExecutionUpdate?: CodeExecutionStreamEmitter;
};
@@ -1,41 +1,9 @@
import { type ToolSet } from 'ai';
import { type CodeExecutionData, type ToolCategory } from 'twenty-shared/ai';
import { type ActorMetadata } from 'twenty-shared/types';
import { type ToolCategory } from 'twenty-shared/ai';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import {
type ToolDescriptor,
type ToolIndexEntry,
} from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { type FlatAgentWithRoleId } from 'src/engine/metadata-modules/flat-agent/types/flat-agent.type';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
export type CodeExecutionStreamEmitter = (data: CodeExecutionData) => void;
// Unified context for tool generation - used by all consumers
export type ToolProviderContext = {
workspaceId: string;
roleId: string;
rolePermissionConfig: RolePermissionConfig;
// Optional fields for different use cases
authContext?: WorkspaceAuthContext;
actorContext?: ActorMetadata;
userId?: string;
userWorkspaceId?: string;
agent?: FlatAgentWithRoleId | null;
onCodeExecutionUpdate?: CodeExecutionStreamEmitter;
};
// Options for tool retrieval
export type ToolRetrievalOptions = {
categories?: ToolCategory[];
excludeTools?: string[];
wrapWithErrorContext?: boolean;
};
export type GenerateDescriptorOptions = {
includeSchemas?: boolean; // defaults to true for backward compat
};
import { type GenerateDescriptorOptions } from 'src/engine/core-modules/tool-provider/interfaces/generate-descriptor-options.type';
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-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';
export interface ToolProvider {
readonly category: ToolCategory;
@@ -47,13 +15,3 @@ export interface ToolProvider {
options?: GenerateDescriptorOptions,
): Promise<(ToolIndexEntry | ToolDescriptor)[]>;
}
// NativeModelToolProvider is special: SDK-native tools are opaque and not
// serializable. It keeps the old generateTools() contract.
export interface NativeToolProvider {
readonly category: ToolCategory;
isAvailable(context: ToolProviderContext): Promise<boolean>;
generateTools(context: ToolProviderContext): Promise<ToolSet>;
}
@@ -0,0 +1,7 @@
import { type ToolCategory } from 'twenty-shared/ai';
export type ToolRetrievalOptions = {
categories?: ToolCategory[];
excludeTools?: string[];
wrapWithErrorContext?: boolean;
};
@@ -3,21 +3,15 @@ import { Injectable } from '@nestjs/common';
import { PermissionFlagType } from 'twenty-shared/constants';
import { z } from 'zod';
import {
type GenerateDescriptorOptions,
type ToolProvider,
type ToolProviderContext,
} from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
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,
ToolExecutorService,
} from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
import {
type ToolDescriptor,
type ToolIndexEntry,
} from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
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';
import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type';
import { CodeInterpreterService } from 'src/engine/core-modules/code-interpreter/code-interpreter.service';
import { CodeInterpreterTool } from 'src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool';
import { DraftEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/draft-email-tool';
@@ -2,20 +2,16 @@ import { Inject, Injectable, OnModuleInit, Optional } from '@nestjs/common';
import { PermissionFlagType } from 'twenty-shared/constants';
import {
type GenerateDescriptorOptions,
type ToolProvider,
type ToolProviderContext,
} from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
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 { DASHBOARD_TOOL_SERVICE_TOKEN } from 'src/engine/core-modules/tool-provider/constants/dashboard-tool-service.token';
import { ToolCategory } from 'twenty-shared/ai';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
import {
type ToolDescriptor,
type ToolIndexEntry,
} from 'src/engine/core-modules/tool-provider/types/tool-descriptor.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 { toolSetToDescriptors } from 'src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
@@ -7,11 +7,9 @@ import {
import { camelToSnakeCase, isDefined } from 'twenty-shared/utils';
import { z } from 'zod';
import {
type GenerateDescriptorOptions,
type ToolProvider,
type ToolProviderContext,
} from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
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 { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-flat-fields-for-flat-object-metadata.util';
import { generateCreateManyRecordInputSchema } from 'src/engine/core-modules/record-crud/utils/generate-create-many-record-input-schema.util';
@@ -22,10 +20,8 @@ import { DeleteToolInputSchema } from 'src/engine/core-modules/record-crud/zod-s
import { FindOneToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/find-one-tool.zod-schema';
import { generateFindToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/find-tool.zod-schema';
import { ToolCategory } from 'twenty-shared/ai';
import {
type ToolDescriptor,
type ToolIndexEntry,
} from 'src/engine/core-modules/tool-provider/types/tool-descriptor.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 { isFavoriteRelatedObject } from 'src/engine/metadata-modules/ai/ai-agent/utils/is-favorite-related-object.util';
import { isWorkflowRelatedObject } from 'src/engine/metadata-modules/ai/ai-agent/utils/is-workflow-related-object.util';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
@@ -3,17 +3,13 @@ import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { DEFAULT_TOOL_INPUT_SCHEMA } from 'twenty-shared/logic-function';
import {
type GenerateDescriptorOptions,
type ToolProvider,
type ToolProviderContext,
} from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
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 ToolDescriptor,
type ToolIndexEntry,
} from 'src/engine/core-modules/tool-provider/types/tool-descriptor.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 { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { type FlatLogicFunction } from 'src/engine/metadata-modules/logic-function/types/flat-logic-function.type';
@@ -2,18 +2,14 @@ import { Injectable, OnModuleInit } from '@nestjs/common';
import { PermissionFlagType } from 'twenty-shared/constants';
import {
type GenerateDescriptorOptions,
type ToolProvider,
type ToolProviderContext,
} from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
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 { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
import {
type ToolDescriptor,
type ToolIndexEntry,
} from 'src/engine/core-modules/tool-provider/types/tool-descriptor.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 { toolSetToDescriptors } from 'src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util';
import { FieldMetadataToolsFactory } from 'src/engine/metadata-modules/field-metadata/tools/field-metadata-tools.factory';
import { ObjectMetadataToolsFactory } from 'src/engine/metadata-modules/object-metadata/tools/object-metadata-tools.factory';
@@ -3,10 +3,8 @@ import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { isDefined } from 'twenty-shared/utils';
import {
type NativeToolProvider,
type ToolProviderContext,
} from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
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 { AgentModelConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/agent-model-config.service';
@@ -2,18 +2,14 @@ import { Injectable, OnModuleInit } from '@nestjs/common';
import { PermissionFlagType } from 'twenty-shared/constants';
import {
type GenerateDescriptorOptions,
type ToolProvider,
type ToolProviderContext,
} from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
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 { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
import {
type ToolDescriptor,
type ToolIndexEntry,
} from 'src/engine/core-modules/tool-provider/types/tool-descriptor.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 { toolSetToDescriptors } from 'src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { ViewFieldToolsFactory } from 'src/engine/metadata-modules/view-field/tools/view-field-tools.factory';
@@ -2,18 +2,14 @@ import { Injectable, OnModuleInit } from '@nestjs/common';
import { PermissionFlagType } from 'twenty-shared/constants';
import {
type GenerateDescriptorOptions,
type ToolProvider,
type ToolProviderContext,
} from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
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 { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
import {
type ToolDescriptor,
type ToolIndexEntry,
} from 'src/engine/core-modules/tool-provider/types/tool-descriptor.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 { toolSetToDescriptors } from 'src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { ViewFilterToolsFactory } from 'src/engine/metadata-modules/view-filter/tools/view-filter-tools.factory';
@@ -2,20 +2,16 @@ import { Inject, Injectable, OnModuleInit, Optional } from '@nestjs/common';
import { PermissionFlagType } from 'twenty-shared/constants';
import {
type GenerateDescriptorOptions,
type ToolProvider,
type ToolProviderContext,
} from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
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 { WORKFLOW_TOOL_SERVICE_TOKEN } from 'src/engine/core-modules/tool-provider/constants/workflow-tool-service.token';
import { ToolCategory } from 'twenty-shared/ai';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
import {
type ToolDescriptor,
type ToolIndexEntry,
} from 'src/engine/core-modules/tool-provider/types/tool-descriptor.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 { toolSetToDescriptors } from 'src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
@@ -1,13 +1,12 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { type ToolSet } from 'ai';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
import { fromUserEntityToFlat } from 'src/engine/core-modules/user/utils/from-user-entity-to-flat.util';
import { type ToolProviderContext } 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 {
AuthException,
@@ -20,29 +19,20 @@ import { CreateManyRecordsService } from 'src/engine/core-modules/record-crud/se
import { CreateRecordService } from 'src/engine/core-modules/record-crud/services/create-record.service';
import { DeleteRecordService } from 'src/engine/core-modules/record-crud/services/delete-record.service';
import { FindRecordsService } from 'src/engine/core-modules/record-crud/services/find-records.service';
import { type FindRecordsParams } from 'src/engine/core-modules/record-crud/types/find-records-params.type';
import { UpdateManyRecordsService } from 'src/engine/core-modules/record-crud/services/update-many-records.service';
import { UpdateRecordService } from 'src/engine/core-modules/record-crud/services/update-record.service';
import { type ToolCategory } from 'twenty-shared/ai';
import {
type ToolDescriptor,
type ToolIndexEntry,
} from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
import { type StaticToolHandler } from 'src/engine/core-modules/tool-provider/interfaces/static-tool-handler.interface';
import { type CategoryToolGenerator } from 'src/engine/core-modules/tool-provider/types/category-tool-generator.type';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { type ToolExecutionRef } from 'src/engine/core-modules/tool-provider/types/tool-execution-ref.type';
import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import { stripLoadingMessage } from 'src/engine/core-modules/tool/utils/wrap-tool-for-execution.util';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
// Handler for individually registered static tools (e.g., action tools)
export interface StaticToolHandler {
execute(args: ToolInput, context: ToolProviderContext): Promise<unknown>;
}
// Generator that produces a ToolSet on demand for a category (workflow, view, etc.)
// Used as a fallback when no per-tool handler is registered.
export type CategoryToolGenerator = (
context: ToolProviderContext,
) => Promise<ToolSet>;
@Injectable()
export class ToolExecutorService {
private readonly logger = new Logger(ToolExecutorService.name);
@@ -84,7 +74,7 @@ export class ToolExecutorService {
descriptor: ToolIndexEntry | ToolDescriptor,
args: Record<string, unknown>,
context: ToolProviderContext,
): Promise<unknown> {
): Promise<ToolOutput> {
const cleanArgs = stripLoadingMessage(args);
switch (descriptor.executionRef.kind) {
@@ -106,10 +96,10 @@ export class ToolExecutorService {
}
private async dispatchDatabaseCrud(
ref: { objectNameSingular: string; operation: string },
ref: Extract<ToolExecutionRef, { kind: 'database_crud' }>,
args: Record<string, unknown>,
context: ToolProviderContext,
): Promise<unknown> {
): Promise<ToolOutput> {
const authContext =
context.authContext ?? (await this.buildAuthContext(context));
@@ -120,7 +110,7 @@ export class ToolExecutorService {
return this.findRecordsService.execute({
objectName: ref.objectNameSingular,
filter,
orderBy: orderBy as never,
orderBy: orderBy as FindRecordsParams['orderBy'],
limit: limit as number | undefined,
offset: offset as number | undefined,
authContext,
@@ -191,9 +181,6 @@ export class ToolExecutorService {
rolePermissionConfig: context.rolePermissionConfig,
soft: true,
});
default:
throw new Error(`Unknown database_crud operation: ${ref.operation}`);
}
}
@@ -201,7 +188,7 @@ export class ToolExecutorService {
descriptor: ToolIndexEntry | ToolDescriptor,
args: Record<string, unknown>,
context: ToolProviderContext,
): Promise<unknown> {
): Promise<ToolOutput> {
if (descriptor.executionRef.kind !== 'static') {
throw new Error('Expected static executionRef');
}
@@ -231,19 +218,17 @@ export class ToolExecutorService {
);
}
// The tool's execute expects (args, ToolExecutionOptions). Pass args with
// a dummy loadingMessage since the tool's internal strip is harmless.
return tool.execute(
{ loadingMessage: '', ...args },
{ toolCallId: '', messages: [] },
);
) as Promise<ToolOutput>;
}
private async dispatchLogicFunction(
ref: { logicFunctionId: string },
ref: Extract<ToolExecutionRef, { kind: 'logic_function' }>,
args: Record<string, unknown>,
context: ToolProviderContext,
): Promise<unknown> {
): Promise<ToolOutput> {
const result = await this.logicFunctionExecutorService.execute({
logicFunctionId: ref.logicFunctionId,
workspaceId: context.workspaceId,
@@ -253,13 +238,15 @@ export class ToolExecutorService {
if (result.error) {
return {
success: false,
message: 'Logic function execution failed',
error: result.error.errorMessage,
};
}
return {
success: true,
result: result.data,
message: 'Logic function executed successfully',
result: result.data ?? undefined,
};
}
@@ -2,34 +2,24 @@ import { Inject, Injectable, Logger } from '@nestjs/common';
import { type ToolExecutionOptions, type ToolSet, jsonSchema } from 'ai';
import {
type NativeToolProvider,
type ToolProvider,
type ToolProviderContext,
type ToolRetrievalOptions,
} from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
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 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 { compactToolOutput } from 'src/engine/core-modules/tool-provider/output-serialization/compact-tool-output.util';
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 ExecuteToolResult } from 'src/engine/core-modules/tool-provider/tools/execute-tool.tool';
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,
type ToolIndexEntry,
} from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import {
generateErrorSuggestion,
wrapWithErrorHandler,
} from 'src/engine/core-modules/tool-provider/utils/tool-error.util';
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';
export { type ToolContext } from 'src/engine/core-modules/tool-provider/types/tool-context.type';
@Injectable()
export class ToolRegistryService {
private readonly logger = new Logger(ToolRegistryService.name);
@@ -125,7 +115,7 @@ export class ToolRegistryService {
const executeFn = async (
args: Record<string, unknown>,
): Promise<unknown> =>
): Promise<ToolOutput> =>
this.toolExecutorService.dispatch(descriptor, args, context);
toolSet[descriptor.name] = {
@@ -220,7 +210,7 @@ export class ToolRegistryService {
args: Record<string, unknown>,
context: ToolContext,
_options: ToolExecutionOptions,
): Promise<ExecuteToolResult> {
): Promise<ToolOutput> {
try {
const fullContext = this.buildContextFromToolContext(context);
@@ -229,25 +219,13 @@ export class ToolRegistryService {
if (!entry) {
return {
toolName,
error: {
message: `Tool "${toolName}" not found. Check the tool catalog for correct names.`,
suggestion:
'Use learn_tools to discover available tools and their correct names.',
},
success: false,
message: `Tool "${toolName}" not found`,
error: `Tool "${toolName}" not found. Use learn_tools to discover available tools.`,
};
}
const result = await this.toolExecutorService.dispatch(
entry,
args,
fullContext,
);
return {
toolName,
result: compactToolOutput(result),
};
return await this.toolExecutorService.dispatch(entry, args, fullContext);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
@@ -255,11 +233,9 @@ export class ToolRegistryService {
this.logger.error(`Error executing tool "${toolName}": ${errorMessage}`);
return {
toolName,
error: {
message: errorMessage,
suggestion: generateErrorSuggestion(toolName, errorMessage),
},
success: false,
message: `Failed to execute ${toolName}`,
error: errorMessage,
};
}
}
@@ -4,6 +4,7 @@ import { z } from 'zod';
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';
@@ -40,15 +41,6 @@ export const executeToolInputSchema = jsonSchema<ExecuteToolInput>(
},
);
export type ExecuteToolResult = {
toolName: string;
result?: unknown;
error?: {
message: string;
suggestion: string;
};
};
export const createExecuteToolTool = (
toolRegistry: ToolRegistryService,
context: ToolContext,
@@ -61,25 +53,21 @@ export const createExecuteToolTool = (
execute: async (
parameters: ExecuteToolInput,
options: ToolExecutionOptions,
): Promise<ExecuteToolResult> => {
): Promise<ToolOutput> => {
const { toolName, arguments: args } = parameters;
if (excludeTools?.has(toolName)) {
return {
toolName,
error: {
message: `Tool "${toolName}" is not available in this context.`,
suggestion: 'Use get_tool_catalog to see which tools are available.',
},
success: false,
message: `Tool "${toolName}" is not available`,
error: `Tool "${toolName}" is not available in this context. Use get_tool_catalog to see which tools are available.`,
};
}
const directTool = directTools?.[toolName];
if (directTool?.execute) {
const result = await directTool.execute(args, options);
return { toolName, result };
return directTool.execute(args, options) as Promise<ToolOutput>;
}
return toolRegistry.resolveAndExecute(toolName, args, context, options);
@@ -2,7 +2,7 @@ import { z } from 'zod';
import { ToolCategory } from 'twenty-shared/ai';
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-descriptor.type';
import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type';
export const GET_TOOL_CATALOG_TOOL_NAME = 'get_tool_catalog';
@@ -12,7 +12,6 @@ export {
createExecuteToolTool,
executeToolInputSchema,
type ExecuteToolInput,
type ExecuteToolResult,
} from './execute-tool.tool';
export {
@@ -0,0 +1,7 @@
import { type ToolSet } from 'ai';
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
export type CategoryToolGenerator = (
context: ToolProviderContext,
) => Promise<ToolSet>;
@@ -0,0 +1,8 @@
export type DatabaseCrudOperation =
| 'find'
| 'find_one'
| 'create'
| 'create_many'
| 'update'
| 'update_many'
| 'delete';
@@ -1,6 +1,6 @@
import { type ActorMetadata } from 'twenty-shared/types';
import { type CodeExecutionStreamEmitter } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
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';
@@ -1,35 +1,5 @@
import { type ToolCategory } from 'twenty-shared/ai';
import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type';
export type DatabaseCrudOperation =
| 'find'
| 'find_one'
| 'create'
| 'create_many'
| 'update'
| 'update_many'
| 'delete';
export type ToolExecutionRef =
| {
kind: 'database_crud';
objectNameSingular: string;
operation: DatabaseCrudOperation;
}
| { kind: 'static'; toolId: string }
| { kind: 'logic_function'; logicFunctionId: string };
// Lightweight entry for catalog/index (no schema)
export type ToolIndexEntry = {
name: string;
description: string;
category: ToolCategory;
executionRef: ToolExecutionRef;
objectName?: string;
operation?: string;
icon?: string;
};
// Full descriptor with schema (on-demand)
export type ToolDescriptor = ToolIndexEntry & {
inputSchema: object;
};
@@ -0,0 +1,10 @@
import { type DatabaseCrudOperation } from 'src/engine/core-modules/tool-provider/types/database-crud-operation.type';
export type ToolExecutionRef =
| {
kind: 'database_crud';
objectNameSingular: string;
operation: DatabaseCrudOperation;
}
| { kind: 'static'; toolId: string }
| { kind: 'logic_function'; logicFunctionId: string };
@@ -0,0 +1,13 @@
import { type ToolCategory } from 'twenty-shared/ai';
import { type ToolExecutionRef } from 'src/engine/core-modules/tool-provider/types/tool-execution-ref.type';
export type ToolIndexEntry = {
name: string;
description: string;
category: ToolCategory;
executionRef: ToolExecutionRef;
objectName?: string;
operation?: string;
icon?: string;
};
@@ -1,46 +1,9 @@
export const generateErrorSuggestion = (
_toolName: string,
errorMessage: string,
): string => {
const lowerError = errorMessage.toLowerCase();
if (
lowerError.includes('not found') ||
lowerError.includes('does not exist')
) {
return 'Verify the ID or name exists with a search query first';
}
if (
lowerError.includes('permission') ||
lowerError.includes('forbidden') ||
lowerError.includes('unauthorized')
) {
return 'This operation requires elevated permissions or a different role';
}
if (lowerError.includes('invalid') || lowerError.includes('validation')) {
return 'Check the tool schema for valid parameter formats and types';
}
if (
lowerError.includes('duplicate') ||
lowerError.includes('already exists')
) {
return 'A record with this identifier already exists. Try updating instead of creating';
}
if (lowerError.includes('required') || lowerError.includes('missing')) {
return 'Required fields are missing. Check which fields are mandatory for this operation';
}
return 'Try adjusting the parameters or using a different approach';
};
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
export const wrapWithErrorHandler = (
toolName: string,
executeFn: (args: Record<string, unknown>) => Promise<unknown>,
): ((args: Record<string, unknown>) => Promise<unknown>) => {
executeFn: (args: Record<string, unknown>) => Promise<ToolOutput>,
): ((args: Record<string, unknown>) => Promise<ToolOutput>) => {
return async (args: Record<string, unknown>) => {
try {
return await executeFn(args);
@@ -50,11 +13,8 @@ export const wrapWithErrorHandler = (
return {
success: false,
error: {
message: errorMessage,
tool: toolName,
suggestion: generateErrorSuggestion(toolName, errorMessage),
},
message: `Failed to execute ${toolName}`,
error: errorMessage,
};
}
};
@@ -2,10 +2,8 @@ import { type ToolSet } from 'ai';
import { z } from 'zod';
import { type ToolCategory } from 'twenty-shared/ai';
import {
type ToolDescriptor,
type ToolIndexEntry,
} from 'src/engine/core-modules/tool-provider/types/tool-descriptor.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';
export type ToolSetToDescriptorsOptions = {
includeSchemas?: boolean;
@@ -34,10 +34,8 @@ import {
} from 'src/engine/core-modules/tool/tools/code-interpreter-tool/types/code-interpreter-input.type';
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import {
type Tool,
type ToolExecutionContext,
} from 'src/engine/core-modules/tool/types/tool.type';
import { type ToolExecutionContext } from 'src/engine/core-modules/tool/types/tool-execution-context.type';
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
@@ -3,13 +3,12 @@ import { Injectable, Logger } from '@nestjs/common';
import { EmailComposerService } from 'src/engine/core-modules/tool/tools/email-tool/email-composer.service';
import { EmailToolInputZodSchema } from 'src/engine/core-modules/tool/tools/email-tool/email-tool.schema';
import { EmailToolException } from 'src/engine/core-modules/tool/tools/email-tool/exceptions/email-tool.exception';
import { isInsufficientPermissionsError } from 'src/engine/core-modules/tool/tools/email-tool/utils/is-insufficient-permissions-error.util';
import { type ComposedEmail } from 'src/engine/core-modules/tool/tools/email-tool/types/composed-email.type';
import { type EmailToolInput } from 'src/engine/core-modules/tool/tools/email-tool/types/email-tool-input.type';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import {
type Tool,
type ToolExecutionContext,
} from 'src/engine/core-modules/tool/types/tool.type';
import { type ToolExecutionContext } from 'src/engine/core-modules/tool/types/tool-execution-context.type';
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
import { MessagingMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/services/messaging-message-outbound.service';
@Injectable()
@@ -70,6 +69,16 @@ export class DraftEmailTool implements Tool {
this.logger.error(`Failed to create draft: ${error}`);
if (isInsufficientPermissionsError(error)) {
return {
success: false,
message: 'Failed to create draft due to insufficient permissions',
error:
'The connected email account does not have permission to create drafts. ' +
'The user should disconnect and reconnect their account in Settings > Accounts to grant the required permissions.',
};
}
return {
success: false,
message: 'Failed to create draft',
@@ -19,7 +19,7 @@ import {
import { EmailComposerResult } from 'src/engine/core-modules/tool/tools/email-tool/types/email-composer-result.type';
import { EmailToolInput } from 'src/engine/core-modules/tool/tools/email-tool/types/email-tool-input.type';
import { parseCommaSeparatedEmails } from 'src/engine/core-modules/tool/tools/email-tool/utils/parse-comma-separated-emails.util';
import { ToolExecutionContext } from 'src/engine/core-modules/tool/types/tool.type';
import { type ToolExecutionContext } from 'src/engine/core-modules/tool/types/tool-execution-context.type';
import { ConnectedAccountDataAccessService } from 'src/engine/metadata-modules/connected-account/data-access/services/connected-account-data-access.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
@@ -3,13 +3,12 @@ import { Injectable, Logger } from '@nestjs/common';
import { EmailComposerService } from 'src/engine/core-modules/tool/tools/email-tool/email-composer.service';
import { EmailToolInputZodSchema } from 'src/engine/core-modules/tool/tools/email-tool/email-tool.schema';
import { EmailToolException } from 'src/engine/core-modules/tool/tools/email-tool/exceptions/email-tool.exception';
import { isInsufficientPermissionsError } from 'src/engine/core-modules/tool/tools/email-tool/utils/is-insufficient-permissions-error.util';
import { type ComposedEmail } from 'src/engine/core-modules/tool/tools/email-tool/types/composed-email.type';
import { type EmailToolInput } from 'src/engine/core-modules/tool/tools/email-tool/types/email-tool-input.type';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import {
type Tool,
type ToolExecutionContext,
} from 'src/engine/core-modules/tool/types/tool.type';
import { type ToolExecutionContext } from 'src/engine/core-modules/tool/types/tool-execution-context.type';
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
import { MessagingMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/services/messaging-message-outbound.service';
@Injectable()
@@ -70,6 +69,16 @@ export class SendEmailTool implements Tool {
this.logger.error(`Failed to send email: ${error}`);
if (isInsufficientPermissionsError(error)) {
return {
success: false,
message: 'Failed to send email due to insufficient permissions',
error:
'The connected email account does not have permission to send emails. ' +
'The user should disconnect and reconnect their account in Settings > Accounts to grant the required permissions.',
};
}
return {
success: false,
message: 'Failed to send email',
@@ -0,0 +1,23 @@
// Detects permission/scope errors from email provider APIs (Google, Microsoft).
// These typically occur when the OAuth consent didn't include the required
// scope for the operation (e.g., gmail.compose for drafts).
export const isInsufficientPermissionsError = (error: unknown): boolean => {
if (!(error instanceof Error)) {
return false;
}
const message = error.message.toLowerCase();
if (
message.includes('insufficient permission') ||
message.includes('insufficient authentication scopes') ||
message.includes('access denied') ||
message.includes('forbidden')
) {
return true;
}
const response = (error as { response?: { status?: number } }).response;
return response?.status === 401 || response?.status === 403;
};
@@ -9,10 +9,8 @@ import { HttpRequestInputZodSchema } from 'src/engine/core-modules/tool/tools/ht
import { type HttpRequestInput } from 'src/engine/core-modules/tool/tools/http-tool/types/http-request-input.type';
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import {
type Tool,
type ToolExecutionContext,
} from 'src/engine/core-modules/tool/types/tool.type';
import { type ToolExecutionContext } from 'src/engine/core-modules/tool/types/tool-execution-context.type';
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
@Injectable()
export class HttpTool implements Tool {
@@ -12,10 +12,8 @@ import {
} from 'src/engine/core-modules/tool/tools/navigate-tool/navigate-app-tool.schema';
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
import { ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import {
type Tool,
type ToolExecutionContext,
} from 'src/engine/core-modules/tool/types/tool.type';
import { type ToolExecutionContext } from 'src/engine/core-modules/tool/types/tool-execution-context.type';
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
@@ -6,10 +6,8 @@ import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-cli
import { SearchHelpCenterInputZodSchema } from 'src/engine/core-modules/tool/tools/search-help-center-tool/search-help-center-tool.schema';
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import {
type Tool,
type ToolExecutionContext,
} from 'src/engine/core-modules/tool/types/tool.type';
import { type ToolExecutionContext } from 'src/engine/core-modules/tool/types/tool-execution-context.type';
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@Injectable()
@@ -0,0 +1,5 @@
export type RecordReference = {
objectNameSingular: string;
recordId: string;
displayName: string;
};
@@ -0,0 +1,8 @@
import { type CodeExecutionStreamEmitter } from 'src/engine/core-modules/tool-provider/interfaces/code-execution-stream-emitter.type';
export type ToolExecutionContext = {
workspaceId: string;
userId?: string;
userWorkspaceId?: string;
onCodeExecutionUpdate?: CodeExecutionStreamEmitter;
};
@@ -1,8 +1,4 @@
export type RecordReference = {
objectNameSingular: string;
recordId: string;
displayName: string;
};
import { type RecordReference } from 'src/engine/core-modules/tool/types/record-reference.type';
export type ToolOutput<T = object> = {
success: boolean;
@@ -12,6 +8,5 @@ export type ToolOutput<T = object> = {
status?: number;
statusText?: string;
headers?: Record<string, string>;
// Record references for linking to created/found records
recordReferences?: RecordReference[];
};
@@ -1,18 +1,10 @@
import { type FlexibleSchema } from '@ai-sdk/provider-utils';
import { type PermissionFlagType } from 'twenty-shared/constants';
import { type CodeExecutionStreamEmitter } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
import { type ToolExecutionContext } from 'src/engine/core-modules/tool/types/tool-execution-context.type';
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
export type ToolExecutionContext = {
workspaceId: string;
userId?: string;
userWorkspaceId?: string;
onCodeExecutionUpdate?: CodeExecutionStreamEmitter;
};
export type Tool = {
description: string;
inputSchema: FlexibleSchema<unknown>;