fix(ai-chat): stop duplicate tool_use ids from bricking threads (#23277)
## Issue Some AI chat threads become permanently broken. Every turn fails with an Anthropic 400: messages.5.content.1: tool_use ids must be unique The error is on the message *history*, so once a thread is in this state every subsequent turn fails too, not just the one that triggered it. It surfaces most visibly when aborting a thread and continuing it, but the abort is incidental: it just replays the already-corrupted history. ## Root cause A single tool call gets persisted as **two message parts sharing one `toolCallId`**. Confirmed in the DB for the affected thread, one assistant message held: - `tool-extract_json_paths` and `dynamic-tool`, both `toolu_01FXxxfBYP27NZEvA6AZ3AJc` - `tool-search_output` and `dynamic-tool`, both `toolu_01VqnFrYdGCERAc2dbG3zAyx` On the next turn `convertToModelMessages` turns each pair into two `tool_use` blocks with the same id, which Anthropic rejects. ## Why it happens It is not two concurrent calls. It is one call the AI SDK classifies inconsistently across its own stream chunks. The chat only exposes a small set of directly-callable tools (`execute_tool`, `learn_tools`, `load_skills`, `ask_questions`, plus native/preloaded ones). Registry tools like `extract_json_paths` and `search_output` are reachable only through `execute_tool`. When the model shortcuts that and calls one directly, the name is not in the active `ToolSet`, and the SDK does this: 1. On `tool-input-start`, `dynamic` is derived from the tool set: `tools[name]?.type === "dynamic"`. The tool is absent, so `dynamic: false`, and a **static** `tool-<name>` part is created. 2. On finalization the unknown tool throws `NoSuchToolError`. `repairToolCall` intentionally skips name errors (`return null`), so the SDK re-emits the call with a hardcoded `dynamic: true`. That error routes to the **dynamic** path and creates a second `dynamic-tool` part with the same id. The UI-message builder keeps static and dynamic tool parts in separate buckets, each searched by `toolCallId` independently, so the mid-call static-to-dynamic flip produces two parts for one call. Both persist and break the next turn. ## Fix Two independent read/convert-path passes, both in `sanitizeMessagePartsForModel` in `chat-execution.service.ts`, running before `convertToModelMessages`: 1. **`finalizeDanglingToolParts`** now dedupes tool parts by `toolCallId` (first-wins), keeping the `input-streaming` filter ahead of the dedup so a leading streaming duplicate can't strand the call. Because it runs before conversion and on the write paths too, already-corrupted threads are un-bricked on their next turn with no migration. 2. **`guideUncallableToolCallsToMetaTool`** addresses the behavior that caused it: when the model calls a tool that is not directly callable, it appends the `learn_tools` -> `execute_tool` flow to that failed tool result, so the model reads how to reach the tool. Detection is structural (a failed tool part whose name is not in the active tool set), not string-matched against the SDK's error wording. Unit tests added for both. Typecheck, lint, and the suite pass. Note: the stale duplicate rows already in the DB are harmless (deduped on every read); no migration is required. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23277?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
+34
@@ -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',
|
||||
|
||||
+84
@@ -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<string, unknown> = {},
|
||||
): 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);
|
||||
});
|
||||
});
|
||||
+18
-2
@@ -13,9 +13,24 @@ export const finalizeDanglingToolParts = <
|
||||
TPart extends UIMessagePart<UIDataTypes, UITools>,
|
||||
>(
|
||||
parts: TPart[],
|
||||
): TPart[] =>
|
||||
parts
|
||||
): TPart[] => {
|
||||
const seenToolCallIds = new Set<string>();
|
||||
|
||||
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;
|
||||
});
|
||||
};
|
||||
|
||||
+40
@@ -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<UIDataTypes, UITools>,
|
||||
>(
|
||||
parts: TPart[],
|
||||
directlyCallableToolNames: Set<string>,
|
||||
): 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)}`,
|
||||
};
|
||||
});
|
||||
+18
-4
@@ -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<string>,
|
||||
): ExtendedUIMessage[] {
|
||||
return messages.map((message) => ({
|
||||
...message,
|
||||
parts: guideUncallableToolCallsToMetaTool(
|
||||
finalizeDanglingToolParts(message.parts),
|
||||
directlyCallableToolNames,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
private injectBrowsingContextIntoLastUserMessage(
|
||||
messages: ExtendedUIMessage[],
|
||||
contextString: string,
|
||||
|
||||
Reference in New Issue
Block a user