From f3b4975c8caee0dd605a214530dc7fa04e6f9d96 Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Tue, 27 May 2025 23:45:01 +0200 Subject: [PATCH] [onechat] Introduce plugin and tool registry (#220889) ## Summary Implements the onechat tool registry RFC. Fix https://github.com/elastic/search-team/issues/9938 Fix https://github.com/elastic/search-team/issues/10019 This PR introduces the following artifacts: **plugins:** - `onechat` **packages:** - `@kbn/onechat-common` - `@kbn/onechat-server` - `@kbn/onechat-browser` ## Tool APIs overview ### Registering a tool ```ts class MyPlugin { setup(core: CoreSetup, { onechat }: { onechat: OnechatPluginSetup }) { onechat.tools.register({ id: 'my_tool', name: 'My Tool', description: 'My very first tool', meta: { tags: ['foo', 'bar'], }, schema: z.object({ someNumber: z.number().describe('Some random number'), }), handler: ({ someNumber }, context) => { return 42 + someNumber; }, }); } } ``` ### Executing a tool Using the `execute` API: ```ts const { result } = await onechat.tools.execute({ toolId: 'my_tool', toolParams: { someNumber: 9000 }, request, }); ``` Using a tool descriptor: ```ts const tool = await onechat.tools.registry.get({ toolId: 'my_tool', request }); const { result } = await tool.execute({ toolParams: { someNumber: 9000 } }); ``` With error handling: ```ts import { isToolNotFoundError } from '@kbn/onechat-common'; try { const { result } = await onechat.tools.execute({ toolId: 'my_tool', toolParams: { someNumber: 9000 }, request, }); } catch (e) { if (isToolNotFoundError(e)) { throw new Error(`run ${e.meta.runId} failed because tool was not found`); } } ``` ### Listing tools ```ts const tools = await onechat.tools.registry.list({ request }); ``` *More details and example in the plugin's readme.* ### What is **not** included in this PR: - tool access control / authorization - we have a dedicated RFC - dynamic tool registration / permissions checks part/of depends on the authorization RFC - feature / capabilities - will come with browser-side and HTTP APIs - fully defining tool meta - hard to do now - filter parameters for the tool list API - depends on the meta being defined *Those will be follow-ups*. Everything else from the RFC should be there. --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .github/CODEOWNERS | 4 + docs/extend/plugin-list.md | 1 + package.json | 4 + packages/kbn-optimizer/limits.yml | 1 + tsconfig.base.json | 8 + .../shared/onechat/onechat-browser/README.md | 3 + .../shared/onechat/onechat-browser/index.ts | 10 + .../onechat/onechat-browser/jest.config.js | 12 + .../onechat/onechat-browser/kibana.jsonc | 7 + .../onechat/onechat-browser/package.json | 7 + .../onechat/onechat-browser/tsconfig.json | 19 ++ .../shared/onechat/onechat-common/README.md | 23 ++ .../shared/onechat/onechat-common/index.ts | 36 +++ .../onechat/onechat-common/jest.config.js | 12 + .../onechat/onechat-common/kibana.jsonc | 7 + .../onechat/onechat-common/package.json | 7 + .../onechat/onechat-common/src/errors.test.ts | 114 +++++++++ .../onechat/onechat-common/src/errors.ts | 89 +++++++ .../onechat/onechat-common/src/events.ts | 28 +++ .../onechat/onechat-common/src/tools.test.ts | 150 ++++++++++++ .../onechat/onechat-common/src/tools.ts | 196 ++++++++++++++++ .../onechat/onechat-common/tsconfig.json | 19 ++ .../shared/onechat/onechat-server/README.md | 3 + .../shared/onechat/onechat-server/index.ts | 46 ++++ .../onechat/onechat-server/jest.config.js | 12 + .../onechat/onechat-server/kibana.jsonc | 7 + .../onechat/onechat-server/package.json | 7 + .../onechat/onechat-server/src/events.ts | 92 ++++++++ .../onechat-server/src/model_provider.ts | 45 ++++ .../onechat/onechat-server/src/runner.ts | 123 ++++++++++ .../onechat/onechat-server/src/tools.ts | 154 ++++++++++++ .../onechat/onechat-server/tsconfig.json | 25 ++ .../platform/plugins/shared/onechat/README.md | 221 ++++++++++++++++++ .../plugins/shared/onechat/common/features.ts | 10 + .../plugins/shared/onechat/jest.config.js | 23 ++ .../plugins/shared/onechat/kibana.jsonc | 17 ++ .../plugins/shared/onechat/public/index.ts | 27 +++ .../plugins/shared/onechat/public/mocks.ts | 21 ++ .../plugins/shared/onechat/public/plugin.tsx | 42 ++++ .../plugins/shared/onechat/public/types.ts | 18 ++ .../plugins/shared/onechat/server/config.ts | 19 ++ .../plugins/shared/onechat/server/index.ts | 30 +++ .../plugins/shared/onechat/server/mocks.ts | 44 ++++ .../plugins/shared/onechat/server/plugin.ts | 98 ++++++++ .../shared/onechat/server/routes/index.ts | 13 ++ .../shared/onechat/server/routes/tools.ts | 37 +++ .../shared/onechat/server/routes/types.ts | 18 ++ .../onechat/server/routes/wrap_handler.ts | 29 +++ .../server/services/create_services.ts | 73 ++++++ .../shared/onechat/server/services/index.ts | 9 + .../onechat/server/services/runner/index.ts | 9 + .../server/services/runner/model_provider.ts | 74 ++++++ .../server/services/runner/runner.test.ts | 211 +++++++++++++++++ .../onechat/server/services/runner/runner.ts | 132 +++++++++++ .../server/services/runner/runner_factory.ts | 42 ++++ .../onechat/server/services/runner/types.ts | 37 +++ .../services/runner/utils/events.test.ts | 137 +++++++++++ .../server/services/runner/utils/events.ts | 67 ++++++ .../runner/utils/get_connector_list.ts | 32 +++ .../runner/utils/get_default_connector.ts | 35 +++ .../server/services/runner/utils/index.ts | 11 + .../services/runner/utils/run_context.test.ts | 85 +++++++ .../services/runner/utils/run_context.ts | 32 +++ .../services/tools/builtin_registry.test.ts | 132 +++++++++++ .../server/services/tools/builtin_registry.ts | 130 +++++++++++ .../onechat/server/services/tools/index.ts | 14 ++ .../server/services/tools/tools_service.ts | 41 ++++ .../onechat/server/services/tools/types.ts | 75 ++++++ .../utils/combine_tool_providers.test.ts | 113 +++++++++ .../tools/utils/combine_tool_providers.ts | 58 +++++ .../tools/utils/create_internal_registry.ts | 80 +++++++ .../server/services/tools/utils/index.ts | 10 + .../services/tools/utils/tool_conversion.ts | 57 +++++ .../shared/onechat/server/services/types.ts | 33 +++ .../onechat/server/test_utils/common.ts | 10 + .../shared/onechat/server/test_utils/index.ts | 27 +++ .../server/test_utils/model_provider.ts | 26 +++ .../onechat/server/test_utils/runner.ts | 37 +++ .../shared/onechat/server/test_utils/tools.ts | 110 +++++++++ .../plugins/shared/onechat/server/types.ts | 83 +++++++ .../plugins/shared/onechat/tsconfig.json | 33 +++ yarn.lock | 16 ++ 82 files changed, 4009 insertions(+) create mode 100644 x-pack/platform/packages/shared/onechat/onechat-browser/README.md create mode 100644 x-pack/platform/packages/shared/onechat/onechat-browser/index.ts create mode 100644 x-pack/platform/packages/shared/onechat/onechat-browser/jest.config.js create mode 100644 x-pack/platform/packages/shared/onechat/onechat-browser/kibana.jsonc create mode 100644 x-pack/platform/packages/shared/onechat/onechat-browser/package.json create mode 100644 x-pack/platform/packages/shared/onechat/onechat-browser/tsconfig.json create mode 100644 x-pack/platform/packages/shared/onechat/onechat-common/README.md create mode 100644 x-pack/platform/packages/shared/onechat/onechat-common/index.ts create mode 100644 x-pack/platform/packages/shared/onechat/onechat-common/jest.config.js create mode 100644 x-pack/platform/packages/shared/onechat/onechat-common/kibana.jsonc create mode 100644 x-pack/platform/packages/shared/onechat/onechat-common/package.json create mode 100644 x-pack/platform/packages/shared/onechat/onechat-common/src/errors.test.ts create mode 100644 x-pack/platform/packages/shared/onechat/onechat-common/src/errors.ts create mode 100644 x-pack/platform/packages/shared/onechat/onechat-common/src/events.ts create mode 100644 x-pack/platform/packages/shared/onechat/onechat-common/src/tools.test.ts create mode 100644 x-pack/platform/packages/shared/onechat/onechat-common/src/tools.ts create mode 100644 x-pack/platform/packages/shared/onechat/onechat-common/tsconfig.json create mode 100644 x-pack/platform/packages/shared/onechat/onechat-server/README.md create mode 100644 x-pack/platform/packages/shared/onechat/onechat-server/index.ts create mode 100644 x-pack/platform/packages/shared/onechat/onechat-server/jest.config.js create mode 100644 x-pack/platform/packages/shared/onechat/onechat-server/kibana.jsonc create mode 100644 x-pack/platform/packages/shared/onechat/onechat-server/package.json create mode 100644 x-pack/platform/packages/shared/onechat/onechat-server/src/events.ts create mode 100644 x-pack/platform/packages/shared/onechat/onechat-server/src/model_provider.ts create mode 100644 x-pack/platform/packages/shared/onechat/onechat-server/src/runner.ts create mode 100644 x-pack/platform/packages/shared/onechat/onechat-server/src/tools.ts create mode 100644 x-pack/platform/packages/shared/onechat/onechat-server/tsconfig.json create mode 100644 x-pack/platform/plugins/shared/onechat/README.md create mode 100644 x-pack/platform/plugins/shared/onechat/common/features.ts create mode 100644 x-pack/platform/plugins/shared/onechat/jest.config.js create mode 100644 x-pack/platform/plugins/shared/onechat/kibana.jsonc create mode 100644 x-pack/platform/plugins/shared/onechat/public/index.ts create mode 100644 x-pack/platform/plugins/shared/onechat/public/mocks.ts create mode 100644 x-pack/platform/plugins/shared/onechat/public/plugin.tsx create mode 100644 x-pack/platform/plugins/shared/onechat/public/types.ts create mode 100644 x-pack/platform/plugins/shared/onechat/server/config.ts create mode 100644 x-pack/platform/plugins/shared/onechat/server/index.ts create mode 100644 x-pack/platform/plugins/shared/onechat/server/mocks.ts create mode 100644 x-pack/platform/plugins/shared/onechat/server/plugin.ts create mode 100644 x-pack/platform/plugins/shared/onechat/server/routes/index.ts create mode 100644 x-pack/platform/plugins/shared/onechat/server/routes/tools.ts create mode 100644 x-pack/platform/plugins/shared/onechat/server/routes/types.ts create mode 100644 x-pack/platform/plugins/shared/onechat/server/routes/wrap_handler.ts create mode 100644 x-pack/platform/plugins/shared/onechat/server/services/create_services.ts create mode 100644 x-pack/platform/plugins/shared/onechat/server/services/index.ts create mode 100644 x-pack/platform/plugins/shared/onechat/server/services/runner/index.ts create mode 100644 x-pack/platform/plugins/shared/onechat/server/services/runner/model_provider.ts create mode 100644 x-pack/platform/plugins/shared/onechat/server/services/runner/runner.test.ts create mode 100644 x-pack/platform/plugins/shared/onechat/server/services/runner/runner.ts create mode 100644 x-pack/platform/plugins/shared/onechat/server/services/runner/runner_factory.ts create mode 100644 x-pack/platform/plugins/shared/onechat/server/services/runner/types.ts create mode 100644 x-pack/platform/plugins/shared/onechat/server/services/runner/utils/events.test.ts create mode 100644 x-pack/platform/plugins/shared/onechat/server/services/runner/utils/events.ts create mode 100644 x-pack/platform/plugins/shared/onechat/server/services/runner/utils/get_connector_list.ts create mode 100644 x-pack/platform/plugins/shared/onechat/server/services/runner/utils/get_default_connector.ts create mode 100644 x-pack/platform/plugins/shared/onechat/server/services/runner/utils/index.ts create mode 100644 x-pack/platform/plugins/shared/onechat/server/services/runner/utils/run_context.test.ts create mode 100644 x-pack/platform/plugins/shared/onechat/server/services/runner/utils/run_context.ts create mode 100644 x-pack/platform/plugins/shared/onechat/server/services/tools/builtin_registry.test.ts create mode 100644 x-pack/platform/plugins/shared/onechat/server/services/tools/builtin_registry.ts create mode 100644 x-pack/platform/plugins/shared/onechat/server/services/tools/index.ts create mode 100644 x-pack/platform/plugins/shared/onechat/server/services/tools/tools_service.ts create mode 100644 x-pack/platform/plugins/shared/onechat/server/services/tools/types.ts create mode 100644 x-pack/platform/plugins/shared/onechat/server/services/tools/utils/combine_tool_providers.test.ts create mode 100644 x-pack/platform/plugins/shared/onechat/server/services/tools/utils/combine_tool_providers.ts create mode 100644 x-pack/platform/plugins/shared/onechat/server/services/tools/utils/create_internal_registry.ts create mode 100644 x-pack/platform/plugins/shared/onechat/server/services/tools/utils/index.ts create mode 100644 x-pack/platform/plugins/shared/onechat/server/services/tools/utils/tool_conversion.ts create mode 100644 x-pack/platform/plugins/shared/onechat/server/services/types.ts create mode 100644 x-pack/platform/plugins/shared/onechat/server/test_utils/common.ts create mode 100644 x-pack/platform/plugins/shared/onechat/server/test_utils/index.ts create mode 100644 x-pack/platform/plugins/shared/onechat/server/test_utils/model_provider.ts create mode 100644 x-pack/platform/plugins/shared/onechat/server/test_utils/runner.ts create mode 100644 x-pack/platform/plugins/shared/onechat/server/test_utils/tools.ts create mode 100644 x-pack/platform/plugins/shared/onechat/server/types.ts create mode 100644 x-pack/platform/plugins/shared/onechat/tsconfig.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 5c3a890d0cda..3e6636519895 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -854,6 +854,9 @@ x-pack/platform/packages/shared/ml/random_sampler_utils @elastic/ml-ui x-pack/platform/packages/shared/ml/response_stream @elastic/ml-ui x-pack/platform/packages/shared/ml/runtime_field_utils @elastic/ml-ui x-pack/platform/packages/shared/ml/trained_models_utils @elastic/ml-ui +x-pack/platform/packages/shared/onechat/onechat-browser @elastic/workchat-eng +x-pack/platform/packages/shared/onechat/onechat-common @elastic/workchat-eng +x-pack/platform/packages/shared/onechat/onechat-server @elastic/workchat-eng x-pack/platform/packages/shared/security/api_key_management @elastic/kibana-security x-pack/platform/packages/shared/security/form_components @elastic/kibana-security x-pack/platform/packages/shared/security/plugin_types_common @elastic/kibana-security @@ -932,6 +935,7 @@ x-pack/platform/plugins/shared/maps @elastic/kibana-presentation x-pack/platform/plugins/shared/ml @elastic/ml-ui x-pack/platform/plugins/shared/notifications @elastic/appex-sharedux x-pack/platform/plugins/shared/observability_ai_assistant @elastic/obs-ai-assistant +x-pack/platform/plugins/shared/onechat @elastic/workchat-eng x-pack/platform/plugins/shared/osquery @elastic/security-defend-workflows x-pack/platform/plugins/shared/rule_registry @elastic/response-ops @elastic/obs-ux-management-team x-pack/platform/plugins/shared/saved_objects_tagging @elastic/appex-sharedux diff --git a/docs/extend/plugin-list.md b/docs/extend/plugin-list.md index d7523d4aaba8..512a72ee70dc 100644 --- a/docs/extend/plugin-list.md +++ b/docs/extend/plugin-list.md @@ -180,6 +180,7 @@ mapped_pages: | [observabilityLogsExplorer](https://github.com/elastic/kibana/blob/main/x-pack/solutions/observability/plugins/observability_logs_explorer/README.md) | This plugin provides an app based on the LogsExplorer component from the logs_explorer plugin, but adds observability-specific affordances. | | [observabilityOnboarding](https://github.com/elastic/kibana/blob/main/x-pack/solutions/observability/plugins/observability_onboarding/README.md) | This plugin provides an onboarding framework for observability solutions: Logs and APM. | | [observabilityShared](https://github.com/elastic/kibana/blob/main/x-pack/solutions/observability/plugins/observability_shared/README.md) | A plugin that contains components and utilities shared by all Observability plugins. | +| [onechat](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/onechat/README.md) | Home of the workchat framework. | | [osquery](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/osquery/README.md) | This plugin adds extended support to Security Solution Fleet Osquery integration | | [painlessLab](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/private/painless_lab/README.md) | This plugin helps users learn how to use the Painless scripting language. | | [productDocBase](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/ai_infra/product_doc_base/README.md) | This plugin contains the product documentation base service. | diff --git a/package.json b/package.json index c33512a92f32..eb4cdf309b27 100644 --- a/package.json +++ b/package.json @@ -726,6 +726,10 @@ "@kbn/observability-utils-common": "link:x-pack/solutions/observability/packages/utils-common", "@kbn/observability-utils-server": "link:x-pack/solutions/observability/packages/utils-server", "@kbn/oidc-provider-plugin": "link:x-pack/test/security_api_integration/plugins/oidc_provider", + "@kbn/onechat-browser": "link:x-pack/platform/packages/shared/onechat/onechat-browser", + "@kbn/onechat-common": "link:x-pack/platform/packages/shared/onechat/onechat-common", + "@kbn/onechat-plugin": "link:x-pack/platform/plugins/shared/onechat", + "@kbn/onechat-server": "link:x-pack/platform/packages/shared/onechat/onechat-server", "@kbn/open-telemetry-instrumented-plugin": "link:src/platform/test/common/plugins/otel_metrics", "@kbn/openapi-common": "link:src/platform/packages/shared/kbn-openapi-common", "@kbn/osquery-io-ts-types": "link:src/platform/packages/shared/kbn-osquery-io-ts-types", diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 928d6c6cdee3..8c0867ca70c2 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -113,6 +113,7 @@ pageLoadAssetSize: observabilityLogsExplorer: 46650 observabilityOnboarding: 19573 observabilityShared: 111036 + onechat: 25000 osquery: 107090 painlessLab: 179748 presentationPanel: 11550 diff --git a/tsconfig.base.json b/tsconfig.base.json index 43afeabf586d..8e9bb03e987b 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1380,6 +1380,14 @@ "@kbn/observability-utils-server/*": ["x-pack/solutions/observability/packages/utils-server/*"], "@kbn/oidc-provider-plugin": ["x-pack/test/security_api_integration/plugins/oidc_provider"], "@kbn/oidc-provider-plugin/*": ["x-pack/test/security_api_integration/plugins/oidc_provider/*"], + "@kbn/onechat-browser": ["x-pack/platform/packages/shared/onechat/onechat-browser"], + "@kbn/onechat-browser/*": ["x-pack/platform/packages/shared/onechat/onechat-browser/*"], + "@kbn/onechat-common": ["x-pack/platform/packages/shared/onechat/onechat-common"], + "@kbn/onechat-common/*": ["x-pack/platform/packages/shared/onechat/onechat-common/*"], + "@kbn/onechat-plugin": ["x-pack/platform/plugins/shared/onechat"], + "@kbn/onechat-plugin/*": ["x-pack/platform/plugins/shared/onechat/*"], + "@kbn/onechat-server": ["x-pack/platform/packages/shared/onechat/onechat-server"], + "@kbn/onechat-server/*": ["x-pack/platform/packages/shared/onechat/onechat-server/*"], "@kbn/open-telemetry-instrumented-plugin": ["src/platform/test/common/plugins/otel_metrics"], "@kbn/open-telemetry-instrumented-plugin/*": ["src/platform/test/common/plugins/otel_metrics/*"], "@kbn/openapi-bundler": ["src/platform/packages/shared/kbn-openapi-bundler"], diff --git a/x-pack/platform/packages/shared/onechat/onechat-browser/README.md b/x-pack/platform/packages/shared/onechat/onechat-browser/README.md new file mode 100644 index 000000000000..8d497b2a73be --- /dev/null +++ b/x-pack/platform/packages/shared/onechat/onechat-browser/README.md @@ -0,0 +1,3 @@ +# @kbn/onechat-browser + +Browser-side types and utilities for the onechat framework. diff --git a/x-pack/platform/packages/shared/onechat/onechat-browser/index.ts b/x-pack/platform/packages/shared/onechat/onechat-browser/index.ts new file mode 100644 index 000000000000..d76fbca6ecda --- /dev/null +++ b/x-pack/platform/packages/shared/onechat/onechat-browser/index.ts @@ -0,0 +1,10 @@ +/* + * 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. + */ + +export function onechat() { + return 'You know...'; +} diff --git a/x-pack/platform/packages/shared/onechat/onechat-browser/jest.config.js b/x-pack/platform/packages/shared/onechat/onechat-browser/jest.config.js new file mode 100644 index 000000000000..e8f95e94ed11 --- /dev/null +++ b/x-pack/platform/packages/shared/onechat/onechat-browser/jest.config.js @@ -0,0 +1,12 @@ +/* + * 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. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../../../..', + roots: ['/x-pack/platform/packages/shared/onechat/onechat-browser'], +}; diff --git a/x-pack/platform/packages/shared/onechat/onechat-browser/kibana.jsonc b/x-pack/platform/packages/shared/onechat/onechat-browser/kibana.jsonc new file mode 100644 index 000000000000..6cb6082e6b6f --- /dev/null +++ b/x-pack/platform/packages/shared/onechat/onechat-browser/kibana.jsonc @@ -0,0 +1,7 @@ +{ + "type": "shared-common", + "id": "@kbn/onechat-browser", + "owner": "@elastic/workchat-eng", + "group": "platform", + "visibility": "shared" +} diff --git a/x-pack/platform/packages/shared/onechat/onechat-browser/package.json b/x-pack/platform/packages/shared/onechat/onechat-browser/package.json new file mode 100644 index 000000000000..79903cb94c1e --- /dev/null +++ b/x-pack/platform/packages/shared/onechat/onechat-browser/package.json @@ -0,0 +1,7 @@ +{ + "name": "@kbn/onechat-browser", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0", + "sideEffects": false +} \ No newline at end of file diff --git a/x-pack/platform/packages/shared/onechat/onechat-browser/tsconfig.json b/x-pack/platform/packages/shared/onechat/onechat-browser/tsconfig.json new file mode 100644 index 000000000000..18d16ae2e883 --- /dev/null +++ b/x-pack/platform/packages/shared/onechat/onechat-browser/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node", + "react" + ] + }, + "include": [ + "**/*.ts", + "**/*.tsx", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [] +} diff --git a/x-pack/platform/packages/shared/onechat/onechat-common/README.md b/x-pack/platform/packages/shared/onechat/onechat-common/README.md new file mode 100644 index 000000000000..6e2463551645 --- /dev/null +++ b/x-pack/platform/packages/shared/onechat/onechat-common/README.md @@ -0,0 +1,23 @@ +# @kbn/onechat-common + +Common (server+browser) types and utilities for the onechat framework. + +## Tool identifier utilities + +The package exposes various helper function and typeguard to manipulate and +convert tool identifiers + +- `isSerializedToolIdentifier` +- `isStructuredToolIdentifier` +- `isPlainToolIdentifier` +- `toStructuredToolIdentifier` +- `toSerializedToolIdentifier` +- `createBuiltinToolId` + +## Error utilities + +The package exposes type guards for all onechat error types + +- `isOnechatError` +- `isToolNotFoundError` +- `isInternalError` \ No newline at end of file diff --git a/x-pack/platform/packages/shared/onechat/onechat-common/index.ts b/x-pack/platform/packages/shared/onechat/onechat-common/index.ts new file mode 100644 index 000000000000..a7e6f2d713db --- /dev/null +++ b/x-pack/platform/packages/shared/onechat/onechat-common/index.ts @@ -0,0 +1,36 @@ +/* + * 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. + */ + +export type { OnechatEvent } from './src/events'; +export { + type ToolDescriptor, + type ToolDescriptorMeta, + type PlainIdToolIdentifier, + type SerializedToolIdentifier, + type StructuredToolIdentifier, + type ToolIdentifier, + ToolSourceType, + isSerializedToolIdentifier, + isStructuredToolIdentifier, + isPlainToolIdentifier, + toStructuredToolIdentifier, + toSerializedToolIdentifier, + createBuiltinToolId, + builtinSourceId, +} from './src/tools'; +export { + OnechatErrorCode, + OnechatErrorUtils, + isInternalError, + isToolNotFoundError, + isOnechatError, + createInternalError, + createToolNotFoundError, + type OnechatError, + type OnechatInternalError, + type OnechatToolNotFoundError, +} from './src/errors'; diff --git a/x-pack/platform/packages/shared/onechat/onechat-common/jest.config.js b/x-pack/platform/packages/shared/onechat/onechat-common/jest.config.js new file mode 100644 index 000000000000..6b9c7fa6dd9c --- /dev/null +++ b/x-pack/platform/packages/shared/onechat/onechat-common/jest.config.js @@ -0,0 +1,12 @@ +/* + * 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. + */ + +module.exports = { + preset: '@kbn/test/jest_node', + rootDir: '../../../../../..', + roots: ['/x-pack/platform/packages/shared/onechat/onechat-common'], +}; diff --git a/x-pack/platform/packages/shared/onechat/onechat-common/kibana.jsonc b/x-pack/platform/packages/shared/onechat/onechat-common/kibana.jsonc new file mode 100644 index 000000000000..359ae7d28155 --- /dev/null +++ b/x-pack/platform/packages/shared/onechat/onechat-common/kibana.jsonc @@ -0,0 +1,7 @@ +{ + "type": "shared-common", + "id": "@kbn/onechat-common", + "owner": "@elastic/workchat-eng", + "group": "platform", + "visibility": "shared" +} diff --git a/x-pack/platform/packages/shared/onechat/onechat-common/package.json b/x-pack/platform/packages/shared/onechat/onechat-common/package.json new file mode 100644 index 000000000000..b1fd80bfce50 --- /dev/null +++ b/x-pack/platform/packages/shared/onechat/onechat-common/package.json @@ -0,0 +1,7 @@ +{ + "name": "@kbn/onechat-common", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0", + "sideEffects": false +} \ No newline at end of file diff --git a/x-pack/platform/packages/shared/onechat/onechat-common/src/errors.test.ts b/x-pack/platform/packages/shared/onechat/onechat-common/src/errors.test.ts new file mode 100644 index 000000000000..402b2cad8ec1 --- /dev/null +++ b/x-pack/platform/packages/shared/onechat/onechat-common/src/errors.test.ts @@ -0,0 +1,114 @@ +/* + * 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 { + isOnechatError, + createInternalError, + createToolNotFoundError, + isInternalError, + isToolNotFoundError, + OnechatErrorCode, +} from './errors'; +import { toSerializedToolIdentifier } from './tools'; + +describe('Onechat errors', () => { + describe('isOnechatError', () => { + it('should return true for a OnechatError instance', () => { + const error = createInternalError('test error'); + expect(isOnechatError(error)).toBe(true); + }); + + it('should return false for a regular Error', () => { + const error = new Error('test error'); + expect(isOnechatError(error)).toBe(false); + }); + + it('should return false for non-error values', () => { + expect(isOnechatError(null)).toBe(false); + expect(isOnechatError(undefined)).toBe(false); + expect(isOnechatError('string')).toBe(false); + expect(isOnechatError({})).toBe(false); + }); + }); + + describe('isInternalError', () => { + it('should return true for an internal error', () => { + const error = createInternalError('test error'); + expect(isInternalError(error)).toBe(true); + }); + + it('should return false for a tool not found error', () => { + const error = createToolNotFoundError({ toolId: toSerializedToolIdentifier('test-tool') }); + expect(isInternalError(error)).toBe(false); + }); + + it('should return false for a regular Error', () => { + const error = new Error('test error'); + expect(isInternalError(error)).toBe(false); + }); + }); + + describe('isToolNotFoundError', () => { + it('should return true for a tool not found error', () => { + const error = createToolNotFoundError({ toolId: toSerializedToolIdentifier('test-tool') }); + expect(isToolNotFoundError(error)).toBe(true); + }); + + it('should return false for an internal error', () => { + const error = createInternalError('test error'); + expect(isToolNotFoundError(error)).toBe(false); + }); + + it('should return false for a regular Error', () => { + const error = new Error('test error'); + expect(isToolNotFoundError(error)).toBe(false); + }); + }); + + describe('createInternalError', () => { + it('should create an error with the correct code and message', () => { + const error = createInternalError('test error'); + expect(error.code).toBe(OnechatErrorCode.internalError); + expect(error.message).toBe('test error'); + }); + + it('should include optional metadata', () => { + const meta = { foo: 'bar' }; + const error = createInternalError('test error', meta); + expect(error.meta).toEqual(meta); + }); + + it('should use empty object as default metadata', () => { + const error = createInternalError('test error'); + expect(error.meta).toEqual({}); + }); + }); + + describe('createToolNotFoundError', () => { + it('should create an error with the correct code and default message', () => { + const error = createToolNotFoundError({ toolId: toSerializedToolIdentifier('test-tool') }); + expect(error.code).toBe(OnechatErrorCode.toolNotFound); + expect(error.message).toBe(`Tool ${toSerializedToolIdentifier('test-tool')} not found`); + expect(error.meta).toEqual({ + toolId: toSerializedToolIdentifier('test-tool'), + statusCode: 404, + }); + }); + + it('should use custom message when provided', () => { + const error = createToolNotFoundError({ + toolId: toSerializedToolIdentifier('test-tool'), + customMessage: 'Custom error message', + }); + expect(error.message).toBe('Custom error message'); + expect(error.meta).toEqual({ + toolId: toSerializedToolIdentifier('test-tool'), + statusCode: 404, + }); + }); + }); +}); diff --git a/x-pack/platform/packages/shared/onechat/onechat-common/src/errors.ts b/x-pack/platform/packages/shared/onechat/onechat-common/src/errors.ts new file mode 100644 index 000000000000..3faa2da5fefa --- /dev/null +++ b/x-pack/platform/packages/shared/onechat/onechat-common/src/errors.ts @@ -0,0 +1,89 @@ +/* + * 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 { ServerSentEventError } from '@kbn/sse-utils'; +import type { SerializedToolIdentifier } from './tools'; + +/** + * Code to identify onechat errors + */ +export enum OnechatErrorCode { + internalError = 'internalError', + toolNotFound = 'toolNotFound', +} + +const OnechatError = ServerSentEventError; + +/** + * Base error class used for all onechat errors. + */ +export type OnechatError< + TCode extends OnechatErrorCode, + TMeta extends Record = Record +> = ServerSentEventError; + +export const isOnechatError = (err: unknown): err is OnechatError => { + return err instanceof OnechatError; +}; + +/** + * Represents an internal error + */ +export type OnechatInternalError = OnechatError; + +/** + * Checks if the given error is a {@link OnechatInternalError} + */ +export const isInternalError = (err: unknown): err is OnechatInternalError => { + return isOnechatError(err) && err.code === OnechatErrorCode.internalError; +}; + +export const createInternalError = ( + message: string, + meta?: Record +): OnechatInternalError => { + return new OnechatError(OnechatErrorCode.internalError, message, meta ?? {}); +}; + +/** + * Error thrown when trying to retrieve or execute a tool not present or available in the current context. + */ +export type OnechatToolNotFoundError = OnechatError; + +/** + * Checks if the given error is a {@link OnechatInternalError} + */ +export const isToolNotFoundError = (err: unknown): err is OnechatInternalError => { + return isOnechatError(err) && err.code === OnechatErrorCode.toolNotFound; +}; + +export const createToolNotFoundError = ({ + toolId, + customMessage, + meta = {}, +}: { + toolId: SerializedToolIdentifier; + customMessage?: string; + meta?: Record; +}): OnechatToolNotFoundError => { + return new OnechatError( + OnechatErrorCode.toolNotFound, + customMessage ?? `Tool ${toolId} not found`, + { ...meta, toolId, statusCode: 404 } + ); +}; + +/** + * Global utility exposing all error utilities from a single export. + */ +export const OnechatErrorUtils = { + isOnechatError, + isInternalError, + isToolNotFoundError, + createInternalError, + createToolNotFoundError, +}; diff --git a/x-pack/platform/packages/shared/onechat/onechat-common/src/events.ts b/x-pack/platform/packages/shared/onechat/onechat-common/src/events.ts new file mode 100644 index 000000000000..5205bd65d0e4 --- /dev/null +++ b/x-pack/platform/packages/shared/onechat/onechat-common/src/events.ts @@ -0,0 +1,28 @@ +/* + * 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. + */ + +/** + * Base type for all onechat events + */ +export interface OnechatEvent< + TEventType extends string, + TData extends Record, + TMeta extends Record +> { + /** + * Unique type identifier for the event. + */ + type: TEventType; + /** + * Data bound to this event. + */ + data: TData; + /** + * Metadata bound to this event. + */ + meta: TMeta; +} diff --git a/x-pack/platform/packages/shared/onechat/onechat-common/src/tools.test.ts b/x-pack/platform/packages/shared/onechat/onechat-common/src/tools.test.ts new file mode 100644 index 000000000000..7f1c0f0f339a --- /dev/null +++ b/x-pack/platform/packages/shared/onechat/onechat-common/src/tools.test.ts @@ -0,0 +1,150 @@ +/* + * 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 { + isPlainToolIdentifier, + isStructuredToolIdentifier, + isSerializedToolIdentifier, + createBuiltinToolId, + toStructuredToolIdentifier, + toSerializedToolIdentifier, + ToolSourceType, + StructuredToolIdentifier, +} from './tools'; + +describe('Tool Identifier utilities', () => { + describe('isPlainToolIdentifier', () => { + it('should return true for a plain string identifier', () => { + expect(isPlainToolIdentifier('my-tool')).toBe(true); + }); + + it('should return false for an unstructured identifier', () => { + expect(isPlainToolIdentifier('my-tool||builtIn||none')).toBe(false); + }); + + it('should return false for a structured identifier', () => { + expect( + isPlainToolIdentifier({ + toolId: 'my-tool', + sourceType: 'builtIn' as ToolSourceType, + sourceId: 'none', + }) + ).toBe(false); + }); + }); + + describe('isStructuredToolIdentifier', () => { + it('should return true for a valid structured identifier', () => { + const structuredId: StructuredToolIdentifier = { + toolId: 'my-tool', + sourceType: ToolSourceType.builtIn, + sourceId: 'none', + }; + expect(isStructuredToolIdentifier(structuredId)).toBe(true); + }); + + it('should return false for a plain string identifier', () => { + expect(isStructuredToolIdentifier('my-tool')).toBe(false); + }); + + it('should return false for an unstructured identifier', () => { + expect(isStructuredToolIdentifier('my-tool||builtIn||none')).toBe(false); + }); + }); + + describe('isUnstructuredToolIdentifier', () => { + it('should return true for a valid unstructured identifier', () => { + expect(isSerializedToolIdentifier('my-tool||builtIn||none')).toBe(true); + }); + + it('should return false for a plain string identifier', () => { + expect(isSerializedToolIdentifier('my-tool')).toBe(false); + }); + + it('should return false for a structured identifier', () => { + expect( + isSerializedToolIdentifier({ + toolId: 'my-tool', + sourceType: ToolSourceType.builtIn, + sourceId: 'none', + }) + ).toBe(false); + }); + }); + + describe('builtinToolId', () => { + it('should create a structured identifier for a builtIn tool', () => { + const result = createBuiltinToolId('my-tool'); + expect(result).toEqual({ + toolId: 'my-tool', + sourceType: ToolSourceType.builtIn, + sourceId: 'builtIn', + }); + }); + }); + + describe('toSerializedToolIdentifier', () => { + it('should return the same string for a serialized identifier', () => { + const serializedId = 'my-tool||builtIn||none'; + expect(toSerializedToolIdentifier(serializedId)).toBe(serializedId); + }); + + it('should convert a structured identifier to serialized format', () => { + const structuredId = { + toolId: 'my-tool', + sourceType: ToolSourceType.builtIn, + sourceId: 'none', + }; + expect(toSerializedToolIdentifier(structuredId)).toBe('my-tool||builtIn||none'); + }); + + it('should convert a plain identifier to serialized format with unknown source', () => { + expect(toSerializedToolIdentifier('my-tool')).toBe('my-tool||unknown||unknown'); + }); + + it('should throw an error for malformed identifiers', () => { + expect(() => toSerializedToolIdentifier('invalid||format')).toThrow( + 'Malformed tool identifier' + ); + }); + }); + + describe('toStructuredToolIdentifier', () => { + it('should return the same object for a structured identifier', () => { + const structuredId = { + toolId: 'my-tool', + sourceType: ToolSourceType.builtIn, + sourceId: 'none', + }; + expect(toStructuredToolIdentifier(structuredId)).toEqual(structuredId); + }); + + it('should convert an unstructured identifier to structured', () => { + const result = toStructuredToolIdentifier('my-tool||builtIn||none'); + expect(result).toEqual({ + toolId: 'my-tool', + sourceType: ToolSourceType.builtIn, + sourceId: 'none', + }); + }); + + it('should convert a plain identifier to structured', () => { + const result = toStructuredToolIdentifier('my-tool'); + expect(result).toEqual({ + toolId: 'my-tool', + sourceType: ToolSourceType.unknown, + sourceId: 'unknown', + }); + }); + + it('should throw an error for malformed identifiers', () => { + expect(() => toStructuredToolIdentifier('invalid||format')).toThrow( + 'Malformed tool identifier' + ); + }); + }); +}); diff --git a/x-pack/platform/packages/shared/onechat/onechat-common/src/tools.ts b/x-pack/platform/packages/shared/onechat/onechat-common/src/tools.ts new file mode 100644 index 000000000000..9fc814ea1ec4 --- /dev/null +++ b/x-pack/platform/packages/shared/onechat/onechat-common/src/tools.ts @@ -0,0 +1,196 @@ +/* + * 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 { createInternalError } from './errors'; + +/** + * Represents a plain toolId, without source information attached to it. + */ +export type PlainIdToolIdentifier = string; + +/** + * Represents the source type for a tool. + */ +export enum ToolSourceType { + /** + * Source used for built-in tools + */ + builtIn = 'builtIn', + /** + * Unknown source - used when converting plain ids to structured or serialized format. + */ + unknown = 'unknown', +} + +/** + * Structured representation of a tool identifier. + */ +export interface StructuredToolIdentifier { + /** The unique ID of this tool, relative to the source **/ + toolId: string; + /** The type of source the tool is being provided from, e.g. builtIn or MCP **/ + sourceType: ToolSourceType; + /** Id of the source, e.g. for MCP server it will be the server/connector ID */ + sourceId: string; +} + +export const serializedPartsSeparator = '||'; + +/** + * The singleton sourceId used for all builtIn tools. + */ +export const builtinSourceId = 'builtIn'; +/** + * Unknown sourceId used from converting plain Ids to structured or serialized ids. + */ +export const unknownSourceId = 'unknown'; + +/** + * Build a structured tool identifier for given builtin tool ID. + */ +export const createBuiltinToolId = (plainId: PlainIdToolIdentifier): StructuredToolIdentifier => { + return { + toolId: plainId, + sourceType: ToolSourceType.builtIn, + sourceId: builtinSourceId, + }; +}; + +/** + * String representation of {@link StructuredToolIdentifier} + * Follow a `{toolId}||{sourceType}||{sourceId}` format. + */ +export type SerializedToolIdentifier = `${PlainIdToolIdentifier}||${ToolSourceType}||${string}`; + +/** + * Defines all possible shapes for a tool identifier. + */ +export type ToolIdentifier = + | PlainIdToolIdentifier + | StructuredToolIdentifier + | SerializedToolIdentifier; + +/** + * Check if the given {@link ToolIdentifier} is a {@link SerializedToolIdentifier} + */ +export const isSerializedToolIdentifier = ( + identifier: ToolIdentifier +): identifier is SerializedToolIdentifier => { + return typeof identifier === 'string' && identifier.split(serializedPartsSeparator).length === 3; +}; + +/** + * Check if the given {@link ToolIdentifier} is a {@link StructuredToolIdentifier} + */ +export const isStructuredToolIdentifier = ( + identifier: ToolIdentifier +): identifier is StructuredToolIdentifier => { + return typeof identifier === 'object' && 'toolId' in identifier && 'sourceType' in identifier; +}; + +/** + * Check if the given {@link ToolIdentifier} is a {@link PlainIdToolIdentifier} + */ +export const isPlainToolIdentifier = ( + identifier: ToolIdentifier +): identifier is PlainIdToolIdentifier => { + return typeof identifier === 'string' && identifier.split(serializedPartsSeparator).length === 1; +}; + +/** + * Convert the given {@link ToolIdentifier} to a {@link SerializedToolIdentifier} + */ +export const toSerializedToolIdentifier = ( + identifier: ToolIdentifier +): SerializedToolIdentifier => { + if (isSerializedToolIdentifier(identifier)) { + return identifier; + } + if (isStructuredToolIdentifier(identifier)) { + return `${identifier.toolId}||${identifier.sourceType}||${identifier.sourceId}`; + } + if (isPlainToolIdentifier(identifier)) { + return `${identifier}||${ToolSourceType.unknown}||${unknownSourceId}`; + } + + throw createInternalError(`Malformed tool identifier: "${identifier}"`); +}; + +/** + * Convert the given {@link ToolIdentifier} to a {@link StructuredToolIdentifier} + */ +export const toStructuredToolIdentifier = ( + identifier: ToolIdentifier +): StructuredToolIdentifier => { + if (isStructuredToolIdentifier(identifier)) { + return identifier; + } + if (isSerializedToolIdentifier(identifier)) { + const [toolId, sourceType, sourceId] = identifier.split(serializedPartsSeparator); + return { + toolId, + sourceType: sourceType as ToolSourceType, + sourceId, + }; + } + if (isPlainToolIdentifier(identifier)) { + return { + toolId: identifier, + sourceType: ToolSourceType.unknown, + sourceId: unknownSourceId, + }; + } + + throw createInternalError(`Malformed tool identifier: "${identifier}"`); +}; + +/** + * Serializable representation of a tool, without its handler or schema. + * + * Use as a common base for browser-side and server-side tool types. + */ +export interface ToolDescriptor { + /** + * A unique id for this tool. + */ + id: string; + /** + * Name of the tool, which will be exposed to the LLM. + */ + name: string; + /** + * The description for this tool, which will be exposed to the LLM. + */ + description: string; + /** + * Meta associated with this tool + */ + meta: ToolDescriptorMeta; +} + +/** + * Metadata associated with a tool. + * + * Some of them are specified by the tool owner during registration, + * others are automatically added by the framework. + */ +export interface ToolDescriptorMeta { + /** + * The type of the source this tool is provided by. + */ + sourceType: ToolSourceType; + /** + * The id of the source this tool is provided by. + * E.g. for MCP source, this will be the ID of the MCP connector. + */ + sourceId: string; + /** + * Optional list of tags attached to this tool. + * For built-in tools, this is specified during registration. + */ + tags: string[]; +} diff --git a/x-pack/platform/packages/shared/onechat/onechat-common/tsconfig.json b/x-pack/platform/packages/shared/onechat/onechat-common/tsconfig.json new file mode 100644 index 000000000000..dcab24f623f1 --- /dev/null +++ b/x-pack/platform/packages/shared/onechat/onechat-common/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node" + ] + }, + "include": [ + "**/*.ts", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [ + "@kbn/sse-utils", + ] +} diff --git a/x-pack/platform/packages/shared/onechat/onechat-server/README.md b/x-pack/platform/packages/shared/onechat/onechat-server/README.md new file mode 100644 index 000000000000..09bb02ea3460 --- /dev/null +++ b/x-pack/platform/packages/shared/onechat/onechat-server/README.md @@ -0,0 +1,3 @@ +# @kbn/onechat-server + +Server-side types and utilities for the onechat framework. diff --git a/x-pack/platform/packages/shared/onechat/onechat-server/index.ts b/x-pack/platform/packages/shared/onechat/onechat-server/index.ts new file mode 100644 index 000000000000..0194e807cb12 --- /dev/null +++ b/x-pack/platform/packages/shared/onechat/onechat-server/index.ts @@ -0,0 +1,46 @@ +/* + * 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. + */ + +export type { + RegisteredTool, + ToolHandlerFn, + ToolHandlerContext, + ToolProvider, + ToolProviderHasOptions, + ToolProviderGetOptions, + ToolProviderListOptions, + ExecutableTool, + ExecutableToolHandlerParams, + ExecutableToolHandlerFn, +} from './src/tools'; +export type { ModelProvider, ScopedModel } from './src/model_provider'; +export type { + ScopedRunner, + ScopedRunToolFn, + ScopedRunnerRunToolsParams, + RunContext, + RunContextStackEntry, + RunToolParams, + RunToolFn, + Runner, + RunToolReturn, +} from './src/runner'; +export { + OnechatRunEventType, + isToolResponseEvent, + isToolCallEvent, + type OnechatRunEvent, + type RunEventHandlerFn, + type ToolResponseEventData, + type RunEventEmitter, + type RunEventEmitterFn, + type InternalRunEvent, + type OnechatRunEventMeta, + type ToolCallEventData, + type ToolResponseEvent, + type ToolCallEvent, +} from './src/events'; diff --git a/x-pack/platform/packages/shared/onechat/onechat-server/jest.config.js b/x-pack/platform/packages/shared/onechat/onechat-server/jest.config.js new file mode 100644 index 000000000000..dfe71a327841 --- /dev/null +++ b/x-pack/platform/packages/shared/onechat/onechat-server/jest.config.js @@ -0,0 +1,12 @@ +/* + * 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. + */ + +module.exports = { + preset: '@kbn/test/jest_node', + rootDir: '../../../../../..', + roots: ['/x-pack/platform/packages/shared/onechat/onechat-server'], +}; diff --git a/x-pack/platform/packages/shared/onechat/onechat-server/kibana.jsonc b/x-pack/platform/packages/shared/onechat/onechat-server/kibana.jsonc new file mode 100644 index 000000000000..e0dba85bfb57 --- /dev/null +++ b/x-pack/platform/packages/shared/onechat/onechat-server/kibana.jsonc @@ -0,0 +1,7 @@ +{ + "type": "shared-common", + "id": "@kbn/onechat-server", + "owner": "@elastic/workchat-eng", + "group": "platform", + "visibility": "shared" +} diff --git a/x-pack/platform/packages/shared/onechat/onechat-server/package.json b/x-pack/platform/packages/shared/onechat/onechat-server/package.json new file mode 100644 index 000000000000..2e0c08eaf330 --- /dev/null +++ b/x-pack/platform/packages/shared/onechat/onechat-server/package.json @@ -0,0 +1,7 @@ +{ + "name": "@kbn/onechat-server", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0", + "sideEffects": false +} \ No newline at end of file diff --git a/x-pack/platform/packages/shared/onechat/onechat-server/src/events.ts b/x-pack/platform/packages/shared/onechat/onechat-server/src/events.ts new file mode 100644 index 000000000000..9e02311597a5 --- /dev/null +++ b/x-pack/platform/packages/shared/onechat/onechat-server/src/events.ts @@ -0,0 +1,92 @@ +/* + * 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 { OnechatEvent } from '@kbn/onechat-common'; +import type { RunContextStackEntry } from './runner'; + +export enum OnechatRunEventType { + toolCall = 'toolCall', + toolResponse = 'toolResponse', +} + +/** + * Base set of meta for all onechat run events. + */ +export interface OnechatRunEventMeta { + /** + * Current runId + */ + runId: string; + /** + * Execution stack + */ + stack: RunContextStackEntry[]; +} + +/** + * Public-facing events, as received by the API consumer. + */ +export type OnechatRunEvent< + TEventType extends string = string, + TData extends Record = Record, + TMeta extends OnechatRunEventMeta = OnechatRunEventMeta +> = OnechatEvent; + +/** + * Internal-facing events, as emitted by tool or agent owners. + */ +export type InternalRunEvent< + TEventType extends string = string, + TData extends Record = Record, + TMeta extends Record = Record +> = Omit, 'meta'> & { + meta?: TMeta; +}; +/** + * Event handler function to listen to run events during execution of tools, agents or other onechat primitives. + */ +export type RunEventHandlerFn = (event: OnechatRunEvent) => void; + +/** + * Event emitter function, exposed from tool or agent runnable context. + */ +export type RunEventEmitterFn = (event: InternalRunEvent) => void; + +export interface RunEventEmitter { + emit: RunEventEmitterFn; +} + +// toolCall + +export type ToolCallEvent = OnechatRunEvent; + +export interface ToolCallEventData { + toolId: string; + toolParams: Record; +} + +export const isToolCallEvent = (event: OnechatEvent): event is ToolCallEvent => { + return event.type === OnechatRunEventType.toolCall; +}; + +// toolResponse + +export type ToolResponseEvent = OnechatRunEvent< + OnechatRunEventType.toolResponse, + ToolResponseEventData +>; + +export interface ToolResponseEventData { + toolId: string; + toolResult: unknown; +} + +export const isToolResponseEvent = ( + event: OnechatEvent +): event is ToolResponseEvent => { + return event.type === OnechatRunEventType.toolResponse; +}; diff --git a/x-pack/platform/packages/shared/onechat/onechat-server/src/model_provider.ts b/x-pack/platform/packages/shared/onechat/onechat-server/src/model_provider.ts new file mode 100644 index 000000000000..655045127340 --- /dev/null +++ b/x-pack/platform/packages/shared/onechat/onechat-server/src/model_provider.ts @@ -0,0 +1,45 @@ +/* + * 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 { InferenceChatModel } from '@kbn/inference-langchain'; +import type { BoundInferenceClient } from '@kbn/inference-plugin/server'; + +/** + * Represents a model that can be used within the onechat framework (e.g. tools). + * + * It exposes different interfaces to models. + */ +export interface ScopedModel { + /** + * langchain chat model. + */ + chatModel: InferenceChatModel; + /** + * Inference client. + */ + inferenceClient: BoundInferenceClient; +} + +/** + * Provider, allowing to select various models depending on the needs. + */ +export interface ModelProvider { + /** + * Returns the default model to be used for LLM tasks. + * + * Will use Elasticsearch LLMs by default if present, otherwise will pick + * the first GenAI compatible connector. + */ + getDefaultModel: () => Promise; + /** + * Returns a model using the given connectorId. + * + * Will throw if connector doesn't exist, user has no access, or connector + * is not a GenAI connector. + */ + getModel: (options: { connectorId: string }) => Promise; +} diff --git a/x-pack/platform/packages/shared/onechat/onechat-server/src/runner.ts b/x-pack/platform/packages/shared/onechat/onechat-server/src/runner.ts new file mode 100644 index 000000000000..5159f100123a --- /dev/null +++ b/x-pack/platform/packages/shared/onechat/onechat-server/src/runner.ts @@ -0,0 +1,123 @@ +/* + * 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 { KibanaRequest } from '@kbn/core-http-server'; +import type { ToolIdentifier, SerializedToolIdentifier } from '@kbn/onechat-common'; +import type { RunEventHandlerFn } from './events'; + +/** + * Return type for tool invocation APIs. + * + * Wrapping the plain result to allow extending the shape later without + * introducing breaking changes. + */ +export interface RunToolReturn { + /** + * The result value as returned by the tool. + */ + result: TResult; +} + +/** + * Represents a runner, which is the entry point to execute all onechat primitives, + * such as tools or agents. + * + * This version is pre-scoped to a given request, meaning APIs don't need to be passed + * down a request object. + */ +export interface ScopedRunner { + /** + * Execute a tool. + */ + runTool: ScopedRunToolFn; +} + +/** + * Public onechat API to execute a tools. + */ +export type ScopedRunToolFn = , TResult = unknown>( + params: ScopedRunnerRunToolsParams +) => Promise>; + +/** + * Context bound to a run execution. + * Contains metadata associated with the run's current state. + * Will be attached to errors thrown during a run. + */ +export interface RunContext { + /** + * The run identifier, which can be used for tracing + */ + runId: string; + /** + * The current execution stack + */ + stack: RunContextStackEntry[]; +} + +/** + * Represents an element in the run context's stack. + * Used to follow nested / chained execution. + */ +export type RunContextStackEntry = + /** tool invocation */ + | { type: 'tool'; toolId: SerializedToolIdentifier } + /** agent invocation */ + | { type: 'agent'; agentId: string }; + +/** + * Params for {@link RunToolFn} + */ +export interface RunToolParams> { + /** + * ID of the tool to call. + */ + toolId: ToolIdentifier; + /** + * Parameters to call the tool with. + */ + toolParams: TParams; + /** + * Optional event handler. + */ + onEvent?: RunEventHandlerFn; + /** + * The request that initiated that run. + */ + request: KibanaRequest; + /** + * Optional genAI connector id to use as default. + * If unspecified, will use internal logic to use the default connector + * (EIS if there, otherwise openAI, otherwise any GenAI) + */ + defaultConnectorId?: string; +} + +/** + * Params for {@link ScopedRunner.runTool} + */ +export type ScopedRunnerRunToolsParams> = Omit< + RunToolParams, + 'request' +>; + +/** + * Public onechat API to execute a tools. + */ +export type RunToolFn = , TResult = unknown>( + params: RunToolParams +) => Promise>; + +/** + * Public onechat runner. + */ +export interface Runner { + /** + * Execute a tool. + */ + runTool: RunToolFn; +} diff --git a/x-pack/platform/packages/shared/onechat/onechat-server/src/tools.ts b/x-pack/platform/packages/shared/onechat/onechat-server/src/tools.ts new file mode 100644 index 000000000000..c3b70fb3ade0 --- /dev/null +++ b/x-pack/platform/packages/shared/onechat/onechat-server/src/tools.ts @@ -0,0 +1,154 @@ +/* + * 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 { z, ZodObject } from '@kbn/zod'; +import type { MaybePromise } from '@kbn/utility-types'; +import type { IScopedClusterClient } from '@kbn/core-elasticsearch-server'; +import type { KibanaRequest } from '@kbn/core-http-server'; +import type { ToolDescriptor, ToolDescriptorMeta, ToolIdentifier } from '@kbn/onechat-common'; +import type { ModelProvider } from './model_provider'; +import type { ScopedRunner, RunToolReturn, ScopedRunnerRunToolsParams } from './runner'; +import type { RunEventEmitter } from './events'; + +/** + * Subset of {@link ToolDescriptorMeta} that can be defined during tool registration. + */ +export type RegisteredToolMeta = Partial>; + +/** + * Onechat tool, as registered by built-in tool providers. + */ +export interface RegisteredTool< + RunInput extends ZodObject = ZodObject, + RunOutput = unknown +> extends Omit { + /** + * Tool's input schema, defined as a zod schema. + */ + schema: RunInput; + /** + * Handler to call to execute the tool. + */ + handler: ToolHandlerFn, RunOutput>; + /** + * Optional set of metadata for this tool. + */ + meta?: RegisteredToolMeta; +} + +/** + * Onechat tool, as exposed by the onechat tool registry. + */ +export interface ExecutableTool< + RunInput extends ZodObject = ZodObject, + RunOutput = unknown +> extends ToolDescriptor { + /** + * Tool's input schema, defined as a zod schema. + */ + schema: RunInput; + /** + * Run handler that can be used to execute the tool. + */ + execute: ExecutableToolHandlerFn, RunOutput>; +} + +/** + * Param type for {@link ExecutableToolHandlerFn} + */ +export type ExecutableToolHandlerParams> = Omit< + ScopedRunnerRunToolsParams, + 'toolId' +>; + +/** + * Execution handler for {@link ExecutableTool} + */ +export type ExecutableToolHandlerFn, TResult = unknown> = ( + params: ExecutableToolHandlerParams +) => Promise>; + +/** + * Tool handler function for {@link RegisteredTool} handlers. + */ +export type ToolHandlerFn< + TParams extends Record = Record, + RunOutput = unknown +> = (args: TParams, context: ToolHandlerContext) => MaybePromise; + +/** + * Scoped context which can be used during tool execution to access + * a panel of built-in services, such as a pre-scoped elasticsearch client. + */ +export interface ToolHandlerContext { + /** + * The request that was provided when initiating that tool execution. + * Can be used to create scoped services not directly exposed by this context. + */ + request: KibanaRequest; + /** + * A cluster client scoped to the current user. + * Can be used to access ES on behalf of either the current user or the system user. + */ + esClient: IScopedClusterClient; + /** + * Inference model provider scoped to the current user. + * Can be used to access the inference APIs or chatModel. + */ + modelProvider: ModelProvider; + /** + * Onechat runner scoped to the current execution. + * Can be used to run other workchat primitive as part of the tool execution. + */ + runner: ScopedRunner; + /** + * Event emitter that can be used to emits custom events + */ + events: RunEventEmitter; +} + +/** + * Common interface shared across all tool providers. + */ +export interface ToolProvider { + /** + * Check if a tool is available in the provider + */ + has(options: ToolProviderHasOptions): Promise; + /** + * Retrieve a tool based on its identifier. + * If not found, will throw a toolNotFound error. + */ + get(options: ToolProviderGetOptions): Promise; + /** + * List all tools based on the provided filters + */ + list(options: ToolProviderListOptions): Promise; +} + +/** + * Options for {@link ToolProvider.has} + */ +export interface ToolProviderHasOptions { + toolId: ToolIdentifier; + request: KibanaRequest; +} + +/** + * Options for {@link ToolProvider.get} + */ +export interface ToolProviderGetOptions { + toolId: ToolIdentifier; + request: KibanaRequest; +} + +/** + * Options for {@link ToolProvider.list} + */ +export interface ToolProviderListOptions { + request: KibanaRequest; +} diff --git a/x-pack/platform/packages/shared/onechat/onechat-server/tsconfig.json b/x-pack/platform/packages/shared/onechat/onechat-server/tsconfig.json new file mode 100644 index 000000000000..0a238a2aca02 --- /dev/null +++ b/x-pack/platform/packages/shared/onechat/onechat-server/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "../../../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node" + ] + }, + "include": [ + "**/*.ts", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [ + "@kbn/inference-langchain", + "@kbn/inference-plugin", + "@kbn/zod", + "@kbn/utility-types", + "@kbn/core-elasticsearch-server", + "@kbn/core-http-server", + "@kbn/onechat-common", + ] +} diff --git a/x-pack/platform/plugins/shared/onechat/README.md b/x-pack/platform/plugins/shared/onechat/README.md new file mode 100644 index 000000000000..53fc3c671b19 --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/README.md @@ -0,0 +1,221 @@ +# Onechat plugin + +Home of the workchat framework. + +Note: as many other platform features, onechat isolates its public types and static utils, exposed from packages, +from its APIs, exposed from the plugin. + +The onechat plugin has 3 main packages: + +- `@kbn/onechat-common`: types and utilities which are shared between browser and server +- `@kbn/onechat-server`: server-specific types and utilities +- `@kbn/onechat-browser`: browser-specific types and utilities. + +## Overview + +The onechat plugin exposes APIs to interact with onechat primitives. + +The main primitives are: + +- tools + +## Tools + +A tool can be thought of as a LLM-friendly function, with the metadata required for the LLM to understand its purpose +and how to call it attached to it. + +Tool can come from multiple sources: built-in from Kibana, from MCP servers, and so on. At the moment, +only built-in tools are implemented + +### Registering a built-in tool + +#### Basic example + +```ts +class MyPlugin { + setup(core: CoreSetup, { onechat }: { onechat: OnechatPluginSetup }) { + onechat.tools.register({ + id: 'my_tool', + name: 'My Tool', + description: 'My very first tool', + meta: { + tags: ['foo', 'bar'], + }, + schema: z.object({ + someNumber: z.number().describe('Some random number'), + }), + handler: ({ someNumber }, context) => { + return 42 + someNumber; + }, + }); + } +} +``` + +#### using the handler context to use scoped services + +```ts +onechat.tools.register({ + id: 'my_es_tool', + name: 'My Tool', + description: 'Some example', + schema: z.object({ + indexPattern: z.string().describe('Index pattern to filter on'), + }), + handler: async ({ indexPattern }, { modelProvider, esClient }) => { + const indices = await esClient.asCurrentUser.cat.indices({ index: indexPattern }); + + const model = await modelProvider.getDefaultModel(); + const response = await model.inferenceClient.chatComplete(somethingWith(indices)); + + return response; + }, +}); +``` + +#### emitting events + +```ts +onechat.tools.register({ + id: 'my_es_tool', + name: 'My Tool', + description: 'Some example', + schema: z.object({}), + handler: async ({}, { events }) => { + events.emit({ + type: 'my_custom_event', + data: { stage: 'before' }, + }); + + const response = doSomething(); + + events.emit({ + type: 'my_custom_event', + data: { stage: 'after' }, + }); + + return response; + }, +}); +``` + +### Executing a tool + +Executing a tool can be done using the `execute` API of the onechat tool start service: + +```ts +const { result } = await onechat.tools.execute({ + toolId: 'my_tool', + toolParams: { someNumber: 9000 }, + request, +}); +``` + +It can also be done directly from a tool definition: + +```ts +const tool = await onechat.tools.registry.get({ toolId: 'my_tool', request }); +const { result } = await tool.execute({ toolParams: { someNumber: 9000 } }); +``` + +### Event handling + +Tool execution emits `toolCall` and `toolResponse` events: + +```ts +import { isToolCallEvent, isToolResponseEvent } from '@kbn/onechat-server'; + +const { result } = await onechat.tools.execute({ + toolId: 'my_tool', + toolParams: { someNumber: 9000 }, + request, + onEvent: (event) => { + if (isToolCallEvent(event)) { + const { + data: { toolId, toolParams }, + } = event; + } + if (isToolResponseEvent(event)) { + const { + data: { toolResult }, + } = event; + } + }, +}); +``` + +### Tool identifiers + +Because tools are coming from multiple sources, and because we need to be able to identify +which source a given tool is coming from (e.g. for execution), we're using the concept of tool identifier +to represent more than a plain id. + +Tool identifier come into 3 shapes: + +- `PlainIdToolIdentifier`: plain tool identifiers +- `StructuredToolIdentifier`: structured (object version) +- `SerializedToolIdentifier`: serialized string version + +Using a `plain` id is always possible but discouraged, as in case of id conflict, +the system will then just pick an arbitrary tool in any source available. + +E.g. avoid doing: + +```ts +await onechat.tools.execute({ + toolId: 'my_tool', + toolParams: { someNumber: 9000 }, + request, +}); +``` + +And instead do: + +```ts +import { ToolSourceType, builtinSourceId } from '@kbn/onechat-common'; + +await onechat.tools.execute({ + toolId: { + toolId: 'my_tool', + sourceType: ToolSourceType.builtIn, + sourceId: builtinSourceId, + }, + toolParams: { someNumber: 9000 }, + request, +}); +``` + +Or, with the corresponding utility: + +```ts +import { createBuiltinToolId } from '@kbn/onechat-common'; + +await onechat.tools.execute({ + toolId: createBuiltinToolId('my_tool'), + toolParams: { someNumber: 9000 }, + request, +}); +``` + +### Error handling + +All onechat errors inherit from the `OnechatError` error type. Various error utilities +are exposed from the `@kbn/onechat-common` package to identify and handle those errors. + +Some simple example of handling a specific type of error: + +```ts +import { isToolNotFoundError } from '@kbn/onechat-common'; + +try { + const { result } = await onechat.tools.execute({ + toolId: 'my_tool', + toolParams: { someNumber: 9000 }, + request, + }); +} catch (e) { + if (isToolNotFoundError(e)) { + throw new Error(`run ${e.meta.runId} failed because tool was not found`); + } +} +``` diff --git a/x-pack/platform/plugins/shared/onechat/common/features.ts b/x-pack/platform/plugins/shared/onechat/common/features.ts new file mode 100644 index 000000000000..25d81f9e21bb --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/common/features.ts @@ -0,0 +1,10 @@ +/* + * 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. + */ + +export const ONECHAT_FRAMEWORK_FEATURE_ID = 'onechat_framework'; +export const ONECHAT_FRAMEWORK_FEATURE_NAME = 'onechat_framework'; +export const ONECHAT_FRAMEWORK_APP_ID = 'onechat_framework'; diff --git a/x-pack/platform/plugins/shared/onechat/jest.config.js b/x-pack/platform/plugins/shared/onechat/jest.config.js new file mode 100644 index 000000000000..aa78843f594e --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/jest.config.js @@ -0,0 +1,23 @@ +/* + * 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. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../../..', + roots: [ + '/x-pack/platform/plugins/shared/onechat/public', + '/x-pack/platform/plugins/shared/onechat/server', + '/x-pack/platform/plugins/shared/onechat/common', + ], + setupFiles: [], + collectCoverage: true, + collectCoverageFrom: [ + '/x-pack/platform/plugins/shared/onechat/{public,server,common}/**/*.{js,ts,tsx}', + ], + + coverageReporters: ['html'], +}; diff --git a/x-pack/platform/plugins/shared/onechat/kibana.jsonc b/x-pack/platform/plugins/shared/onechat/kibana.jsonc new file mode 100644 index 000000000000..2a2fb6abd03e --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/kibana.jsonc @@ -0,0 +1,17 @@ +{ + "type": "plugin", + "id": "@kbn/onechat-plugin", + "owner": "@elastic/workchat-eng", + "group": "platform", + "visibility": "shared", + "plugin": { + "id": "onechat", + "server": true, + "browser": true, + "configPath": ["xpack", "onechat"], + "requiredPlugins": ["actions", "inference", "features"], + "requiredBundles": [], + "optionalPlugins": [], + "extraPublicDirs": [] + } +} diff --git a/x-pack/platform/plugins/shared/onechat/public/index.ts b/x-pack/platform/plugins/shared/onechat/public/index.ts new file mode 100644 index 000000000000..9833cd7847c2 --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/public/index.ts @@ -0,0 +1,27 @@ +/* + * 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 { PluginInitializer, PluginInitializerContext } from '@kbn/core/public'; +import type { + OnechatPluginSetup, + OnechatPluginStart, + OnechatSetupDependencies, + OnechatStartDependencies, + ConfigSchema, +} from './types'; +import { OnechatPlugin } from './plugin'; + +export type { OnechatPluginSetup, OnechatPluginStart }; + +export const plugin: PluginInitializer< + OnechatPluginSetup, + OnechatPluginStart, + OnechatSetupDependencies, + OnechatStartDependencies +> = (pluginInitializerContext: PluginInitializerContext) => { + return new OnechatPlugin(pluginInitializerContext); +}; diff --git a/x-pack/platform/plugins/shared/onechat/public/mocks.ts b/x-pack/platform/plugins/shared/onechat/public/mocks.ts new file mode 100644 index 000000000000..0fa2a14c369c --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/public/mocks.ts @@ -0,0 +1,21 @@ +/* + * 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 { OnechatPluginSetup, OnechatPluginStart } from './types'; + +const createSetupContractMock = (): jest.Mocked => { + return {}; +}; + +const createStartContractMock = (): jest.Mocked => { + return {}; +}; + +export const onechatMocks = { + createSetup: createSetupContractMock, + createStart: createStartContractMock, +}; diff --git a/x-pack/platform/plugins/shared/onechat/public/plugin.tsx b/x-pack/platform/plugins/shared/onechat/public/plugin.tsx new file mode 100644 index 000000000000..be0155db1eb4 --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/public/plugin.tsx @@ -0,0 +1,42 @@ +/* + * 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 { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public'; +import type { Logger } from '@kbn/logging'; +import type { + ConfigSchema, + OnechatPluginSetup, + OnechatPluginStart, + OnechatSetupDependencies, + OnechatStartDependencies, +} from './types'; + +export class OnechatPlugin + implements + Plugin< + OnechatPluginSetup, + OnechatPluginStart, + OnechatSetupDependencies, + OnechatStartDependencies + > +{ + logger: Logger; + + constructor(context: PluginInitializerContext) { + this.logger = context.logger.get(); + } + setup( + coreSetup: CoreSetup, + pluginsSetup: OnechatSetupDependencies + ): OnechatPluginSetup { + return {}; + } + + start(coreStart: CoreStart, pluginsStart: OnechatStartDependencies): OnechatPluginStart { + return {}; + } +} diff --git a/x-pack/platform/plugins/shared/onechat/public/types.ts b/x-pack/platform/plugins/shared/onechat/public/types.ts new file mode 100644 index 000000000000..ed421ba55f19 --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/public/types.ts @@ -0,0 +1,18 @@ +/* + * 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. + */ + +/* eslint-disable @typescript-eslint/no-empty-interface*/ + +export interface ConfigSchema {} + +export interface OnechatSetupDependencies {} + +export interface OnechatStartDependencies {} + +export interface OnechatPluginSetup {} + +export interface OnechatPluginStart {} diff --git a/x-pack/platform/plugins/shared/onechat/server/config.ts b/x-pack/platform/plugins/shared/onechat/server/config.ts new file mode 100644 index 000000000000..cb51cf762e5a --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/server/config.ts @@ -0,0 +1,19 @@ +/* + * 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 { PluginConfigDescriptor } from '@kbn/core/server'; +import { schema, type TypeOf } from '@kbn/config-schema'; + +export const configSchema = schema.object({ + enabled: schema.boolean({ defaultValue: true }), +}); + +export type OnechatConfig = TypeOf; + +export const config: PluginConfigDescriptor = { + schema: configSchema, +}; diff --git a/x-pack/platform/plugins/shared/onechat/server/index.ts b/x-pack/platform/plugins/shared/onechat/server/index.ts new file mode 100644 index 000000000000..65d1b9bc5fe0 --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/server/index.ts @@ -0,0 +1,30 @@ +/* + * 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 { PluginInitializer, PluginInitializerContext } from '@kbn/core/server'; +import type { OnechatConfig } from './config'; +import type { + OnechatPluginSetup, + OnechatPluginStart, + OnechatSetupDependencies, + OnechatStartDependencies, +} from './types'; +import { OnechatPlugin } from './plugin'; + +export type { OnechatPluginSetup, OnechatPluginStart, ToolsSetup, ToolsStart } from './types'; +export type { ScopedPublicToolRegistry, ScopedPublicToolRegistryFactoryFn } from './services/tools'; + +export const plugin: PluginInitializer< + OnechatPluginSetup, + OnechatPluginStart, + OnechatSetupDependencies, + OnechatStartDependencies +> = async (pluginInitializerContext: PluginInitializerContext) => { + return new OnechatPlugin(pluginInitializerContext); +}; + +export { config } from './config'; diff --git a/x-pack/platform/plugins/shared/onechat/server/mocks.ts b/x-pack/platform/plugins/shared/onechat/server/mocks.ts new file mode 100644 index 000000000000..d85ca3e2e574 --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/server/mocks.ts @@ -0,0 +1,44 @@ +/* + * 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 { OnechatPluginSetup, OnechatPluginStart, ScopedToolsStart } from './types'; +import { + createMockedExecutableTool, + createToolProviderMock, + createScopedPublicToolRegistryMock, +} from './test_utils/tools'; + +const createSetupContractMock = (): jest.Mocked => { + return { + tools: { + register: jest.fn(), + }, + }; +}; + +export const createScopedToolStartMock = (): jest.Mocked => { + return { + execute: jest.fn(), + registry: createScopedPublicToolRegistryMock(), + }; +}; + +const createStartContractMock = (): jest.Mocked => { + return { + tools: { + execute: jest.fn(), + registry: createToolProviderMock(), + asScoped: jest.fn().mockImplementation(() => createScopedToolStartMock()), + }, + }; +}; + +export const onechatMocks = { + createSetup: createSetupContractMock, + createStart: createStartContractMock, + createTool: createMockedExecutableTool, +}; diff --git a/x-pack/platform/plugins/shared/onechat/server/plugin.ts b/x-pack/platform/plugins/shared/onechat/server/plugin.ts new file mode 100644 index 000000000000..d6dd205028c2 --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/server/plugin.ts @@ -0,0 +1,98 @@ +/* + * 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 { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/server'; +import type { Logger } from '@kbn/logging'; +import type { OnechatConfig } from './config'; +import type { + OnechatPluginSetup, + OnechatPluginStart, + OnechatSetupDependencies, + OnechatStartDependencies, +} from './types'; +import { registerRoutes } from './routes'; +import { ServiceManager } from './services'; + +export class OnechatPlugin + implements + Plugin< + OnechatPluginSetup, + OnechatPluginStart, + OnechatSetupDependencies, + OnechatStartDependencies + > +{ + private logger: Logger; + // @ts-expect-error unused for now + private config: OnechatConfig; + private serviceManager = new ServiceManager(); + + constructor(context: PluginInitializerContext) { + this.logger = context.logger.get(); + this.config = context.config.get(); + } + + setup( + coreSetup: CoreSetup, + pluginsSetup: OnechatSetupDependencies + ): OnechatPluginSetup { + const serviceSetups = this.serviceManager.setupServices(); + + const router = coreSetup.http.createRouter(); + registerRoutes({ + router, + coreSetup, + logger: this.logger, + getInternalServices: () => { + const services = this.serviceManager.internalStart; + if (!services) { + throw new Error('getInternalServices called before service init'); + } + return services; + }, + }); + + return { + tools: { + register: serviceSetups.tools.register.bind(serviceSetups.tools), + }, + }; + } + + start( + { elasticsearch, security }: CoreStart, + { actions, inference }: OnechatStartDependencies + ): OnechatPluginStart { + const startServices = this.serviceManager.startServices({ + logger: this.logger.get('services'), + security, + elasticsearch, + actions, + inference, + }); + + const { tools, runnerFactory } = startServices; + const runner = runnerFactory.getRunner(); + + return { + tools: { + registry: tools.registry.asPublicRegistry(), + execute: runner.runTool.bind(runner), + asScoped: ({ request }) => { + return { + registry: tools.registry.asScopedPublicRegistry({ request }), + execute: (args) => { + return runner.runTool({ ...args, request }); + }, + }; + }, + }, + }; + } + + stop() {} +} diff --git a/x-pack/platform/plugins/shared/onechat/server/routes/index.ts b/x-pack/platform/plugins/shared/onechat/server/routes/index.ts new file mode 100644 index 000000000000..beb21b333b4f --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/server/routes/index.ts @@ -0,0 +1,13 @@ +/* + * 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 { RouteDependencies } from './types'; +import { registerToolsRoutes } from './tools'; + +export const registerRoutes = (dependencies: RouteDependencies) => { + registerToolsRoutes(dependencies); +}; diff --git a/x-pack/platform/plugins/shared/onechat/server/routes/tools.ts b/x-pack/platform/plugins/shared/onechat/server/routes/tools.ts new file mode 100644 index 000000000000..637f88ab0b83 --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/server/routes/tools.ts @@ -0,0 +1,37 @@ +/* + * 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 { RouteDependencies } from './types'; +import { getHandlerWrapper } from './wrap_handler'; +import { toolToDescriptor } from '../services/tools/utils/tool_conversion'; + +export function registerToolsRoutes({ router, getInternalServices, logger }: RouteDependencies) { + const wrapHandler = getHandlerWrapper({ logger }); + + router.get( + { + path: '/api/onechat/tools/list', + security: { + authz: { + enabled: false, + reason: 'Platform feature - RBAC in lower layers', + }, + }, + validate: false, + }, + wrapHandler(async (ctx, request, response) => { + const { tools: toolService } = getInternalServices(); + const registry = toolService.registry.asScopedPublicRegistry({ request }); + const tools = await registry.list({}); + return response.ok({ + body: { + tools: tools.map(toolToDescriptor), + }, + }); + }) + ); +} diff --git a/x-pack/platform/plugins/shared/onechat/server/routes/types.ts b/x-pack/platform/plugins/shared/onechat/server/routes/types.ts new file mode 100644 index 000000000000..bb49dee87fd9 --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/server/routes/types.ts @@ -0,0 +1,18 @@ +/* + * 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 { Logger } from '@kbn/logging'; +import type { CoreSetup, IRouter } from '@kbn/core/server'; +import type { OnechatPluginStart, OnechatStartDependencies } from '../types'; +import type { InternalStartServices } from '../services'; + +export interface RouteDependencies { + router: IRouter; + logger: Logger; + coreSetup: CoreSetup; + getInternalServices: () => InternalStartServices; +} diff --git a/x-pack/platform/plugins/shared/onechat/server/routes/wrap_handler.ts b/x-pack/platform/plugins/shared/onechat/server/routes/wrap_handler.ts new file mode 100644 index 000000000000..a230827895ab --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/server/routes/wrap_handler.ts @@ -0,0 +1,29 @@ +/* + * 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 { Logger, RequestHandler } from '@kbn/core/server'; +import { isOnechatError } from '@kbn/onechat-common'; + +export const getHandlerWrapper = + ({ logger }: { logger: Logger }) => + (handler: RequestHandler): RequestHandler => { + return (ctx, req, res) => { + try { + return handler(ctx, req, res); + } catch (e) { + if (isOnechatError(e)) { + logger.error(e); + return res.customError({ + body: { message: e.message }, + statusCode: e.meta?.statusCode ?? 500, + }); + } else { + throw e; + } + } + }; + }; diff --git a/x-pack/platform/plugins/shared/onechat/server/services/create_services.ts b/x-pack/platform/plugins/shared/onechat/server/services/create_services.ts new file mode 100644 index 000000000000..2095331487ef --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/server/services/create_services.ts @@ -0,0 +1,73 @@ +/* + * 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 { Runner } from '@kbn/onechat-server'; +import type { InternalSetupServices, InternalStartServices, ServicesStartDeps } from './types'; +import { ToolsService } from './tools'; +import { RunnerFactoryImpl } from './runner'; + +interface ServiceInstances { + tools: ToolsService; +} + +export class ServiceManager { + private services?: ServiceInstances; + public internalSetup?: InternalSetupServices; + public internalStart?: InternalStartServices; + + setupServices(): InternalSetupServices { + this.services = { + tools: new ToolsService(), + }; + + this.internalSetup = { + tools: this.services.tools.setup(), + }; + + return this.internalSetup; + } + + startServices({ + logger, + security, + elasticsearch, + actions, + inference, + }: ServicesStartDeps): InternalStartServices { + if (!this.services) { + throw new Error('#startServices called before #setupServices'); + } + + // eslint-disable-next-line prefer-const + let runner: Runner | undefined; + + const tools = this.services.tools.start({ + getRunner: () => { + if (!runner) { + throw new Error('Trying to access runner before initialization'); + } + return runner; + }, + }); + const runnerFactory = new RunnerFactoryImpl({ + logger: logger.get('runnerFactory'), + security, + elasticsearch, + actions, + inference, + toolsService: tools, + }); + runner = runnerFactory.getRunner(); + + this.internalStart = { + tools, + runnerFactory, + }; + + return this.internalStart; + } +} diff --git a/x-pack/platform/plugins/shared/onechat/server/services/index.ts b/x-pack/platform/plugins/shared/onechat/server/services/index.ts new file mode 100644 index 000000000000..30c45706df32 --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/server/services/index.ts @@ -0,0 +1,9 @@ +/* + * 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. + */ + +export type { InternalSetupServices, InternalStartServices } from './types'; +export { ServiceManager } from './create_services'; diff --git a/x-pack/platform/plugins/shared/onechat/server/services/runner/index.ts b/x-pack/platform/plugins/shared/onechat/server/services/runner/index.ts new file mode 100644 index 000000000000..600cd97b6f7e --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/server/services/runner/index.ts @@ -0,0 +1,9 @@ +/* + * 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. + */ + +export type { RunnerFactory, RunnerFactoryDeps, CreateScopedRunnerExtraParams } from './types'; +export { RunnerFactoryImpl } from './runner_factory'; diff --git a/x-pack/platform/plugins/shared/onechat/server/services/runner/model_provider.ts b/x-pack/platform/plugins/shared/onechat/server/services/runner/model_provider.ts new file mode 100644 index 000000000000..0e012a30fb70 --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/server/services/runner/model_provider.ts @@ -0,0 +1,74 @@ +/* + * 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 { KibanaRequest } from '@kbn/core-http-server'; +import type { ModelProvider, ScopedModel } from '@kbn/onechat-server'; +import type { InferenceServerStart } from '@kbn/inference-plugin/server'; +import type { PluginStartContract as ActionsPluginStart } from '@kbn/actions-plugin/server'; +import { getConnectorList, getDefaultConnector } from './utils'; + +export interface CreateModelProviderOpts { + inference: InferenceServerStart; + actions: ActionsPluginStart; + request: KibanaRequest; + defaultConnectorId?: string; +} + +export type CreateModelProviderFactoryFn = ( + opts: Omit +) => ModelProviderFactoryFn; + +export type ModelProviderFactoryFn = ( + opts: Pick +) => ModelProvider; + +/** + * Utility function to creates a {@link ModelProviderFactoryFn} + */ +export const createModelProviderFactory: CreateModelProviderFactoryFn = (factoryOpts) => (opts) => { + return createModelProvider({ + ...factoryOpts, + ...opts, + }); +}; + +/** + * Utility function to creates a {@link ModelProvider} + */ +export const createModelProvider = ({ + inference, + actions, + request, + defaultConnectorId, +}: CreateModelProviderOpts): ModelProvider => { + const getDefaultConnectorId = async () => { + if (defaultConnectorId) { + return defaultConnectorId; + } + const connectors = await getConnectorList({ actions, request }); + const defaultConnector = getDefaultConnector({ connectors }); + return defaultConnector.connectorId; + }; + + const getModel = async (connectorId: string): Promise => { + const chatModel = await inference.getChatModel({ + request, + connectorId, + chatModelOptions: {}, + }); + const inferenceClient = inference.getClient({ request, bindTo: { connectorId } }); + return { + chatModel, + inferenceClient, + }; + }; + + return { + getDefaultModel: async () => getModel(await getDefaultConnectorId()), + getModel: ({ connectorId }) => getModel(connectorId), + }; +}; diff --git a/x-pack/platform/plugins/shared/onechat/server/services/runner/runner.test.ts b/x-pack/platform/plugins/shared/onechat/server/services/runner/runner.test.ts new file mode 100644 index 000000000000..fd213730fe44 --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/server/services/runner/runner.test.ts @@ -0,0 +1,211 @@ +/* + * 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 { toSerializedToolIdentifier } from '@kbn/onechat-common'; +import type { + ScopedRunnerRunToolsParams, + RunToolParams, + OnechatRunEvent, +} from '@kbn/onechat-server'; +import { + createScopedRunnerDepsMock, + createMockedTool, + CreateScopedRunnerDepsMock, + MockedTool, +} from '../../test_utils'; +import { createScopedRunner, createRunner, runTool, RunnerManager } from './runner'; + +describe('Onechat runner', () => { + let runnerDeps: CreateScopedRunnerDepsMock; + let runnerManager: RunnerManager; + + beforeEach(() => { + runnerDeps = createScopedRunnerDepsMock(); + runnerManager = new RunnerManager(runnerDeps); + }); + + describe('runTool', () => { + let tool: MockedTool; + + beforeEach(() => { + const { + toolsService: { registry }, + } = runnerDeps; + + tool = createMockedTool({}); + registry.get.mockResolvedValue(tool); + }); + + it('calls the tool registry with the expected parameters', async () => { + const { + toolsService: { registry }, + } = runnerDeps; + + const params: ScopedRunnerRunToolsParams = { + toolId: 'test-tool', + toolParams: { foo: 'bar' }, + }; + + await runTool({ + toolExecutionParams: params, + parentManager: runnerManager, + }); + + expect(registry.get).toHaveBeenCalledTimes(1); + expect(registry.get).toHaveBeenCalledWith({ + toolId: params.toolId, + request: runnerDeps.request, + }); + }); + + it('calls the tool handler with the expected parameters', async () => { + const params: ScopedRunnerRunToolsParams = { + toolId: 'test-tool', + toolParams: { foo: 'bar' }, + }; + + await runTool({ + toolExecutionParams: params, + parentManager: runnerManager, + }); + + expect(tool.handler).toHaveBeenCalledTimes(1); + expect(tool.handler).toHaveBeenCalledWith(params.toolParams, expect.any(Object)); + }); + + it('returns the expected value', async () => { + const params: ScopedRunnerRunToolsParams = { + toolId: 'test-tool', + toolParams: {}, + }; + + tool.handler.mockReturnValue({ test: true, over: 9000 }); + + const result = await runTool({ + toolExecutionParams: params, + parentManager: runnerManager, + }); + + expect(result).toEqual({ + result: { test: true, over: 9000 }, + }); + }); + + it('exposes a context with the expected shape to the tool handler', async () => { + const params: ScopedRunnerRunToolsParams = { + toolId: 'test-tool', + toolParams: {}, + }; + + tool.handler.mockImplementation((toolParams, context) => { + return 'foo'; + }); + + await runTool({ + toolExecutionParams: params, + parentManager: runnerManager, + }); + + expect(tool.handler).toHaveBeenCalledTimes(1); + const context = tool.handler.mock.lastCall![1]; + + expect(context).toEqual( + expect.objectContaining({ + request: runnerDeps.request, + esClient: expect.anything(), + modelProvider: expect.anything(), + runner: expect.anything(), + }) + ); + }); + + it('exposes an event emitter to the tool handler caller can attach to using the onEvent param', async () => { + const emittedEvents: OnechatRunEvent[] = []; + + const params: ScopedRunnerRunToolsParams = { + toolId: 'test-tool', + toolParams: {}, + onEvent: (event) => { + emittedEvents.push(event); + }, + }; + + tool.handler.mockImplementation((toolParams, { events }) => { + events.emit({ + type: 'test-event', + data: { foo: 'bar' }, + meta: { hello: 'dolly' }, + }); + return 42; + }); + + await runTool({ + toolExecutionParams: params, + parentManager: runnerManager, + }); + + expect(emittedEvents).toHaveLength(1); + expect(emittedEvents[0]).toEqual({ + type: 'test-event', + data: { + foo: 'bar', + }, + meta: { + hello: 'dolly', + runId: expect.any(String), + stack: [ + { + type: 'tool', + toolId: toSerializedToolIdentifier('test-tool'), + }, + ], + }, + }); + }); + + it('can be invoked through a scoped runner', async () => { + tool.handler.mockReturnValue({ someProp: 'someValue' }); + + const params: ScopedRunnerRunToolsParams = { + toolId: 'test-tool', + toolParams: { foo: 'bar' }, + }; + + const runner = createScopedRunner(runnerDeps); + const response = await runner.runTool(params); + + expect(tool.handler).toHaveBeenCalledTimes(1); + expect(tool.handler).toHaveBeenCalledWith(params.toolParams, expect.any(Object)); + + expect(response).toEqual({ + result: { someProp: 'someValue' }, + }); + }); + + it('can be invoked through a runner', async () => { + tool.handler.mockReturnValue({ someProp: 'someValue' }); + + const { request, ...otherRunnerDeps } = runnerDeps; + + const params: RunToolParams = { + toolId: 'test-tool', + toolParams: { foo: 'bar' }, + request, + }; + + const runner = createRunner(otherRunnerDeps); + const response = await runner.runTool(params); + + expect(tool.handler).toHaveBeenCalledTimes(1); + expect(tool.handler).toHaveBeenCalledWith(params.toolParams, expect.any(Object)); + + expect(response).toEqual({ + result: { someProp: 'someValue' }, + }); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/onechat/server/services/runner/runner.ts b/x-pack/platform/plugins/shared/onechat/server/services/runner/runner.ts new file mode 100644 index 000000000000..09f3a3966bd2 --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/server/services/runner/runner.ts @@ -0,0 +1,132 @@ +/* + * 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 { Logger } from '@kbn/logging'; +import type { ElasticsearchServiceStart } from '@kbn/core-elasticsearch-server'; +import type { KibanaRequest } from '@kbn/core-http-server'; +import type { SecurityServiceStart } from '@kbn/core-security-server'; +import { isOnechatError, createInternalError } from '@kbn/onechat-common'; +import type { + ToolHandlerContext, + ScopedRunner, + ScopedRunnerRunToolsParams, + RunContext, + Runner, + RunToolReturn, +} from '@kbn/onechat-server'; +import type { ToolsServiceStart } from '../tools'; +import { ModelProviderFactoryFn } from './model_provider'; +import { createEmptyRunContext, forkContextForToolRun } from './utils/run_context'; +import { createEventEmitter, createNoopEventEmitter } from './utils/events'; + +export interface CreateScopedRunnerDeps { + // core services + elasticsearch: ElasticsearchServiceStart; + security: SecurityServiceStart; + // internal service deps + modelProviderFactory: ModelProviderFactoryFn; + toolsService: ToolsServiceStart; + // other deps + logger: Logger; + request: KibanaRequest; + defaultConnectorId?: string; +} + +export type CreateRunnerDeps = Omit; + +export class RunnerManager { + public readonly deps: CreateScopedRunnerDeps; + public readonly context: RunContext; + + constructor(deps: CreateScopedRunnerDeps, context?: RunContext) { + this.deps = deps; + this.context = context ?? createEmptyRunContext(); + } + + getRunner(): ScopedRunner { + return { + runTool: , TResult = unknown>( + toolExecutionParams: ScopedRunnerRunToolsParams + ): Promise> => { + try { + return runTool({ toolExecutionParams, parentManager: this }); + } catch (e) { + if (isOnechatError(e)) { + throw e; + } else { + throw createInternalError(e.message); + } + } + }, + }; + } + + createChild(childContext: RunContext): RunnerManager { + return new RunnerManager(this.deps, childContext); + } +} + +export const runTool = async , TResult = unknown>({ + toolExecutionParams, + parentManager, +}: { + toolExecutionParams: ScopedRunnerRunToolsParams; + parentManager: RunnerManager; +}): Promise> => { + const { toolId, toolParams } = toolExecutionParams; + + const context = forkContextForToolRun({ parentContext: parentManager.context, toolId }); + const manager = parentManager.createChild(context); + + const { toolsService, request } = manager.deps; + + const tool = await toolsService.registry.get({ toolId, request }); + + const toolHandlerContext = createToolHandlerContext({ toolExecutionParams, manager }); + + const toolResult = await tool.handler(toolParams as Record, toolHandlerContext); + + return { + result: toolResult as TResult, + }; +}; + +export const createToolHandlerContext = >({ + manager, + toolExecutionParams, +}: { + toolExecutionParams: ScopedRunnerRunToolsParams; + manager: RunnerManager; +}): ToolHandlerContext => { + const { onEvent } = toolExecutionParams; + const { request, defaultConnectorId, elasticsearch, modelProviderFactory } = manager.deps; + return { + request, + esClient: elasticsearch.client.asScoped(request), + modelProvider: modelProviderFactory({ request, defaultConnectorId }), + runner: manager.getRunner(), + events: onEvent + ? createEventEmitter({ eventHandler: onEvent, context: manager.context }) + : createNoopEventEmitter(), + }; +}; + +export const createScopedRunner = (deps: CreateScopedRunnerDeps): ScopedRunner => { + const manager = new RunnerManager(deps, createEmptyRunContext()); + return manager.getRunner(); +}; + +export const createRunner = (deps: CreateRunnerDeps): Runner => { + return { + runTool: (runToolsParams) => { + const { request, defaultConnectorId, ...otherParams } = runToolsParams; + const allDeps = { ...deps, request, defaultConnectorId }; + const runner = createScopedRunner(allDeps); + return runner.runTool(otherParams); + }, + }; +}; diff --git a/x-pack/platform/plugins/shared/onechat/server/services/runner/runner_factory.ts b/x-pack/platform/plugins/shared/onechat/server/services/runner/runner_factory.ts new file mode 100644 index 000000000000..89ca7d3356a0 --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/server/services/runner/runner_factory.ts @@ -0,0 +1,42 @@ +/* + * 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 { RunnerFactoryDeps } from './types'; +import type { CreateScopedRunnerExtraParams, RunnerFactory } from './types'; +import { createModelProviderFactory } from './model_provider'; +import { + createScopedRunner as createScopedRunnerInternal, + createRunner, + type CreateRunnerDeps, +} from './runner'; + +export class RunnerFactoryImpl implements RunnerFactory { + private readonly deps: RunnerFactoryDeps; + + constructor(deps: RunnerFactoryDeps) { + this.deps = deps; + } + + getRunner() { + return createRunner(this.createRunnerDeps()); + } + + createScopedRunner(scopedParams: CreateScopedRunnerExtraParams) { + return createScopedRunnerInternal({ + ...this.createRunnerDeps(), + ...scopedParams, + }); + } + + private createRunnerDeps(): CreateRunnerDeps { + const { inference, actions, ...otherDeps } = this.deps; + return { + ...otherDeps, + modelProviderFactory: createModelProviderFactory({ inference, actions }), + }; + } +} diff --git a/x-pack/platform/plugins/shared/onechat/server/services/runner/types.ts b/x-pack/platform/plugins/shared/onechat/server/services/runner/types.ts new file mode 100644 index 000000000000..c9a5c0abe6c7 --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/server/services/runner/types.ts @@ -0,0 +1,37 @@ +/* + * 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 { Logger } from '@kbn/logging'; +import type { ElasticsearchServiceStart } from '@kbn/core-elasticsearch-server'; +import type { SecurityServiceStart } from '@kbn/core-security-server'; +import type { InferenceServerStart } from '@kbn/inference-plugin/server'; +import type { PluginStartContract as ActionsPluginStart } from '@kbn/actions-plugin/server'; +import type { ScopedRunner, Runner } from '@kbn/onechat-server'; +import type { ToolsServiceStart } from '../tools'; +import type { CreateScopedRunnerDeps } from './runner'; + +export interface RunnerFactoryDeps { + // core services + logger: Logger; + elasticsearch: ElasticsearchServiceStart; + security: SecurityServiceStart; + // plugin deps + inference: InferenceServerStart; + actions: ActionsPluginStart; + // internal service deps + toolsService: ToolsServiceStart; +} + +export type CreateScopedRunnerExtraParams = Pick< + CreateScopedRunnerDeps, + 'request' | 'defaultConnectorId' +>; + +export interface RunnerFactory { + getRunner(): Runner; + createScopedRunner(params: CreateScopedRunnerExtraParams): ScopedRunner; +} diff --git a/x-pack/platform/plugins/shared/onechat/server/services/runner/utils/events.test.ts b/x-pack/platform/plugins/shared/onechat/server/services/runner/utils/events.test.ts new file mode 100644 index 000000000000..b6b70c5ecc77 --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/server/services/runner/utils/events.test.ts @@ -0,0 +1,137 @@ +/* + * 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 { createEventEmitter, createNoopEventEmitter, convertInternalEvent } from './events'; +import type { InternalRunEvent, RunContext } from '@kbn/onechat-server'; + +describe('Event utilities', () => { + describe('createEventEmitter', () => { + it('should emit events with context metadata', () => { + const mockEventHandler = jest.fn(); + const context: RunContext = { + runId: 'test-run-id', + stack: [], + }; + + const emitter = createEventEmitter({ + eventHandler: mockEventHandler, + context, + }); + + const testEvent: InternalRunEvent = { + type: 'test-event', + data: { foo: 'bar' }, + meta: { baz: 'qux' }, + }; + + emitter.emit(testEvent); + + expect(mockEventHandler).toHaveBeenCalledWith({ + type: 'test-event', + data: { foo: 'bar' }, + meta: { + baz: 'qux', + runId: 'test-run-id', + stack: [], + }, + }); + }); + + it('should handle events without meta data', () => { + const mockEventHandler = jest.fn(); + const context: RunContext = { + runId: 'test-run-id', + stack: [], + }; + + const emitter = createEventEmitter({ + eventHandler: mockEventHandler, + context, + }); + + const testEvent: InternalRunEvent = { + type: 'test-event', + data: { foo: 'bar' }, + }; + + emitter.emit(testEvent); + + expect(mockEventHandler).toHaveBeenCalledWith({ + type: 'test-event', + data: { foo: 'bar' }, + meta: { + runId: 'test-run-id', + stack: [], + }, + }); + }); + }); + + describe('createNoopEventEmitter', () => { + it('should create an emitter that does nothing', () => { + const emitter = createNoopEventEmitter(); + // This should not throw + emitter.emit({ type: 'test-event', data: {} }); + }); + }); + + describe('convertInternalEvent', () => { + it('should convert internal event to public event with context', () => { + const context: RunContext = { + runId: 'test-run-id', + stack: [], + }; + + const internalEvent: InternalRunEvent = { + type: 'test-event', + data: { foo: 'bar' }, + meta: { baz: 'qux' }, + }; + + const publicEvent = convertInternalEvent({ + event: internalEvent, + context, + }); + + expect(publicEvent).toEqual({ + type: 'test-event', + data: { foo: 'bar' }, + meta: { + baz: 'qux', + runId: 'test-run-id', + stack: [], + }, + }); + }); + + it('should handle events without meta data', () => { + const context: RunContext = { + runId: 'test-run-id', + stack: [], + }; + + const internalEvent: InternalRunEvent = { + type: 'test-event', + data: { foo: 'bar' }, + }; + + const publicEvent = convertInternalEvent({ + event: internalEvent, + context, + }); + + expect(publicEvent).toEqual({ + type: 'test-event', + data: { foo: 'bar' }, + meta: { + runId: 'test-run-id', + stack: [], + }, + }); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/onechat/server/services/runner/utils/events.ts b/x-pack/platform/plugins/shared/onechat/server/services/runner/utils/events.ts new file mode 100644 index 000000000000..fb7c84712f05 --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/server/services/runner/utils/events.ts @@ -0,0 +1,67 @@ +/* + * 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 { + OnechatRunEvent, + InternalRunEvent, + RunContext, + OnechatRunEventMeta, + RunEventHandlerFn, + RunEventEmitter, +} from '@kbn/onechat-server'; + +/** + * Creates a run event emitter sending events to the provided event handler. + */ +export const createEventEmitter = ({ + eventHandler, + context, +}: { + eventHandler: RunEventHandlerFn; + context: RunContext; +}): RunEventEmitter => { + return { + emit: (internalEvent) => { + const event = convertInternalEvent({ event: internalEvent, context }); + eventHandler(event); + }, + }; +}; + +/** + * Creates a run event emitter sending events to the provided event handler. + */ +export const createNoopEventEmitter = (): RunEventEmitter => { + return { + emit: () => {}, + }; +}; + +/** + * Convert an internal onechat run event to its public-facing format. + */ +export const convertInternalEvent = < + TEventType extends string = string, + TData extends Record = Record, + TMeta extends Record = Record +>({ + event: { type, data, meta }, + context, +}: { + event: InternalRunEvent; + context: RunContext; +}): OnechatRunEvent => { + return { + type, + data, + meta: { + ...((meta ?? {}) as TMeta), + runId: context.runId, + stack: context.stack, + }, + }; +}; diff --git a/x-pack/platform/plugins/shared/onechat/server/services/runner/utils/get_connector_list.ts b/x-pack/platform/plugins/shared/onechat/server/services/runner/utils/get_connector_list.ts new file mode 100644 index 000000000000..4cead7bbd4c3 --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/server/services/runner/utils/get_connector_list.ts @@ -0,0 +1,32 @@ +/* + * 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 { KibanaRequest } from '@kbn/core/server'; +import type { PluginStartContract as ActionsPluginStart } from '@kbn/actions-plugin/server'; +import { + isSupportedConnector, + connectorToInference, + InferenceConnector, +} from '@kbn/inference-common'; + +export const getConnectorList = async ({ + actions, + request, +}: { + actions: ActionsPluginStart; + request: KibanaRequest; +}): Promise => { + const actionClient = await actions.getActionsClientWithRequest(request); + + const allConnectors = await actionClient.getAll({ + includeSystemActions: false, + }); + + return allConnectors + .filter((connector) => isSupportedConnector(connector)) + .map(connectorToInference); +}; diff --git a/x-pack/platform/plugins/shared/onechat/server/services/runner/utils/get_default_connector.ts b/x-pack/platform/plugins/shared/onechat/server/services/runner/utils/get_default_connector.ts new file mode 100644 index 000000000000..0258fc53878b --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/server/services/runner/utils/get_default_connector.ts @@ -0,0 +1,35 @@ +/* + * 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 { InferenceConnector, InferenceConnectorType } from '@kbn/inference-common'; + +/** + * Naive utility function to consistently return the "best" default connector for onechat features. + * + * For now, the logic is, by order of priority: + * - any inference connector + * - any openAI connector + * - any other GenAI-compatible connector. + * + */ +export const getDefaultConnector = ({ connectors }: { connectors: InferenceConnector[] }) => { + const inferenceConnector = connectors.find( + (connector) => connector.type === InferenceConnectorType.Inference + ); + if (inferenceConnector) { + return inferenceConnector; + } + + const openAIConnector = connectors.find( + (connector) => connector.type === InferenceConnectorType.OpenAI + ); + if (openAIConnector) { + return openAIConnector; + } + + return connectors[0]; +}; diff --git a/x-pack/platform/plugins/shared/onechat/server/services/runner/utils/index.ts b/x-pack/platform/plugins/shared/onechat/server/services/runner/utils/index.ts new file mode 100644 index 000000000000..30e893f5f501 --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/server/services/runner/utils/index.ts @@ -0,0 +1,11 @@ +/* + * 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. + */ + +export { getConnectorList } from './get_connector_list'; +export { getDefaultConnector } from './get_default_connector'; +export { createEmptyRunContext, forkContextForToolRun } from './run_context'; +export { convertInternalEvent, createEventEmitter, createNoopEventEmitter } from './events'; diff --git a/x-pack/platform/plugins/shared/onechat/server/services/runner/utils/run_context.test.ts b/x-pack/platform/plugins/shared/onechat/server/services/runner/utils/run_context.test.ts new file mode 100644 index 000000000000..894a36a38c3b --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/server/services/runner/utils/run_context.test.ts @@ -0,0 +1,85 @@ +/* + * 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 { createBuiltinToolId, toSerializedToolIdentifier } from '@kbn/onechat-common'; +import type { RunContext } from '@kbn/onechat-server'; +import { createEmptyRunContext, forkContextForToolRun } from './run_context'; + +describe('RunContext utilities', () => { + describe('creatEmptyRunContext', () => { + it('should create an empty run context with a generated UUID', () => { + const context = createEmptyRunContext(); + expect(context).toEqual({ + runId: expect.any(String), + stack: [], + }); + }); + + it('should create an empty run context with provided runId', () => { + const runId = 'test-run-id'; + const context = createEmptyRunContext({ runId }); + expect(context).toEqual({ + runId, + stack: [], + }); + }); + }); + + describe('forkContextForToolRun', () => { + it('should fork context and add tool to stack', () => { + const parentContext: RunContext = { + runId: 'parent-run-id', + stack: [], + }; + + const toolId = createBuiltinToolId('test-tool'); + const expectedSerializedToolId = toSerializedToolIdentifier(toolId); + const forkedContext = forkContextForToolRun({ toolId, parentContext }); + + expect(forkedContext).toEqual({ + runId: 'parent-run-id', + stack: [ + { + type: 'tool', + toolId: expectedSerializedToolId, + }, + ], + }); + }); + + it('should preserve existing stack entries when forking', () => { + const existingToolId = createBuiltinToolId('existing-tool'); + const newToolId = createBuiltinToolId('new-tool'); + + const parentContext: RunContext = { + runId: 'parent-run-id', + stack: [ + { + type: 'tool', + toolId: toSerializedToolIdentifier(existingToolId), + }, + ], + }; + + const forkedContext = forkContextForToolRun({ toolId: newToolId, parentContext }); + + expect(forkedContext).toEqual({ + runId: 'parent-run-id', + stack: [ + { + type: 'tool', + toolId: toSerializedToolIdentifier(existingToolId), + }, + { + type: 'tool', + toolId: toSerializedToolIdentifier(newToolId), + }, + ], + }); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/onechat/server/services/runner/utils/run_context.ts b/x-pack/platform/plugins/shared/onechat/server/services/runner/utils/run_context.ts new file mode 100644 index 000000000000..0c198a90b683 --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/server/services/runner/utils/run_context.ts @@ -0,0 +1,32 @@ +/* + * 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 { v4 as uuidv4 } from 'uuid'; +import { type ToolIdentifier, toSerializedToolIdentifier } from '@kbn/onechat-common'; +import type { RunContext } from '@kbn/onechat-server'; + +export const createEmptyRunContext = ({ + runId = uuidv4(), +}: { runId?: string } = {}): RunContext => { + return { + runId, + stack: [], + }; +}; + +export const forkContextForToolRun = ({ + toolId, + parentContext, +}: { + toolId: ToolIdentifier; + parentContext: RunContext; +}): RunContext => { + return { + ...parentContext, + stack: [...parentContext.stack, { type: 'tool', toolId: toSerializedToolIdentifier(toolId) }], + }; +}; diff --git a/x-pack/platform/plugins/shared/onechat/server/services/tools/builtin_registry.test.ts b/x-pack/platform/plugins/shared/onechat/server/services/tools/builtin_registry.test.ts new file mode 100644 index 000000000000..7907aa9fe64d --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/server/services/tools/builtin_registry.test.ts @@ -0,0 +1,132 @@ +/* + * 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 { z } from '@kbn/zod'; +import type { RegisteredTool } from '@kbn/onechat-server'; +import type { KibanaRequest } from '@kbn/core-http-server'; +import { httpServerMock } from '@kbn/core-http-server-mocks'; +import { BuiltinToolRegistry, createBuiltinToolRegistry } from './builtin_registry'; +import { addBuiltinSystemMeta } from './utils/tool_conversion'; + +describe('BuiltinToolRegistry', () => { + let registry: BuiltinToolRegistry; + let mockRequest: KibanaRequest; + + beforeEach(() => { + registry = createBuiltinToolRegistry(); + mockRequest = httpServerMock.createKibanaRequest(); + }); + + describe('register', () => { + it('should register a tool', async () => { + const mockTool: RegisteredTool = { + id: 'test-tool', + name: 'Test Tool', + description: 'A test tool', + schema: z.object({}), + handler: async () => 'test', + }; + + registry.register(mockTool); + await expect(registry.list({ request: mockRequest })).resolves.toEqual([ + addBuiltinSystemMeta(mockTool), + ]); + }); + }); + + describe('has', () => { + it('should return true when tool exists', async () => { + const mockTool: RegisteredTool = { + id: 'test-tool', + name: 'Test Tool', + description: 'A test tool', + schema: z.object({}), + handler: async () => 'test', + }; + + registry.register(mockTool); + const exists = await registry.has({ toolId: 'test-tool', request: mockRequest }); + expect(exists).toBe(true); + }); + + it('should return false when tool does not exist', async () => { + const mockTool: RegisteredTool = { + id: 'test-tool', + name: 'Test Tool', + description: 'A test tool', + schema: z.object({}), + handler: async () => 'test', + }; + + registry.register(mockTool); + const exists = await registry.has({ toolId: 'non-existent-tool', request: mockRequest }); + expect(exists).toBe(false); + }); + }); + + describe('get', () => { + it('should return the tool when it exists', async () => { + const mockTool: RegisteredTool = { + id: 'test-tool', + name: 'Test Tool', + description: 'A test tool', + schema: z.object({}), + handler: async () => 'test', + }; + + registry.register(mockTool); + const tool = await registry.get({ toolId: 'test-tool', request: mockRequest }); + expect(tool).toEqual(addBuiltinSystemMeta(mockTool)); + }); + + it('should throw an error when tool does not exist', async () => { + const mockTool: RegisteredTool = { + id: 'test-tool', + name: 'Test Tool', + description: 'A test tool', + schema: z.object({}), + handler: async () => 'test', + }; + + registry.register(mockTool); + await expect( + registry.get({ toolId: 'non-existent-tool', request: mockRequest }) + ).rejects.toThrow(/not found/); + }); + }); + + describe('list', () => { + it('should return all registered tools', async () => { + const mockTool1: RegisteredTool = { + id: 'test-tool-1', + name: 'Test Tool 1', + description: 'A test tool', + schema: z.object({}), + handler: async () => 'test1', + }; + + const mockTool2: RegisteredTool = { + id: 'test-tool-2', + name: 'Test Tool 2', + description: 'Another test tool', + schema: z.object({}), + handler: async () => 'test2', + }; + + registry.register(mockTool1); + registry.register(mockTool2); + + const tools = await registry.list({ request: mockRequest }); + expect(tools).toEqual([addBuiltinSystemMeta(mockTool1), addBuiltinSystemMeta(mockTool2)]); + }); + + it('should return empty array when no tools are registered', async () => { + const tools = await registry.list({ request: mockRequest }); + expect(tools).toEqual([]); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/onechat/server/services/tools/builtin_registry.ts b/x-pack/platform/plugins/shared/onechat/server/services/tools/builtin_registry.ts new file mode 100644 index 000000000000..77f0fc3f9db2 --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/server/services/tools/builtin_registry.ts @@ -0,0 +1,130 @@ +/* + * 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 { ZodObject } from '@kbn/zod'; +import type { MaybePromise } from '@kbn/utility-types'; +import type { KibanaRequest } from '@kbn/core-http-server'; +import { + OnechatErrorUtils, + ToolSourceType, + type ToolIdentifier, + toSerializedToolIdentifier, + toStructuredToolIdentifier, +} from '@kbn/onechat-common'; +import type { RegisteredTool } from '@kbn/onechat-server'; +import type { RegisteredToolWithMeta, InternalToolProvider } from './types'; +import { addBuiltinSystemMeta } from './utils/tool_conversion'; + +export type ToolRegistrationFn = (opts: { + request: KibanaRequest; +}) => MaybePromise; +export type ToolDirectRegistration = RegisteredTool; + +export type ToolRegistration< + RunInput extends ZodObject = ZodObject, + RunOutput = unknown +> = RegisteredTool | ToolRegistrationFn; + +export const isToolRegistrationFn = (tool: ToolRegistration): tool is ToolRegistrationFn => { + return typeof tool === 'function'; +}; + +export const wrapToolRegistration = (tool: ToolDirectRegistration): ToolRegistrationFn => { + return () => { + return [tool]; + }; +}; + +export interface BuiltinToolRegistry extends InternalToolProvider { + register(tool: RegisteredTool): void; +} + +export const createBuiltinToolRegistry = (): BuiltinToolRegistry => { + return new BuiltinToolRegistryImpl(); +}; + +const isValidSource = (source: ToolSourceType) => { + return source === ToolSourceType.builtIn || source === ToolSourceType.unknown; +}; + +export class BuiltinToolRegistryImpl implements BuiltinToolRegistry { + private registrations: ToolRegistrationFn[] = []; + + constructor() {} + + register(registration: RegisteredTool) { + this.registrations.push( + isToolRegistrationFn(registration) ? registration : wrapToolRegistration(registration) + ); + } + + async has(options: { toolId: ToolIdentifier; request: KibanaRequest }): Promise { + const { toolId: structuredToolId, request } = options; + const { toolId, sourceType } = toStructuredToolIdentifier(structuredToolId); + + if (!isValidSource(sourceType)) { + return false; + } + + for (const registration of this.registrations) { + const tools = await this.eval(registration, { request }); + for (const tool of tools) { + if (tool.id === toolId) { + return true; + } + } + } + + return false; + } + + async get(options: { + toolId: ToolIdentifier; + request: KibanaRequest; + }): Promise { + const { toolId: structuredToolId, request } = options; + const { toolId, sourceType } = toStructuredToolIdentifier(structuredToolId); + + if (!isValidSource(sourceType)) { + throw OnechatErrorUtils.createToolNotFoundError({ + toolId: toSerializedToolIdentifier(toolId), + }); + } + + for (const registration of this.registrations) { + const tools = await this.eval(registration, { request }); + for (const tool of tools) { + if (tool.id === toolId) { + return addBuiltinSystemMeta(tool); + } + } + } + + throw OnechatErrorUtils.createToolNotFoundError({ + toolId: toSerializedToolIdentifier(toolId), + }); + } + + async list(options: { request: KibanaRequest }): Promise { + const { request } = options; + const matchingTools: RegisteredToolWithMeta[] = []; + + for (const registration of this.registrations) { + const tools = await this.eval(registration, { request }); + matchingTools.push(...tools.map((tool) => addBuiltinSystemMeta(tool))); + } + + return matchingTools; + } + + private async eval( + registration: ToolRegistrationFn, + { request }: { request: KibanaRequest } + ): Promise { + return await registration({ request }); + } +} diff --git a/x-pack/platform/plugins/shared/onechat/server/services/tools/index.ts b/x-pack/platform/plugins/shared/onechat/server/services/tools/index.ts new file mode 100644 index 000000000000..ae73022f92c7 --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/server/services/tools/index.ts @@ -0,0 +1,14 @@ +/* + * 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. + */ + +export type { + ToolsServiceSetup, + ToolsServiceStart, + ScopedPublicToolRegistryFactoryFn, + ScopedPublicToolRegistry, +} from './types'; +export { ToolsService } from './tools_service'; diff --git a/x-pack/platform/plugins/shared/onechat/server/services/tools/tools_service.ts b/x-pack/platform/plugins/shared/onechat/server/services/tools/tools_service.ts new file mode 100644 index 000000000000..761e7a215035 --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/server/services/tools/tools_service.ts @@ -0,0 +1,41 @@ +/* + * 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 { Runner, RegisteredTool } from '@kbn/onechat-server'; +import { createBuiltinToolRegistry, type BuiltinToolRegistry } from './builtin_registry'; +import type { ToolsServiceSetup, ToolsServiceStart } from './types'; +import { createInternalRegistry } from './utils'; + +export interface ToolsServiceStartDeps { + getRunner: () => Runner; +} + +export class ToolsService { + private builtinRegistry: BuiltinToolRegistry; + + constructor() { + this.builtinRegistry = createBuiltinToolRegistry(); + } + + setup(): ToolsServiceSetup { + return { + register: (reg) => this.register(reg), + }; + } + + start({ getRunner }: ToolsServiceStartDeps): ToolsServiceStart { + const registry = createInternalRegistry({ providers: [this.builtinRegistry], getRunner }); + + return { + registry, + }; + } + + private register(toolRegistration: RegisteredTool) { + this.builtinRegistry.register(toolRegistration); + } +} diff --git a/x-pack/platform/plugins/shared/onechat/server/services/tools/types.ts b/x-pack/platform/plugins/shared/onechat/server/services/tools/types.ts new file mode 100644 index 000000000000..fc59c8393e6d --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/server/services/tools/types.ts @@ -0,0 +1,75 @@ +/* + * 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 { ZodObject } from '@kbn/zod'; +import type { KibanaRequest } from '@kbn/core-http-server'; +import type { ToolDescriptorMeta, ToolIdentifier } from '@kbn/onechat-common'; +import type { + ToolProvider, + RegisteredTool, + ToolProviderHasOptions, + ToolProviderGetOptions, + ToolProviderListOptions, + ExecutableTool, +} from '@kbn/onechat-server'; + +export interface ToolsServiceSetup { + register, RunOutput = unknown>( + tool: RegisteredTool + ): void; +} + +export interface ToolsServiceStart { + /** + * Internal tool registry, exposing internal APIs to interact with tool providers. + */ + registry: InternalToolRegistry; +} + +/** + * Registered tool with full meta. + */ +export type RegisteredToolWithMeta< + RunInput extends ZodObject = ZodObject, + RunOutput = unknown +> = Omit, 'meta'> & { + meta: ToolDescriptorMeta; +}; + +/** + * Internal tool provider interface + */ +export interface InternalToolProvider { + has(options: ToolProviderHasOptions): Promise; + get(options: ToolProviderGetOptions): Promise; + list(options: ToolProviderListOptions): Promise; +} + +/** + * Internal registry interface for the runner to interact with + */ +export interface InternalToolRegistry extends InternalToolProvider { + asPublicRegistry: () => PublicToolRegistry; + asScopedPublicRegistry: ScopedPublicToolRegistryFactoryFn; +} + +// type alias for now, we may extend later. +export type PublicToolRegistry = ToolProvider; + +/** + * Public tool registry exposed from the plugin's contract, + * and pre-bound to a given request. + */ +export interface ScopedPublicToolRegistry { + has(toolId: ToolIdentifier): Promise; + get(toolId: ToolIdentifier): Promise; + list(options?: {}): Promise; +} + +export type ScopedPublicToolRegistryFactoryFn = (opts: { + request: KibanaRequest; +}) => ScopedPublicToolRegistry; diff --git a/x-pack/platform/plugins/shared/onechat/server/services/tools/utils/combine_tool_providers.test.ts b/x-pack/platform/plugins/shared/onechat/server/services/tools/utils/combine_tool_providers.test.ts new file mode 100644 index 000000000000..28216315d530 --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/server/services/tools/utils/combine_tool_providers.test.ts @@ -0,0 +1,113 @@ +/* + * 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 { z } from '@kbn/zod'; +import type { KibanaRequest } from '@kbn/core-http-server'; +import { httpServerMock } from '@kbn/core-http-server-mocks'; +import type { RegisteredTool } from '@kbn/onechat-server'; +import type { InternalToolProvider } from '../types'; +import { combineToolProviders } from './combine_tool_providers'; + +describe('combineToolProviders', () => { + let mockRequest: KibanaRequest; + + beforeEach(() => { + mockRequest = httpServerMock.createKibanaRequest(); + }); + + const createMockTool = (id: string): RegisteredTool => ({ + id, + name: `Tool ${id}`, + description: `Description for tool ${id}`, + schema: z.object({}), + handler: jest.fn(), + }); + + const createMockProvider = (tools: RegisteredTool[]): InternalToolProvider => ({ + has: jest.fn().mockImplementation(async ({ toolId }) => { + return tools.some((t) => t.id === toolId); + }), + get: jest.fn().mockImplementation(async ({ toolId }) => { + const tool = tools.find((t) => t.id === toolId); + if (!tool) { + throw new Error(`Tool ${toolId} not found`); + } + return tool; + }), + list: jest.fn().mockResolvedValue(tools), + }); + + it('should return a tool from the first provider that has it', async () => { + const tool1 = createMockTool('tool1'); + const tool2 = createMockTool('tool2'); + const provider1 = createMockProvider([tool1]); + const provider2 = createMockProvider([tool2]); + + const combined = combineToolProviders(provider1, provider2); + const result = await combined.get({ toolId: 'tool1', request: mockRequest }); + + expect(result).toBe(tool1); + expect(provider1.get).toHaveBeenCalledWith({ toolId: 'tool1', request: mockRequest }); + expect(provider2.get).not.toHaveBeenCalled(); + }); + + it('should throw an error if no provider has the requested tool', async () => { + const provider1 = createMockProvider([]); + const provider2 = createMockProvider([]); + + const combined = combineToolProviders(provider1, provider2); + + await expect(combined.get({ toolId: 'nonexistent', request: mockRequest })).rejects.toThrow( + 'Tool with id nonexistent not found' + ); + }); + + it('should combine tools from all providers without duplicates', async () => { + const tool1 = createMockTool('tool1'); + const tool2 = createMockTool('tool2'); + const tool3 = createMockTool('tool3'); + const provider1 = createMockProvider([tool1, tool2]); + const provider2 = createMockProvider([tool2, tool3]); // tool2 is duplicated + + const combined = combineToolProviders(provider1, provider2); + const result = await combined.list({ request: mockRequest }); + + expect(result).toHaveLength(3); + expect(result).toContainEqual(tool1); + expect(result).toContainEqual(tool2); + expect(result).toContainEqual(tool3); + // Verify tool2 only appears once + expect(result.filter((t) => t.id === 'tool2')).toHaveLength(1); + }); + + it('should handle empty providers', async () => { + const combined = combineToolProviders(); + const result = await combined.list({ request: mockRequest }); + expect(result).toHaveLength(0); + }); + + it('should handle providers with no tools', async () => { + const provider1 = createMockProvider([]); + const provider2 = createMockProvider([]); + const combined = combineToolProviders(provider1, provider2); + const result = await combined.list({ request: mockRequest }); + expect(result).toHaveLength(0); + }); + + it('should preserve tool order from providers', async () => { + const tool1 = createMockTool('tool1'); + const tool2 = createMockTool('tool2'); + const tool3 = createMockTool('tool3'); + const provider1 = createMockProvider([tool1, tool2]); + const provider2 = createMockProvider([tool3]); + + const combined = combineToolProviders(provider1, provider2); + const result = await combined.list({ request: mockRequest }); + + expect(result).toEqual([tool1, tool2, tool3]); + }); +}); diff --git a/x-pack/platform/plugins/shared/onechat/server/services/tools/utils/combine_tool_providers.ts b/x-pack/platform/plugins/shared/onechat/server/services/tools/utils/combine_tool_providers.ts new file mode 100644 index 000000000000..ae3786c0ff5a --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/server/services/tools/utils/combine_tool_providers.ts @@ -0,0 +1,58 @@ +/* + * 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 { + ToolProviderHasOptions, + ToolProviderGetOptions, + ToolProviderListOptions, +} from '@kbn/onechat-server'; +import type { InternalToolProvider, RegisteredToolWithMeta } from '../types'; + +/** + * Creates a tool provider that combines multiple tool providers. + * + * Note: order matters - providers will be checked in the order they are in the list (in case of ID conflict) + */ +export const combineToolProviders = ( + ...providers: InternalToolProvider[] +): InternalToolProvider => { + return { + has: async (options: ToolProviderHasOptions) => { + for (const provider of providers) { + const providerHasTool = await provider.has(options); + if (providerHasTool) { + return true; + } + } + return false; + }, + get: async (options: ToolProviderGetOptions) => { + for (const provider of providers) { + if (await provider.has(options)) { + return provider.get(options); + } + } + throw new Error(`Tool with id ${options.toolId} not found`); + }, + list: async (options: ToolProviderListOptions) => { + const tools: RegisteredToolWithMeta[] = []; + const toolIds = new Set(); + + for (const provider of providers) { + const providerTools = await provider.list(options); + for (const tool of providerTools) { + if (!toolIds.has(tool.id)) { + tools.push(tool); + toolIds.add(tool.id); + } + } + } + + return tools; + }, + }; +}; diff --git a/x-pack/platform/plugins/shared/onechat/server/services/tools/utils/create_internal_registry.ts b/x-pack/platform/plugins/shared/onechat/server/services/tools/utils/create_internal_registry.ts new file mode 100644 index 000000000000..eaac4d4d122e --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/server/services/tools/utils/create_internal_registry.ts @@ -0,0 +1,80 @@ +/* + * 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 { + ExecutableTool, + Runner, + ToolProviderGetOptions, + ToolProviderHasOptions, + ToolProviderListOptions, +} from '@kbn/onechat-server'; +import { + InternalToolRegistry, + InternalToolProvider, + ScopedPublicToolRegistryFactoryFn, + PublicToolRegistry, +} from '../types'; +import { combineToolProviders } from './combine_tool_providers'; +import { toExecutableTool } from './tool_conversion'; + +export const createInternalRegistry = ({ + providers, + getRunner, +}: { + providers: InternalToolProvider[]; + getRunner: () => Runner; +}): InternalToolRegistry => { + const mainProvider = combineToolProviders(...providers); + const publicRegistry = internalProviderToPublic({ provider: mainProvider, getRunner }); + + return Object.assign(mainProvider, { + asPublicRegistry: () => publicRegistry, + asScopedPublicRegistry: createPublicFactory(publicRegistry), + }); +}; + +export const internalProviderToPublic = ({ + provider, + getRunner, +}: { + provider: InternalToolProvider; + getRunner: () => Runner; +}): PublicToolRegistry => { + return { + has(options: ToolProviderHasOptions): Promise { + return provider.has(options); + }, + async get(options: ToolProviderGetOptions): Promise { + const tool = await provider.get(options); + return toExecutableTool({ tool, runner: getRunner(), request: options.request }); + }, + async list(options: ToolProviderListOptions): Promise { + const tools = await provider.list(options); + return tools.map((tool) => + toExecutableTool({ tool, runner: getRunner(), request: options.request }) + ); + }, + }; +}; + +export const createPublicFactory = ( + provider: PublicToolRegistry +): ScopedPublicToolRegistryFactoryFn => { + return ({ request }) => { + return { + has: (toolId) => { + return provider.has({ toolId, request }); + }, + get: async (toolId) => { + return provider.get({ toolId, request }); + }, + list: async (options) => { + return provider.list({ request, ...options }); + }, + }; + }; +}; diff --git a/x-pack/platform/plugins/shared/onechat/server/services/tools/utils/index.ts b/x-pack/platform/plugins/shared/onechat/server/services/tools/utils/index.ts new file mode 100644 index 000000000000..93fa32486e8b --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/server/services/tools/utils/index.ts @@ -0,0 +1,10 @@ +/* + * 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. + */ + +export { combineToolProviders } from './combine_tool_providers'; +export { toolToDescriptor, toExecutableTool, addBuiltinSystemMeta } from './tool_conversion'; +export { createInternalRegistry } from './create_internal_registry'; diff --git a/x-pack/platform/plugins/shared/onechat/server/services/tools/utils/tool_conversion.ts b/x-pack/platform/plugins/shared/onechat/server/services/tools/utils/tool_conversion.ts new file mode 100644 index 000000000000..464b1ce41240 --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/server/services/tools/utils/tool_conversion.ts @@ -0,0 +1,57 @@ +/* + * 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 { ZodObject } from '@kbn/zod'; +import type { KibanaRequest } from '@kbn/core-http-server'; +import { builtinSourceId, ToolSourceType, ToolDescriptor } from '@kbn/onechat-common'; +import type { Runner, RegisteredTool, ExecutableTool } from '@kbn/onechat-server'; +import { RegisteredToolWithMeta } from '../types'; + +export const addBuiltinSystemMeta = < + RunInput extends ZodObject = ZodObject, + RunOutput = unknown +>( + tool: RegisteredTool +): RegisteredToolWithMeta => { + return { + ...tool, + meta: { + tags: tool.meta?.tags ?? [], + sourceType: ToolSourceType.builtIn, + sourceId: builtinSourceId, + }, + }; +}; + +export const toExecutableTool = , RunOutput>({ + tool, + runner, + request, +}: { + tool: RegisteredTool; + runner: Runner; + request: KibanaRequest; +}): ExecutableTool => { + const { handler, ...toolParts } = addBuiltinSystemMeta(tool); + + return { + ...toolParts, + execute: (params) => { + return runner.runTool({ ...params, toolId: tool.id, request }); + }, + }; +}; + +/** + * Remove all additional properties from a tool descriptor. + * + * Can be used to convert/clean tool registration for public-facing APIs. + */ +export const toolToDescriptor = (tool: T): ToolDescriptor => { + const { id, name, description, meta } = tool; + return { id, name, description, meta }; +}; diff --git a/x-pack/platform/plugins/shared/onechat/server/services/types.ts b/x-pack/platform/plugins/shared/onechat/server/services/types.ts new file mode 100644 index 000000000000..95ed429de1f5 --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/server/services/types.ts @@ -0,0 +1,33 @@ +/* + * 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 { Logger } from '@kbn/logging'; +import type { ElasticsearchServiceStart } from '@kbn/core-elasticsearch-server'; +import type { SecurityServiceStart } from '@kbn/core-security-server'; +import type { InferenceServerStart } from '@kbn/inference-plugin/server'; +import type { PluginStartContract as ActionsPluginStart } from '@kbn/actions-plugin/server'; +import type { ToolsServiceSetup, ToolsServiceStart } from './tools'; +import type { RunnerFactory } from './runner'; + +export interface InternalSetupServices { + tools: ToolsServiceSetup; +} + +export interface InternalStartServices { + tools: ToolsServiceStart; + runnerFactory: RunnerFactory; +} + +export interface ServicesStartDeps { + // core services + logger: Logger; + elasticsearch: ElasticsearchServiceStart; + security: SecurityServiceStart; + // plugin deps + inference: InferenceServerStart; + actions: ActionsPluginStart; +} diff --git a/x-pack/platform/plugins/shared/onechat/server/test_utils/common.ts b/x-pack/platform/plugins/shared/onechat/server/test_utils/common.ts new file mode 100644 index 000000000000..c91d0f40f4a9 --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/server/test_utils/common.ts @@ -0,0 +1,10 @@ +/* + * 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. + */ + +export type ChangeReturnType = T extends (...args: infer A) => any + ? (...args: A) => NewReturn + : never; diff --git a/x-pack/platform/plugins/shared/onechat/server/test_utils/index.ts b/x-pack/platform/plugins/shared/onechat/server/test_utils/index.ts new file mode 100644 index 000000000000..f0a31f0bc695 --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/server/test_utils/index.ts @@ -0,0 +1,27 @@ +/* + * 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. + */ + +export { + createModelProviderMock, + createModelProviderFactoryMock, + type ModelProviderMock, + type ModelProviderFactoryMock, +} from './model_provider'; +export { createScopedRunnerDepsMock, type CreateScopedRunnerDepsMock } from './runner'; +export { + createToolsServiceStartMock, + createToolProviderMock, + createScopedPublicToolRegistryMock, + createMockedTool, + createMockedExecutableTool, + type ScopedPublicToolRegistryFactoryFnMock, + type ScopedPublicToolRegistryMock, + type ToolProviderMock, + type ToolsServiceStartMock, + type MockedTool, + type MockedExecutableTool, +} from './tools'; diff --git a/x-pack/platform/plugins/shared/onechat/server/test_utils/model_provider.ts b/x-pack/platform/plugins/shared/onechat/server/test_utils/model_provider.ts new file mode 100644 index 000000000000..1b3a00e13592 --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/server/test_utils/model_provider.ts @@ -0,0 +1,26 @@ +/* + * 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 { ModelProvider } from '@kbn/onechat-server'; +import type { ModelProviderFactoryFn } from '../services/runner/model_provider'; +import { ChangeReturnType } from './common'; + +export type ModelProviderMock = jest.Mocked; +export type ModelProviderFactoryMock = jest.MockedFn< + ChangeReturnType +>; + +export const createModelProviderMock = (): ModelProviderMock => { + return { + getDefaultModel: jest.fn(), + getModel: jest.fn(), + }; +}; + +export const createModelProviderFactoryMock = (): ModelProviderFactoryMock => { + return jest.fn().mockImplementation(() => createModelProviderMock()); +}; diff --git a/x-pack/platform/plugins/shared/onechat/server/test_utils/runner.ts b/x-pack/platform/plugins/shared/onechat/server/test_utils/runner.ts new file mode 100644 index 000000000000..117b911dbe2b --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/server/test_utils/runner.ts @@ -0,0 +1,37 @@ +/* + * 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 { loggerMock, MockedLogger } from '@kbn/logging-mocks'; +import { + elasticsearchServiceMock, + httpServerMock, + securityServiceMock, +} from '@kbn/core/server/mocks'; +import type { KibanaRequest } from '@kbn/core-http-server'; +import type { CreateScopedRunnerDeps } from '../services/runner/runner'; +import { ModelProviderFactoryMock, createModelProviderFactoryMock } from './model_provider'; +import { ToolsServiceStartMock, createToolsServiceStartMock } from './tools'; + +export interface CreateScopedRunnerDepsMock extends CreateScopedRunnerDeps { + elasticsearch: ReturnType; + security: ReturnType; + modelProviderFactory: ModelProviderFactoryMock; + toolsService: ToolsServiceStartMock; + logger: MockedLogger; + request: KibanaRequest; +} + +export const createScopedRunnerDepsMock = (): CreateScopedRunnerDepsMock => { + return { + elasticsearch: elasticsearchServiceMock.createStart(), + security: securityServiceMock.createStart(), + modelProviderFactory: createModelProviderFactoryMock(), + toolsService: createToolsServiceStartMock(), + logger: loggerMock.create(), + request: httpServerMock.createKibanaRequest(), + }; +}; diff --git a/x-pack/platform/plugins/shared/onechat/server/test_utils/tools.ts b/x-pack/platform/plugins/shared/onechat/server/test_utils/tools.ts new file mode 100644 index 000000000000..5cc01d963299 --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/server/test_utils/tools.ts @@ -0,0 +1,110 @@ +/* + * 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 { z } from '@kbn/zod'; +import { ToolSourceType } from '@kbn/onechat-common'; +import type { + ToolProvider, + ToolHandlerFn, + ExecutableTool, + ExecutableToolHandlerFn, +} from '@kbn/onechat-server'; +import type { + ToolsServiceStart, + InternalToolRegistry, + ScopedPublicToolRegistry, + ScopedPublicToolRegistryFactoryFn, + RegisteredToolWithMeta, +} from '../services/tools/types'; +import { ChangeReturnType } from './common'; + +export type ToolProviderMock = jest.Mocked; +export type ScopedPublicToolRegistryMock = jest.Mocked; +export type ScopedPublicToolRegistryFactoryFnMock = jest.MockedFn< + ChangeReturnType +>; +export type InternalToolRegistryMock = jest.Mocked; + +export type ToolsServiceStartMock = ToolsServiceStart & { + registry: InternalToolRegistryMock; +}; + +export const createToolProviderMock = (): ToolProviderMock => { + return { + has: jest.fn(), + get: jest.fn(), + list: jest.fn(), + }; +}; + +export const createInternalToolRegistryMock = (): InternalToolRegistryMock => { + return { + has: jest.fn(), + get: jest.fn(), + list: jest.fn(), + asPublicRegistry: jest.fn().mockImplementation(() => createToolProviderMock()), + asScopedPublicRegistry: jest + .fn() + .mockImplementation(() => createScopedPublicToolRegistryMock()), + }; +}; + +export const createScopedPublicToolRegistryMock = (): ScopedPublicToolRegistryMock => { + return { + has: jest.fn(), + get: jest.fn(), + list: jest.fn(), + }; +}; + +export const createToolsServiceStartMock = (): ToolsServiceStartMock => { + return { + registry: createInternalToolRegistryMock(), + }; +}; + +export type MockedTool = Omit & { + handler: jest.MockedFunction; +}; + +export type MockedExecutableTool = Omit & { + execute: jest.MockedFunction; +}; + +export const createMockedTool = (parts: Partial = {}): MockedTool => { + return { + id: 'test-tool', + name: 'my test tool', + description: 'test description', + schema: z.object({}), + meta: { + sourceType: ToolSourceType.builtIn, + sourceId: 'foo', + tags: ['tag-1', 'tag-2'], + }, + ...parts, + handler: jest.fn(parts.handler), + }; +}; + +export const createMockedExecutableTool = ( + parts: Partial = {} +): MockedExecutableTool => { + return { + id: 'test-tool', + name: 'my test tool', + description: 'test description', + schema: z.object({}), + meta: { + sourceType: ToolSourceType.builtIn, + sourceId: 'foo', + tags: ['tag-1', 'tag-2'], + }, + ...parts, + execute: jest.fn(parts.execute), + }; +}; diff --git a/x-pack/platform/plugins/shared/onechat/server/types.ts b/x-pack/platform/plugins/shared/onechat/server/types.ts new file mode 100644 index 000000000000..90246a6c25ab --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/server/types.ts @@ -0,0 +1,83 @@ +/* + * 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 { KibanaRequest } from '@kbn/core-http-server'; +import type { RunToolFn, ScopedRunToolFn, ToolProvider } from '@kbn/onechat-server'; +import type { + PluginStartContract as ActionsPluginStart, + PluginSetupContract as ActionsPluginSetup, +} from '@kbn/actions-plugin/server'; +import type { InferenceServerSetup, InferenceServerStart } from '@kbn/inference-plugin/server'; +import type { ToolsServiceSetup, ScopedPublicToolRegistry } from './services/tools'; + +export interface OnechatSetupDependencies { + actions: ActionsPluginSetup; + inference: InferenceServerSetup; +} + +export interface OnechatStartDependencies { + actions: ActionsPluginStart; + inference: InferenceServerStart; +} + +/** + * Onechat tool service's setup contract + */ +export interface ToolsSetup { + /** + * Register a built-in tool to be available in onechat. + * + * Refer to {@link ToolRegistration} + */ + register: ToolsServiceSetup['register']; +} + +/** + * Onechat tool service's start contract + */ +export interface ToolsStart { + /** + * Access the tool registry's APIs. + */ + registry: ToolProvider; + /** + * Execute a tool. + */ + execute: RunToolFn; + /** + * Return a version of the tool APIs scoped to the provided request. + */ + asScoped: (opts: { request: KibanaRequest }) => ScopedToolsStart; +} + +/** + * Scoped tools APIs. + */ +export interface ScopedToolsStart { + /** + * scoped tools registry + */ + registry: ScopedPublicToolRegistry; + /** + * Scoped tool runner + */ + execute: ScopedRunToolFn; +} + +/** + * Setup contract of the onechat plugin. + */ +export interface OnechatPluginSetup { + tools: ToolsSetup; +} + +/** + * Start contract of the onechat plugin. + */ +export interface OnechatPluginStart { + tools: ToolsStart; +} diff --git a/x-pack/platform/plugins/shared/onechat/tsconfig.json b/x-pack/platform/plugins/shared/onechat/tsconfig.json new file mode 100644 index 000000000000..28ab33572188 --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types" + }, + "include": [ + "../../../../../typings/**/*", + "common/**/*", + "public/**/*", + "server/**/*", + ], + "exclude": [ + "target/**/*", + ".storybook/**/*.js" + ], + "kbn_references": [ + "@kbn/core", + "@kbn/logging", + "@kbn/actions-plugin", + "@kbn/config-schema", + "@kbn/inference-plugin", + "@kbn/utility-types", + "@kbn/onechat-server", + "@kbn/core-http-server", + "@kbn/core-http-server-mocks", + "@kbn/onechat-common", + "@kbn/core-elasticsearch-server", + "@kbn/core-security-server", + "@kbn/inference-common", + "@kbn/logging-mocks", + "@kbn/zod" + ] +} diff --git a/yarn.lock b/yarn.lock index 54d7699322ad..1c4968ed55f8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6456,6 +6456,22 @@ version "0.0.0" uid "" +"@kbn/onechat-browser@link:x-pack/platform/packages/shared/onechat/onechat-browser": + version "0.0.0" + uid "" + +"@kbn/onechat-common@link:x-pack/platform/packages/shared/onechat/onechat-common": + version "0.0.0" + uid "" + +"@kbn/onechat-plugin@link:x-pack/platform/plugins/shared/onechat": + version "0.0.0" + uid "" + +"@kbn/onechat-server@link:x-pack/platform/packages/shared/onechat/onechat-server": + version "0.0.0" + uid "" + "@kbn/open-telemetry-instrumented-plugin@link:src/platform/test/common/plugins/otel_metrics": version "0.0.0" uid ""