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
@@ -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>;