diff --git a/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts b/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts index 91bdd09977d..aa5cc821d56 100644 --- a/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts +++ b/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts @@ -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, { diff --git a/packages/@n8n/agents/src/runtime/loop/agent-runtime.ts b/packages/@n8n/agents/src/runtime/loop/agent-runtime.ts index 83143657b78..0684470c983 100644 --- a/packages/@n8n/agents/src/runtime/loop/agent-runtime.ts +++ b/packages/@n8n/agents/src/runtime/loop/agent-runtime.ts @@ -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; diff --git a/packages/@n8n/agents/src/runtime/tools/deferred-tool-manager.ts b/packages/@n8n/agents/src/runtime/tools/deferred-tool-manager.ts index c159c9aadae..cc852791ccc 100644 --- a/packages/@n8n/agents/src/runtime/tools/deferred-tool-manager.ts +++ b/packages/@n8n/agents/src/runtime/tools/deferred-tool-manager.ts @@ -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))