Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -151,4 +151,12 @@ If no relevant information is found, inform the user you could not locate the re
default: 'Call this for Elastic Defend insights.',
},
},
{
promptId: 'IntegrationKnowledgeTool',
promptGroupId,
prompt: {
default:
'Call this for knowledge from Fleet-installed integrations, which contains information on how to configure and use integrations for data ingestion.',
},
},
Comment on lines +154 to +161
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We've discussed this offline, but just commenting for visibility that we'll need to do release of the security_ai_prompts integration as the bot mentioned once this is merged.

While technically I don't think you need to do this (it'll just fall back to this local prompt defined here), it's a nice to have option for tweaking the prompt out of band. cc @stephmilovic

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep! I have that as a to-do item in this PR description, instructions recommend to do the integration update after merge as well

];
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export const INVOKE_ASSISTANT_SUCCESS_EVENT: EventTypeOpts<{
ProductDocumentationTool?: number;
CustomTool?: number;
EntityRiskScoreTool?: number;
IntegrationKnowledgeTool?: number;
};
model?: string;
isOssModel?: boolean;
Expand Down Expand Up @@ -152,6 +153,7 @@ export const INVOKE_ASSISTANT_SUCCESS_EVENT: EventTypeOpts<{
SecurityLabsKnowledgeBaseTool: toolCountSchema,
CustomTool: toolCountSchema,
EntityRiskScoreTool: toolCountSchema,
IntegrationKnowledgeTool: toolCountSchema,
},
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { KNOWLEDGE_BASE_RETRIEVAL_TOOL } from './knowledge_base/knowledge_base_r
import { KNOWLEDGE_BASE_WRITE_TOOL } from './knowledge_base/knowledge_base_write_tool';
import { SECURITY_LABS_KNOWLEDGE_BASE_TOOL } from './security_labs/security_labs_tool';
import { ENTITY_RISK_SCORE_TOOL } from './entity_risk_score/entity_risk_score';
import { INTEGRATION_KNOWLEDGE_TOOL } from './integration_knowledge/integration_knowledge_tool';

// any new tool should also be added to telemetry schema in
// x-pack/solutions/security/plugins/elastic_assistant/server/lib/telemetry/event_based_telemetry.ts
Expand All @@ -27,4 +28,5 @@ export const assistantTools = [
PRODUCT_DOCUMENTATION_TOOL,
SECURITY_LABS_KNOWLEDGE_BASE_TOOL,
ENTITY_RISK_SCORE_TOOL,
INTEGRATION_KNOWLEDGE_TOOL,
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import type { DynamicStructuredTool } from '@langchain/core/tools';
import { INTEGRATION_KNOWLEDGE_TOOL } from './integration_knowledge_tool';
import type {
ContentReferencesStore,
HrefContentReference,
KnowledgeBaseEntryContentReference,
} from '@kbn/elastic-assistant-common';
import { newContentReferencesStoreMock } from '@kbn/elastic-assistant-common/impl/content_references/content_references_store/__mocks__/content_references_store.mock';
import type { AssistantToolParams } from '@kbn/elastic-assistant-plugin/server';

const mockSearch = jest.fn();
const mockAssistantContext = {
core: {
elasticsearch: {
client: {
asInternalUser: {
search: mockSearch,
},
},
},
},
getServerBasePath: () => '/test-base-path',
};

describe('IntegrationKnowledgeTool', () => {
const contentReferencesStore = newContentReferencesStoreMock();
const defaultArgs = {
assistantContext: mockAssistantContext,
contentReferencesStore,
} as unknown as AssistantToolParams;

beforeEach(() => {
jest.clearAllMocks();
// Default to index existing - mock search call with size: 0 for index existence check
mockSearch.mockResolvedValue({ hits: { total: { value: 0 } } });
});

describe('isSupported', () => {
it('returns true when assistantContext is provided', () => {
expect(INTEGRATION_KNOWLEDGE_TOOL.isSupported(defaultArgs)).toBe(true);
});

it('returns false when assistantContext is not provided', () => {
const argsWithoutContext = { contentReferencesStore } as AssistantToolParams;
expect(INTEGRATION_KNOWLEDGE_TOOL.isSupported(argsWithoutContext)).toBe(false);
});
});

describe('getTool', () => {
it('returns null when not supported', async () => {
const argsWithoutContext = { contentReferencesStore } as AssistantToolParams;
const result = await INTEGRATION_KNOWLEDGE_TOOL.getTool(argsWithoutContext);
expect(result).toBeNull();
});

it('returns a DynamicStructuredTool when supported and index exists', async () => {
const tool = await INTEGRATION_KNOWLEDGE_TOOL.getTool(defaultArgs);
expect(tool).toBeDefined();
expect(tool?.name).toBe('IntegrationKnowledgeTool');
expect(mockSearch).toHaveBeenCalledWith({
index: '.integration_knowledge',
size: 0,
});
});

it('returns null when index does not exist', async () => {
mockSearch.mockRejectedValue(new Error('index_not_found_exception'));
const tool = await INTEGRATION_KNOWLEDGE_TOOL.getTool(defaultArgs);
expect(tool).toBeNull();
});

it('returns null when index existence check throws error', async () => {
mockSearch.mockRejectedValue(new Error('Index check failed'));
const tool = await INTEGRATION_KNOWLEDGE_TOOL.getTool(defaultArgs);
expect(tool).toBeNull();
});
});

describe('DynamicStructuredTool', () => {
it('includes href citations for integration packages', async () => {
// First call is for index existence check
mockSearch.mockResolvedValueOnce({ hits: { total: { value: 0 } } }).mockResolvedValueOnce({
hits: {
hits: [
{
_id: 'test-id',
_source: {
package_name: 'nginx',
filename: 'README.md',
content: 'This is how to configure nginx integration for web server monitoring.',
version: '1.2.3',
},
},
],
},
});

const tool = (await INTEGRATION_KNOWLEDGE_TOOL.getTool(defaultArgs)) as DynamicStructuredTool;

(contentReferencesStore.add as jest.Mock).mockImplementation(
(creator: Parameters<ContentReferencesStore['add']>[0]) => {
const reference = creator({ id: 'exampleContentReferenceId' });
expect(reference.type).toEqual('Href');
expect((reference as HrefContentReference).href).toEqual(
'/test-base-path/app/integrations/detail/nginx'
);
expect((reference as HrefContentReference).label).toEqual(
'nginx integration (README.md)'
);
return reference;
}
);

const result = await tool.func({ question: 'How do I configure nginx?' });

expect(mockSearch).toHaveBeenCalledWith({
index: '.integration_knowledge',
size: 10,
query: {
semantic: {
field: 'content',
query: 'How do I configure nginx?',
},
},
_source: ['package_name', 'filename', 'content', 'version'],
});

expect(result).toContain('Citation: {reference(exampleContentReferenceId)}');
expect(result).toContain('Package: nginx (v1.2.3)');
expect(result).toContain('File: README.md');
expect(result).toContain('This is how to configure nginx integration');
});

it('includes knowledge base citations as fallback', async () => {
// First call is for index existence check
mockSearch.mockResolvedValueOnce({ hits: { total: { value: 0 } } }).mockResolvedValueOnce({
hits: {
hits: [
{
_id: 'test-id',
_source: {
package_name: 'apache',
filename: 'config.yml',
content: 'Apache integration configuration details.',
},
},
],
},
});

const tool = (await INTEGRATION_KNOWLEDGE_TOOL.getTool(defaultArgs)) as DynamicStructuredTool;

// Mock the href reference creation to throw an error to trigger fallback
(contentReferencesStore.add as jest.Mock)
.mockImplementationOnce(() => {
throw new Error('Reference creation failed');
})
.mockImplementationOnce((creator: Parameters<ContentReferencesStore['add']>[0]) => {
const reference = creator({ id: 'fallbackReferenceId' });
expect(reference.type).toEqual('KnowledgeBaseEntry');
expect((reference as KnowledgeBaseEntryContentReference).knowledgeBaseEntryId).toEqual(
'integrationKnowledge'
);
expect((reference as KnowledgeBaseEntryContentReference).knowledgeBaseEntryName).toEqual(
'Integration knowledge for apache'
);
return reference;
});

const result = await tool.func({ question: 'How do I setup apache?' });

expect(result).toContain('Citation: {reference(fallbackReferenceId)}');
expect(result).toContain('Package: apache');
expect(result).toContain('File: config.yml');
});

it('handles package without version', async () => {
// First call is for index existence check
mockSearch.mockResolvedValueOnce({ hits: { total: { value: 0 } } }).mockResolvedValueOnce({
hits: {
hits: [
{
_id: 'test-id',
_source: {
package_name: 'mysql',
filename: 'setup.md',
content: 'MySQL integration setup instructions.',
},
},
],
},
});

const tool = (await INTEGRATION_KNOWLEDGE_TOOL.getTool(defaultArgs)) as DynamicStructuredTool;

(contentReferencesStore.add as jest.Mock).mockImplementation(
(creator: Parameters<ContentReferencesStore['add']>[0]) => {
const reference = creator({ id: 'mysqlReferenceId' });
return reference;
}
);

const result = await tool.func({ question: 'MySQL setup' });

expect(result).toContain('Package: mysql');
expect(result).not.toContain('(v');
});

it('returns appropriate message when no results found', async () => {
// First call is for index existence check
mockSearch.mockResolvedValueOnce({ hits: { total: { value: 0 } } }).mockResolvedValueOnce({
hits: {
hits: [],
},
});

const tool = (await INTEGRATION_KNOWLEDGE_TOOL.getTool(defaultArgs)) as DynamicStructuredTool;

const result = await tool.func({ question: 'nonexistent integration' });

expect(result).toBe('[]');
});

it('handles search errors gracefully', async () => {
// First call is for index existence check, second call throws error
mockSearch
.mockResolvedValueOnce({ hits: { total: { value: 0 } } })
.mockRejectedValueOnce(new Error('Elasticsearch connection failed'));

const tool = (await INTEGRATION_KNOWLEDGE_TOOL.getTool(defaultArgs)) as DynamicStructuredTool;

const result = await tool.func({ question: 'test question' });

expect(result).toBe(
'Error querying integration knowledge: Elasticsearch connection failed. The integration knowledge base may not be available.'
);
});

it('truncates long results to 20000 characters', async () => {
const longContent = 'A'.repeat(25000);
// First call is for index existence check
mockSearch.mockResolvedValueOnce({ hits: { total: { value: 0 } } }).mockResolvedValueOnce({
hits: {
hits: [
{
_id: 'test-id',
_source: {
package_name: 'large-integration',
filename: 'large-doc.md',
content: longContent,
},
},
],
},
});

const tool = (await INTEGRATION_KNOWLEDGE_TOOL.getTool(defaultArgs)) as DynamicStructuredTool;

(contentReferencesStore.add as jest.Mock).mockImplementation(
(creator: Parameters<ContentReferencesStore['add']>[0]) => {
return creator({ id: 'largeContentId' });
}
);

const result = await tool.func({ question: 'large integration' });

expect(result.length).toBe(20000);
});
});
});
Loading
Loading