fix(core): Resume tool calls for deferred tools that a skill loaded (backport to release-candidate/2.43.x) (#40746)

Co-authored-by: Raúl Gómez Morales <raul00gm@gmail.com>
Co-authored-by: Mutasem Aldmour <mutasem@n8n.io>
This commit is contained in:
n8n-assistant[bot] 2026-10-09 11:39:32 +00:00 committed by GitHub
parent 3d47f4bfaa
commit ea448c6f61
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 59 additions and 3 deletions

View File

@ -8753,6 +8753,55 @@ describe('AgentRuntime — mid-run observation', () => {
expect(JSON.stringify(capturedCall(2))).not.toContain('Old workflow policy.');
});
it('resumes a deferred tool that a skill dependency loaded', async () => {
const source = createRuntimeSkillSource([
{
id: 'planning',
name: 'planning',
description: 'Plan multi-step work.',
instructions: 'Ask the user to approve the plan.',
dependencies: { tools: ['approve'] },
},
]);
const checkpointStore = makeClaimingCheckpointStore();
const memory = new InMemoryMemory();
const options = {
skillSource: source,
tools: createRuntimeSkillTools(source),
deferredTools: [makeInterruptibleTool()],
checkpointStorage: checkpointStore,
};
const first = buildMidRunRuntime(memory, options);
generateText
.mockResolvedValueOnce(
makeGenerateWithToolCall('load-planning', 'load_skill', { skillId: 'planning' }),
)
.mockResolvedValueOnce(
makeGenerateWithToolCall('confirm', 'approve', { question: 'Run the plan?' }),
);
const result = await first.generate('Plan it', { persistence: PERSISTENCE });
await first.dispose();
const suspension = result.pendingSuspend?.[0];
if (!suspension) throw new Error('Expected a plan confirmation');
const resumed = buildMidRunRuntime(memory, options);
generateText.mockResolvedValueOnce(makeGenerateSuccess('Plan approved.'));
const resumeResult = await resumed.resume(
'generate',
{ approved: true },
{ runId: suspension.runId, toolCallId: suspension.toolCallId },
);
await resumed.dispose();
expect(resumeResult.error).toBeUndefined();
expect(resumeResult.toolCalls).toEqual(
expect.arrayContaining([
expect.objectContaining({ tool: 'approve', output: { approved: true } }),
]),
);
expect(capturedCall(2).tools).toHaveProperty('approve');
});
it('merges system messages after compaction for custom OpenAI-compatible endpoints', async () => {
const memory = new InMemoryMemory();
const runtime = buildMidRunRuntime(memory, {

View File

@ -509,9 +509,11 @@ export class AgentRuntime {
const list = await this.restoreCheckpointMessages(state);
const tool = this.context
.getCurrentTools(state.persistence)
.find((t) => t.name === toolCall.toolName);
// A skill dependency loads a deferred tool without a `load_tool` record, and
// skills restore only when the loop starts. Accept any registered deferred tool.
const tool =
this.context.getCurrentTools(state.persistence).find((t) => t.name === toolCall.toolName) ??
this.deferredToolManager?.getTool(toolCall.toolName);
if (!tool) throw new Error(`Tool ${toolCall.toolName} not found`);
const resumeSchema = toolCall.suspended ? toolCall.resumeSchema : tool.resumeSchema;

View File

@ -136,6 +136,11 @@ export class DeferredToolManager {
return [this.searchTool, this.loadTool];
}
/** A registered deferred tool, loaded or not. */
getTool(name: string): BuiltTool | undefined {
return this.toolsByName.get(name);
}
getLoadedTools(): BuiltTool[] {
return Array.from(this.loadedToolNames)
.map((name) => this.toolsByName.get(name))