diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/__tests__/finalize-dangling-tool-parts.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/__tests__/finalize-dangling-tool-parts.util.spec.ts index 8d3b15a030..b2c02b8b51 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/__tests__/finalize-dangling-tool-parts.util.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/__tests__/finalize-dangling-tool-parts.util.spec.ts @@ -102,6 +102,40 @@ describe('finalizeDanglingToolParts', () => { expect(finalizeDanglingToolParts(parts)).toEqual(parts); }); + it('drops a duplicate dynamic-tool part sharing a tool call id with a typed part', () => { + const typed = buildToolPart('output-error', { + type: 'tool-search_output', + toolCallId: 'call_dup', + errorText: 'boom', + }); + const dynamicDuplicate = buildToolPart('output-error', { + type: 'dynamic-tool', + toolName: 'search_output', + toolCallId: 'call_dup', + errorText: 'boom', + }); + + expect(finalizeDanglingToolParts([typed, dynamicDuplicate])).toEqual([ + typed, + ]); + }); + + it('keeps the first part when a tool call id is duplicated across states', () => { + const first = buildToolPart('output-error', { + type: 'tool-execute_tool', + toolCallId: 'call_dup', + errorText: 'boom', + }); + const duplicate = buildToolPart('output-error', { + type: 'dynamic-tool', + toolName: 'execute_tool', + toolCallId: 'call_dup', + errorText: 'boom', + }); + + expect(finalizeDanglingToolParts([first, duplicate])).toEqual([first]); + }); + it('finalizes only the dangling parts in a mixed batch', () => { const completed = buildToolPart('output-available', { toolCallId: 'call_done', diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/__tests__/guide-uncallable-tool-calls-to-meta-tool.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/__tests__/guide-uncallable-tool-calls-to-meta-tool.util.spec.ts new file mode 100644 index 0000000000..47a710abf6 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/__tests__/guide-uncallable-tool-calls-to-meta-tool.util.spec.ts @@ -0,0 +1,84 @@ +import { type ExtendedUIMessagePart } from 'twenty-shared/ai'; + +import { guideUncallableToolCallsToMetaTool } from 'src/engine/metadata-modules/ai/ai-agent-execution/utils/guide-uncallable-tool-calls-to-meta-tool.util'; + +const DIRECTLY_CALLABLE = new Set(['execute_tool', 'learn_tools']); + +const buildToolPart = ( + overrides: Record = {}, +): ExtendedUIMessagePart => + ({ + type: 'tool-extract_json_paths', + toolCallId: 'call_1', + input: { fileId: 'abc' }, + state: 'output-error', + errorText: "Model tried to call unavailable tool 'extract_json_paths'.", + ...overrides, + }) as ExtendedUIMessagePart; + +const errorTextOf = (part: ExtendedUIMessagePart): string => + (part as { errorText: string }).errorText; + +describe('guideUncallableToolCallsToMetaTool', () => { + it('appends learn_tools -> execute_tool guidance for a direct call to an uncallable tool', () => { + const [part] = guideUncallableToolCallsToMetaTool( + [buildToolPart()], + DIRECTLY_CALLABLE, + ); + + expect(errorTextOf(part)).toContain( + 'learn_tools({ toolNames: ["extract_json_paths"] })', + ); + expect(errorTextOf(part)).toContain( + 'execute_tool({ toolName: "extract_json_paths", arguments: { ... } })', + ); + }); + + it('reads the tool name from a dynamic-tool part', () => { + const [part] = guideUncallableToolCallsToMetaTool( + [ + buildToolPart({ + type: 'dynamic-tool', + toolName: 'search_output', + errorText: 'Tool execution was interrupted.', + }), + ], + DIRECTLY_CALLABLE, + ); + + expect(errorTextOf(part)).toContain( + 'execute_tool({ toolName: "search_output", arguments: { ... } })', + ); + }); + + it('leaves failures of directly callable tools untouched', () => { + const part = buildToolPart({ + type: 'tool-execute_tool', + errorText: 'Tool "foo" not found.', + }); + + expect( + guideUncallableToolCallsToMetaTool([part], DIRECTLY_CALLABLE), + ).toEqual([part]); + }); + + it('leaves successful tool parts untouched', () => { + const part = buildToolPart({ + state: 'output-available', + output: { ok: true }, + errorText: undefined, + }); + + expect( + guideUncallableToolCallsToMetaTool([part], DIRECTLY_CALLABLE), + ).toEqual([part]); + }); + + it('leaves non-tool parts untouched', () => { + const parts = [{ type: 'text', text: 'hello' }] as ExtendedUIMessagePart[]; + + expect( + guideUncallableToolCallsToMetaTool(parts, DIRECTLY_CALLABLE), + ).toEqual(parts); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/finalize-dangling-tool-parts.util.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/finalize-dangling-tool-parts.util.ts index 4b74e611d1..1511b57dfd 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/finalize-dangling-tool-parts.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/finalize-dangling-tool-parts.util.ts @@ -13,9 +13,24 @@ export const finalizeDanglingToolParts = < TPart extends UIMessagePart, >( parts: TPart[], -): TPart[] => - parts +): TPart[] => { + const seenToolCallIds = new Set(); + + return parts .filter((part) => !(isToolUIPart(part) && part.state === 'input-streaming')) + .filter((part) => { + if (!isToolUIPart(part)) { + return true; + } + + if (seenToolCallIds.has(part.toolCallId)) { + return false; + } + + seenToolCallIds.add(part.toolCallId); + + return true; + }) .map((part) => { if (!isToolUIPart(part)) { return part; @@ -41,3 +56,4 @@ export const finalizeDanglingToolParts = < return part; }); +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/guide-uncallable-tool-calls-to-meta-tool.util.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/guide-uncallable-tool-calls-to-meta-tool.util.ts new file mode 100644 index 0000000000..9efc7b223b --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/guide-uncallable-tool-calls-to-meta-tool.util.ts @@ -0,0 +1,40 @@ +import { + getToolName, + isToolUIPart, + type UIDataTypes, + type UIMessagePart, + type UITools, +} from 'ai'; + +import { + EXECUTE_TOOL_TOOL_NAME, + LEARN_TOOLS_TOOL_NAME, +} from 'src/engine/core-modules/tool-provider/tools'; + +const buildMetaToolGuidance = (toolName: string): string => + ` "${toolName}" is not directly callable. Discover its input schema with ` + + `${LEARN_TOOLS_TOOL_NAME}({ toolNames: ["${toolName}"] }), then run it through ` + + `${EXECUTE_TOOL_TOOL_NAME}({ toolName: "${toolName}", arguments: { ... } }).`; + +export const guideUncallableToolCallsToMetaTool = < + TPart extends UIMessagePart, +>( + parts: TPart[], + directlyCallableToolNames: Set, +): TPart[] => + parts.map((part) => { + if (!isToolUIPart(part) || part.state !== 'output-error') { + return part; + } + + const toolName = getToolName(part); + + if (directlyCallableToolNames.has(toolName)) { + return part; + } + + return { + ...part, + errorText: `${part.errorText ?? ''}${buildMetaToolGuidance(toolName)}`, + }; + }); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts index 951fb74905..1a289f9e94 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts @@ -43,6 +43,7 @@ import { resolveToolName } from 'src/engine/core-modules/tool-provider/utils/res 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 { finalizeDanglingToolParts } from 'src/engine/metadata-modules/ai/ai-agent-execution/utils/finalize-dangling-tool-parts.util'; +import { guideUncallableToolCallsToMetaTool } from 'src/engine/metadata-modules/ai/ai-agent-execution/utils/guide-uncallable-tool-calls-to-meta-tool.util'; import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-config.const'; import { BrowsingContextType } from 'src/engine/metadata-modules/ai/ai-agent/types/browsingContext.type'; import { repairToolCall } from 'src/engine/metadata-modules/ai/ai-agent/utils/repair-tool-call.util'; @@ -301,10 +302,10 @@ export class ChatExecutionService { providerOptions: getCacheProviderOptions(registeredModel.sdkPackage), }; - const sanitizedMessages = processedMessages.map((message) => ({ - ...message, - parts: finalizeDanglingToolParts(message.parts), - })); + const sanitizedMessages = this.sanitizeMessagePartsForModel( + processedMessages, + new Set(Object.keys(activeTools)), + ); const rawModelMessages = await convertToModelMessages(sanitizedMessages); @@ -610,6 +611,19 @@ export class ChatExecutionService { }; } + private sanitizeMessagePartsForModel( + messages: ExtendedUIMessage[], + directlyCallableToolNames: Set, + ): ExtendedUIMessage[] { + return messages.map((message) => ({ + ...message, + parts: guideUncallableToolCallsToMetaTool( + finalizeDanglingToolParts(message.parts), + directlyCallableToolNames, + ), + })); + } + private injectBrowsingContextIntoLastUserMessage( messages: ExtendedUIMessage[], contextString: string,