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:
Etienne
2026-07-24 16:54:13 +02:00
committed by GitHub
parent 9aaa5b7778
commit 0bba75dd18
5 changed files with 194 additions and 6 deletions
@@ -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',
@@ -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);
});
});
@@ -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;
});
};
@@ -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)}`,
};
});
@@ -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,