Skip to content

Commit 3935548

Browse files
[Security Solution] [Elastic AI Assistant] LangChain integration (experimental) (elastic#164908)
## [Security Solution] [Elastic AI Assistant] LangChain integration (experimental) This PR integrates [LangChain](https://www.langchain.com/) with the [Elastic AI Assistant](https://www.elastic.co/blog/introducing-elastic-ai-assistant) as an experimental, alternative execution path. ### How it works - There are virtually no client side changes to the assistant, apart from a new branch in `x-pack/packages/kbn-elastic-assistant/impl/assistant/api.tsx` that chooses a path based on the value of the `assistantLangChain` flag: ```typescript const path = assistantLangChain ? `/internal/elastic_assistant/actions/connector/${apiConfig?.connectorId}/_execute` : `/api/actions/connector/${apiConfig?.connectorId}/_execute`; ``` Execution of the LangChain chain happens server-side. The new route still executes the request via the `connectorId` in the route, but the connector won't execute the request exactly as it was sent by the client. Instead, the connector will execute one (or more) prompts that are generated by LangChain. Requests routed to `/internal/elastic_assistant/actions/connector/${apiConfig?.connectorId}/_execute` will be processed by a new Kibana plugin located in: ``` x-pack/plugins/elastic_assistant ``` - Requests are processed in the `postActionsConnectorExecuteRoute` handler in `x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.ts`. The `postActionsConnectorExecuteRoute` route handler: 1. Extracts the chat messages sent by the assistant 2. Converts the extracted messages to the format expected by LangChain 3. Passes the converted messages to `executeCustomLlmChain` - The `executeCustomLlmChain` function in `x-pack/plugins/elastic_assistant/server/lib/langchain/execute_custom_llm_chain/index.ts`: 1. Splits the messages into `pastMessages` and `latestMessage`, where the latter contains only the last message sent by the user 2. Wraps the conversation history in the `BufferMemory` LangChain abstraction 3. Executes the chain, kicking it off with `latestMessage` ```typescript const llm = new ActionsClientLlm({ actions, connectorId, request }); const pastMessages = langchainMessages.slice(0, -1); // all but the last message const latestMessage = langchainMessages.slice(-1); // the last message const memory = new BufferMemory({ chatHistory: new ChatMessageHistory(pastMessages), }); const chain = new ConversationChain({ llm, memory }); await chain.call({ input: latestMessage[0].content }); // kick off the chain with the last message }; ``` - When LangChain executes the chain, it will invoke `ActionsClientLlm`'s `_call` function in `x-pack/plugins/elastic_assistant/server/lib/langchain/llm/actions_client_llm.ts` one or more times. The `_call` function's signature is defined by LangChain: ``` async _call(prompt: string): Promise<string> ``` - The contents of `prompt` are completely determined by LangChain. - The string returned by the promise is the "answer" from the LLM The `ActionsClientLlm` extends LangChain's LLM interface: ```typescript export class ActionsClientLlm extends LLM ``` This let's us do additional "work" in the `_call` function: 1. Create a new assistant message using the contents of the `prompt` (`string`) argument to `_call` 2. Create a request body in the format expected by the connector 3. Create an actions client from the authenticated request context 4. Execute the actions client with the request body 5. Save the raw response from the connector, because that's what the assistant expects 6. Return the result as a plain string, as per the contact of `_call` ## Desk testing This experimental LangChain integration may NOT be enabled via a feature flag (yet). Set ```typescript assistantLangChain={true} ``` in `x-pack/plugins/security_solution/public/app/app.tsx` to enable this experimental feature in development environments.
1 parent 31e9557 commit 3935548

File tree

46 files changed

+1954
-36
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

46 files changed

+1954
-36
lines changed

.eslintrc.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -984,6 +984,7 @@ module.exports = {
984984
// front end and common typescript and javascript files only
985985
files: [
986986
'x-pack/plugins/ecs_data_quality_dashboard/common/**/*.{js,mjs,ts,tsx}',
987+
'x-pack/plugins/elastic_assistant/common/**/*.{js,mjs,ts,tsx}',
987988
'x-pack/packages/kbn-elastic-assistant/**/*.{js,mjs,ts,tsx}',
988989
'x-pack/packages/security-solution/**/*.{js,mjs,ts,tsx}',
989990
'x-pack/plugins/security_solution/public/**/*.{js,mjs,ts,tsx}',
@@ -1016,6 +1017,7 @@ module.exports = {
10161017
// This should be a very small set as most linter rules are useful for tests as well.
10171018
files: [
10181019
'x-pack/plugins/ecs_data_quality_dashboard/**/*.{ts,tsx}',
1020+
'x-pack/plugins/elastic_assistant/**/*.{ts,tsx}',
10191021
'x-pack/packages/kbn-elastic-assistant/**/*.{ts,tsx}',
10201022
'x-pack/packages/security-solution/**/*.{ts,tsx}',
10211023
'x-pack/plugins/security_solution/**/*.{ts,tsx}',
@@ -1026,6 +1028,7 @@ module.exports = {
10261028
],
10271029
excludedFiles: [
10281030
'x-pack/plugins/ecs_data_quality_dashboard/**/*.{test,mock,test_helper}.{ts,tsx}',
1031+
'x-pack/plugins/elastic_assistant/**/*.{test,mock,test_helper}.{ts,tsx}',
10291032
'x-pack/packages/kbn-elastic-assistant/**/*.{test,mock,test_helper}.{ts,tsx}',
10301033
'x-pack/packages/security-solution/**/*.{test,mock,test_helper}.{ts,tsx}',
10311034
'x-pack/plugins/security_solution/**/*.{test,mock,test_helper}.{ts,tsx}',
@@ -1042,6 +1045,7 @@ module.exports = {
10421045
// typescript only for front and back end
10431046
files: [
10441047
'x-pack/plugins/ecs_data_quality_dashboard/**/*.{ts,tsx}',
1048+
'x-pack/plugins/elastic_assistant/**/*.{ts,tsx}',
10451049
'x-pack/packages/kbn-elastic-assistant/**/*.{ts,tsx}',
10461050
'x-pack/packages/security-solution/**/*.{ts,tsx}',
10471051
'x-pack/plugins/security_solution/**/*.{ts,tsx}',
@@ -1077,6 +1081,7 @@ module.exports = {
10771081
// typescript and javascript for front and back end
10781082
files: [
10791083
'x-pack/plugins/ecs_data_quality_dashboard/**/*.{js,mjs,ts,tsx}',
1084+
'x-pack/plugins/elastic_assistant/**/*.{js,mjs,ts,tsx}',
10801085
'x-pack/packages/kbn-elastic-assistant/**/*.{js,mjs,ts,tsx}',
10811086
'x-pack/packages/security-solution/**/*.{js,mjs,ts,tsx}',
10821087
'x-pack/plugins/security_solution/**/*.{js,mjs,ts,tsx}',

.github/CODEOWNERS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,7 @@ packages/kbn-ecs @elastic/kibana-core @elastic/security-threat-hunting-investiga
340340
x-pack/packages/security-solution/ecs_data_quality_dashboard @elastic/security-threat-hunting-investigations
341341
x-pack/plugins/ecs_data_quality_dashboard @elastic/security-threat-hunting-investigations
342342
x-pack/packages/kbn-elastic-assistant @elastic/security-solution
343+
x-pack/plugins/elastic_assistant @elastic/security-solution
343344
test/plugin_functional/plugins/elasticsearch_client_plugin @elastic/kibana-core
344345
x-pack/test/plugin_api_integration/plugins/elasticsearch_client @elastic/kibana-core
345346
x-pack/plugins/embeddable_enhanced @elastic/kibana-presentation

docs/developer/plugin-list.asciidoc

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,10 @@ Plugin server-side only. Plugin has three main functions:
528528
|This plugin implements (server) APIs used to render the content of the Data Quality dashboard.
529529
530530
531+
|{kib-repo}blob/{branch}/x-pack/plugins/elastic_assistant/README.md[elasticAssistant]
532+
|This plugin implements (only) server APIs for the Elastic AI Assistant.
533+
534+
531535
|<<enhanced-embeddables-plugin>>
532536
|Enhances Embeddables by registering a custom factory provider. The enhanced factory provider
533537
adds dynamic actions to every embeddables state, in order to support drilldowns.

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,7 @@
383383
"@kbn/ecs-data-quality-dashboard": "link:x-pack/packages/security-solution/ecs_data_quality_dashboard",
384384
"@kbn/ecs-data-quality-dashboard-plugin": "link:x-pack/plugins/ecs_data_quality_dashboard",
385385
"@kbn/elastic-assistant": "link:x-pack/packages/kbn-elastic-assistant",
386+
"@kbn/elastic-assistant-plugin": "link:x-pack/plugins/elastic_assistant",
386387
"@kbn/elasticsearch-client-plugin": "link:test/plugin_functional/plugins/elasticsearch_client_plugin",
387388
"@kbn/elasticsearch-client-xpack-plugin": "link:x-pack/test/plugin_api_integration/plugins/elasticsearch_client",
388389
"@kbn/embeddable-enhanced-plugin": "link:x-pack/plugins/embeddable_enhanced",
@@ -897,6 +898,7 @@
897898
"jsonwebtoken": "^9.0.0",
898899
"jsts": "^1.6.2",
899900
"kea": "^2.4.2",
901+
"langchain": "^0.0.132",
900902
"launchdarkly-js-client-sdk": "^2.22.1",
901903
"launchdarkly-node-server-sdk": "^6.4.2",
902904
"load-json-file": "^6.2.0",
@@ -1559,6 +1561,7 @@
15591561
"val-loader": "^1.1.1",
15601562
"vinyl-fs": "^4.0.0",
15611563
"watchpack": "^1.6.0",
1564+
"web-streams-polyfill": "^3.2.1",
15621565
"webpack": "^4.41.5",
15631566
"webpack-bundle-analyzer": "^4.5.0",
15641567
"webpack-cli": "^4.10.0",

packages/kbn-test/jest-preset.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,10 @@ module.exports = {
105105
transformIgnorePatterns: [
106106
// ignore all node_modules except monaco-editor and react-monaco-editor which requires babel transforms to handle dynamic import()
107107
// since ESM modules are not natively supported in Jest yet (https://github.com/facebook/jest/issues/4842)
108-
'[/\\\\]node_modules(?![\\/\\\\](byte-size|monaco-editor|monaco-yaml|vscode-languageserver-types|react-monaco-editor|d3-interpolate|d3-color))[/\\\\].+\\.js$',
108+
'[/\\\\]node_modules(?![\\/\\\\](byte-size|monaco-editor|monaco-yaml|vscode-languageserver-types|react-monaco-editor|d3-interpolate|d3-color|langchain|langsmith))[/\\\\].+\\.js$',
109109
'packages/kbn-pm/dist/index.js',
110+
'[/\\\\]node_modules(?![\\/\\\\](langchain|langsmith))/dist/[/\\\\].+\\.js$',
111+
'[/\\\\]node_modules(?![\\/\\\\](langchain|langsmith))/dist/util/[/\\\\].+\\.js$',
110112
],
111113

112114
// An array of regexp pattern strings that are matched against all source file paths, matched files to include/exclude for code coverage

packages/kbn-test/jest_integration_node/jest-preset.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ module.exports = {
1919
testPathIgnorePatterns: preset.testPathIgnorePatterns.filter(
2020
(pattern) => !pattern.includes('integration_tests')
2121
),
22+
// An array of regexp pattern strings that are matched against, matched files will skip transformation:
23+
transformIgnorePatterns: [
24+
// since ESM modules are not natively supported in Jest yet (https://github.com/facebook/jest/issues/4842)
25+
'[/\\\\]node_modules(?![\\/\\\\](langchain|langsmith))[/\\\\].+\\.js$',
26+
'[/\\\\]node_modules(?![\\/\\\\](langchain|langsmith))/dist/[/\\\\].+\\.js$',
27+
'[/\\\\]node_modules(?![\\/\\\\](langchain|langsmith))/dist/util/[/\\\\].+\\.js$',
28+
],
2229
setupFilesAfterEnv: [
2330
'<rootDir>/packages/kbn-test/src/jest/setup/after_env.integration.js',
2431
'<rootDir>/packages/kbn-test/src/jest/setup/mocks.moment_timezone.js',

packages/kbn-test/src/jest/setup/setup_test.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
import 'jest-styled-components';
1515
import '@testing-library/jest-dom';
16+
import 'web-streams-polyfill/es6'; // ReadableStream polyfill
1617

1718
/**
1819
* Removed in Jest 27/jsdom, used in some transitive dependencies

tsconfig.base.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -674,6 +674,8 @@
674674
"@kbn/ecs-data-quality-dashboard-plugin/*": ["x-pack/plugins/ecs_data_quality_dashboard/*"],
675675
"@kbn/elastic-assistant": ["x-pack/packages/kbn-elastic-assistant"],
676676
"@kbn/elastic-assistant/*": ["x-pack/packages/kbn-elastic-assistant/*"],
677+
"@kbn/elastic-assistant-plugin": ["x-pack/plugins/elastic_assistant"],
678+
"@kbn/elastic-assistant-plugin/*": ["x-pack/plugins/elastic_assistant/*"],
677679
"@kbn/elasticsearch-client-plugin": ["test/plugin_functional/plugins/elasticsearch_client_plugin"],
678680
"@kbn/elasticsearch-client-plugin/*": ["test/plugin_functional/plugins/elasticsearch_client_plugin/*"],
679681
"@kbn/elasticsearch-client-xpack-plugin": ["x-pack/test/plugin_api_integration/plugins/elasticsearch_client"],
@@ -1645,4 +1647,5 @@
16451647
"@kbn/ambient-storybook-types"
16461648
]
16471649
}
1648-
}
1650+
}
1651+
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import { HttpSetup } from '@kbn/core-http-browser';
9+
import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/public/common';
10+
11+
import { fetchConnectorExecuteAction, FetchConnectorExecuteAction } from './api';
12+
import type { Conversation, Message } from '../assistant_context/types';
13+
import { API_ERROR } from './translations';
14+
15+
jest.mock('@kbn/core-http-browser');
16+
17+
const mockHttp = {
18+
fetch: jest.fn(),
19+
} as unknown as HttpSetup;
20+
21+
const apiConfig: Conversation['apiConfig'] = {
22+
connectorId: 'foo',
23+
model: 'gpt-4',
24+
provider: OpenAiProviderType.OpenAi,
25+
};
26+
27+
const messages: Message[] = [
28+
{ content: 'This is a test', role: 'user', timestamp: new Date().toLocaleString() },
29+
];
30+
31+
describe('fetchConnectorExecuteAction', () => {
32+
beforeEach(() => {
33+
jest.clearAllMocks();
34+
});
35+
36+
it('calls the internal assistant API when assistantLangChain is true', async () => {
37+
const testProps: FetchConnectorExecuteAction = {
38+
assistantLangChain: true,
39+
http: mockHttp,
40+
messages,
41+
apiConfig,
42+
};
43+
44+
await fetchConnectorExecuteAction(testProps);
45+
46+
expect(mockHttp.fetch).toHaveBeenCalledWith(
47+
'/internal/elastic_assistant/actions/connector/foo/_execute',
48+
{
49+
body: '{"params":{"subActionParams":{"body":"{\\"model\\":\\"gpt-4\\",\\"messages\\":[{\\"role\\":\\"user\\",\\"content\\":\\"This is a test\\"}],\\"n\\":1,\\"stop\\":null,\\"temperature\\":0.2}"},"subAction":"test"}}',
50+
headers: { 'Content-Type': 'application/json' },
51+
method: 'POST',
52+
signal: undefined,
53+
}
54+
);
55+
});
56+
57+
it('calls the actions connector api when assistantLangChain is false', async () => {
58+
const testProps: FetchConnectorExecuteAction = {
59+
assistantLangChain: false,
60+
http: mockHttp,
61+
messages,
62+
apiConfig,
63+
};
64+
65+
await fetchConnectorExecuteAction(testProps);
66+
67+
expect(mockHttp.fetch).toHaveBeenCalledWith('/api/actions/connector/foo/_execute', {
68+
body: '{"params":{"subActionParams":{"body":"{\\"model\\":\\"gpt-4\\",\\"messages\\":[{\\"role\\":\\"user\\",\\"content\\":\\"This is a test\\"}],\\"n\\":1,\\"stop\\":null,\\"temperature\\":0.2}"},"subAction":"test"}}',
69+
headers: { 'Content-Type': 'application/json' },
70+
method: 'POST',
71+
signal: undefined,
72+
});
73+
});
74+
75+
it('returns API_ERROR when the response status is not ok', async () => {
76+
(mockHttp.fetch as jest.Mock).mockResolvedValue({ status: 'error' });
77+
78+
const testProps: FetchConnectorExecuteAction = {
79+
assistantLangChain: false,
80+
http: mockHttp,
81+
messages,
82+
apiConfig,
83+
};
84+
85+
const result = await fetchConnectorExecuteAction(testProps);
86+
87+
expect(result).toBe(API_ERROR);
88+
});
89+
90+
it('returns API_ERROR when there are no choices', async () => {
91+
(mockHttp.fetch as jest.Mock).mockResolvedValue({ status: 'ok', data: {} });
92+
const testProps: FetchConnectorExecuteAction = {
93+
assistantLangChain: false,
94+
http: mockHttp,
95+
messages,
96+
apiConfig,
97+
};
98+
99+
const result = await fetchConnectorExecuteAction(testProps);
100+
101+
expect(result).toBe(API_ERROR);
102+
});
103+
104+
it('return the trimmed first `choices` `message` `content` when the API call is successful', async () => {
105+
(mockHttp.fetch as jest.Mock).mockResolvedValue({
106+
status: 'ok',
107+
data: {
108+
choices: [
109+
{
110+
message: {
111+
content: ' Test response ', // leading and trailing whitespace
112+
},
113+
},
114+
],
115+
},
116+
});
117+
118+
const testProps: FetchConnectorExecuteAction = {
119+
assistantLangChain: false,
120+
http: mockHttp,
121+
messages,
122+
apiConfig,
123+
};
124+
125+
const result = await fetchConnectorExecuteAction(testProps);
126+
127+
expect(result).toBe('Test response');
128+
});
129+
});

x-pack/packages/kbn-elastic-assistant/impl/assistant/api.tsx

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,15 @@ import { API_ERROR } from './translations';
1414
import { MODEL_GPT_3_5_TURBO } from '../connectorland/models/model_selector/model_selector';
1515

1616
export interface FetchConnectorExecuteAction {
17+
assistantLangChain: boolean;
1718
apiConfig: Conversation['apiConfig'];
1819
http: HttpSetup;
1920
messages: Message[];
2021
signal?: AbortSignal | undefined;
2122
}
2223

2324
export const fetchConnectorExecuteAction = async ({
25+
assistantLangChain,
2426
http,
2527
messages,
2628
apiConfig,
@@ -54,19 +56,20 @@ export const fetchConnectorExecuteAction = async ({
5456
};
5557

5658
try {
59+
const path = assistantLangChain
60+
? `/internal/elastic_assistant/actions/connector/${apiConfig?.connectorId}/_execute`
61+
: `/api/actions/connector/${apiConfig?.connectorId}/_execute`;
62+
5763
// TODO: Find return type for this API
5864
// eslint-disable-next-line @typescript-eslint/no-explicit-any
59-
const response = await http.fetch<any>(
60-
`/api/actions/connector/${apiConfig?.connectorId}/_execute`,
61-
{
62-
method: 'POST',
63-
headers: {
64-
'Content-Type': 'application/json',
65-
},
66-
body: JSON.stringify(requestBody),
67-
signal,
68-
}
69-
);
65+
const response = await http.fetch<any>(path, {
66+
method: 'POST',
67+
headers: {
68+
'Content-Type': 'application/json',
69+
},
70+
body: JSON.stringify(requestBody),
71+
signal,
72+
});
7073

7174
const data = response.data;
7275
if (response.status !== 'ok') {

0 commit comments

Comments
 (0)