[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>
This commit is contained in:
Pierre Gayvallet 2025-05-27 23:45:01 +02:00 committed by GitHub
parent 6d5acfca0d
commit f3b4975c8c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
82 changed files with 4009 additions and 0 deletions

4
.github/CODEOWNERS vendored
View file

@ -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

View file

@ -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. |

View file

@ -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",

View file

@ -113,6 +113,7 @@ pageLoadAssetSize:
observabilityLogsExplorer: 46650
observabilityOnboarding: 19573
observabilityShared: 111036
onechat: 25000
osquery: 107090
painlessLab: 179748
presentationPanel: 11550

View file

@ -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"],

View file

@ -0,0 +1,3 @@
# @kbn/onechat-browser
Browser-side types and utilities for the onechat framework.

View file

@ -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...';
}

View file

@ -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: ['<rootDir>/x-pack/platform/packages/shared/onechat/onechat-browser'],
};

View file

@ -0,0 +1,7 @@
{
"type": "shared-common",
"id": "@kbn/onechat-browser",
"owner": "@elastic/workchat-eng",
"group": "platform",
"visibility": "shared"
}

View file

@ -0,0 +1,7 @@
{
"name": "@kbn/onechat-browser",
"private": true,
"version": "1.0.0",
"license": "Elastic License 2.0",
"sideEffects": false
}

View file

@ -0,0 +1,19 @@
{
"extends": "../../../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node",
"react"
]
},
"include": [
"**/*.ts",
"**/*.tsx",
],
"exclude": [
"target/**/*"
],
"kbn_references": []
}

View file

@ -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`

View file

@ -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';

View file

@ -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: ['<rootDir>/x-pack/platform/packages/shared/onechat/onechat-common'],
};

View file

@ -0,0 +1,7 @@
{
"type": "shared-common",
"id": "@kbn/onechat-common",
"owner": "@elastic/workchat-eng",
"group": "platform",
"visibility": "shared"
}

View file

@ -0,0 +1,7 @@
{
"name": "@kbn/onechat-common",
"private": true,
"version": "1.0.0",
"license": "Elastic License 2.0",
"sideEffects": false
}

View file

@ -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,
});
});
});
});

View file

@ -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<string, any> = Record<string, any>
> = ServerSentEventError<TCode, TMeta>;
export const isOnechatError = (err: unknown): err is OnechatError<OnechatErrorCode> => {
return err instanceof OnechatError;
};
/**
* Represents an internal error
*/
export type OnechatInternalError = OnechatError<OnechatErrorCode.internalError>;
/**
* 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<string, any>
): 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<OnechatErrorCode.toolNotFound>;
/**
* 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<string, any>;
}): 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,
};

View file

@ -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<string, any>,
TMeta extends Record<string, any>
> {
/**
* Unique type identifier for the event.
*/
type: TEventType;
/**
* Data bound to this event.
*/
data: TData;
/**
* Metadata bound to this event.
*/
meta: TMeta;
}

View file

@ -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'
);
});
});
});

View file

@ -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[];
}

View file

@ -0,0 +1,19 @@
{
"extends": "../../../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node"
]
},
"include": [
"**/*.ts",
],
"exclude": [
"target/**/*"
],
"kbn_references": [
"@kbn/sse-utils",
]
}

View file

@ -0,0 +1,3 @@
# @kbn/onechat-server
Server-side types and utilities for the onechat framework.

View file

@ -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';

View file

@ -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: ['<rootDir>/x-pack/platform/packages/shared/onechat/onechat-server'],
};

View file

@ -0,0 +1,7 @@
{
"type": "shared-common",
"id": "@kbn/onechat-server",
"owner": "@elastic/workchat-eng",
"group": "platform",
"visibility": "shared"
}

View file

@ -0,0 +1,7 @@
{
"name": "@kbn/onechat-server",
"private": true,
"version": "1.0.0",
"license": "Elastic License 2.0",
"sideEffects": false
}

View file

@ -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<string, any> = Record<string, any>,
TMeta extends OnechatRunEventMeta = OnechatRunEventMeta
> = OnechatEvent<TEventType, TData, TMeta>;
/**
* Internal-facing events, as emitted by tool or agent owners.
*/
export type InternalRunEvent<
TEventType extends string = string,
TData extends Record<string, any> = Record<string, any>,
TMeta extends Record<string, any> = Record<string, any>
> = Omit<OnechatEvent<TEventType, TData, TMeta>, '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<OnechatRunEventType.toolCall, ToolCallEventData>;
export interface ToolCallEventData {
toolId: string;
toolParams: Record<string, unknown>;
}
export const isToolCallEvent = (event: OnechatEvent<any, any, any>): 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<any, any, any>
): event is ToolResponseEvent => {
return event.type === OnechatRunEventType.toolResponse;
};

View file

@ -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<ScopedModel>;
/**
* 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<ScopedModel>;
}

View file

@ -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<TResult = unknown> {
/**
* 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 = <TParams = Record<string, unknown>, TResult = unknown>(
params: ScopedRunnerRunToolsParams<TParams>
) => Promise<RunToolReturn<TResult>>;
/**
* 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<TParams = Record<string, unknown>> {
/**
* 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<TParams = Record<string, unknown>> = Omit<
RunToolParams<TParams>,
'request'
>;
/**
* Public onechat API to execute a tools.
*/
export type RunToolFn = <TParams = Record<string, unknown>, TResult = unknown>(
params: RunToolParams<TParams>
) => Promise<RunToolReturn<TResult>>;
/**
* Public onechat runner.
*/
export interface Runner {
/**
* Execute a tool.
*/
runTool: RunToolFn;
}

View file

@ -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<Omit<ToolDescriptorMeta, 'sourceType' | 'sourceId'>>;
/**
* Onechat tool, as registered by built-in tool providers.
*/
export interface RegisteredTool<
RunInput extends ZodObject<any> = ZodObject<any>,
RunOutput = unknown
> extends Omit<ToolDescriptor, 'meta'> {
/**
* Tool's input schema, defined as a zod schema.
*/
schema: RunInput;
/**
* Handler to call to execute the tool.
*/
handler: ToolHandlerFn<z.infer<RunInput>, 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<any> = ZodObject<any>,
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<z.infer<RunInput>, RunOutput>;
}
/**
* Param type for {@link ExecutableToolHandlerFn}
*/
export type ExecutableToolHandlerParams<TParams = Record<string, unknown>> = Omit<
ScopedRunnerRunToolsParams<TParams>,
'toolId'
>;
/**
* Execution handler for {@link ExecutableTool}
*/
export type ExecutableToolHandlerFn<TParams = Record<string, unknown>, TResult = unknown> = (
params: ExecutableToolHandlerParams<TParams>
) => Promise<RunToolReturn<TResult>>;
/**
* Tool handler function for {@link RegisteredTool} handlers.
*/
export type ToolHandlerFn<
TParams extends Record<string, unknown> = Record<string, unknown>,
RunOutput = unknown
> = (args: TParams, context: ToolHandlerContext) => MaybePromise<RunOutput>;
/**
* 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<boolean>;
/**
* Retrieve a tool based on its identifier.
* If not found, will throw a toolNotFound error.
*/
get(options: ToolProviderGetOptions): Promise<ExecutableTool>;
/**
* List all tools based on the provided filters
*/
list(options: ToolProviderListOptions): Promise<ExecutableTool[]>;
}
/**
* 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;
}

View file

@ -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",
]
}

View file

@ -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`);
}
}
```

View file

@ -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';

View file

@ -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: [
'<rootDir>/x-pack/platform/plugins/shared/onechat/public',
'<rootDir>/x-pack/platform/plugins/shared/onechat/server',
'<rootDir>/x-pack/platform/plugins/shared/onechat/common',
],
setupFiles: [],
collectCoverage: true,
collectCoverageFrom: [
'<rootDir>/x-pack/platform/plugins/shared/onechat/{public,server,common}/**/*.{js,ts,tsx}',
],
coverageReporters: ['html'],
};

View file

@ -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": []
}
}

View file

@ -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<ConfigSchema>) => {
return new OnechatPlugin(pluginInitializerContext);
};

View file

@ -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<OnechatPluginSetup> => {
return {};
};
const createStartContractMock = (): jest.Mocked<OnechatPluginStart> => {
return {};
};
export const onechatMocks = {
createSetup: createSetupContractMock,
createStart: createStartContractMock,
};

View file

@ -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<ConfigSchema>) {
this.logger = context.logger.get();
}
setup(
coreSetup: CoreSetup<OnechatStartDependencies, OnechatPluginStart>,
pluginsSetup: OnechatSetupDependencies
): OnechatPluginSetup {
return {};
}
start(coreStart: CoreStart, pluginsStart: OnechatStartDependencies): OnechatPluginStart {
return {};
}
}

View file

@ -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 {}

View file

@ -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<typeof configSchema>;
export const config: PluginConfigDescriptor<OnechatConfig> = {
schema: configSchema,
};

View file

@ -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<OnechatConfig>) => {
return new OnechatPlugin(pluginInitializerContext);
};
export { config } from './config';

View file

@ -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<OnechatPluginSetup> => {
return {
tools: {
register: jest.fn(),
},
};
};
export const createScopedToolStartMock = (): jest.Mocked<ScopedToolsStart> => {
return {
execute: jest.fn(),
registry: createScopedPublicToolRegistryMock(),
};
};
const createStartContractMock = (): jest.Mocked<OnechatPluginStart> => {
return {
tools: {
execute: jest.fn(),
registry: createToolProviderMock(),
asScoped: jest.fn().mockImplementation(() => createScopedToolStartMock()),
},
};
};
export const onechatMocks = {
createSetup: createSetupContractMock,
createStart: createStartContractMock,
createTool: createMockedExecutableTool,
};

View file

@ -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<OnechatConfig>) {
this.logger = context.logger.get();
this.config = context.config.get();
}
setup(
coreSetup: CoreSetup<OnechatStartDependencies, OnechatPluginStart>,
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() {}
}

View file

@ -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);
};

View file

@ -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),
},
});
})
);
}

View file

@ -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<OnechatStartDependencies, OnechatPluginStart>;
getInternalServices: () => InternalStartServices;
}

View file

@ -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 }) =>
<P, Q, B>(handler: RequestHandler<P, Q, B>): RequestHandler<P, Q, B> => {
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;
}
}
};
};

View file

@ -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;
}
}

View file

@ -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';

View file

@ -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';

View file

@ -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<CreateModelProviderOpts, 'request' | 'defaultConnectorId'>
) => ModelProviderFactoryFn;
export type ModelProviderFactoryFn = (
opts: Pick<CreateModelProviderOpts, 'request' | 'defaultConnectorId'>
) => 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<ScopedModel> => {
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),
};
};

View file

@ -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' },
});
});
});
});

View file

@ -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<CreateScopedRunnerDeps, 'request' | 'defaultConnectorId'>;
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: <TParams = Record<string, unknown>, TResult = unknown>(
toolExecutionParams: ScopedRunnerRunToolsParams<TParams>
): Promise<RunToolReturn<TResult>> => {
try {
return runTool<TParams, TResult>({ 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 <TParams = Record<string, unknown>, TResult = unknown>({
toolExecutionParams,
parentManager,
}: {
toolExecutionParams: ScopedRunnerRunToolsParams<TParams>;
parentManager: RunnerManager;
}): Promise<RunToolReturn<TResult>> => {
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<TParams>({ toolExecutionParams, manager });
const toolResult = await tool.handler(toolParams as Record<string, any>, toolHandlerContext);
return {
result: toolResult as TResult,
};
};
export const createToolHandlerContext = <TParams = Record<string, unknown>>({
manager,
toolExecutionParams,
}: {
toolExecutionParams: ScopedRunnerRunToolsParams<TParams>;
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);
},
};
};

View file

@ -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 }),
};
}
}

View file

@ -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;
}

View file

@ -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: [],
},
});
});
});
});

View file

@ -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<string, any> = Record<string, any>,
TMeta extends Record<string, any> = Record<string, any>
>({
event: { type, data, meta },
context,
}: {
event: InternalRunEvent<TEventType, TData, TMeta>;
context: RunContext;
}): OnechatRunEvent<TEventType, TData, TMeta & OnechatRunEventMeta> => {
return {
type,
data,
meta: {
...((meta ?? {}) as TMeta),
runId: context.runId,
stack: context.stack,
},
};
};

View file

@ -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<InferenceConnector[]> => {
const actionClient = await actions.getActionsClientWithRequest(request);
const allConnectors = await actionClient.getAll({
includeSystemActions: false,
});
return allConnectors
.filter((connector) => isSupportedConnector(connector))
.map(connectorToInference);
};

View file

@ -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];
};

View file

@ -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';

View file

@ -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),
},
],
});
});
});
});

View file

@ -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) }],
};
};

View file

@ -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([]);
});
});
});

View file

@ -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<RegisteredTool[]>;
export type ToolDirectRegistration = RegisteredTool;
export type ToolRegistration<
RunInput extends ZodObject<any> = ZodObject<any>,
RunOutput = unknown
> = RegisteredTool<RunInput, RunOutput> | 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<boolean> {
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<RegisteredToolWithMeta> {
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<RegisteredToolWithMeta[]> {
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<RegisteredTool[]> {
return await registration({ request });
}
}

View file

@ -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';

View file

@ -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<any, any>) {
this.builtinRegistry.register(toolRegistration);
}
}

View file

@ -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<RunInput extends ZodObject<any>, RunOutput = unknown>(
tool: RegisteredTool<RunInput, RunOutput>
): 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<any> = ZodObject<any>,
RunOutput = unknown
> = Omit<RegisteredTool<RunInput, RunOutput>, 'meta'> & {
meta: ToolDescriptorMeta;
};
/**
* Internal tool provider interface
*/
export interface InternalToolProvider {
has(options: ToolProviderHasOptions): Promise<boolean>;
get(options: ToolProviderGetOptions): Promise<RegisteredToolWithMeta>;
list(options: ToolProviderListOptions): Promise<RegisteredToolWithMeta[]>;
}
/**
* 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<boolean>;
get(toolId: ToolIdentifier): Promise<ExecutableTool>;
list(options?: {}): Promise<ExecutableTool[]>;
}
export type ScopedPublicToolRegistryFactoryFn = (opts: {
request: KibanaRequest;
}) => ScopedPublicToolRegistry;

View file

@ -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]);
});
});

View file

@ -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<string>();
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;
},
};
};

View file

@ -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<boolean> {
return provider.has(options);
},
async get(options: ToolProviderGetOptions): Promise<ExecutableTool> {
const tool = await provider.get(options);
return toExecutableTool({ tool, runner: getRunner(), request: options.request });
},
async list(options: ToolProviderListOptions): Promise<ExecutableTool[]> {
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 });
},
};
};
};

View file

@ -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';

View file

@ -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<any> = ZodObject<any>,
RunOutput = unknown
>(
tool: RegisteredTool<RunInput, RunOutput>
): RegisteredToolWithMeta<RunInput, RunOutput> => {
return {
...tool,
meta: {
tags: tool.meta?.tags ?? [],
sourceType: ToolSourceType.builtIn,
sourceId: builtinSourceId,
},
};
};
export const toExecutableTool = <RunInput extends ZodObject<any>, RunOutput>({
tool,
runner,
request,
}: {
tool: RegisteredTool<RunInput, RunOutput>;
runner: Runner;
request: KibanaRequest;
}): ExecutableTool<RunInput, RunOutput> => {
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 = <T extends ToolDescriptor>(tool: T): ToolDescriptor => {
const { id, name, description, meta } = tool;
return { id, name, description, meta };
};

View file

@ -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;
}

View file

@ -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, NewReturn> = T extends (...args: infer A) => any
? (...args: A) => NewReturn
: never;

View file

@ -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';

View file

@ -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<ModelProvider>;
export type ModelProviderFactoryMock = jest.MockedFn<
ChangeReturnType<ModelProviderFactoryFn, ModelProviderMock>
>;
export const createModelProviderMock = (): ModelProviderMock => {
return {
getDefaultModel: jest.fn(),
getModel: jest.fn(),
};
};
export const createModelProviderFactoryMock = (): ModelProviderFactoryMock => {
return jest.fn().mockImplementation(() => createModelProviderMock());
};

View file

@ -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<typeof elasticsearchServiceMock.createStart>;
security: ReturnType<typeof securityServiceMock.createStart>;
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(),
};
};

View file

@ -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<ToolProvider>;
export type ScopedPublicToolRegistryMock = jest.Mocked<ScopedPublicToolRegistry>;
export type ScopedPublicToolRegistryFactoryFnMock = jest.MockedFn<
ChangeReturnType<ScopedPublicToolRegistryFactoryFn, ScopedPublicToolRegistryMock>
>;
export type InternalToolRegistryMock = jest.Mocked<InternalToolRegistry>;
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<RegisteredToolWithMeta, 'handler'> & {
handler: jest.MockedFunction<ToolHandlerFn>;
};
export type MockedExecutableTool = Omit<ExecutableTool, 'execute'> & {
execute: jest.MockedFunction<ExecutableToolHandlerFn>;
};
export const createMockedTool = (parts: Partial<RegisteredToolWithMeta> = {}): 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<ExecutableTool> = {}
): 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),
};
};

View file

@ -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;
}

View file

@ -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"
]
}

View file

@ -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 ""